@ultimat3/cli 24.0.0 → 25.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/CLAUDE.md +15 -8
  2. package/README.md +30 -5
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +3 -1
  6. package/src/app-boundaries.ts +17 -4
  7. package/src/app-config-load.ts +3 -3
  8. package/src/app-env.ts +21 -14
  9. package/src/app-load.ts +38 -14
  10. package/src/app-manifest.ts +2 -2
  11. package/src/app-mcp.ts +25 -27
  12. package/src/app-name.ts +52 -0
  13. package/src/app-permissions-borrowed.ts +2 -8
  14. package/src/app-permissions-site.ts +31 -7
  15. package/src/app-root.ts +9 -5
  16. package/src/bin.ts +3 -7
  17. package/src/browser-launcher.ts +4 -2
  18. package/src/budgets-charged.ts +60 -0
  19. package/src/budgets-scan.ts +76 -0
  20. package/src/budgets-unmeasured.ts +91 -0
  21. package/src/budgets.ts +115 -121
  22. package/src/cdp-shot-driver.ts +17 -0
  23. package/src/cdp-shot-watch.ts +35 -31
  24. package/src/ci-log.ts +0 -0
  25. package/src/cmd-auth.ts +2 -2
  26. package/src/cmd-build-spec.ts +7 -1
  27. package/src/cmd-build.ts +89 -9
  28. package/src/cmd-db-backfill.ts +1 -1
  29. package/src/cmd-db-branch.ts +2 -2
  30. package/src/cmd-db.ts +5 -2
  31. package/src/cmd-deploy-compose.ts +94 -0
  32. package/src/cmd-deploy-drain.ts +48 -0
  33. package/src/cmd-deploy-start-first.ts +178 -0
  34. package/src/cmd-deploy.ts +158 -36
  35. package/src/cmd-dev.ts +18 -357
  36. package/src/cmd-doctor.ts +16 -66
  37. package/src/cmd-env.ts +29 -14
  38. package/src/cmd-generate.ts +12 -2
  39. package/src/cmd-i18n.ts +11 -8
  40. package/src/cmd-jobs-spec.ts +7 -22
  41. package/src/cmd-jobs.ts +18 -137
  42. package/src/cmd-mcp.ts +25 -7
  43. package/src/cmd-new.ts +36 -8
  44. package/src/cmd-planned.ts +9 -0
  45. package/src/cmd-routes.ts +3 -3
  46. package/src/cmd-shot-matrix.ts +21 -4
  47. package/src/cmd-shot-spec.ts +10 -2
  48. package/src/cmd-shot.ts +35 -39
  49. package/src/cmd-test.ts +1 -1
  50. package/src/coverage-lcov.ts +4 -2
  51. package/src/db-backfill.ts +2 -2
  52. package/src/db-replay-engine.ts +4 -10
  53. package/src/db-subscribes.ts +19 -16
  54. package/src/dev-boot.ts +290 -0
  55. package/src/dev-child-watch.ts +63 -12
  56. package/src/dev-dashboard-guard.ts +1 -1
  57. package/src/dev-dashboard.ts +5 -1
  58. package/src/dev-lock.ts +96 -24
  59. package/src/dev-rebuild.ts +80 -0
  60. package/src/dev-restart-retire.ts +46 -0
  61. package/src/dev-route-table.ts +30 -8
  62. package/src/dev-supervisor.ts +114 -22
  63. package/src/dev-traces.ts +53 -20
  64. package/src/dispatch.ts +14 -7
  65. package/src/doctor-auth.ts +4 -4
  66. package/src/doctor-offline.ts +6 -5
  67. package/src/document-options.ts +7 -0
  68. package/src/drift-replica-identity.ts +2 -2
  69. package/src/drift.ts +29 -6
  70. package/src/error-codes.ts +6 -0
  71. package/src/error-page-csp.ts +14 -15
  72. package/src/exec.ts +8 -0
  73. package/src/favicon.ts +2 -2
  74. package/src/fix-command.ts +9 -1
  75. package/src/framework-env.ts +148 -0
  76. package/src/framework-schema.ts +13 -2
  77. package/src/generate-catalog-locales.ts +79 -0
  78. package/src/generate-kinds.ts +3 -17
  79. package/src/generate-plural.ts +50 -0
  80. package/src/generate-shadow.ts +50 -1
  81. package/src/hold.ts +56 -1
  82. package/src/i18n-audit.ts +21 -8
  83. package/src/i18n-index.ts +5 -6
  84. package/src/i18n-registration.ts +7 -9
  85. package/src/import-scan.ts +28 -3
  86. package/src/index.ts +1 -3
  87. package/src/island-bundle.ts +54 -64
  88. package/src/island-capture.ts +1 -1
  89. package/src/island-duplicate-refusal.ts +57 -0
  90. package/src/island-duplicates.ts +221 -0
  91. package/src/island-entry-missing.ts +64 -0
  92. package/src/island-harness-route.ts +20 -4
  93. package/src/island-harness-script.ts +23 -2
  94. package/src/island-identity.ts +17 -14
  95. package/src/island-package-dedupe.ts +150 -0
  96. package/src/island-realtime.ts +36 -16
  97. package/src/island-routes.ts +2 -2
  98. package/src/island-runtime.ts +241 -0
  99. package/src/island-sources.ts +105 -0
  100. package/src/island-states-load.ts +6 -0
  101. package/src/island-store.ts +90 -6
  102. package/src/jobs-json.ts +0 -26
  103. package/src/jobs-report.ts +32 -16
  104. package/src/local-cli-handoff.ts +90 -0
  105. package/src/mcp-errors.ts +8 -1
  106. package/src/mcp-host.ts +6 -5
  107. package/src/mcp-ui-inspect.ts +3 -2
  108. package/src/mcp-ui-interact.ts +3 -2
  109. package/src/mcp-ui.ts +8 -7
  110. package/src/measure-scope.ts +15 -10
  111. package/src/messages.ts +27 -16
  112. package/src/metrics-endpoint.ts +3 -2
  113. package/src/migration-extensions.ts +1 -1
  114. package/src/otlp-export.ts +2 -2
  115. package/src/output.ts +18 -0
  116. package/src/page-speculation.ts +14 -11
  117. package/src/parse.ts +0 -8
  118. package/src/permission-grants.ts +3 -3
  119. package/src/posix-path.ts +30 -0
  120. package/src/prerender-locales.ts +6 -4
  121. package/src/prerender-report.ts +58 -0
  122. package/src/prerender-served.ts +52 -0
  123. package/src/prerender.ts +63 -98
  124. package/src/pwa-artifacts.ts +8 -1
  125. package/src/pwa-manifest-locales.ts +9 -9
  126. package/src/retry-memo.ts +1 -1
  127. package/src/role-realtime.ts +7 -1
  128. package/src/role-replicator.ts +110 -31
  129. package/src/role-start.ts +66 -87
  130. package/src/role-sync.ts +65 -6
  131. package/src/role-wake.ts +2 -3
  132. package/src/root-env.ts +157 -28
  133. package/src/route-islands.ts +69 -0
  134. package/src/runtime-admin.ts +4 -1
  135. package/src/runtime-assets.ts +15 -2
  136. package/src/runtime-bindings.ts +23 -3
  137. package/src/runtime-cache.ts +8 -8
  138. package/src/runtime-hooks.ts +2 -1
  139. package/src/runtime-isr-outcome.ts +100 -0
  140. package/src/runtime-isr.ts +2 -2
  141. package/src/runtime-jobs.ts +78 -18
  142. package/src/runtime-live-feed.ts +1 -1
  143. package/src/runtime-mail.ts +26 -0
  144. package/src/runtime-mfa-warning.ts +2 -2
  145. package/src/runtime-overrides.ts +3 -3
  146. package/src/runtime-purge.ts +1 -1
  147. package/src/runtime-queue.ts +19 -30
  148. package/src/runtime-realtime.ts +5 -3
  149. package/src/runtime-render.ts +77 -82
  150. package/src/runtime-replica.ts +3 -8
  151. package/src/runtime-services.ts +36 -15
  152. package/src/runtime-storage-upload.ts +2 -2
  153. package/src/runtime-storage.ts +3 -3
  154. package/src/schema-diff.ts +1 -1
  155. package/src/secrets-rotation.ts +8 -9
  156. package/src/seo-routes.ts +2 -2
  157. package/src/serve-boot.ts +22 -11
  158. package/src/serve-drain.ts +3 -1
  159. package/src/serve-retire.ts +95 -0
  160. package/src/serve-web.ts +19 -8
  161. package/src/serve.ts +27 -10
  162. package/src/server-barrels.ts +6 -3
  163. package/src/shot-browser.ts +1 -1
  164. package/src/shot-locale.ts +6 -15
  165. package/src/shot-settle.ts +3 -3
  166. package/src/shot-theme.ts +1 -1
  167. package/src/shot-viewport.ts +152 -0
  168. package/src/signal-shred.ts +26 -12
  169. package/src/site-asset-routes.ts +2 -2
  170. package/src/site-seo.ts +6 -4
  171. package/src/source-files.ts +13 -3
  172. package/src/static-report.ts +5 -0
  173. package/src/style-routes.ts +2 -2
  174. package/src/sw-artifacts.ts +8 -8
  175. package/src/sw-precache-plan.ts +34 -4
  176. package/src/sync-authenticator.ts +3 -3
  177. package/src/sync-url.ts +1 -1
  178. package/src/templates/action.ts +4 -4
  179. package/src/templates/backfill.ts +4 -4
  180. package/src/templates/entity.ts +2 -2
  181. package/src/templates/github/ci.yml.ts +28 -18
  182. package/src/templates/github/image.yml.ts +92 -0
  183. package/src/templates/github/runner.ts +18 -0
  184. package/src/templates/guard-bare-error.ts +16 -2
  185. package/src/templates/guard-unzoned-date.ts +56 -6
  186. package/src/templates/index.ts +1 -1
  187. package/src/templates/job-body-test.ts +2 -2
  188. package/src/templates/job.ts +6 -6
  189. package/src/templates/query.ts +9 -8
  190. package/src/templates/resource-page.ts +9 -7
  191. package/src/templates/resource-service.ts +2 -2
  192. package/src/templates/route.ts +23 -8
  193. package/src/templates/scaffold-api.ts +2 -3
  194. package/src/templates/scaffold-app.ts +2 -1
  195. package/src/templates/scaffold-auth.ts +205 -70
  196. package/src/templates/scaffold-bin.ts +122 -0
  197. package/src/templates/scaffold-claude-commands.ts +11 -11
  198. package/src/templates/scaffold-container.ts +12 -6
  199. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  200. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  201. package/src/templates/scaffold-dashboard-example.ts +112 -172
  202. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  203. package/src/templates/scaffold-dashboard.ts +9 -5
  204. package/src/templates/scaffold-db-package.ts +2 -2
  205. package/src/templates/scaffold-docs.ts +20 -77
  206. package/src/templates/scaffold-domain-package.ts +0 -4
  207. package/src/templates/scaffold-entries.ts +4 -3
  208. package/src/templates/scaffold-env.ts +6 -3
  209. package/src/templates/scaffold-gitattributes.ts +59 -0
  210. package/src/templates/scaffold-helm-templates.ts +74 -18
  211. package/src/templates/scaffold-helm.ts +31 -4
  212. package/src/templates/scaffold-http.ts +1 -1
  213. package/src/templates/scaffold-i18n.ts +15 -2
  214. package/src/templates/scaffold-icon.ts +2 -2
  215. package/src/templates/scaffold-repo.ts +18 -15
  216. package/src/templates/scaffold-shell.ts +17 -2
  217. package/src/test-only-shipped.ts +193 -0
  218. package/src/test-select.ts +2 -1
  219. package/src/test-shards.ts +1 -4
  220. package/src/theme-boot.ts +2 -2
  221. package/src/theme-brand.ts +66 -0
  222. package/src/trusted-hops.ts +49 -0
  223. package/src/unscanned-admin.ts +2 -2
  224. package/src/verify-checks.ts +12 -2
  225. package/src/verify-coverage-run.ts +3 -2
  226. package/src/verify-deadline.ts +87 -10
  227. package/src/verify-merge.ts +48 -16
  228. package/src/verify-part-step.ts +10 -2
  229. package/src/verify-role-load-probe.ts +12 -2
  230. package/src/verify-role-load.ts +9 -4
  231. package/src/verify-run.ts +3 -0
  232. package/src/verify-step.ts +2 -0
  233. package/src/verify-test-run.ts +3 -2
  234. package/src/verify-tests.ts +0 -1
  235. package/src/worker-bundle.ts +2 -2
  236. package/src/workspace-checks.ts +53 -7
  237. package/src/workspace-graph.ts +3 -2
  238. package/src/write-line.ts +82 -7
  239. package/src/browser-launcher-fake-html.ts +0 -80
  240. package/src/browser-launcher-fake.ts +0 -168
  241. package/src/jobs-drain.ts +0 -230
  242. package/src/scaffold-typecheck.ts +0 -227
  243. package/src/thrown-by.ts +0 -24
