@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-build.ts CHANGED
@@ -4,19 +4,21 @@
4
4
 
5
5
  import { existsSync } from 'node:fs';
6
6
  import { join, resolve } from 'node:path';
7
- import { ERROR_DOCS_URL, frameworkVersion, VERSION_DEFINE } from '@ultimat3/core';
7
+ import { ERROR_DOCS_URL, frameworkVersion, UltimateError, VERSION_DEFINE } from '@ultimat3/core';
8
8
  import { requireAppRoot } from './app-root';
9
9
  import { buildSpec } from './cmd-build-spec';
10
10
  import { runVerify } from './cmd-verify';
11
11
  import type { CliCommand, CommandContext } from './command';
12
12
  import { externalArgs } from './compile-externals';
13
- import { BadFlagError, BuildEntryMissingError, UnknownCommandError } from './errors';
13
+ import { BadFlagError, UnknownCommandError } from './errors';
14
14
  import type { ExecResult } from './exec';
15
15
  import { execOutput } from './exec';
16
16
  import { msg } from './messages';
17
17
  import type { CommandResult } from './output';
18
+ import type { ParsedArgs } from './parse';
18
19
  import { flagString } from './parse';
19
20
  import { PREBUILT_DIR } from './serve-prebuilt-paths';
21
+ import { quoteArg } from './shell-quote';
20
22
  import type { StaticReport } from './static-report';
21
23
  import {
22
24
  readStaticReport,
@@ -24,6 +26,22 @@ import {
24
26
  renderStaticReport,
25
27
  staticReportData,
26
28
  } from './static-report';
29
+ import type { VerifyStepName } from './verify-step';
30
+
31
+ /**
32
+ * A build target names an entry file the app does not have. `x build` refuses before it spawns the
33
+ * builder: `bun build`'s own "module not found" says nothing about which file an Ultimate app is
34
+ * supposed to own, and `docker build`'s says nothing about which target wanted it.
35
+ */
36
+ export class BuildEntryMissingError extends UltimateError {
37
+ constructor(input: { target: string; entry: string }) {
38
+ super({
39
+ code: 'X_BUILD_ENTRY_MISSING',
40
+ cause: `x build --target ${input.target} builds from ${input.entry}, and the app does not have it`,
41
+ fix: `x new scratch-app --dry-run --json # its file list carries ${input.entry}; copy that file into this app`,
42
+ });
43
+ }
44
+ }
27
45
 
28
46
  export const BUILD_TARGETS = ['docker', 'binary', 'static', 'prebuilt'] as const;
29
47
 
@@ -85,24 +103,94 @@ export function dockerArgs(root: string, tag: string, buildId: string): readonly
85
103
  }
86
104
 
