@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/cmd-doctor.ts CHANGED
@@ -4,31 +4,22 @@
4
4
 
5
5
  import { existsSync } from 'node:fs';
6
6
  import { dirname, join, resolve, sep } from 'node:path';
7
- import {
8
- ENV_EXAMPLE_PATH,
9
- ERROR_DOCS_URL,
10
- tryResolveEnvironment,
11
- usesDevCursorSecret,
12
- } from '@ultimat3/core';
13
- import {
14
- checkDb,
15
- createPostgresClient,
16
- PGLITE_FIX,
17
- PGLITE_MISSING,
18
- PGLITE_PACKAGE,
19
- } from '@ultimat3/db';
20
- import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
7
+ import { ENV_EXAMPLE_PATH, ERROR_DOCS_URL } from '@ultimat3/core';
8
+ import { checkDb, PGLITE_FIX, PGLITE_MISSING, PGLITE_PACKAGE, postgresClient } from '@ultimat3/db';
21
9
  import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
22
- import { DEFAULT_DOCTOR_PORT, doctorSpec } from './cmd-doctor-spec';
10
+ import { doctorSpec } from './cmd-doctor-spec';
23
11
  import type { CliCommand, CommandContext } from './command';
24
12
  import { checkMigrationSnapshots } from './db-snapshot';
13
+ import { appUrlFindings } from './doctor-app-url';
14
+ import { type AuthStorageFact, authStorageFindings, authStorageProbe } from './doctor-auth';
25
15
  import { coverageExcludesProbe, withCoverageExcludes } from './doctor-coverage';
26
16
  import { shippedGuardsProbe, withGuardListing } from './doctor-guards';
27
17
  import type { OfflineFallbackFact } from './doctor-offline';
28
18
  import { offlineFallbackFinding, offlineFallbackProbe } from './doctor-offline';
29
19
  import type { SealedKeysFact } from './doctor-sealed';
30
20
  import { sealedKeyFindings, sealedKeysProbe } from './doctor-sealed';
31
- import { intFlagOr, PORT_RANGE, portPairAfter } from './flag-number';
21
+ import { PORT_RANGE, portPairAfter, readIntFlag } from './flag-number';
22
+ import { machineSecretFindings } from './framework-env';
32
23
  import { ICON_SOURCE } from './icon-assets';
33
24
  import { msg } from './messages';
34
25
  import type { CommandResult, Finding } from './output';
@@ -37,6 +28,8 @@ import type { ParsedArgs } from './parse';
37
28
  import { portFree } from './port-probe';
38
29
  import { syncPortFor } from './role-sync';
39
30
  import { checkMigrationDrift } from './schema-drift';
31
+ import { portFromEnv } from './serve-env';
32
+ import { quoteArg } from './shell-quote';
40
33
 
41
34
  /**
42
35
  * The injection seam `runDoctor` reads instead of the environment. Not a semver surface —
@@ -49,17 +42,15 @@ export interface DoctorProbe {
49
42
  /** App root, or undefined when the command runs outside an app. */
50
43
  readonly root: string | undefined;
51
44
  readonly port: number;
52
- /** True while cursors are signed with the key shipped in the published package. */
53
- readonly devCursorSecret: boolean;
45
+ /** `APP_URL` as this environment declares it; `undefined` when unset. */
46
+ readonly appUrl: string | undefined;
54
47
  /**
55
- * True while the local disk WOULD sign upload grants with the key shipped in the published
56
- * package. Same semantics as `devCursorSecret`, environment only — an app that passes an
57
- * explicit `signingSecret` in `app.config.ts` never consults the env var, so this can read true
58
- * for an app that is fine. The finding is worded as a condition to check, not a certainty.
48
+ * The framework's own secrets this machine lacks — `framework-env.ts`'s
49
+ * `machineSecretFindings`, gated by `namedDeployed`: only an environment NAMED staging or
50
+ * production (an unnamed developer shell owes nothing; `x env check` is the fail-closed answer).
51
+ * The list's own `needed`/`unsafe` decide; doctor reports.
59
52
  */
60
- readonly devStorageSecret: boolean;
61
- /** True when this process believes it is serving real clients. */
62
- readonly production: boolean;
53
+ readonly frameworkFindings: readonly Finding[];
63
54
  exists(relativePath: string): boolean;
64
55
  portFree(port: number): Promise<boolean>;
65
56
  /**
@@ -101,6 +92,11 @@ export interface DoctorProbe {
101
92
  * a retired key beside a sealed column means is `sealedKeyFindings`' pure rule.
102
93
  */
103
94
  sealedKeys(): Promise<SealedKeysFact>;
