@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
@@ -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
+ }
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:?…}` and `${POSTGRES_PASSWORD:?…}` from the shell and
180
- // `--env-file` only — never from a service's `env_file:`. Without this an operator who put
181
- // them in `.env.production`, the one file the compose file tells them to fill, had every
182
- // 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
  ],