87
105
  /**
88
- * The define is not optional. A single-file executable carries no `package.json`, so
89
- * `frameworkVersion()` has nothing to read and throws — which is exactly how this target came to
90
- * compile an artifact that could never boot. The value is this CLI's own `@ultimat3/core`, which is
91
- * the app's too: the packages release in lockstep and `x new` pins them together.
106
+ * What `--platform` may name: Bun's own `--target` spellings, so the flag maps onto Bun's one to
107
+ * one and an agent who knows Bun's list knows this one. Four, not Bun's every target — these are
108
+ * the hosts the framework is tested on; a musl or baseline build is `bun build` by hand.
109
+ */
110
+ export const BINARY_PLATFORMS = [
111
+ 'bun-linux-x64',
112
+ 'bun-linux-arm64',
113
+ 'bun-windows-x64',
114
+ 'bun-darwin-arm64',
115
+ ] as const;
116
+
117
+ export type BinaryPlatform = (typeof BINARY_PLATFORMS)[number];
118
+
119
+ /** `--platform`, checked before the gate runs: an unknown value is a typo, never a guess. */
120
+ export function readPlatform(raw: string | undefined): BinaryPlatform | undefined {
121
+ if (raw === undefined) return undefined;
122
+ const known: readonly string[] = BINARY_PLATFORMS;
123
+ if (known.includes(raw)) return raw as BinaryPlatform;
124
+ throw new BadFlagError({
125
+ flag: 'platform',
126
+ command: 'build',
127
+ reason: `${quoteArg(raw)} is not a platform x build compiles for — one of ${BINARY_PLATFORMS.join(', ')}`,
128
+ fix: 'x build --target binary --platform bun-linux-x64',
129
+ });
130
+ }
131
+
132
+ /**
133
+ * The path the executable really lands at. Bun names a Windows executable `<out>.exe` — whether
134
+ * `--platform bun-windows-x64` asked for it or the host is Windows and no platform was named — so
135
+ * the `.exe` is appended HERE and passed to `--outfile`, and the path reported is the path written.
136
+ */
137
+ export function binaryOutfile(
138
+ out: string,
139
+ platform: BinaryPlatform | undefined,
140
+ host: string = process.platform,
141
+ ): string {
142
+ const windows = platform === undefined ? host === 'win32' : platform.startsWith('bun-windows-');
143
+ return windows && !out.toLowerCase().endsWith('.exe') ? `${out}.exe` : out;
144
+ }
145
+
146
+ /**
147
+ * The define answers `frameworkVersion()` for framework code BUNDLED into an executable, where no
148
+ * `package.json` exists — the reason this target once compiled an artifact that could never boot,
149
+ * and why `docker/Dockerfile`'s self-contained `x` still needs it. Here nothing framework is
150
+ * bundled any more (`LAUNCHER_EXTERNALS`): `@ultimat3/core` loads from the app's `node_modules` and
151
+ * reads its own manifest, so the define reaches no module of this graph. It stays as a pin on that
152
+ * one version, this CLI's own — the packages release in lockstep and `x new` pins them together.
92
153
  *
93
154
  * `externalArgs()` is not optional either, and for the opposite reason: it names the one specifier
94
155
  * this graph must NOT resolve. `apps/web/server.ts` reaches `serve.ts`, which reaches the island
95
156
  * builder, which reaches `@babel/core` — whose `.cts`-config loader requires a package we
96
157
  * deliberately do not install. Bun 1.3 fails the compile on it. See `compile-externals.ts`.
158
+ *
159
+ * The two autoload flags are what make the binary a LAUNCHER at all. It imports the app tree it is
160
+ * started in at run time — `app.config.ts`, then every module under `apps/*` — and a standalone
161
+ * executable reads neither `package.json` nor `tsconfig.json` at run time by default (Bun:
162
+ * `--compile-autoload-{package-json,tsconfig}`, both off). With no `package.json` the resolver
163
+ * ignores `exports`, so every `@ultimat3/*` (`exports` → `./src/index.ts`) was
164
+ * `Cannot find module '@ultimat3/core' from '<app>/app.config.ts'` and the binary never answered
165
+ * `/healthz` — on Windows, where CI first ran it, and on Linux alike. With no `tsconfig.json` the
166
+ * app's own `paths` aliases fail the same way. `e2e/binary-launch.e2e.test.ts` compiles and runs it.
167
+ */
168
+ /**
169
+ * The framework is resolved from the app tree at run time, never bundled into the binary. The app's
170
+ * own modules import `@ultimat3/*` from its `node_modules` — they are loaded off disk, after the
171
+ * build — so a bundled copy was a SECOND instance of every package: the app's `configureAuthenticator`
172
+ * filled the disk copy's slot while the bundled server read its own, empty one (measured:
173
+ * `X_CONFIG_INVALID … no authenticator is configured` on a scaffold that installs one), and every
174
+ * registry split the same way. One instance is the one the app's imports reach.
97
175
  */