95
+ /**
96
+ * What an upgrade left in the auth tables for an operator to finish: unsealed second-factor
97
+ * secrets, and tables no release reads. Asked of an external database only.
98
+ */
99
+ authStorage(): Promise<AuthStorageFact>;
104
100
  }
105
101
 
106
102
  /** What `x doctor` reads about the embedded database, without opening it. */
@@ -181,7 +177,7 @@ async function portFindings(probe: DoctorProbe): Promise<readonly Finding[]> {
181
177
  finding(
182
178
  'X_PORT_IN_USE',
183
179
  `port ${entry.port} is already listening, and \`x dev --port ${probe.port}\` binds it for the ${entry.role} role`,
184
- `x dev --port ${portPairAfter(probe.port)}`,
180
+ `x dev --port ${quoteArg(String(portPairAfter(probe.port)))}`,
185
181
  ),
186
182
  );
187
183
  }
@@ -221,42 +217,16 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
221
217
  // `.env.example` is the committed projection of `envSchema`, so the copy lands every
222
218
  // declared key with its default and blank secrets; `x env example` writes it where an app
223
219
  // has none, and `X_ENV_EXAMPLE_DRIFT` is what reports that.
224
- `cp ${ENV_EXAMPLE_PATH} ${ENV_DEVELOPMENT}`,
220
+ `cp ${quoteArg(ENV_EXAMPLE_PATH)} ${quoteArg(ENV_DEVELOPMENT)}`,
225
221
  ENV_DEVELOPMENT,
226
222
  ),
227
223
  );
228
224
  }
229
- // Production only, and the gate is the point: every development environment signs with the
230
- // shipped key on purpose — that is what lets `x dev` page with no configuration — so an
231
- // unconditional finding would make `x doctor` red for every developer on day one and teach the
232
- // reader to skim past the report. The key is a defect only where cursors reach real clients,
233
- // who can read it out of the published package and forge a page position.
234
- // Sits here because it costs two env reads and a comparison — cheaper than binding a port.
235
- if (probe.production && probe.devCursorSecret) {
236
- findings.push(
237
- finding(
238
- 'X_CURSOR_SECRET_DEV',
239
- 'cursors are signed with the shipped development key, so a client can forge a page position',
240
- 'export ULTIMATE_CURSOR_SECRET="$(openssl rand -hex 32)"',
241
- ),
242
- );
243
- }
244
- // The storage twin of the cursor key above, and the more expensive one to get wrong: the
245
- // published string mints a signed `PUT` for any key with any `maxBytes` and `contentType`, and
246
- // `acceptSignedUpload` trusts the signed constraints over the app's own `uploadPolicy`.
247
- // Production only, for the reason the cursor check gives — every dev environment signs with the
248
- // shipped key on purpose. `@ultimat3/storage` refuses this at construction; `x doctor` is what
249
- // reports it before a deploy reaches the refusal.
250
- if (probe.production && probe.devStorageSecret) {
251
- findings.push(
252
- finding(
253
- 'X_STORAGE_SECRET_DEV',
254
- `${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`,
255
- 'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
256
- ),
257
- );
258
- }
225
+ // Silent in development and on a shell naming no environment; reported where this machine names
226
+ // a deploy. One list, one wording (`framework-env.ts`, #679).
227
+ findings.push(...probe.frameworkFindings);
259
228
  findings.push(...(await portFindings(probe)));
229
+ findings.push(...appUrlFindings(probe.appUrl, probe.port));
260
230
  // `@ultimat3/pwa`'s own codes, not CLI twins of them. `X_PWA_NO_ICON_SOURCE` and
261
231
  // `X_PWA_NO_FALLBACK` used to be declared here for the same two conditions the package already
262
232
  // names — two codes for one condition, one of them registered by nobody, so `x errors explain`
@@ -293,20 +263,28 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
293
263
  findings.push(...(await probe.snapshots()));
294
264
  // Sealed columns against the key ring: no key at all, or a rotation whose re-seal is pending.
295
265
  findings.push(...sealedKeyFindings(await probe.sealedKeys()));
266
+ findings.push(...authStorageFindings(await probe.authStorage()));
296
267
  return findings;
297
268
  }
298
269
 
299
270
  /**
300
- * The port to TEST, and the one place `x doctor` reads it. `PORT_RANGE.min` is 0 because `x dev
301
- * --port 0` means "let the kernel pick"; here 0 means nothing, and `Bun.serve({ port: 0 })` always
302
- * succeeds — so the port check could not fail, which is worse than not running it.
271
+ * The port to TEST, and the one place `x doctor` reads it: `devPortFor`'s order — `--port`, then
272
+ * `PORT` (`portFromEnv`, the one reader of it), then 3000 — so the probe answers about the port
273
+ * `x dev` will bind. It probed 3000 under `PORT=4000` while dev bound 4000 and 4001. `PORT_RANGE.min`
274
+ * is 0 because `x dev --port 0` means "let the kernel pick"; here 0 means nothing, and
275
+ * `Bun.serve({ port: 0 })` always succeeds — so the port check could not fail.
303
276
  */
