@ultimat3/cli 24.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/CLAUDE.md +15 -8
  2. package/README.md +30 -5
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +3 -1
  6. package/src/app-boundaries.ts +17 -4
  7. package/src/app-config-load.ts +3 -3
  8. package/src/app-env.ts +21 -14
  9. package/src/app-load.ts +38 -14
  10. package/src/app-manifest.ts +2 -2
  11. package/src/app-mcp.ts +25 -27
  12. package/src/app-name.ts +52 -0
  13. package/src/app-permissions-borrowed.ts +2 -8
  14. package/src/app-permissions-site.ts +31 -7
  15. package/src/app-root.ts +9 -5
  16. package/src/bin.ts +3 -7
  17. package/src/browser-launcher.ts +4 -2
  18. package/src/budgets-charged.ts +60 -0
  19. package/src/budgets-scan.ts +76 -0
  20. package/src/budgets-unmeasured.ts +91 -0
  21. package/src/budgets.ts +115 -121
  22. package/src/cdp-shot-driver.ts +17 -0
  23. package/src/cdp-shot-watch.ts +35 -31
  24. package/src/ci-log.ts +0 -0
  25. package/src/cmd-auth.ts +2 -2
  26. package/src/cmd-build-spec.ts +7 -1
  27. package/src/cmd-build.ts +89 -9
  28. package/src/cmd-db-backfill.ts +1 -1
  29. package/src/cmd-db-branch.ts +2 -2
  30. package/src/cmd-db.ts +5 -2
  31. package/src/cmd-deploy-compose.ts +94 -0
  32. package/src/cmd-deploy-drain.ts +48 -0
  33. package/src/cmd-deploy-start-first.ts +178 -0
  34. package/src/cmd-deploy.ts +158 -36
  35. package/src/cmd-dev.ts +18 -357
  36. package/src/cmd-doctor.ts +16 -66
  37. package/src/cmd-env.ts +29 -14
  38. package/src/cmd-generate.ts +12 -2
  39. package/src/cmd-i18n.ts +11 -8
  40. package/src/cmd-jobs-spec.ts +7 -22
  41. package/src/cmd-jobs.ts +18 -137
  42. package/src/cmd-mcp.ts +25 -7
  43. package/src/cmd-new.ts +36 -8
  44. package/src/cmd-planned.ts +9 -0
  45. package/src/cmd-routes.ts +3 -3
  46. package/src/cmd-shot-matrix.ts +21 -4
  47. package/src/cmd-shot-spec.ts +10 -2
  48. package/src/cmd-shot.ts +35 -39
  49. package/src/cmd-test.ts +1 -1
  50. package/src/coverage-lcov.ts +4 -2
  51. package/src/db-backfill.ts +2 -2
  52. package/src/db-replay-engine.ts +4 -10
  53. package/src/db-subscribes.ts +19 -16
  54. package/src/dev-boot.ts +290 -0
  55. package/src/dev-child-watch.ts +63 -12
  56. package/src/dev-dashboard-guard.ts +1 -1
  57. package/src/dev-dashboard.ts +5 -1
  58. package/src/dev-lock.ts +96 -24
  59. package/src/dev-rebuild.ts +80 -0
  60. package/src/dev-restart-retire.ts +46 -0
  61. package/src/dev-route-table.ts +30 -8
  62. package/src/dev-supervisor.ts +114 -22
  63. package/src/dev-traces.ts +53 -20
  64. package/src/dispatch.ts +14 -7
  65. package/src/doctor-auth.ts +4 -4
  66. package/src/doctor-offline.ts +6 -5
  67. package/src/document-options.ts +7 -0
  68. package/src/drift-replica-identity.ts +2 -2
  69. package/src/drift.ts +29 -6
  70. package/src/error-codes.ts +6 -0
  71. package/src/error-page-csp.ts +14 -15
  72. package/src/exec.ts +8 -0
  73. package/src/favicon.ts +2 -2
  74. package/src/fix-command.ts +9 -1
  75. package/src/framework-env.ts +148 -0
  76. package/src/framework-schema.ts +13 -2
  77. package/src/generate-catalog-locales.ts +79 -0
  78. package/src/generate-kinds.ts +3 -17
  79. package/src/generate-plural.ts +50 -0
  80. package/src/generate-shadow.ts +50 -1
  81. package/src/hold.ts +56 -1
  82. package/src/i18n-audit.ts +21 -8
  83. package/src/i18n-index.ts +5 -6
  84. package/src/i18n-registration.ts +7 -9
  85. package/src/import-scan.ts +28 -3
  86. package/src/index.ts +1 -3
  87. package/src/island-bundle.ts +54 -64
  88. package/src/island-capture.ts +1 -1
  89. package/src/island-duplicate-refusal.ts +57 -0
  90. package/src/island-duplicates.ts +221 -0
  91. package/src/island-entry-missing.ts +64 -0
  92. package/src/island-harness-route.ts +20 -4
  93. package/src/island-harness-script.ts +23 -2
  94. package/src/island-identity.ts +17 -14
  95. package/src/island-package-dedupe.ts +150 -0
  96. package/src/island-realtime.ts +36 -16
  97. package/src/island-routes.ts +2 -2
  98. package/src/island-runtime.ts +241 -0
  99. package/src/island-sources.ts +105 -0
  100. package/src/island-states-load.ts +6 -0
  101. package/src/island-store.ts +90 -6
  102. package/src/jobs-json.ts +0 -26
  103. package/src/jobs-report.ts +32 -16
  104. package/src/local-cli-handoff.ts +90 -0
  105. package/src/mcp-errors.ts +8 -1
  106. package/src/mcp-host.ts +6 -5
  107. package/src/mcp-ui-inspect.ts +3 -2
  108. package/src/mcp-ui-interact.ts +3 -2
  109. package/src/mcp-ui.ts +8 -7
  110. package/src/measure-scope.ts +15 -10
  111. package/src/messages.ts +27 -16
  112. package/src/metrics-endpoint.ts +3 -2
  113. package/src/migration-extensions.ts +1 -1
  114. package/src/otlp-export.ts +2 -2
  115. package/src/output.ts +18 -0
  116. package/src/page-speculation.ts +14 -11
  117. package/src/parse.ts +0 -8
  118. package/src/permission-grants.ts +3 -3
  119. package/src/posix-path.ts +30 -0
  120. package/src/prerender-locales.ts +6 -4
  121. package/src/prerender-report.ts +58 -0
  122. package/src/prerender-served.ts +52 -0
  123. package/src/prerender.ts +63 -98
  124. package/src/pwa-artifacts.ts +8 -1
  125. package/src/pwa-manifest-locales.ts +9 -9
  126. package/src/retry-memo.ts +1 -1
  127. package/src/role-realtime.ts +7 -1
  128. package/src/role-replicator.ts +110 -31
  129. package/src/role-start.ts +64 -85
  130. package/src/role-sync.ts +65 -6
  131. package/src/root-env.ts +157 -28
  132. package/src/route-islands.ts +69 -0
  133. package/src/runtime-admin.ts +4 -1
  134. package/src/runtime-assets.ts +15 -2
  135. package/src/runtime-bindings.ts +23 -3
  136. package/src/runtime-cache.ts +8 -8
  137. package/src/runtime-hooks.ts +2 -1
  138. package/src/runtime-isr-outcome.ts +100 -0
  139. package/src/runtime-isr.ts +2 -2
  140. package/src/runtime-jobs.ts +78 -18
  141. package/src/runtime-live-feed.ts +1 -1
  142. package/src/runtime-mail.ts +26 -0
  143. package/src/runtime-mfa-warning.ts +2 -2
  144. package/src/runtime-overrides.ts +3 -3
  145. package/src/runtime-purge.ts +1 -1
  146. package/src/runtime-queue.ts +12 -12
  147. package/src/runtime-realtime.ts +5 -3
  148. package/src/runtime-render.ts +77 -82
  149. package/src/runtime-replica.ts +3 -8
  150. package/src/runtime-services.ts +33 -13
  151. package/src/runtime-storage-upload.ts +2 -2
  152. package/src/runtime-storage.ts +3 -3
  153. package/src/schema-diff.ts +1 -1
  154. package/src/secrets-rotation.ts +8 -9
  155. package/src/seo-routes.ts +2 -2
  156. package/src/serve-boot.ts +22 -11
  157. package/src/serve-drain.ts +3 -1
  158. package/src/serve-retire.ts +95 -0
  159. package/src/serve-web.ts +19 -8
  160. package/src/serve.ts +27 -10
  161. package/src/server-barrels.ts +6 -3
  162. package/src/shot-browser.ts +1 -1
  163. package/src/shot-locale.ts +6 -15
  164. package/src/shot-settle.ts +3 -3
  165. package/src/shot-theme.ts +1 -1
  166. package/src/shot-viewport.ts +152 -0
  167. package/src/signal-shred.ts +26 -12
  168. package/src/site-asset-routes.ts +2 -2
  169. package/src/site-seo.ts +6 -4
  170. package/src/source-files.ts +13 -3
  171. package/src/static-report.ts +5 -0
  172. package/src/style-routes.ts +2 -2
  173. package/src/sw-artifacts.ts +8 -8
  174. package/src/sw-precache-plan.ts +34 -4
  175. package/src/sync-authenticator.ts +3 -3
  176. package/src/sync-url.ts +1 -1
  177. package/src/templates/action.ts +4 -4
  178. package/src/templates/backfill.ts +4 -4
  179. package/src/templates/entity.ts +2 -2
  180. package/src/templates/github/ci.yml.ts +28 -18
  181. package/src/templates/github/image.yml.ts +92 -0
  182. package/src/templates/github/runner.ts +18 -0
  183. package/src/templates/guard-bare-error.ts +16 -2
  184. package/src/templates/guard-unzoned-date.ts +56 -6
  185. package/src/templates/index.ts +1 -1
  186. package/src/templates/job-body-test.ts +2 -2
  187. package/src/templates/job.ts +6 -6
  188. package/src/templates/query.ts +9 -8
  189. package/src/templates/resource-page.ts +9 -7
  190. package/src/templates/resource-service.ts +2 -2
  191. package/src/templates/route.ts +23 -8
  192. package/src/templates/scaffold-api.ts +2 -3
  193. package/src/templates/scaffold-app.ts +2 -1
  194. package/src/templates/scaffold-auth.ts +205 -70
  195. package/src/templates/scaffold-bin.ts +122 -0
  196. package/src/templates/scaffold-claude-commands.ts +11 -11
  197. package/src/templates/scaffold-container.ts +12 -6
  198. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  199. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  200. package/src/templates/scaffold-dashboard-example.ts +112 -172
  201. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  202. package/src/templates/scaffold-dashboard.ts +9 -5
  203. package/src/templates/scaffold-db-package.ts +2 -2
  204. package/src/templates/scaffold-docs.ts +20 -77
  205. package/src/templates/scaffold-domain-package.ts +0 -4
  206. package/src/templates/scaffold-entries.ts +4 -3
  207. package/src/templates/scaffold-env.ts +6 -3
  208. package/src/templates/scaffold-gitattributes.ts +59 -0
  209. package/src/templates/scaffold-helm-templates.ts +74 -18
  210. package/src/templates/scaffold-helm.ts +31 -4
  211. package/src/templates/scaffold-http.ts +1 -1
  212. package/src/templates/scaffold-i18n.ts +15 -2
  213. package/src/templates/scaffold-icon.ts +2 -2
  214. package/src/templates/scaffold-repo.ts +18 -15
  215. package/src/templates/scaffold-shell.ts +17 -2
  216. package/src/test-only-shipped.ts +193 -0
  217. package/src/test-select.ts +2 -1
  218. package/src/test-shards.ts +1 -4
  219. package/src/theme-boot.ts +2 -2
  220. package/src/theme-brand.ts +66 -0
  221. package/src/trusted-hops.ts +49 -0
  222. package/src/unscanned-admin.ts +2 -2
  223. package/src/verify-checks.ts +12 -2
  224. package/src/verify-coverage-run.ts +3 -2
  225. package/src/verify-deadline.ts +87 -10
  226. package/src/verify-merge.ts +48 -16
  227. package/src/verify-part-step.ts +10 -2
  228. package/src/verify-role-load-probe.ts +12 -2
  229. package/src/verify-role-load.ts +9 -4
  230. package/src/verify-run.ts +3 -0
  231. package/src/verify-step.ts +2 -0
  232. package/src/verify-test-run.ts +3 -2
  233. package/src/verify-tests.ts +0 -1
  234. package/src/worker-bundle.ts +2 -2
  235. package/src/workspace-checks.ts +53 -7
  236. package/src/workspace-graph.ts +3 -2
  237. package/src/write-line.ts +82 -7
  238. package/src/browser-launcher-fake-html.ts +0 -80
  239. package/src/browser-launcher-fake.ts +0 -168
  240. package/src/jobs-drain.ts +0 -230
  241. package/src/scaffold-typecheck.ts +0 -227
  242. package/src/thrown-by.ts +0 -24