package/src/cmd-build.ts CHANGED
@@ -103,24 +103,94 @@ export function dockerArgs(root: string, tag: string, buildId: string): readonly
103
103
  }
104
104
 
105
105
  /**
106
- * The define is not optional. A single-file executable carries no `package.json`, so
107
- * `frameworkVersion()` has nothing to read and throws — which is exactly how this target came to
108
- * compile an artifact that could never boot. The value is this CLI's own `@ultimat3/core`, which is
109
- * 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.
110
153
  *
111
154
  * `externalArgs()` is not optional either, and for the opposite reason: it names the one specifier
112
155
  * this graph must NOT resolve. `apps/web/server.ts` reaches `serve.ts`, which reaches the island
113
156
  * builder, which reaches `@babel/core` — whose `.cts`-config loader requires a package we
114
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.
115
167
  */
116
- export function binaryArgs(root: string, out: string): readonly string[] {
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.
175
+ */
176
+ const LAUNCHER_EXTERNALS: readonly string[] = ['@ultimat3/*'];
177
+
178
+ export function binaryArgs(
179
+ root: string,
180
+ out: string,
181
+ platform?: BinaryPlatform,
182
+ ): readonly string[] {
117
183
  return [
118
184
  'bun',
119
185
  'build',
120
186
  '--compile',
121
187
  '--minify',
188
+ ...(platform === undefined ? [] : ['--target', platform]),
122
189
  '--define',
123
190
  `${VERSION_DEFINE}=${JSON.stringify(frameworkVersion())}`,
191
+ '--compile-autoload-package-json',
192
+ '--compile-autoload-tsconfig',
193
+ ...LAUNCHER_EXTERNALS.flatMap((specifier) => ['--external', specifier]),
124
194
  ...externalArgs(),
125
195
  join(root, BUILD_ENTRY.binary),
126
196
  '--outfile',
@@ -140,10 +210,12 @@ export function argsFor(
140
210
  readonly out: string;
141
211
  /** The docker target's `BUILD_ID` build arg — `appManifest(root)`'s own. */
142
212
  readonly buildId?: string;
213
+ /** The binary target's `--platform`, as Bun's `--target`. */
214
+ readonly platform?: BinaryPlatform;
143
215
  },
144
216
  ): readonly string[] {
145
217
  if (target === 'docker') return dockerArgs(paths.root, paths.tag, paths.buildId ?? 'dev');
146
- if (target === 'binary') return binaryArgs(paths.root, paths.out);
218
+ if (target === 'binary') return binaryArgs(paths.root, paths.out, paths.platform);
147
219
  return staticArgs(paths.root, paths.out);
148
220
  }
149
221
 
@@ -176,6 +248,8 @@ export function buildResult(input: {
176
248
  readonly command: readonly string[];
177
249
  readonly result: ExecResult;
178
250
  readonly report?: StaticReport;
251
+ /** The binary target's `--platform`, when one was named. */
252
+ readonly platform?: BinaryPlatform;
179
253
  }): CommandResult {
180
254
  const { report, result, target } = input;
181
255
  const output = result.ok ? '' : execOutput(result);
@@ -195,6 +269,7 @@ export function buildResult(input: {
195
269
  ],
196
270
  data: {
197
271
  target,
272
+ ...(input.platform === undefined ? {} : { platform: input.platform }),
198
273
  artifact: input.artifact,
199
274
  durationMs: result.durationMs,
200
275
  ...(result.ok ? {} : { output }),
@@ -209,14 +284,14 @@ export function buildResult(input: {
209
284
  }
210
285
 
211
286
  /** The flags only some targets read. */
212
- const TARGET_SCOPED_FLAGS = ['tag', 'out', 'preflight'] as const;
287
+ const TARGET_SCOPED_FLAGS = ['tag', 'out', 'platform', 'preflight'] as const;
213
288
 
214
289
  type TargetScopedFlag = (typeof TARGET_SCOPED_FLAGS)[number];
215
290
 
216
291
  /** The optional flags each target READS. One table, so a flag cannot be read by one and ignored. */
217
292
  const TARGET_FLAGS: ReadonlyMap<BuildTarget, readonly TargetScopedFlag[]> = new Map([
218
293
  ['docker', ['tag', 'preflight']],
219
- ['binary', ['out', 'preflight']],
294
+ ['binary', ['out', 'platform', 'preflight']],
220
295
  ['static', ['out', 'preflight']],
221
296
  // The boot reads ONE place, and the image holds no devDependencies to run a gate with.
222
297
  ['prebuilt', []],
@@ -226,6 +301,7 @@ const TARGET_FLAGS: ReadonlyMap<BuildTarget, readonly TargetScopedFlag[]> = new
226
301
  const UNREAD_BECAUSE: Readonly<Record<TargetScopedFlag, string>> = {
227
302
  tag: 'only the docker target tags an image',
228
303
  out: 'the docker target writes an image, not a path',
304
+ platform: 'only the binary target compiles an executable for a platform',
229
305
  preflight: 'the prebuilt target runs no gate: `x verify` ran before `docker build` was called',
230
306
  };
231
307
 
@@ -297,6 +373,7 @@ export const buildCommand: CliCommand = {
297
373
  // an agent spends on the wrong question.
298
374
  requireEntry(root, target);
299
375
  refuseUnreadFlags(ctx.args, target);
376
+ const platform = readPlatform(flagString(ctx.args, 'platform'));
300
377
  if (target === 'prebuilt') return buildPrebuilt(root);
301
378
 
302
379
  // Run static verify steps before building — unless the caller is a gate that runs the same
@@ -317,10 +394,11 @@ export const buildCommand: CliCommand = {
317
394
  // A relative `--out` is a path the caller typed from where they stand, so it resolves against
318
395
  // the cwd — against the root it landed somewhere else whenever `x build` ran from `apps/web`.
319
396
  const outFlag = flagString(ctx.args, 'out');
320
- const out =
397
+ const requested =
321
398
  outFlag === undefined
322
399
  ? join(root, '.x', target === 'static' ? 'static' : 'app')
323
400
  : resolve(ctx.cwd, outFlag);
401
+ const out = target === 'binary' ? binaryOutfile(requested, platform) : requested;
324
402
  const tag = flagString(ctx.args, 'tag') ?? 'ultimate-app:dev';
325
403
  // The docker target stamps the manifest's build id into the image, so no role derives one at
326
404
  // boot. The island chunks and stylesheets are the image build's own (`--target prebuilt`).
@@ -331,6 +409,7 @@ export const buildCommand: CliCommand = {
331
409
  tag,
332
410
  out,
333
411
  ...(buildId === undefined ? {} : { buildId }),
412
+ ...(platform === undefined ? {} : { platform }),
334
413
  });
335
414
  // Removed BEFORE the builder runs, so a build that writes no inventory can never be reported
336
415
  // with the last one's: a stale emitted list is worse than none, because it reads as this run's.
@@ -346,6 +425,7 @@ export const buildCommand: CliCommand = {
346
425
  command,
347
426
  result,
348
427
  ...(report === undefined ? {} : { report }),
428
+ ...(platform === undefined ? {} : { platform }),
349
429
  });
350
430
  },
351
431
  };
@@ -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) {
@@ -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
@@ -255,8 +255,11 @@ async function runReset(ctx: CommandContext, root: string): Promise<CommandResul
255
255
  const services = resolveServices(root, ctx.env);
256
256
  if (services.db.mode === 'external') {
257
257
  throw new NotImplementedError({
258
- cause: 'x db reset against an external Postgres is not implemented in this build',
259
- fix: 'drop and recreate the database yourself, then run: x db migrate',
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
@@ -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
+ }
@@ -0,0 +1,178 @@
1
+ // One compose role rolled start-first: the new containers come up beside the old ones, are waited
2
+ // on until healthy, and only then are the old ones stopped and removed. Converges on the declared
3
+ // replica count, so a rerun after a half-finished deploy finishes it rather than adding replicas.
4
+
5
+ import type { ExecOptions, Runner } from './exec';
6
+
7
+ /**
8
+ * Bounds `--wait`: a new container that never reports healthy fails the step rather than holding
9
+ * the deploy open. Five minutes is past any `start_period` the scaffold declares (30s) plus a cold
10
+ * boot, and `--wait` already returns at once when a container turns `unhealthy` or exits.
11
+ */
12
+ export const COMPOSE_WAIT_TIMEOUT_SECONDS = 300;
13
+
14
+ /** The label compose stamps on each container: the hash of the service definition it was made from. */
15
+ const CONFIG_HASH = 'com.docker.compose.config-hash';
16
+ const INSPECT_FORMAT = `{{.Id}} {{.Created}} {{index .Config.Labels "${CONFIG_HASH}"}}`;
17
+
18
+ /** The `up` that starts the new replicas beside the running ones; `--no-recreate` keeps the old. */
19
+ export function startFirstUp(
20
+ base: readonly string[],
21
+ role: string,
22
+ scale: number | undefined,
23
+ ): readonly string[] {
24
+ const wait = ['--wait', '--wait-timeout', String(COMPOSE_WAIT_TIMEOUT_SECONDS)];
25
+ return scale === undefined
26
+ ? [...base, 'up', '-d', '--no-deps', ...wait, role]
27
+ : [
28
+ ...base,
29
+ 'up',
30
+ '-d',
31
+ '--no-deps',
32
+ '--no-recreate',
33
+ ...wait,
34
+ '--scale',
35
+ `${role}=${scale}`,
36
+ role,
37
+ ];
38
+ }
39
+
40
+ interface Failed {
41
+ readonly code: number;
42
+ readonly command: readonly string[];
43
+ }
44
+
45
+ export type StartFirstOutcome =
46
+ | { readonly ok: true }
47
+ | ({
48
+ readonly ok: false;
49
+ /**
50
+ * The cleanup of an unhealthy new replica that ALSO failed — its argv names the containers
51
+ * still running, which the operator now owns. Absent when the cleanup ran clean.
52
+ */
53
+ readonly cleanup?: Failed | undefined;
54
+ } & Failed);
55
+
56
+ const linesOf = (stdout: string): readonly string[] =>
57
+ stdout
58
+ .split('\n')
59
+ .map((line) => line.trim())
60
+ .filter((line) => line.length > 0);
61
+
62
+ /** `docker compose config --hash <role>` prints `<role> <hash>`. */
63
+ const hashOf = (stdout: string): string | undefined => linesOf(stdout)[0]?.split(/\s+/)[1];
64
+
65
+ /**
66
+ * The running containers split by the definition they were made from: `current` already runs the
67
+ * one being deployed (newest first), `stale` anything else. A container `inspect` did not describe
68
+ * is stale — stopping one too many is a restart, keeping one too many is the old build serving.
69
+ */
70
+ function partition(
71
+ running: readonly string[],
72
+ inspected: string,
73
+ want: string,
74
+ ): { readonly current: readonly string[]; readonly stale: readonly string[] } {
75
+ const described = new Map(
76
+ linesOf(inspected).map((line) => {
77
+ const [id = '', created = '', hash = ''] = line.split(/\s+/);
78
+ return [id, { created, hash }] as const;
79
+ }),
80
+ );
81
+ const facts = running.map((id) => {
82
+ const match = [...described].find(([full]) => full === id || full.startsWith(id));
83
+ return { id, created: match?.[1].created ?? '', hash: match?.[1].hash };
84
+ });
85
+ return {
86
+ current: facts
87
+ .filter((fact) => fact.hash === want)
88
+ .sort((a, b) => (a.created < b.created ? 1 : -1))
89
+ .map((fact) => fact.id),
90
+ stale: facts.filter((fact) => fact.hash !== want).map((fact) => fact.id),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Start-first for one role. The containers running now are listed and split by compose's config
96
+ * hash against the definition being deployed: those already on it are kept (up to `replicas`,
97
+ * newest first), and only the shortfall is created BESIDE the rest (`--scale running + shortfall
98
+ * --no-recreate`, so compose builds the extra containers from the new definition and leaves the
99
+ * others alone). Everything is waited on until its healthcheck — `/readyz` for web and sync, the
100
+ * scrape port for the others — answers. Only then are the stale containers and any surplus stopped
101
+ * (SIGTERM, then the `stop_grace_period` compose stamped on each as its stop timeout) and removed.
102
+ *
103
+ * Counting the shortfall rather than `running + replicas` is what makes a rerun safe: after a
104
+ * deploy that brought the new containers up and then failed to stop the old, running it again
105
+ * creates nothing and finishes the stop.
106
+ *
107
+ * A new replica that never turns healthy is stopped and removed, and the old ones keep serving;
108
+ * a cleanup that fails is reported beside the failure, naming the containers left running. The
109
+ * same sequence `docker-rollout` performs, because `deploy.update_config.order: start-first` is a
110
+ * Swarm field `docker compose` ignores.
111
+ */
112
+ export async function runStartFirst(step: {
113
+ readonly role: string;
114
+ readonly base: readonly string[];
115
+ readonly replicas: number;
116
+ readonly runner: Runner;
117
+ readonly options: ExecOptions;
118
+ }): Promise<StartFirstOutcome> {
119
+ const { role, base, replicas, runner, options } = step;
120
+ const run = async (command: readonly string[]) => {
121
+ const result = await runner(command, options);
122
+ return { result, failed: { ok: false as const, code: result.code, command } };
123
+ };
124
+ const listing = await run([...base, 'ps', '-q', role]);
125
+ if (!listing.result.ok) return listing.failed;
126
+ const running = linesOf(listing.result.stdout);
127
+ if (running.length === 0) {
128
+ const fresh = await run(startFirstUp(base, role, undefined));
129
+ return fresh.result.ok ? { ok: true } : fresh.failed;
130
+ }
131
+ const config = await run([...base, 'config', '--hash', role]);
132
+ if (!config.result.ok) return config.failed;
133
+ const inspect = await run(['docker', 'inspect', '--format', INSPECT_FORMAT, ...running]);
134
+ if (!inspect.result.ok) return inspect.failed;
135
+ const { current, stale } = partition(
136
+ running,
137
+ inspect.result.stdout,
138
+ hashOf(config.result.stdout) ?? '',
139
+ );
140
+ const shortfall = Math.max(0, replicas - current.length);
141
+ const up = await run(startFirstUp(base, role, running.length + shortfall));
142
+ if (!up.result.ok) return { ...up.failed, ...(await removeCreated(step, running)) };
143
+ const retire = [...stale, ...current.slice(replicas)];
144
+ if (retire.length === 0) return { ok: true };
145
+ const stopped = await run(['docker', 'stop', ...retire]);
146
+ if (!stopped.result.ok) return stopped.failed;
147
+ const removed = await run(['docker', 'rm', ...retire]);
148
+ return removed.result.ok ? { ok: true } : removed.failed;
149
+ }
150
+
151
+ /**
152
+ * After a failed `up`: stop and remove what it created, so the containers that were serving are
153
+ * the role again. A listing that fails leaves the new replicas to the operator; a stop or rm that
154
+ * fails is returned as `cleanup`, its argv naming the containers still there.
155
+ */
156
+ async function removeCreated(
157
+ step: {
158
+ readonly role: string;
159
+ readonly base: readonly string[];
160
+ readonly runner: Runner;
161
+ readonly options: ExecOptions;
162
+ },
163
+ running: readonly string[],
164
+ ): Promise<{ readonly cleanup?: Failed }> {
165
+ const { role, base, runner, options } = step;
166
+ const after = await runner([...base, 'ps', '-q', role], options);
167
+ if (!after.ok) return { cleanup: { code: after.code, command: [...base, 'ps', '-q', role] } };
168
+ const created = linesOf(after.stdout).filter((id) => !running.includes(id));
169
+ if (created.length === 0) return {};
170
+ for (const command of [
171
+ ['docker', 'stop', ...created],
172
+ ['docker', 'rm', ...created],
173
+ ]) {
174
+ const result = await runner(command, options);
175
+ if (!result.ok) return { cleanup: { code: result.code, command } };
176
+ }
177
+ return {};
178
+ }