98
- export function binaryArgs(root: string, out: string): readonly string[] {
176
+ const LAUNCHER_EXTERNALS: readonly string[] = ['@ultimat3/*'];
177
+
178
+ export function binaryArgs(
179
+ root: string,
180
+ out: string,
181
+ platform?: BinaryPlatform,
182
+ ): readonly string[] {
99
183
  return [
100
184
  'bun',
101
185
  'build',
102
186
  '--compile',
103
187
  '--minify',
188
+ ...(platform === undefined ? [] : ['--target', platform]),
104
189
  '--define',
105
190
  `${VERSION_DEFINE}=${JSON.stringify(frameworkVersion())}`,
191
+ '--compile-autoload-package-json',
192
+ '--compile-autoload-tsconfig',
193
+ ...LAUNCHER_EXTERNALS.flatMap((specifier) => ['--external', specifier]),
106
194
  ...externalArgs(),
107
195
  join(root, BUILD_ENTRY.binary),
108
196
  '--outfile',
@@ -122,10 +210,12 @@ export function argsFor(
122
210
  readonly out: string;
123
211
  /** The docker target's `BUILD_ID` build arg — `appManifest(root)`'s own. */
124
212
  readonly buildId?: string;
213
+ /** The binary target's `--platform`, as Bun's `--target`. */
214
+ readonly platform?: BinaryPlatform;
125
215
  },
126
216
  ): readonly string[] {
127
217
  if (target === 'docker') return dockerArgs(paths.root, paths.tag, paths.buildId ?? 'dev');
128
- if (target === 'binary') return binaryArgs(paths.root, paths.out);
218
+ if (target === 'binary') return binaryArgs(paths.root, paths.out, paths.platform);
129
219
  return staticArgs(paths.root, paths.out);
130
220
  }
131
221
 
@@ -158,6 +248,8 @@ export function buildResult(input: {
158
248
  readonly command: readonly string[];
159
249
  readonly result: ExecResult;
160
250
  readonly report?: StaticReport;
251
+ /** The binary target's `--platform`, when one was named. */
252
+ readonly platform?: BinaryPlatform;
161
253
  }): CommandResult {
162
254
  const { report, result, target } = input;
163
255
  const output = result.ok ? '' : execOutput(result);
@@ -177,6 +269,7 @@ export function buildResult(input: {
177
269
  ],
178
270
  data: {
179
271
  target,
272
+ ...(input.platform === undefined ? {} : { platform: input.platform }),
180
273
  artifact: input.artifact,
181
274
  durationMs: result.durationMs,
182
275
  ...(result.ok ? {} : { output }),
@@ -190,6 +283,47 @@ export function buildResult(input: {
190
283
  };
191
284
  }
192
285
 
286
+ /** The flags only some targets read. */
287
+ const TARGET_SCOPED_FLAGS = ['tag', 'out', 'platform', 'preflight'] as const;
288
+
289
+ type TargetScopedFlag = (typeof TARGET_SCOPED_FLAGS)[number];
290
+
291
+ /** The optional flags each target READS. One table, so a flag cannot be read by one and ignored. */
292
+ const TARGET_FLAGS: ReadonlyMap<BuildTarget, readonly TargetScopedFlag[]> = new Map([
293
+ ['docker', ['tag', 'preflight']],
294
+ ['binary', ['out', 'platform', 'preflight']],
295
+ ['static', ['out', 'preflight']],
296
+ // The boot reads ONE place, and the image holds no devDependencies to run a gate with.
297
+ ['prebuilt', []],
298
+ ]);
299
+
300
+ /** Why a target takes no `--<flag>`, in the words its refusal prints. */
301
+ const UNREAD_BECAUSE: Readonly<Record<TargetScopedFlag, string>> = {
302
+ tag: 'only the docker target tags an image',
303
+ out: 'the docker target writes an image, not a path',
304
+ platform: 'only the binary target compiles an executable for a platform',
305
+ preflight: 'the prebuilt target runs no gate: `x verify` ran before `docker build` was called',
306
+ };
307
+
308
+ /**
309
+ * A flag the chosen target never reads is refused before the gate runs. Accepted and dropped, it
310
+ * printed a green build: `--out dist` on `docker` and `--tag` on `binary` read as if they landed.
311
+ */
312
+ function refuseUnreadFlags(args: ParsedArgs, target: BuildTarget): void {
313
+ for (const flag of TARGET_SCOPED_FLAGS) {
314
+ if (!args.flags.has(flag) || TARGET_FLAGS.get(target)?.includes(flag) === true) continue;
315
+ throw new BadFlagError({
316
+ flag,
317
+ command: 'build',
318
+ reason:
319
+ target === 'prebuilt' && flag !== 'preflight'
320
+ ? `the prebuilt target writes ${PREBUILT_DIR}, the one place a container boots from, and takes no --${flag}`
321
+ : `--target ${target} never reads it: ${UNREAD_BECAUSE[flag]}`,
322
+ fix: `x build --target ${quoteArg(target)}`,
323
+ });
324
+ }
325
+ }
326
+
193
327
  /**
194
328
  * `x build --target prebuilt`: the island chunks and compiled stylesheets, written by the process
195
329
  * `docker build` runs after the source is copied in. No gate and no subprocess — the image holds
@@ -198,17 +332,7 @@ export function buildResult(input: {
198
332
  * A module that would not import fails the image build rather than warning into a build log
199
333
  * nobody reads: its stylesheets are missing from the store, and every pod would compile them.
200
334
  */
201
- async function buildPrebuilt(ctx: CommandContext, root: string): Promise<CommandResult> {
202
- // The boot reads ONE place; a flag that seemed to move it would write a store no pod opens.
203
- for (const flag of ['tag', 'out']) {
204
- if (flagString(ctx.args, flag) === undefined) continue;
205
- throw new BadFlagError({
206
- flag,
207
- command: 'build',
208
- reason: `the prebuilt target writes ${PREBUILT_DIR}, the one place a container boots from, and takes no --${flag}`,
209
- fix: 'x build --target prebuilt',
210
- });
211
- }
335
+ async function buildPrebuilt(root: string): Promise<CommandResult> {
212
336
  const started = Bun.nanoseconds();
213
337
  const { prebuildImage } = await import('./image-prepare');
214
338
  const built = await prebuildImage(root);
@@ -230,7 +354,7 @@ async function buildPrebuilt(ctx: CommandContext, root: string): Promise<Command
230
354
  }
231
355
 
232
356
  /** The static steps `x build` runs before it builds, and `--no-preflight` leaves to the gate. */
233
- export const PREFLIGHT_STEPS: readonly string[] = [
357
+ export const PREFLIGHT_STEPS: readonly VerifyStepName[] = [
234
358
  'typecheck',
235
359
  'lint',
236
360
  'boundaries',
@@ -248,7 +372,9 @@ export const buildCommand: CliCommand = {
248
372
  // typecheck, and eight seconds of `tsc` ahead of "that file does not exist" is eight seconds
249
373
  // an agent spends on the wrong question.
250
374
  requireEntry(root, target);
251
- if (target === 'prebuilt') return buildPrebuilt(ctx, root);
375
+ refuseUnreadFlags(ctx.args, target);
376
+ const platform = readPlatform(flagString(ctx.args, 'platform'));
377
+ if (target === 'prebuilt') return buildPrebuilt(root);
252
378
 
253
379
  // Run static verify steps before building — unless the caller is a gate that runs the same
254
380
  // six steps itself right after (`bin/check`: `x build --no-preflight && x verify`), where the
@@ -268,10 +394,11 @@ export const buildCommand: CliCommand = {
268
394
  // A relative `--out` is a path the caller typed from where they stand, so it resolves against
269
395
  // the cwd — against the root it landed somewhere else whenever `x build` ran from `apps/web`.
270
396
  const outFlag = flagString(ctx.args, 'out');
271
- const out =
397
+ const requested =
272
398
  outFlag === undefined
273
399
  ? join(root, '.x', target === 'static' ? 'static' : 'app')
274
400
  : resolve(ctx.cwd, outFlag);
401
+ const out = target === 'binary' ? binaryOutfile(requested, platform) : requested;
275
402
  const tag = flagString(ctx.args, 'tag') ?? 'ultimate-app:dev';
276
403
  // The docker target stamps the manifest's build id into the image, so no role derives one at
277
404
  // boot. The island chunks and stylesheets are the image build's own (`--target prebuilt`).
@@ -282,6 +409,7 @@ export const buildCommand: CliCommand = {
282
409
  tag,
283
410
  out,
284
411
  ...(buildId === undefined ? {} : { buildId }),
412
+ ...(platform === undefined ? {} : { platform }),
285
413
  });
286
414
  // Removed BEFORE the builder runs, so a build that writes no inventory can never be reported
287
415
  // with the last one's: a stale emitted list is worse than none, because it reads as this run's.
@@ -297,6 +425,7 @@ export const buildCommand: CliCommand = {
297
425
  command,
298
426
  result,
299
427
  ...(report === undefined ? {} : { report }),
428
+ ...(platform === undefined ? {} : { platform }),
300
429
  });
301
430
  },
302
431
  };
@@ -23,7 +23,7 @@ import { BadFlagError } from './errors';
23
23
  import { withJobDriver } from './jobs-driver';
24
24
  import { backfillToJson } from './jobs-json';
25
25
  import { msg } from './messages';
26
- import type { CommandResult } from './output';
26
+ import type { CommandResult, Finding } from './output';
27
27
  import { findingFrom } from './output';
28
28
  import type { ParsedArgs } from './parse';
29
29
  import { flagBool, flagString } from './parse';
@@ -106,7 +106,7 @@ function readShape(args: ParsedArgs): { readonly shape: BackfillShape; readonly
106
106
  return refuseShape(
107
107
  second.startsWith('--') ? second.slice(2) : 'name',
108
108
  `x db backfill was asked for ${asked.join(' and ')} — one shape per invocation: --list, --pending, <name> or --all`,
109
- `x db backfill ${asked[0]} --json`,
109
+ `x db backfill ${quoteArg(asked[0] ?? '--pending')} --json`,
110
110
  );
111
111
  }
112
112
  if (asked.length === 0) {
@@ -171,15 +171,22 @@ async function runBackfillList(ctx: CommandContext, root: string): Promise<Comma
171
171
  * The alarm the framework did not have. Non-zero when anything is unswept, so a cron or a deploy
172
172
  * check can read the exit code — a `--json` nobody has to parse to know something is wrong.
173
173
  * `loadApp` first: importing the app's modules IS the declaration, and a diff run without it
174
- * would report a clean database against an empty declaration list.
174
+ * would report a clean database against an empty declaration list. Its findings fail the run for
175
+ * the same reason: a module that would not import may be the one declaring the unswept backfill.
175
176
  */
176
177
  async function runBackfillPending(ctx: CommandContext, root: string): Promise<CommandResult> {
177
- await loadApp(root);
178
+ const { findings: loadFindings } = await loadApp(root);
178
179
  const environment = resolveEnvironment({ env: ctx.env });
179
180
  return withJobDriver(root, ctx, async (driver) => {
180
181
  const report = await pendingReport(driver, environment);
182
+ const findings = [
183
+ ...report.pending.map((row) =>
184
+ findingFrom(new BackfillPendingError({ backfill: row.name, environment })),
185
+ ),
186
+ ...loadFindings,
187
+ ];
181
188
  return {
182
- ok: report.pending.length === 0,
189
+ ok: findings.length === 0,
183
190
  command: 'db',
184
191
  summary:
185
192
  report.pending.length === 0
@@ -188,9 +195,7 @@ async function runBackfillPending(ctx: CommandContext, root: string): Promise<Co
188
195
  count: report.pending.length,
189
196
  declared: report.rows.length,
190
197
  }),
191
- findings: report.pending.map((row) =>
192
- findingFrom(new BackfillPendingError({ backfill: row.name, environment })),
193
- ),
198
+ findings,
194
199
  lines: report.rows.length === 0 ? [] : renderPendingTable(report).map((line) => ` ${line}`),
195
200
  data: pendingToJson(report),
196
201
  };
@@ -208,7 +213,7 @@ async function runBackfillPass(
208
213
  root: string,
209
214
  names: readonly string[] | 'all',
210
215
  ): Promise<CommandResult> {
211
- await loadApp(root);
216
+ const { findings: loadFindings } = await loadApp(root);
212
217
  const environment = resolveEnvironment({ env: ctx.env });
213
218
  const write = flagBool(ctx.args, 'write');
214
219
  return withJobDriver(root, ctx, async (driver) => {
@@ -220,16 +225,24 @@ async function runBackfillPass(
220
225
  environment,
221
226
  appliedMigrations: await readAppliedMigrations(),
222
227
  });
223
- return backfillPassResult(rows, write);
228
+ return backfillPassResult(rows, write, loadFindings);
224
229
  });
225
230
  }
226
231
 
227
232
  /**
228
233
  * A blocked or deduped name is a finding and a non-zero exit, and every OTHER name still ran —
229
- * that isolation is what stops one wedged cleanup blocking every later one forever.
234
+ * that isolation is what stops one wedged cleanup blocking every later one forever. A module that
235
+ * would not import fails the pass too: `--all` over it plans every backfill but the unloaded ones.
230
236
  */
231
- function backfillPassResult(rows: readonly BackfillPlanRow[], write: boolean): CommandResult {
232
- const findings = rows.flatMap((row) => (row.finding === null ? [] : [row.finding]));
237
+ function backfillPassResult(
238
+ rows: readonly BackfillPlanRow[],
239
+ write: boolean,
240
+ loadFindings: readonly Finding[],
241
+ ): CommandResult {
242
+ const findings = [
243
+ ...rows.flatMap((row) => (row.finding === null ? [] : [row.finding])),
244
+ ...loadFindings,
245
+ ];
233
246
  // Counted per action, never derived from the total: a deduped pass is neither enqueued nor
234
247
  // blocked, and `rows.length - enqueued` reported it as blocked while `--json` reported it as
235
248
  // deduped. `planToJson` is the same list, so the two renders now add up to the same run.
@@ -4,7 +4,7 @@
4
4
  // The facts (what a branch is, per mode) are `db-branch.ts`; the client lifetime is here.
5
5
 
6
6
  import { ERROR_DOCS_URL, nearestName } from '@ultimat3/core';
7
- import { createPostgresClient, type DbClient } from '@ultimat3/db';
7
+ import { type DbClient, postgresClient } from '@ultimat3/db';
8
8
  import type { CommandContext } from './command';
9
9
  import type { BranchRow } from './db-branch';
10
10
  import {
@@ -62,7 +62,7 @@ function branchRetry(word: string, name: string | undefined): string {
62
62
  * database routinely outlives the 10s a `web` profile allows.
63
63
  */
64
64
  async function withBranchClient<T>(url: string, fn: (client: DbClient) => Promise<T>): Promise<T> {
65
- const client = createPostgresClient({ url, role: 'migrate', applicationName: 'x-db-branch' });
65
+ const client = postgresClient({ url, role: 'migrate', applicationName: 'x-db-branch' });
66
66
  try {
67
67
  return await fn(client);
68
68
  } finally {
package/src/cmd-db.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  // directory tree. `node:path` for `join` — Bun exposes no path joiner.
12
12
  import { rm } from 'node:fs/promises';
13
13
  import { join } from 'node:path';
14
- import { resolveEnvironment } from '@ultimat3/core';
14
+ import { NotImplementedError, resolveEnvironment } from '@ultimat3/core';
15
15
  import { type DriftReport, driftError, withTransaction } from '@ultimat3/db';
16
16
  import { postgresDriver } from '@ultimat3/entity';
17
17
  import { requireAppRoot } from './app-root';
@@ -40,7 +40,7 @@ import {
40
40
  selectSeeds,
41
41
  } from './db-seed';
42
42
  import { DevAlreadyRunningError, liveDevLock } from './dev-lock';
43
- import { CliNotImplementedError, MissingSubcommandError, UnknownCommandError } from './errors';
43
+ import { MissingSubcommandError, UnknownCommandError } from './errors';
44
44
  import { withJobDriver } from './jobs-driver';
45
45
  import { msg } from './messages';
46
46
  import type { CommandResult, Finding } from './output';
@@ -254,9 +254,12 @@ async function runMigrate(
254
254
  async function runReset(ctx: CommandContext, root: string): Promise<CommandResult> {
255
255
  const services = resolveServices(root, ctx.env);
256
256
  if (services.db.mode === 'external') {
257
- throw new CliNotImplementedError({
258
- feature: 'x db reset against an external Postgres',
259
- fix: 'drop and recreate the database yourself, then run: x db migrate',
257
+ throw new NotImplementedError({
258
+ // The usual way here is an EXPORTED `DATABASE_URL` (an integration suite's Postgres) that
259
+ // the shell handed down, so the variable is named and the embedded reset is one command (#674).
260
+ cause:
261
+ 'DATABASE_URL is set, so x db reset would target an external Postgres — dropping a database this process does not own is not implemented in this build',
262
+ fix: 'env -u DATABASE_URL x db reset # resets the embedded database instead — or drop and recreate the external one yourself, then run: x db migrate',
260
263
  });
261
264
  }
262
265
  // A running `x dev` has this directory open: deleting it under a live embedded Postgres is data
@@ -301,7 +304,8 @@ async function runSeed(ctx: CommandContext, root: string): Promise<CommandResult
301
304
  });
302
305
  if (chosen.length === 0) {
303
306
  return {
304
- ok: true,
307
+ // A seed module that would not import may hold the seed that was asked for.
308
+ ok: discovery.findings.length === 0,
305
309
  command: 'db',
306
310
  summary: msg('cli.db.seed.none'),
307
311
  findings: discovery.findings,
@@ -0,0 +1,94 @@
1
+ // The compose half of `x deploy`'s rollout: which serving roles can start their new container
2
+ // BEFORE the old one stops, read off the app's compose file. The sequence that does it is
3
+ // `cmd-deploy-start-first.ts`.
4
+ // `docker compose up -d <role>` alone is stop-first — it recreates, so the role serves nothing
5
+ // between the old container's exit and the new one's healthcheck.
6
+
7
+ /**
8
+ * How one serving role is rolled. `start-first` is the zero-downtime path; `stop-first` is
9
+ * compose's own recreate, kept where two containers of the role cannot coexist, with the reason.
10
+ */
11
+ export type ComposeStrategy =
12
+ | { readonly kind: 'start-first'; readonly replicas: number }
13
+ | { readonly kind: 'stop-first'; readonly why: string };
14
+
15
+ /** The roles that stay up serving — the only ones a rollout strategy applies to. */
16
+ export const SERVING_ROLES = ['web', 'sync', 'worker', 'scheduler'] as const;
17
+
18
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
19
+ typeof value === 'object' && value !== null && !Array.isArray(value);
20
+
21
+ /**
22
+ * The host port a `ports:` entry pins, or `undefined` when Docker picks it. Short syntax is
23
+ * `[[ip:]host:]container[/proto]`: `3000` and `127.0.0.1::3000` leave the host side to Docker, so
24
+ * any number of replicas bind; `3000:3000` and `127.0.0.1:3001:3001` name one, which exactly one
25
+ * container can hold. Long syntax pins one when `published` is said.
26
+ */
27
+ function pinnedHostPort(entry: unknown): string | undefined {
28
+ if (isRecord(entry)) {
29
+ const published = entry['published'];
30
+ return published === undefined || published === null || published === ''
31
+ ? undefined
32
+ : String(published);
33
+ }
34
+ if (typeof entry !== 'string') return undefined;
35
+ const spec = entry.replace(/^\[[^\]]*\]:/, '').split('/')[0] ?? '';
36
+ const parts = spec.split(':');
37
+ const host = parts.length === 3 ? parts[1] : parts.length === 2 ? parts[0] : '';
38
+ return host === undefined || host === '' ? undefined : host;
39
+ }
40
+
41
+ /** One service's strategy: start-first unless something in its definition forbids a second copy. */
42
+ function strategyOf(service: unknown): ComposeStrategy {
43
+ if (!isRecord(service)) {
44
+ return { kind: 'stop-first', why: 'the compose file declares no such service' };
45
+ }
46
+ if (service['container_name'] !== undefined) {
47
+ return {
48
+ kind: 'stop-first',
49
+ why: 'container_name names exactly one container, so a second replica cannot be created',
50
+ };
51
+ }
52
+ if (service['network_mode'] === 'host') {
53
+ return {
54
+ kind: 'stop-first',
55
+ why: 'network_mode: host binds the host ports directly, so two replicas collide',
56
+ };
57
+ }
58
+ const ports = Array.isArray(service['ports']) ? service['ports'] : [];
59
+ const pinned = ports.map(pinnedHostPort).find((port) => port !== undefined);
60
+ if (pinned !== undefined) {
61
+ return {
62
+ kind: 'stop-first',
63
+ why: `publishes host port ${pinned}, which one container can bind — remove ports: and route through a proxy on the compose network to roll it start-first`,
64
+ };
65
+ }
66
+ const deploy = service['deploy'];
67
+ const declared = isRecord(deploy) ? deploy['replicas'] : undefined;
68
+ const replicas =
69
+ typeof declared === 'number' && Number.isSafeInteger(declared) && declared >= 0 ? declared : 1;
70
+ return { kind: 'start-first', replicas };
71
+ }
72
+
73
+ /**
74
+ * Every serving role's strategy, from the compose file's text. A file that is absent or does not
75
+ * parse decides nothing: every role keeps stop-first, and compose reports the file's own error
76
+ * when the step runs — this reader is never a second validator of it.
77
+ */
78
+ export function composeStrategies(text: string | undefined): ReadonlyMap<string, ComposeStrategy> {
79
+ let parsed: unknown;
80
+ try {
81
+ parsed = text === undefined ? undefined : Bun.YAML.parse(text);
82
+ } catch {
83
+ parsed = undefined;
84
+ }
85
+ const services = isRecord(parsed) ? parsed['services'] : undefined;
86
+ if (!isRecord(services)) return new Map();
87
+ return new Map(SERVING_ROLES.map((role) => [role, strategyOf(services[role])]));
88
+ }
89
+
90
+ /** The compose file at `path`, or `undefined` when there is none to read. */
91
+ export async function readComposeFile(path: string): Promise<string | undefined> {
92
+ const file = Bun.file(path);
93
+ return (await file.exists()) ? file.text() : undefined;
94
+ }
@@ -0,0 +1,48 @@
1
+ // What a helm deploy tells the chart about the drain: `app.config.ts`'s `drain` section — the ONE
2
+ // drain budget since 25.0.0 deleted `http.drainTimeoutMs` — in the seconds `docker/helm` derives
3
+ // each role's terminationGracePeriodSeconds from. Passed on every upgrade, so a raised
4
+ // budget can never ship against a grace period sized for 25 s.
5
+
6
+ import type { DrainConfig } from '@ultimat3/core';
7
+ import { isUltimateError, READINESS_GRACE_DEFAULT_MS } from '@ultimat3/core';
8
+ import { loadDrainConfig } from './serve-drain';
9
+
10
+ const seconds = (ms: number): number => Math.ceil(ms / 1000);
11
+
12
+ /**
13
+ * The `--set` pairs, or none when the app declares no budget (the chart's defaults are core's).
14
+ * The chart's `drain.deadlineSeconds` is ONE budget for every role, and so is `drain.deadlineMs`:
15
+ * the web role drains on it too. The margins stay the chart's.
16
+ *
17
+ * The grace is budgeted at no less than core's production default: the value read here is resolved
18
+ * in THIS process's environment, and an `x deploy` run under `NODE_ENV=development` resolves the
19
+ * grace to 0 while the pod it deploys runs with 5 s. Over-budgeting costs a pod nothing — it exits
20
+ * when its drain does — and under-budgeting is a SIGKILL mid-drain.
21
+ */
22
+ export function helmDrainOverrides(drain: Partial<DrainConfig> | undefined): readonly string[] {
23
+ const out: string[] = [];
24
+ if (drain?.deadlineMs !== undefined) {
25
+ out.push('--set', `drain.deadlineSeconds=${seconds(drain.deadlineMs)}`);
26
+ }
27
+ if (drain === undefined) return out;
28
+ const grace = Math.max(drain.readinessGraceMs ?? 0, READINESS_GRACE_DEFAULT_MS);
29
+ out.push('--set', `drain.readinessGraceSeconds=${seconds(grace)}`);
30
+ return out;
31
+ }
32
+
33
+ /**
34
+ * The overrides for the app at `root`, off the one config loader. An `app.config.ts` the loader
35
+ * refuses yields no drain section, and the chart's defaults stand: `X_CONFIG_INVALID`'s own fix for
36
+ * a helm deploy is `--release <name>`, which must stay a way out, and the pods this upgrade starts
37
+ * refuse the same config at boot — which `helm upgrade --wait` reports as the failed rollout it is.
38
+ */
39
+ export async function readHelmDrainOverrides(root: string): Promise<readonly string[]> {
40
+ let drain: Partial<DrainConfig> | undefined;
41
+ try {
42
+ drain = await loadDrainConfig(root);
43
+ } catch (error) {
44
+ if (!isUltimateError(error) || error.code !== 'X_CONFIG_INVALID') throw error;
45
+ return [];
46
+ }
47
+ return helmDrainOverrides(drain);
48
+ }
@@ -8,8 +8,7 @@
8
8
  // why: Bun exposes no path-join primitive; the chart path is handed to helm as one joined string.
9
9
  import { join } from 'node:path';
10
10
  import { UltimateError } from '@ultimat3/core';
11
- import { APP_CONFIG_EXPORT } from './app-auth';
12
- import { APP_CONFIG_FILE } from './app-root';
11
+ import { loadAppConfig } from './app-config-load';
13
12
  import { BadFlagError } from './errors';
14
13
 
15
14
  /** Long enough for a migration a pre-upgrade hook must finish; helm's own 5m default is not. */
@@ -67,16 +66,18 @@ export function readLabel(flag: 'namespace' | 'release', raw: string): string {
67
66
  */
68
67
  export async function readReleaseName(root: string, flag: string | undefined): Promise<string> {
69
68
  if (flag !== undefined) return readLabel('release', flag);
70
- const module = (await import(join(root, APP_CONFIG_FILE))) as Record<string, unknown>;
71
- const config = module[APP_CONFIG_EXPORT];
72
- const name = isRecord(config) ? config['name'] : undefined;
69
+ const config = await loadAppConfig(root);
70
+ // `unknown`, not the declared `string`: core reads an absent name as `"undefined"` and lets it by.
71
+ const name: unknown = config?.name;
73
72
  if (typeof name === 'string' && name.length <= RELEASE_MAX && LABEL.test(name)) return name;
74
73
  throw new UltimateError({
75
74
  code: 'X_CONFIG_INVALID',
76
75
  cause:
77
76
  typeof name === 'string'
78
77
  ? `app.config.ts names the app "${name.slice(0, 80)}", which is not a helm release name (a DNS-1123 label of at most ${RELEASE_MAX} characters)`
79
- : 'app.config.ts exports no config.name, and x deploy --method helm names the release after it',
78
+ : config === undefined
79
+ ? `${root} has no app.config.ts, and x deploy --method helm names the release after its config.name`
80
+ : 'app.config.ts exports no config.name, and x deploy --method helm names the release after it',
80
81
  fix: 'name the release explicitly: x deploy --method helm --release my-app --json',
81
82
  });
82
83
  }