package/src/cmd-deploy.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  import { join } from 'node:path';
6
6
  import { ERROR_DOCS_URL } from '@ultimat3/core';
7
7
  import { requireAppRoot } from './app-root';
8
+ import { type ComposeStrategy, composeStrategies, readComposeFile } from './cmd-deploy-compose';
9
+ import { readHelmDrainOverrides } from './cmd-deploy-drain';
8
10
  import {
9
11
  type HelmTarget,
10
12
  helmUpgradeArgs,
@@ -14,6 +16,7 @@ import {
14
16
  readRollout,
15
17
  } from './cmd-deploy-helm';
16
18
  import { deploySpec } from './cmd-deploy-spec';
19
+ import { runStartFirst, startFirstUp } from './cmd-deploy-start-first';
17
20
  import type { CliCommand, CommandContext } from './command';
18
21
  import { BadFlagError, UnknownCommandError } from './errors';
19
22
  import { msg } from './messages';
@@ -78,17 +81,28 @@ export function readMethod(raw: string | undefined): DeployMethod {
78
81
  /** The roles that run to completion and exit, as against the ones that stay up serving. */
79
82
  const ONE_SHOT_ROLES: readonly string[] = ['migrate', 'backfill'];
80
83
 
84
+ /**
85
+ * One step. A compose serving role carries its rollout `strategy` (`cmd-deploy-compose.ts`); a
86
+ * start-first step's `command` is its scale-up as it runs in the steady state, where the
87
+ * containers running equal the declared replicas — the run counts them first.
88
+ */
89
+ export interface DeployStep {
90
+ readonly role: string;
91
+ readonly command: readonly string[];
92
+ readonly strategy?: ComposeStrategy | undefined;
93
+ }
94
+
81
95
  export interface DeployPlan {
82
96
  readonly image: string;
83
97
  /** Ordered: migrate runs to completion before any role that serves traffic starts. */
84
- readonly steps: readonly { readonly role: string; readonly command: readonly string[] }[];
98
+ readonly steps: readonly DeployStep[];
85
99
  /**
86
100
  * The environment every step runs with — how the COMPOSE method carries the image, because
87
101
  * `docker-compose.prod.yml` resolves each service from `${IMAGE:-ultimate-app:latest}` and
88
102
  * `docker compose` takes no image argument. Without it `--image` decided nothing: the plan
89
103
  * reported the reference the operator asked for while the six steps read `IMAGE` off the
90
104
  * ambient environment, or deployed `ultimate-app:latest` where it was unset. Helm carries none
91
- * — the chart reads `--set image.repository/tag`, and an env var it never looks at would be a
105
+ * — the chart reads `--set-string image.repository/tag`, and an env var it never looks at would be a
92
106
  * second answer to which image is being deployed.
93
107
  */
94
108
  readonly env: Readonly<Record<string, string>>;
@@ -96,7 +110,7 @@ export interface DeployPlan {
96
110
 
97
111
  /**
98
112
  * The chart declares `image` as a MAP — `repository`, `tag`, `pullPolicy` — and `_helpers.tpl`
99
- * renders `printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag)`.
113
+ * renders `repository:tag` from it, the tag defaulting to `.Chart.AppVersion`.
100
114
  * `--set image=<ref>` replaces that map with a string, so every workload template fails on
101
115
  * `.repository` and the deploy that was asked to ship one image ships nothing. The reference is
102
116
  * split into the two keys the chart actually reads; a reference with no tag sets only the
@@ -109,34 +123,66 @@ export function helmImageOverrides(image: string): readonly string[] {
109
123
  const colon = image.lastIndexOf(':');
110
124
  const tag = colon > image.lastIndexOf('/') ? image.slice(colon + 1) : '';
111
125
  const repository = tag === '' ? image : image.slice(0, colon);
126
+ // `--set-string`, never `--set`: `--set` TYPES its value, so `image.tag=1234567` reached the chart
127
+ // as an int64 and rendered `app:%!s(int64=1234567)` — any all-digit tag (a build number, a date,
128
+ // a short SHA that happens to be digits) was an ImagePullBackOff behind `--wait`.
112
129
  return tag === ''
113
- ? ['--set', `image.repository=${repository}`]
114
- : ['--set', `image.repository=${repository}`, '--set', `image.tag=${tag}`];
130
+ ? ['--set-string', `image.repository=${repository}`]
131
+ : ['--set-string', `image.repository=${repository}`, '--set-string', `image.tag=${tag}`];
115
132
  }
116
133
 
134
+ /**
135
+ * The OCI reference grammar (distribution/reference), tag form only — the digest form has its own
136
+ * refusal in `planDeploy`. `[domain[:port]/]path[:tag]`: a domain of dot-separated host labels, path
137
+ * components of lower-case alphanumerics joined by `.`, `_`, `__` or dashes, a tag of word
138
+ * characters, `.` and `-`, at most 128. It holds none of helm's `--set-string` metacharacters (`,`
139
+ * `=` `\` `{` `}`), so a reference it admits never needs escaping, and one it refuses is never
140
+ * split into a second value.
141
+ */
142
+ const LABEL = '[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?';
143
+ const COMPONENT = '[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*';
144
+ const OCI_REFERENCE = new RegExp(
145
+ `^(?:${LABEL}(?:\\.${LABEL})*(?::[0-9]+)?/)?${COMPONENT}(?:/${COMPONENT})*(?::[\\w][\\w.-]{0,127})?$`,
146
+ );
147
+
117
148
  /** What was asked for: the method, and for helm the release it is aimed at. */
118
149
  export type DeployRequest =
119
150
  | { readonly method: 'compose' }
120
- | ({ readonly method: 'helm' } & HelmTarget);
151
+ | ({ readonly method: 'helm' } & HelmDeployTarget);
152
+
153
+ /** A helm target plus the `--set` pairs that size the chart's grace periods (`cmd-deploy-drain.ts`). */
154
+ export interface HelmDeployTarget extends HelmTarget {
155
+ readonly drain?: readonly string[] | undefined;
156
+ }
121
157
 
122
158
  /**
123
159
  * The plan for one method. A helm plan takes its target — release, namespace, timeout — because
124
160
  * the release is read off `app.config.ts`, which only the command can import; the compose plan
125
161
  * needs none, so its three-argument form is unchanged.
126
162
  */
127
- export function planDeploy(image: string, method: 'compose', root: string): DeployPlan;
163
+ export function planDeploy(
164
+ image: string,
165
+ method: 'compose',
166
+ root: string,
167
+ strategies?: ReadonlyMap<string, ComposeStrategy>,
168
+ ): DeployPlan;
128
169
  export function planDeploy(
129
170
  image: string,
130
171
  method: 'helm',
131
172
  root: string,
132
- target: HelmTarget,
173
+ target: HelmDeployTarget,
133
174
  ): DeployPlan;
134
175
  export function planDeploy(
135
176
  image: string,
136
177
  method: DeployMethod,
137
178
  root: string,
138
- target?: HelmTarget,
179
+ extra?: HelmDeployTarget | ReadonlyMap<string, ComposeStrategy>,
139
180
  ): DeployPlan {
181
+ const target = method === 'helm' ? (extra as HelmDeployTarget | undefined) : undefined;
182
+ const strategies =
183
+ method === 'compose' && extra instanceof Map
184
+ ? (extra as ReadonlyMap<string, ComposeStrategy>)
185
+ : new Map<string, ComposeStrategy>();
140
186
  if (method === 'helm') {
141
187
  // A caller from plain JS can still reach this without one; `app` for every app is the
142
188
  // defect the target exists to end, so it is refused rather than defaulted back.
@@ -159,10 +205,29 @@ export function planDeploy(
159
205
  fix: `x deploy --method helm --image ${image.slice(0, image.lastIndexOf('@'))}:<tag> --json`,
160
206
  });
161
207
  }
208
+ // Helm splits a `--set-string` argument on `,` and reads `=` as key/value: an image built from
209
+ // a git ref name (`app,serviceAccount.create=true:1.2`) set a value nobody asked for. The value
210
+ // is quoted with JSON in the cause (it may hold a newline) and never echoed into the fix.
211
+ if (!OCI_REFERENCE.test(image)) {
212
+ throw new BadFlagError({
213
+ flag: 'image',
214
+ command: 'deploy',
215
+ reason: `${JSON.stringify(image)} is not an OCI image reference ([registry[:port]/]path[:tag], lower-case path), and helm would read its , or = as more --set-string values`,
216
+ fix: 'x deploy --method helm --image ghcr.io/<org>/<app>:<tag> --json',
217
+ });
218
+ }
162
219
  return {
163
220
  image,
164
221
  env: {},
165
- steps: [{ role: 'all', command: helmUpgradeArgs(root, target, helmImageOverrides(image)) }],
222
+ steps: [
223
+ {
224
+ role: 'all',
225
+ command: helmUpgradeArgs(root, target, [
226
+ ...helmImageOverrides(image),
227
+ ...(target.drain ?? []),
228
+ ]),
229
+ },
230
+ ],
166
231
  };
167
232
  }
168
233
  return {
@@ -171,27 +236,42 @@ export function planDeploy(
171
236
  // header documents `IMAGE=… docker compose …` as the way to run it by hand; this is that line,
172
237
  // performed.
173
238
  env: { IMAGE: image },
174
- steps: DEPLOY_ROLES.map((role) => ({
175
- role,
176
- command: [
177
- 'docker',
178
- 'compose',
179
- // Compose interpolates `${SYNC_URL:?…}`, `${APP_URL:?…}` and `${POSTGRES_PASSWORD:?…}` from
180
- // the shell and `--env-file` only — never from a service's `env_file:`. Without this an
181
- // operator who put them in `.env.production`, the one file the compose file tells them to
182
- // fill, had every step die on a parse error. Global flag, so it precedes `-f`; the shell still wins over it.
183
- '--env-file',
184
- join(root, PROD_ENV_FILE),
185
- '-f',
186
- join(root, 'docker', 'docker-compose.prod.yml'),
187
- ONE_SHOT_ROLES.includes(role) ? 'run' : 'up',
188
- ONE_SHOT_ROLES.includes(role) ? '--rm' : '-d',
189
- role,
190
- ],
191
- })),
239
+ steps: DEPLOY_ROLES.map((role) => composeStep(composeBase(root), role, strategies.get(role))),
192
240
  };
193
241
  }
194
242
 
243
+ /**
244
+ * Every compose command's prefix. Compose interpolates `${SYNC_URL:?…}`, `${APP_URL:?…}` and
245
+ * `${POSTGRES_PASSWORD:?…}` from the shell and `--env-file` only — never from a service's
246
+ * `env_file:`. Without it an operator who put them in `.env.production`, the one file the compose
247
+ * file tells them to fill, had every step die on a parse error. Global flag, so it precedes `-f`;
248
+ * the shell still wins over it.
249
+ */
250
+ const composeBase = (root: string): readonly string[] => [
251
+ 'docker',
252
+ 'compose',
253
+ '--env-file',
254
+ join(root, PROD_ENV_FILE),
255
+ '-f',
256
+ composeFileOf(root),
257
+ ];
258
+
259
+ const composeFileOf = (root: string): string => join(root, 'docker', 'docker-compose.prod.yml');
260
+
261
+ /** One compose step: a one-shot `run --rm`, a start-first scale-up, or compose's own recreate. */
262
+ function composeStep(
263
+ base: readonly string[],
264
+ role: string,
265
+ strategy: ComposeStrategy | undefined,
266
+ ): DeployStep {
267
+ if (ONE_SHOT_ROLES.includes(role)) return { role, command: [...base, 'run', '--rm', role] };
268
+ if (strategy?.kind === 'start-first') {
269
+ const scale = strategy.replicas === 0 ? undefined : strategy.replicas * 2;
270
+ return { role, command: startFirstUp(base, role, scale), strategy };
271
+ }
272
+ return { role, command: [...base, 'up', '-d', role], strategy };
273
+ }
274
+
195
275
  /**
196
276
  * One step, as the line an operator would type — the environment first, then the command.
197
277
  *
@@ -231,9 +311,23 @@ export async function readDeployRequest(
231
311
  release: await readReleaseName(root, flagString(ctx.args, 'release')),
232
312
  namespace: namespace === undefined ? undefined : readLabel('namespace', namespace),
233
313
  timeout: readHelmTimeout(flagString(ctx.args, 'timeout')),
314
+ drain: await readHelmDrainOverrides(root),
234
315
  };
235
316
  }
236
317
 
318
+ /**
319
+ * What a failed start-first roll could not undo. The unhealthy replicas it created are stopped and
320
+ * removed; when that cleanup fails too, the containers it names are still running beside the old
321
+ * ones, and saying nothing would leave an operator rerunning a deploy over them unaware.
322
+ */
323
+ const cleanupNote = (outcome: {
324
+ readonly ok: false;
325
+ readonly cleanup?: { readonly code: number; readonly command: readonly string[] } | undefined;
326
+ }): string =>
327
+ outcome.cleanup === undefined
328
+ ? ''
329
+ : `; its cleanup \`${outcome.cleanup.command.map(quoteArg).join(' ')}\` exited ${outcome.cleanup.code}, so the containers it names are still running — stop and remove them before rerunning`;
330
+
237
331
  const stepLine = (env: Readonly<Record<string, string>>, command: readonly string[]): string =>
238
332
  [
239
333
  ...Object.entries(env).map(([name, value]) => `${name}=${quoteArg(value)}`),
@@ -255,10 +349,17 @@ export const deployCommand: CliCommand = {
255
349
  // X_DEPLOY_FAILED, whose fix is the exact command to rerun.
256
350
  const method = readMethod(flagString(ctx.args, 'method'));
257
351
  const request = await readDeployRequest(ctx, method, root);
352
+ // Read before the plan, because the plan IS the rollout: which compose roles can start their
353
+ // new container first is a fact of the app's compose file, and a dry run reports it too.
258
354
  const plan =
259
355
  request.method === 'helm'
260
356
  ? planDeploy(image, 'helm', root, request)
261
- : planDeploy(image, 'compose', root);
357
+ : planDeploy(
358
+ image,
359
+ 'compose',
360
+ root,
361
+ composeStrategies(await readComposeFile(composeFileOf(root))),
362
+ );
262
363
  // What the helm flags resolved to — the release above all, which is read off `app.config.ts`
263
364
  // and so is not something the operator typed. Absent on compose, which has no release.
264
365
  const target: { readonly [key: string]: JsonValue } =
@@ -278,7 +379,12 @@ export const deployCommand: CliCommand = {
278
379
  // Reported, because it is what makes `image` above true on the compose method — a dry run
279
380
  // that names an image the steps do not carry is the defect this field closed.
280
381
  env: { ...plan.env },
281
- steps: plan.steps.map((step) => ({ role: step.role, command: step.command.join(' ') })),
382
+ steps: plan.steps.map((step) => ({
383
+ role: step.role,
384
+ command: step.command.join(' '),
385
+ ...(step.strategy === undefined ? {} : { strategy: step.strategy.kind }),
386
+ ...(step.strategy?.kind === 'stop-first' ? { why: step.strategy.why } : {}),
387
+ })),
282
388
  };
283
389
  // The roles THIS plan has, never the compose list: on `--method helm` there is one step,
284
390
  // `all`, and naming `backfill` there promises an operator a sweep the chart cannot run.
@@ -298,10 +404,26 @@ export const deployCommand: CliCommand = {
298
404
  // and revision rather than an exit code standing in for one. Compose has no such record.
299
405
  let rollout: { readonly [key: string]: JsonValue } = {};
300
406
  for (const step of plan.steps) {
301
- const result = await ctx.runner(step.command, { cwd: root, env: plan.env });
302
- if (request.method === 'helm')
303
- rollout = { rollout: { ...readRollout(request, result.stdout) } };
304
- if (!result.ok) {
407
+ const options = { cwd: root, env: plan.env };
408
+ // A start-first step is a sequence — list, scale up, wait, stop the old — and reports the
409
+ // command of the sub-step that failed, so the fix line below is still the one to rerun.
410
+ const outcome =
411
+ step.strategy?.kind === 'start-first'
412
+ ? await runStartFirst({
413
+ role: step.role,
414
+ base: composeBase(root),
415
+ replicas: step.strategy.replicas,
416
+ runner: ctx.runner,
417
+ options,
418
+ })
419
+ : await ctx.runner(step.command, options).then((result) => {
420
+ if (request.method === 'helm')
421
+ rollout = { rollout: { ...readRollout(request, result.stdout) } };
422
+ return result.ok
423
+ ? { ok: true as const }
424
+ : { ok: false as const, code: result.code, command: step.command };
425
+ });
426
+ if (!outcome.ok) {
305
427
  return {
306
428
  ok: false,
307
429
  command: 'deploy',
@@ -309,8 +431,8 @@ export const deployCommand: CliCommand = {
309
431
  findings: [
310
432
  {
311
433
  code: 'X_DEPLOY_FAILED',
312
- cause: `role "${step.role}" step exited ${result.code}`,
313
- fix: `${stepLine(plan.env, step.command)} # run it directly to see the full output`,
434
+ cause: `role "${step.role}" step exited ${outcome.code}${cleanupNote(outcome)}`,
435
+ fix: `${stepLine(plan.env, outcome.command)} # run it directly to see the full output`,
314
436
  docs: ERROR_DOCS_URL,
315
437
  },
316
438
  ],