304
- export const doctorPort = (args: ParsedArgs): number =>
305
- intFlagOr(
306
- args,
307
- { name: 'port', command: 'doctor', ...PORT_RANGE, min: 1, example: 'x doctor --port 3000' },
308
- DEFAULT_DOCTOR_PORT,
309
- );
277
+ export const doctorPort = (
278
+ args: ParsedArgs,
279
+ env: Readonly<Record<string, string | undefined>>,
280
+ ): number =>
281
+ readIntFlag(args, {
282
+ name: 'port',
283
+ command: 'doctor',
284
+ ...PORT_RANGE,
285
+ min: 1,
286
+ example: 'x doctor --port 3000',
287
+ }) ?? portFromEnv(env);
310
288
 
311
289
  /**
312
290
  * `DATABASE_URL` as BOTH halves of the database question read it: unset and blank are one case.
@@ -357,7 +335,7 @@ const installedAbove = (dir: string, specifier: string): boolean => {
357
335
  */
358
336
  async function probeDatabase(url: string | undefined): Promise<Finding | null> {
359
337
  if (externalUrl(url) === undefined) return null;
360
- const client = createPostgresClient({ url, applicationName: 'x-doctor' });
338
+ const client = postgresClient({ url, applicationName: 'x-doctor' });
361
339
  try {
362
340
  const report = await checkDb(client);
363
341
  if (report.ok) return null;
@@ -375,22 +353,20 @@ async function probeDatabase(url: string | undefined): Promise<Finding | null> {
375
353
  }
376
354
  }
377
355
 
378
- export function probeFor(cwd: string, bunVersion: string, port: number): DoctorProbe {
356
+ export function probeFor(
357
+ cwd: string,
358
+ bunVersion: string,
359
+ port: number,
360
+ // The environment `doctorPort` read `PORT` from, so `APP_URL` is judged against the same one.
361
+ env: Readonly<Record<string, string | undefined>> = process.env,
362
+ ): DoctorProbe {
379
363
  const root = findAppRoot(cwd)?.dir;
380
364
  return {
381
365
  bunVersion,
382
366
  root,
383
367
  port,
384
- devCursorSecret: usesDevCursorSecret(),
385
- devStorageSecret: usesDevStorageSecret(),
386
- // `ULTIMATE_ENV`, through core — the one key that says which deploy this is, with `NODE_ENV`
387
- // as its documented fallback. This read `X_ENV ?? NODE_ENV`, a spelling nothing else in the
388
- // repo reads, so a deploy declaring production the framework's own way was told it was not
389
- // production and skipped both secret findings; and the `??` short-circuited, so any non-empty
390
- // `X_ENV` shadowed a real `NODE_ENV=production` too. The non-throwing variant because
391
- // `ULTIMATE_ENV` is not in the env schema — nothing validates it at boot, and a diagnostic
392
- // that crashes on a typo is the one thing worse than a diagnostic that misses.
393
- production: tryResolveEnvironment() === 'production',
368
+ appUrl: env['APP_URL'],
369
+ frameworkFindings: machineSecretFindings(env),
394
370
  exists: (relativePath) => (root === undefined ? false : existsSync(join(root, relativePath))),
395
371
  portFree,
396
372
  database: () => probeDatabase(process.env['DATABASE_URL']),
@@ -408,14 +384,15 @@ export function probeFor(cwd: string, bunVersion: string, port: number): DoctorP
408
384
  root === undefined ? { fallback: null, routes: undefined } : offlineFallbackProbe(root),
409
385
  sealedKeys: async () =>
410
386
  root === undefined ? { columns: undefined, keys: undefined } : sealedKeysProbe(root),
387
+ authStorage: () => authStorageProbe(process.env['DATABASE_URL']),
411
388
  };
412
389
  }
413
390
 
414
391
  export const doctorCommand: CliCommand = {
415
392
  spec: doctorSpec,
416
393
  async run(ctx: CommandContext): Promise<CommandResult> {
417
- const port = doctorPort(ctx.args);
418
- const probe = probeFor(ctx.cwd, ctx.bunVersion, port);
394
+ const port = doctorPort(ctx.args, ctx.env);
395
+ const probe = probeFor(ctx.cwd, ctx.bunVersion, port, ctx.env);
419
396
  const findings = await runDoctor(probe);
420
397
  const listed = withGuardListing(
421
398
  {
package/src/cmd-env.ts CHANGED
@@ -4,15 +4,40 @@
4
4
 
5
5
  // Bun ships no path-join primitive, and `.env.example` is written app-root-relative.
6
6
  import { join } from 'node:path';
7
- import { checkEnv, ENV_EXAMPLE_PATH, ERROR_DOCS_URL, maskedEnvValues } from '@ultimat3/core';
7
+ import {
8
+ checkEnv,
9
+ ENV_EXAMPLE_PATH,
10
+ ERROR_DOCS_URL,
11
+ maskedEnvValues,
12
+ UltimateError,
13
+ } from '@ultimat3/core';
8
14
  import { envExampleFor, loadEnvSchema } from './app-env';
9
15
  import { requireAppRoot } from './app-root';
10
16
  import { envSpec } from './cmd-env-spec';
11
17
  import type { CliCommand, CommandContext } from './command';
12
- import { EnvSchemaMissingError } from './errors';
18
+ import { frameworkSecretFindings } from './framework-env';
13
19
  import { msg } from './messages';
14
20
  import type { CommandResult, Finding, JsonValue } from './output';
15
21
 
22
+ /**
23
+ * `x env` was run in an app whose `app.config.ts` exports no `envSchema`. Not a silent success:
24
+ * writing a `.env.example` with no variables in it, or reporting "0 declared variables, all
25
+ * present", both read as a working environment declaration to whoever runs the command next.
26
+ *
27
+ * `X_CONFIG_INVALID` is core's code for "a configuration this process cannot boot on — env or
28
+ * `app.config.ts`", which is exactly this; the CLI names it in `CLI_BORROWED_ERROR_CODES` rather
29
+ * than minting a synonym.
30
+ */
31
+ export class EnvSchemaMissingError extends UltimateError {
32
+ constructor(input: { subcommand: string }) {
33
+ super({
34
+ code: 'X_CONFIG_INVALID',
35
+ cause: `x env ${input.subcommand} needs the env declaration, and app.config.ts exports no "envSchema"`,
36
+ fix: "add to app.config.ts: export const envSchema = { DATABASE_URL: { type: 'url', description: 'Postgres connection URL' } } satisfies EnvSchema; export const env = defineEnv(envSchema);",
37
+ });
38
+ }
39
+ }
40
+
16
41
  /**
17
42
  * Every subcommand needs the declaration, and an app without one is a usage error rather than an
18
43
  * empty success: `x env example` writing a two-comment file would look like it worked.
@@ -43,33 +68,47 @@ async function writeExample(ctx: CommandContext): Promise<CommandResult> {
43
68
  }
44
69
 
45
70
  /**
46
- * The values are read from the real process environment, and only ever printed through
47
- * `maskedEnvValues` — `checkEnv().values` holds the actual secrets because `defineEnv()` has to
71
+ * The values are read from the environment the command was handed (`ctx.env`, the process's own
72
+ * in `bin.ts`) — one table for the app's keys and the framework's — and only ever printed through
73
+ * `maskedEnvValues`: `checkEnv().values` holds the actual secrets because `defineEnv()` has to
48
74
  * return them, and a `--json` report is the last place a DSN should appear in full.
75
+ *
76
+ * The framework's deploy-required secrets (`framework-env.ts`) are asked exactly where the boot
77
+ * refuses them — outside development/test, and where no environment is named at all — so `ULTIMATE_ENV=production x env check` answers what
78
+ * the first deployed boot would, instead of each role crash-looping on one key at a time (#679).
49
79
  */
50
80
  async function checkProcessEnv(ctx: CommandContext): Promise<CommandResult> {
51
81
  const { schema } = await requireSchema(ctx.cwd, 'check');
52
- const report = checkEnv(schema);
82
+ const report = checkEnv(schema, { env: ctx.env });
53
83
  const total = Object.keys(schema).length;
54
- const findings: readonly Finding[] = report.issues.map((issue) => ({
55
- code: 'X_ENV_MISSING',
56
- cause: `${issue.key} is ${issue.reason} (expected ${issue.expected})`,
57
- fix: issue.fix,
58
- docs: ERROR_DOCS_URL,
59
- at: ENV_EXAMPLE_PATH,
60
- }));
84
+ // A key the app's own declaration already reported is one finding, not two.
85
+ const reported = new Set(report.issues.map((issue) => issue.key));
86
+ // Gated by `deployed()` inside — the boot's own rule, so a table naming no environment is
87
+ // checked as the production process it boots as.
88
+ const framework = frameworkSecretFindings(ctx.env, reported);
89
+ const findings: readonly Finding[] = [
90
+ ...report.issues.map((issue) => ({
91
+ code: 'X_ENV_MISSING',
92
+ cause: `${issue.key} is ${issue.reason} (expected ${issue.expected})`,
93
+ fix: issue.fix,
94
+ docs: ERROR_DOCS_URL,
95
+ at: ENV_EXAMPLE_PATH,
96
+ })),
97
+ ...framework,
98
+ ];
99
+ const ok = findings.length === 0;
61
100
  return {
62
- ok: report.ok,
101
+ ok,
63
102
  command: 'env',
64
- summary: report.ok
103
+ summary: ok
65
104
  ? msg('cli.env.checked', { count: total })
66
- : msg('cli.env.invalid', { count: report.issues.length, total }),
105
+ : msg('cli.env.invalid', { count: findings.length, total }),
67
106
  findings,
68
107
  data: {
69
108
  variables: total,
70
109
  values: maskedEnvValues(schema, report.values) as JsonValue,
71
110
  },
72
- exitCode: report.ok ? 0 : 1,
111
+ exitCode: ok ? 0 : 1,
73
112
  };
74
113
  }
75
114
 
@@ -5,20 +5,23 @@
5
5
  import { existsSync } from 'node:fs';
6
6
  import { MANIFEST_FILENAME } from '@ultimat3/manifest';
7
7
  import { registerAdminResources } from './admin-registration';
8
- import { registerGeneratedPrimitives } from './api-registration';
8
+ import { indexBindingFindings, registerGeneratedPrimitives } from './api-registration';
9
9
  import { writeAppArtifacts } from './app-artifacts';
10
10
  import { appManifest } from './app-manifest';
11
11
  import { requireAppRoot } from './app-root';
12
12
  import { generateSpec } from './cmd-generate-spec';
13
13
  import type { CliCommand, CommandContext } from './command';
14
14
  import { invocationOf } from './command';
15
+ import { localiseCatalogs } from './generate-catalog-locales';
15
16
  import { assertFeatureExists } from './generate-feature';
16
17
  import { generate, sliceDir } from './generate-files';
17
18
  import { ungrantedByGenerator } from './generate-grant-findings';
18
19
  import { grantGeneratedPermissions } from './generate-grants';
19
20
  import type { Generator } from './generate-kinds';
20
21
  import { readFeature, readKind, readName, readPermission, readSurface } from './generate-kinds';
21
- import { containedPath, writeFiles } from './generate-write';
22
+ import { refusePluralTable } from './generate-plural';
23
+ import { refuseShadowedTypes, refuseShadowedValues } from './generate-shadow';
24
+ import { containedPath, planWrites, writeFiles } from './generate-write';
22
25
  import { declareGeneratedImports } from './generated-imports';
23
26
  import { registerGeneratedEntities, resolveDbModule } from './handle-registration';
24
27
  import { resolveCatalogModule } from './i18n-audit';
@@ -28,6 +31,7 @@ import { msg } from './messages';
28
31
  import type { CommandResult, Finding } from './output';
29
32
  import { flagBool, flagList, flagString } from './parse';
30
33
  import { quoteArg } from './shell-quote';
34
+ import type { GeneratedFile } from './templates';
31
35
  import { kebab, names, resolveLocales } from './templates';
32
36
 
33
37
  // One import path for the generator, unchanged by the split: `index.ts`, `x new` and the scaffold
@@ -40,6 +44,24 @@ export { GENERATORS } from './generate-kinds';
40
44
  export type { WriteReport } from './generate-write';
41
45
  export { dedupe, writeFiles } from './generate-write';
42
46
 
47
+ /**
48
+ * Names to suggest when the API index already holds the requested one, in order: the feature's
49
+ * prefix, the kind's suffix, then that suffix numbered — kebab output, so each pastes into a shell
50
+ * as one argument. The caller re-plans each and offers the first the index does not hold either.
51
+ */
52
+ const freeNames = (
53
+ kind: Generator,
54
+ name: string,
55
+ feature: string | undefined,
56
+ ): readonly string[] => {
57
+ const suffixed = `${kebab(name)}-${kind.split(':').at(-1) ?? kind}`;
58
+ return [
59
+ ...(feature === undefined ? [] : [`${kebab(feature)}-${kebab(name)}`]),
60
+ suffixed,
61
+ ...Array.from({ length: 8 }, (_unused, index) => `${suffixed}-${index + 2}`),
62
+ ];
63
+ };
64
+
43
65
  export const generateCommand: CliCommand = {
44
66
  spec: generateSpec,
45
67
  async run(ctx: CommandContext): Promise<CommandResult> {
@@ -72,41 +94,76 @@ export const generateCommand: CliCommand = {
72
94
  // THIS feature's own `entity.ts`/`repo.ts`, not a default the template gets to assume.
73
95
  const sliceEntity = await readSliceFile(root, kind, slice, 'entity.ts');
74
96
  const sliceRepo = await readSliceFile(root, kind, slice, 'repo.ts');
75
- const files = generate({
76
- kind,
77
- name,
78
- ...(featureFlag === undefined ? {} : { feature: featureFlag }),
79
- ...(sliceErrors === undefined ? {} : { sliceErrors }),
80
- ...(sliceEntity === undefined ? {} : { sliceEntity }),
81
- ...(sliceRepo === undefined ? {} : { sliceRepo }),
82
- ...(at === undefined ? {} : { at }),
83
- ...(permission === undefined ? {} : { permission }),
84
- surface,
85
- live: flagBool(ctx.args, 'live'),
86
- admin: flagBool(ctx.args, 'admin'),
87
- locales,
88
- ...(catalogModule === undefined ? {} : { catalogModule }),
89
- ...(dbModule === undefined ? {} : { dbModule }),
90
- shell,
91
- });
97
+ const planFor = (planned: string) =>
98
+ generate({
99
+ kind,
100
+ name: planned,
101
+ ...(featureFlag === undefined ? {} : { feature: featureFlag }),
102
+ ...(sliceErrors === undefined ? {} : { sliceErrors }),
103
+ ...(sliceEntity === undefined ? {} : { sliceEntity }),
104
+ ...(sliceRepo === undefined ? {} : { sliceRepo }),
105
+ ...(at === undefined ? {} : { at }),
106
+ ...(permission === undefined ? {} : { permission }),
107
+ surface,
108
+ live: flagBool(ctx.args, 'live'),
109
+ admin: flagBool(ctx.args, 'admin'),
110
+ locales,
111
+ ...(catalogModule === undefined ? {} : { catalogModule }),
112
+ ...(dbModule === undefined ? {} : { dbModule }),
113
+ shell,
114
+ });
115
+ // A non-default locale's strings arrive marked, and a locale with no catalog yet is refused
116
+ // with `x i18n add <locale>` — before a dry run answers, so it answers the same files.
117
+ const files = await localiseCatalogs(root, planFor(name));
118
+ // On the planned files, before a dry run answers or anything lands: a name whose type spelling
119
+ // the emitted code also uses as a global is a slice that does not compile.
120
+ refuseShadowedTypes(files, kind, name);
121
+ // And one whose binding a planned file both imports and declares (`x g job job`).
122
+ refuseShadowedValues(files, kind, name);
123
+ // And a plural slice name that an `entity.ts` this run writes would pluralise again.
124
+ refusePluralTable(files, kind, name, featureFlag, (path) =>
125
+ existsSync(containedPath(root, path)),
126
+ );
127
+ // The caller's own invocation, EVERY flag it set included: without `--feature` the fix wrote
128
+ // a second slice beside the one that conflicted.
129
+ const flags = reproducedFlags(generateCommand.spec, ctx.args);
130
+ const invocation = [invocationOf(ctx, 'g'), kind, quoteArg(name), ...flags].join(' ');
131
+ // A module the API index would list under a name it already holds: refused before anything is
132
+ // written, with the same run under a name that is free — re-planned and re-checked, so the
133
+ // suggestion is never itself a conflict.
134
+ const pathsOf = (planned: readonly GeneratedFile[]) => planned.map((file) => file.path);
135
+ const held = await indexBindingFindings(root, pathsOf(files), '');
136
+ let bindings: readonly Finding[] = [];
137
+ if (held.length > 0) {
138
+ let free = freeNames(kind, name, featureFlag).at(-1) ?? name;
139
+ for (const candidate of freeNames(kind, name, featureFlag)) {
140
+ if ((await indexBindingFindings(root, pathsOf(planFor(candidate)), '')).length > 0)
141
+ continue;
142
+ free = candidate;
143
+ break;
144
+ }
145
+ const renamed = [invocationOf(ctx, 'g'), kind, quoteArg(free), ...flags].join(' ');
146
+ bindings = held.map((finding) => ({ ...finding, fix: renamed }));
147
+ }
148
+ const force = flagBool(ctx.args, 'force');
92
149
  if (flagBool(ctx.args, 'dry-run')) {
150
+ // The write plan, never the bare file list: what the real run would write, skip and refuse.
151
+ const plan = await planWrites(root, files, force, invocation);
152
+ const findings = [...plan.conflicts, ...bindings];
153
+ const planned = findings.length === 0 ? plan.written : [];
93
154
  return {
94
- ok: true,
155
+ ok: findings.length === 0,
95
156
  command: 'g',
96
- summary: msg('cli.generate.planned', { count: files.length, kind, name }),
97
- data: { files: files.map((file) => file.path), dryRun: true },
98
- lines: files.map((file) => msg('cli.file.added', { path: file.path })),
157
+ summary: msg('cli.generate.planned', { count: planned.length, kind, name }),
158
+ data: { files: planned, dryRun: true },
159
+ lines: planned.map((file) => msg('cli.file.added', { path: file })),
160
+ findings,
99
161
  };
100
162
  }
101
- // The caller's own invocation, EVERY flag it set included: without `--feature` the fix wrote
102
- // a second slice beside the one that conflicted.
103
- const invocation = [
104
- invocationOf(ctx, 'g'),
105
- kind,
106
- quoteArg(name),
107
- ...reproducedFlags(generateCommand.spec, ctx.args),
108
- ].join(' ');
109
- const report = await writeFiles(root, files, flagBool(ctx.args, 'force'), invocation);
163
+ const report =
164
+ bindings.length > 0
165
+ ? { written: [], conflicts: bindings }
166
+ : await writeFiles(root, files, force, invocation);
110
167
  // The three edits a generated primitive needs outside its own slice, performed rather than
111
168
  // left as findings: a declared permission granted to a role, a job listed in `defineApi`, and
112
169
  // an entity added to the typed handle its `repo.ts` reads through. Before the manifest load
package/src/cmd-help.ts CHANGED
@@ -2,7 +2,9 @@
2
2
  // never describe a flag that does not exist. Built as a factory over the spec list to keep the
3
3
  // registry acyclic.
4
4
 
5
+ import { nearestName } from '@ultimat3/core';
5
6
  import type { CliCommand, CommandContext } from './command';
7
+ import { UnknownCommandError } from './errors';
6
8
  import { msg } from './messages';
7
9
  import type { CommandResult, JsonValue } from './output';
8
10
  import type { CommandSpec, FlagSpec } from './parse';
@@ -18,17 +20,28 @@ const flagLine = (flag: FlagSpec): string => {
18
20
  * The one resolution of a topic, read by both renderers. `--json` filtered on `spec.name === topic`
19
21
  * of its own, so the two disagreed about exactly the inputs a caller is least sure of: `x help
20
22
  * generate --json` answered `[]` — "that command does not exist" — while the page beside it printed
21
- * `g`, and `x help nosuch --json` answered `[]` while the page printed the whole catalogue.
23
+ * `g`. A topic naming no command is refused, as `x <topic>` itself is: answering the catalogue
24
+ * with exit 0 read as that command's usage to a caller that never compared the names.
22
25
  */
23
26
  const specFor = (
24
27
  specs: readonly CommandSpec[],
25
28
  topic: string | undefined,
26
- ): CommandSpec | undefined =>
27
- topic === undefined
28
- ? undefined
29
- : specs.find((entry) => entry.name === topic || (entry.aliases ?? []).includes(topic));
29
+ ): CommandSpec | undefined => {
30
+ if (topic === undefined) return undefined;
31
+ const found = specs.find(
32
+ (entry) => entry.name === topic || (entry.aliases ?? []).includes(topic),
33
+ );
34
+ if (found !== undefined) return found;
35
+ const names = specs.map((entry) => entry.name);
36
+ const nearest = nearestName(topic, [...names, ...specs.flatMap((entry) => entry.aliases ?? [])]);
37
+ throw new UnknownCommandError({
38
+ path: `help ${topic}`,
39
+ known: names,
40
+ suggestion: nearest === undefined ? 'help' : `help ${nearest}`,
41
+ });
42
+ };
30
43
 
31
- /** What `--json` reports: the one resolved command, or — as the human render does — all of them. */
44
+ /** What `--json` reports: the one resolved command, or — with no topic — all of them. */
32
45
  export function helpTopic(
33
46
  specs: readonly CommandSpec[],
34
47
  topic: string | undefined,
package/src/cmd-i18n.ts CHANGED
@@ -185,7 +185,7 @@ async function runAdd(root: string, ctx: CommandContext): Promise<CommandResult>
185
185
  const path = catalogPath(locale);
186
186
  const app = await loadApp(root);
187
187
  const catalogs = await loadCatalogs(root);
188
- const from = resolveDefaultLocale(app.defaultLocale, catalogs);
188
+ const from = resolveDefaultLocale(app.locales.defaultLocale, catalogs);
189
189
  const seeded = seedCatalog(from === undefined ? {} : (catalogs[from] ?? {}));
190
190
  await writeNewCatalog(join(root, path), locale, serializeCatalog(seeded));
191
191
  // The second half of adding a locale, and without it this command SHIPPED A RED GATE: the file
@@ -196,14 +196,15 @@ async function runAdd(root: string, ctx: CommandContext): Promise<CommandResult>
196
196
  const { registered, findings: indexFindings } = await syncI18nIndex(root);
197
197
 
198
198
  const keys = catalogKeys(seeded).length;
199
+ // The catalog is on disk either way, but a finding fails the run: a `packages/i18n/src/index.ts`
200
+ // that would not import is why `from` may be the framework's `en` instead of the app's own
201
+ // default, and an index the locale could not be registered in is a red `i18n` gate step.
202
+ const findings = [...indexFindings, ...app.findings];
199
203
  return {
200
- // The catalog is on disk, so the command did what it was asked. `loadApp`'s findings ride along
201
- // rather than flip that: a `packages/i18n/src/index.ts` that would not import is why `from` may
202
- // be the framework's `en` instead of the app's own default, and silence there is the bug.
203
- ok: true,
204
+ ok: findings.length === 0,
204
205
  command: 'i18n',
205
206
  summary: msg('cli.i18n.added', { locale, keys, from: from ?? locale }),
206
- findings: [...indexFindings, ...app.findings],
207
+ findings,
207
208
  // `registered` is false only for an app with no `packages/i18n` at all — a fact a caller has
208
209
  // to be able to read, because it is the one case where the locale is on disk and unselectable.
209
210
  data: { locale, from: from ?? locale, keys, path, registered },
@@ -258,13 +259,15 @@ async function runSync(root: string, ctx: CommandContext): Promise<CommandResult
258
259
  }
259
260
 
260
261
  const app = await loadApp(root);
261
- const from = resolveDefaultLocale(app.defaultLocale, catalogs);
262
+ const from = resolveDefaultLocale(app.locales.defaultLocale, catalogs);
262
263
  // `from === locale` is the default locale asked to sync itself, and `undefined` is an app with
263
264
  // no resolvable default at all — one branch, because both have no catalog to merge from.
265
+ // A real merge copies the default locale's strings MARKED (`seedCatalog`, as `x i18n add` does):
266
+ // copied bare they rendered English to every `fr` reader under a green gate.
264
267
  const seeded = from === undefined || from === locale;
265
268
  const source = seeded
266
269
  ? placeholderCatalog(await untranslatedKeys(root, catalogs, locale))
267
- : (catalogs[from] ?? {});
270
+ : seedCatalog(catalogs[from] ?? {});
268
271
  const { merged, added } = syncCatalog(target, source);
269
272
  if (added.length > 0) await Bun.write(join(root, catalogPath(locale)), serializeCatalog(merged));
270
273
  // Unconditional, and not only when keys moved: a catalog that reached disk some other way — a
@@ -274,16 +277,16 @@ async function runSync(root: string, ctx: CommandContext): Promise<CommandResult
274
277
  const { registered, findings: indexFindings } = await syncI18nIndex(root);
275
278
 
276
279
  const total = catalogKeys(merged).length;
280
+ // Same as `runAdd`: the merge landed, and a finding still fails the run.
281
+ const findings = [...indexFindings, ...app.findings];
277
282
  return {
278
- // Same as `runAdd`: the merge landed, and `loadApp`'s findings say whether `from` is the app's
279
- // own default or the framework's fallback standing in for an i18n module that would not import.
280
- ok: true,
283
+ ok: findings.length === 0,
281
284
  command: 'i18n',
282
285
  summary: msg('cli.i18n.synced', { locale, from: from ?? locale, added: added.length, total }),
283
286
  // The keys themselves, raw — `runCheck` lists gaps the same way, because a key is a value an
284
- // author copies and never prose the catalog owns.
285
- lines: seeded ? added.map((key) => ` ${key}`) : [],
286
- findings: [...indexFindings, ...app.findings],
287
+ // author copies and never prose the catalog owns. Every added key is one a human still owes.
288
+ lines: added.map((key) => ` ${key}`),
289
+ findings,
287
290
  data: {
288
291
  locale,
289
292
  from: from ?? locale,
@@ -291,9 +294,10 @@ async function runSync(root: string, ctx: CommandContext): Promise<CommandResult
291
294
  total,
292
295
  path: catalogPath(locale),
293
296
  registered,
294
- // Which of `added` still need a human. Empty on a real merge, where every value is a real
295
- // string copied from the default locale — `--json` must be able to tell the two apart.
296
- placeholders: seeded ? added : [],
297
+ // Which of `added` still need a human: all of them. `⟦key⟧` where there was no catalog to
298
+ // copy from, `⟦<default string>⟧` on a real merge — both are what `x i18n check` counts as
299
+ // missing until replaced, so `--json` never reports a copy as a translation.
300
+ placeholders: added,
297
301
  },
298
302
  };
299
303
  }