@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
@@ -1,5 +1,5 @@
1
1
  // The human-authored half of what `x new` writes: the READMEs, the agent-facing convention files,
2
- // the bin/ shims and the optional dev compose. Separated from the config half so neither file has
2
+ // the bin/ scripts and the optional dev compose. Separated from the config half so neither file has
3
3
  // to be scrolled to find the other — one file, one job applies to templates too. The image, its
4
4
  // ignore file, the production topology and the deploy page are `scaffold-container.ts`; the
5
5
  // `.claude/` harness that reads AGENTS.md is `scaffold-claude.ts`; the one workflow is
@@ -8,6 +8,7 @@
8
8
  import { LINE_CEILING } from '../workspace-checks';
9
9
  import { githubFiles } from './github/ci.yml';
10
10
  import type { GeneratedFile, NameSet } from './naming';
11
+ import { binFiles } from './scaffold-bin';
11
12
  import { claudeFiles } from './scaffold-claude';
12
13
  import { containerFiles } from './scaffold-container';
13
14
 
@@ -21,7 +22,7 @@ not exist — five of these had an empty column and were each measured green on
21
22
 
22
23
  | Rule | Detail | Refused by |
23
24
  |---|---|---|
24
- | One gate | \`bin/check\` — \`x build\` and then \`x verify\`. Green means shippable; never merge red. | the gate itself, and \`.github/workflows/ci.yml\` on every push and pull request |
25
+ | One gate | \`bun run check\` — \`x build\` and then \`x verify\`. Green means shippable; never merge red. | the gate itself, and \`.github/workflows/ci.yml\` on every push and pull request; \`.github/workflows/image.yml\` publishes only a commit it passed |
25
26
  | Coverage | the unit suite covers 95% of the lines and functions of this app's own source — every file under \`apps/\` and \`packages/\`, one no test loads counted at 0%. A floor, not the goal: a test added to raise it is proven by mutation — break the source, watch it go red, restore | \`X_COVERAGE_BELOW_FLOOR\` on the \`unit\` step; \`coverage\` in \`x.verify.json\` states the floor, and it only rises |
26
27
  | One way | generators, not hand-rolled files: \`x g resource\`, \`x g action\`, \`x g route\` | review |
27
28
  | Surfaces | \`site/\` is 0kb JS and may not import \`app/\`; \`shared/\` is a leaf | \`X_BOUNDARY_SITE_TO_APP\` |
@@ -75,14 +76,14 @@ Every test the scaffold and the generators write is the idiom to copy. All of th
75
76
  | an island | \`mountIsland\`, once per state its \`*.island.states.ts\` declares | \`apps/web/shared/theme-toggle.island.test.ts\` |
76
77
  | an action or a query | \`target.as(actor, input)\` — the refusal first, then what it answers | \`apps/web/api/health.test.ts\` |
77
78
  | a job, a task, a backfill | \`test('…', async ({ runJobs }) => …)\` — a worker in this process; assert on \`trace.steps\` and each run's \`outcome\` | what \`x g job\` writes |
78
- | a repo, a seed | as an actor, \`runWithContext(createContext({ actor }), …)\`, with \`driver.reset?.()\` after each | \`packages/db/src/seed.test.ts\` |
79
+ | a repo, a seed | as an actor, \`runWithContext(ctxOf({ actor }), …)\`, with \`driver.reset?.()\` after each | \`packages/db/src/seed.test.ts\` |
79
80
 
80
81
  Money is the one row with no guard, deliberately: a float has no static signature a text rule can
81
82
  see, and the type already fires — measured, \`price: 19.99\` in a seed is
82
83
  \`TS2322: Type 'number' is not assignable to type 'MoneyInput'\`. A guard that pretended to check
83
84
  it would be worse than the type that really does.
84
85
 
85
- \`bin/check\` is the gate and \`x verify\` is only its second half: the first is
86
+ \`bun run check\` is the gate and \`x verify\` is only its second half: the first is
86
87
  \`x build --target static\`, and the build is what writes the \`.x/build-stats.json\` the
87
88
  \`budgets\` step measures. Run \`x verify\` on a tree nobody has built and \`budgets\` is red with
88
89
  X_BUDGET_UNMEASURED — the gate reporting on a file that does not exist, not on your code.
@@ -93,8 +94,9 @@ here is generated from it. So the scaffold never clobbers a maintainer policy, t
93
94
  clobbers \`AGENTS.md\`, \`bin/\`, \`.claude/\` or \`.github/\`, and deleting either side leaves the
94
95
  other exactly as it was.
95
96
 
96
- Commands: \`bin/setup\`, \`bin/dev\`, \`bin/check\`, \`x g <primitive>\`, \`x g guard <name>\`,
97
- \`x db branch create <name>\`, \`x doctor\`.
97
+ Commands: \`bun run setup\`, \`bun run dev\`, \`bun run check\`, \`x g <primitive>\`,
98
+ \`x g guard <name>\`, \`x db branch create <name>\`, \`x doctor\`. \`setup\` and \`check\` are
99
+ \`bin/*.ts\` files Bun runs, so all three are the same command in PowerShell, cmd and bash.
98
100
 
99
101
  Project notes for ${app.kebab}: replace this line with the conventions a newcomer could not guess.
100
102
  `;
@@ -103,10 +105,10 @@ const claude = (app: NameSet): string => `# CLAUDE.md
103
105
 
104
106
  ${app.kebab} — Ultimate app. Read AGENTS.md first; it is the same content in the same order.
105
107
 
106
- - Gate: \`bin/check\` (add \`--json\` for machine output — it reaches both halves). It is
108
+ - Gate: \`bun run check\` (add \`--json\` for machine output — it reaches both halves). It is
107
109
  \`x build --target static\` and then \`x verify\`; \`x verify\` alone leaves \`budgets\` with
108
- nothing to measure. \`.github/workflows/ci.yml\` runs \`bin/setup && bin/check\` on every push
109
- and pull request, so CI and your terminal run the same two commands.
110
+ nothing to measure. \`.github/workflows/ci.yml\` runs \`bun run setup && bun run check\` on
111
+ every push and pull request, so CI and your terminal run the same two commands.
110
112
  - \`.dz/\` belongs to the developerz.ai platform and is additive both ways: the scaffold writes
111
113
  nothing there, and nothing there is generated from this repo. Neither side clobbers the other.
112
114
  - Scaffold, do not hand-write: \`x g <kind> <name>\` — \`x g --help\` lists every kind, and is the
@@ -129,12 +131,12 @@ Built with [Ultimate](https://github.com/developerz-ai/ultimate). Bun-only, Post
129
131
  ## 🚀 Start
130
132
 
131
133
  \`\`\`sh
132
- bin/setup # prerequisites, deps, env, the first migration, migrate, seed, the manifest
133
- bin/dev # all roles in one process, embedded Postgres, /_x mounted
134
- bin/check # the gate: a static build, then typecheck, lint, boundaries, tests, drift, budgets
134
+ bun run setup # prerequisites, deps, env, the first migration, migrate, seed, the manifest
135
+ bun run dev # all roles in one process, embedded Postgres, /_x mounted
136
+ bun run check # the gate: a static build, then typecheck, lint, boundaries, tests, drift, budgets
135
137
  \`\`\`
136
138
 
137
- \`packages/db/migrations\` starts empty and \`x db gen\` is its only writer — \`bin/setup\` runs
139
+ \`packages/db/migrations\` starts empty and \`x db gen\` is its only writer — \`bun run setup\` runs
138
140
  \`x db gen "initial"\` for you on a fresh clone. Until it has, \`x verify\`'s \`drift\` step is red
139
141
  with \`X_DB_DRIFT\`, and that is the fix it names.
140
142
 
@@ -150,63 +152,8 @@ with \`X_DB_DRIFT\`, and that is the fix it names.
150
152
  | \`packages/*\` | domain, db, i18n, ui, mcp |
151
153
  | \`app.config.ts\` | the one config file |
152
154
  | \`x.manifest.json\` | generated facts: routes, actions, jobs, policies |
153
- | \`.github/workflows/ci.yml\` | \`bin/setup\` then \`bin/check\`, on push and pull request |
154
- `;
155
-
156
- const binSetup = (): string => `#!/usr/bin/env bash
157
- # Fresh clone to running. Idempotent: safe to re-run.
158
- set -euo pipefail
159
- cd "$(dirname "$0")/.."
160
- command -v bun >/dev/null || { echo "X_BUN_MISSING: install bun — https://bun.sh"; exit 1; }
161
- bun install
162
- [ -f .env.development.local ] || printf '# per-box secrets, gitignored, wins over .env.development\\n' > .env.development.local
163
- # \`x db gen\` is the ONE writer of packages/db/migrations — the scaffold no longer hand-writes a
164
- # 0000_initial.sql, because a second writer is how the source and the ledger ended up disagreeing
165
- # about what "initial" meant. Guarded on the directory rather than on the generator being a no-op:
166
- # this script is documented idempotent, and the guard is what makes that true here.
167
- ls packages/db/migrations/*.sql >/dev/null 2>&1 || bunx x db gen "initial"
168
- bunx x db migrate "$@"
169
- # \`x db seed\`, never \`bun run\`: the CLI owns the connection, so this reaches the same embedded
170
- # PGlite the migration above just wrote to. A plain script goes through \`db()\`, which needs a
171
- # \`postgres:\` DATABASE_URL and so dies on a clone with no Postgres — one line after reporting a
172
- # successful migration.
173
- bunx x db seed
174
- # The file \`AGENTS.md\` line 3 tells an agent facts live in, and \`x dev\` prints the path of. It
175
- # is a projection of the loaded app, so \`x new\` cannot write it — node_modules does not exist
176
- # yet — and nothing else ever ran the command: after \`x new\`, \`bin/setup\` and all 13
177
- # generators, \`find . -name '*.manifest.json'\` returned nothing while \`x verify\` reported
178
- # \`\u2713 manifest\`. \`x verify\`'s manifest step now refuses its absence (X_MANIFEST_MISSING).
179
- bunx x manifest
180
- echo "setup complete — next: bin/dev"
181
- `;
182
-
183
- const binDev = (): string => `#!/usr/bin/env bash
184
- # Every role in one process: embedded Postgres, in-process NATS, S3 to a local dir.
185
- set -euo pipefail
186
- cd "$(dirname "$0")/.."
187
- exec bunx x dev "$@"
188
- `;
189
-
190
- const binCheck = (): string => `#!/usr/bin/env bash
191
- # The gate. Same steps as CI, because a check that lives only in CI cannot be run locally.
192
- set -euo pipefail
193
- cd "$(dirname "$0")/.."
194
- # The build FIRST, and not as a convenience: \`x verify\`'s budgets step compares declared limits
195
- # against measured bytes in .x/build-stats.json, so with no build it reports X_BUDGET_UNMEASURED and
196
- # the very first gate anyone runs on a brand-new app is red for a reason that has nothing to do with
197
- # their code. Cheap on a warm tree, and it makes "green" reachable from a fresh clone.
198
- #
199
- # \`--json\` is forwarded to BOTH, or the contract breaks: \`bin/check --json\` would otherwise print
200
- # the build's human renderer to stdout and then the gate's JSON, and a machine consumer reading one
201
- # document off stdout gets neither. Both commands emit one object; a reader takes the last line.
202
- build_flags=""
203
- for arg in "$@"; do
204
- case "$arg" in --json|-j) build_flags="--json" ;; esac
205
- done
206
- # \`--no-preflight\`: the build's own preflight is x verify's first six steps (typecheck, lint,
207
- # boundaries, filesize, package-shape, errors), and the gate below runs them anyway — once is enough.
208
- bunx x build --target static --no-preflight $build_flags
209
- exec bunx x verify "$@"
155
+ | \`.github/workflows/ci.yml\` | \`bun run setup\` then \`bun run check\`, on push and pull request |
156
+ | \`.github/workflows/image.yml\` | after a green \`ci\` on the default branch: \`docker/Dockerfile\` to \`ghcr.io\`, tagged \`sha-<7>\`, always on \`ubuntu-latest\` (bash and Docker). \`ci.yml\` runs on \`vars.CI_RUNNER\`, default \`ubuntu-latest\` |
210
157
  `;
211
158
 
212
159
  const composeDev = (
@@ -243,7 +190,7 @@ services:
243
190
  # the region a request was signed for, and an app that sets no S3_REGION signs for \`auto\` — so
244
191
  # that is this gateway's region. A server for a laptop; production is any S3-compatible endpoint.
245
192
  s3:
246
- image: versity/versitygw:v1.8.0
193
+ image: versity/versitygw:v1.8.0@sha256:30292fc2eeacc67a36993b01f7a7a5e3361a19cced0e80c1d71cfa2a4b0a2499
247
194
  environment:
248
195
  VGW_BACKEND: posix
249
196
  VGW_BACKEND_ARG: /data
@@ -264,9 +211,8 @@ export function docsFiles(app: NameSet): readonly GeneratedFile[] {
264
211
  { path: 'README.md', contents: readme(app) },
265
212
  { path: 'AGENTS.md', contents: agents(app) },
266
213
  { path: 'CLAUDE.md', contents: claude(app) },
267
- { path: 'bin/setup', contents: binSetup() },
268
- { path: 'bin/dev', contents: binDev() },
269
- { path: 'bin/check', contents: binCheck() },
214
+ // The scripts `bun run setup` and `bun run check` run — TypeScript, so one file per OS.
215
+ ...binFiles(),
270
216
  { path: 'docker/docker-compose.dev.yml', contents: composeDev(app) },
271
217
  // The harness half of the same job AGENTS.md does. It lands in the app's own repo rather than
272
218
  // in a global config, so it is visible in the scaffold's diff and deletable in one line.
@@ -278,6 +224,3 @@ export function docsFiles(app: NameSet): readonly GeneratedFile[] {
278
224
  ...containerFiles(app),
279
225
  ];
280
226
  }
281
-
282
- /** Files that must be executable after `x new` writes them. */
283
- export const EXECUTABLE_FILES: readonly string[] = ['bin/setup', 'bin/dev', 'bin/check'];
@@ -23,10 +23,6 @@ import type { MoneyValue as Money } from '@ultimat3/schema';
23
23
 
24
24
  export type { MoneyValue as Money } from '@ultimat3/schema';
25
25
 
26
- export const ROLES = ['owner', 'member', 'viewer'] as const;
27
-
28
- export type Role = (typeof ROLES)[number];
29
-
30
26
  /**
31
27
  * Never a bare Error: an agent reading the failure needs the code, the cause and the exact command
32
28
  * that resolves it. Adding across currencies is a bug in the caller's data, not a runtime hiccup,
@@ -16,6 +16,7 @@ const server =
16
16
 
17
17
  import { join } from 'node:path';
18
18
  import { runRole } from '@ultimat3/cli/serve';
19
+ import { isCompiledBundle } from '@ultimat3/core';
19
20
 
20
21
  // MORE THAN ONE REPLICA? Add these two lines, above \`runRole\`:
21
22
  //
@@ -36,10 +37,10 @@ import { runRole } from '@ultimat3/cli/serve';
36
37
  * holds this module's bundled imports and none of the app's source, and the framework's registries
37
38
  * are filled by scanning that source at boot. So a binary reads its root from the directory it is
38
39
  * started in — it is a launcher for an app tree, not a self-contained copy of one.
40
+ * \`isCompiledBundle\` knows both spellings of that filesystem: \`/$bunfs/\` on Linux and macOS,
41
+ * \`B:\\~BUN\\\` on Windows.
39
42
  */
40
- const root = import.meta.dir.startsWith('/$bunfs')
41
- ? process.cwd()
42
- : join(import.meta.dir, '..', '..');
43
+ const root = isCompiledBundle(import.meta.dir) ? process.cwd() : join(import.meta.dir, '..', '..');
43
44
 
44
45
  // Guarded, because the framework's module scan imports every file under apps/*/ to fill its
45
46
  // registries — an unguarded boot would start a server inside \`x verify\`.
@@ -4,7 +4,7 @@
4
4
  // app whose own `x verify` fails on the drift the framework exists to prevent.
5
5
 
6
6
  import type { EnvSchema, EnvVarDecl } from '@ultimat3/core';
7
- import { renderEnvExample } from '@ultimat3/core';
7
+ import { appEnvExample } from '../framework-env';
8
8
 
9
9
  /**
10
10
  * Committed defaults only, so a fresh clone boots with `x dev` and no scavenger hunt. Every
@@ -90,9 +90,12 @@ export function envSchemaSource(schema: EnvSchema = SCAFFOLD_ENV_SCHEMA): string
90
90
  return lines.join('\n');
91
91
  }
92
92
 
93
- /** Byte-identical to what `x env example` writes, because both call core's one renderer. */
93
+ /**
94
+ * Byte-identical to what `x env example` writes, because both call the one renderer — the app's
95
+ * declaration, then the framework's deploy-required keys (`framework-env.ts`).
96
+ */
94
97
  export const envExampleSource = (schema: EnvSchema = SCAFFOLD_ENV_SCHEMA): string =>
95
- renderEnvExample(schema);
98
+ appEnvExample(schema);
96
99
 
97
100
  /** Named so a reader of `repoFiles` can see the two projections come from one declaration. */
98
101
  export const scaffoldEnvVarNames = (): readonly string[] =>
@@ -0,0 +1,59 @@
1
+ // The root `.gitattributes` `x new` writes: every text file LF in the index and the checkout, every
2
+ // binary type left byte-for-byte. The framework repo's own root file opens with the same block —
3
+ // `scaffold-gitattributes.test.ts` refuses the two drifting apart.
4
+
5
+ /** The path, relative to the app root — git reads attributes from here for the whole tree. */
6
+ export const ROOT_GITATTRIBUTES_PATH = '.gitattributes';
7
+
8
+ /**
9
+ * Binary types an app or the framework plausibly tracks: images, icons, fonts, WebAssembly and the
10
+ * archives fixtures arrive in. `text=auto` would usually detect them, but "usually" is a heuristic
11
+ * over the first 8000 bytes — `binary` is the statement, and it also stops a textual diff.
12
+ */
13
+ export const BINARY_EXTENSIONS: readonly string[] = [
14
+ 'png',
15
+ 'jpg',
16
+ 'jpeg',
17
+ 'gif',
18
+ 'webp',
19
+ 'avif',
20
+ 'ico',
21
+ 'woff',
22
+ 'woff2',
23
+ 'ttf',
24
+ 'otf',
25
+ 'wasm',
26
+ 'pdf',
27
+ 'zip',
28
+ 'gz',
29
+ 'tgz',
30
+ 'tar',
31
+ ];
32
+
33
+ /**
34
+ * `* text=auto eol=lf` is not cosmetic. Git for Windows installs with `core.autocrlf=true`, so a
35
+ * checkout without it rewrites every file to CRLF: Biome (`lineEnding: "lf"`) then flags every
36
+ * file, every `split('\n')` scanner sees a trailing `\r`, and every byte-compared artifact — the
37
+ * manifest, the schema dump, a migration's checksum — reads as drift on a correct tree. Pinned
38
+ * here, it holds for every clone whatever the contributor's git config says.
39
+ */
40
+ export const LINE_ENDING_ATTRIBUTES = `# Every text file is LF, in the index and in the checkout, whatever \`core.autocrlf\` says. Git for
41
+ # Windows defaults it to true: without this line a Windows clone is CRLF, Biome flags every file and
42
+ # every byte-compared artifact (manifest, schema dump, migration checksum) reads as drift.
43
+ * text=auto eol=lf
44
+
45
+ # Binary, stated rather than detected: never line-ending converted, never textually diffed.
46
+ ${BINARY_EXTENSIONS.map((extension) => `*.${extension} binary`).join('\n')}
47
+ `;
48
+
49
+ /** The app's root file: the shared block, then the app's one generated file git should collapse. */
50
+ export const rootGitattributes = (): string => `${LINE_ENDING_ATTRIBUTES}
51
+ # Bun's lockfile. Regenerated by \`bun install\`, never edited by hand.
52
+ bun.lock -diff linguist-generated=true
53
+ `;
54
+
55
+ /** The file `x new` writes at the app root — one entry in `repoFiles`' list. */
56
+ export const rootGitattributesFile = (): { readonly path: string; readonly contents: string } => ({
57
+ path: ROOT_GITATTRIBUTES_PATH,
58
+ contents: rootGitattributes(),
59
+ });
@@ -0,0 +1,76 @@
1
+ // What a compromised pod of the scaffolded chart can reach: its `/tmp`, bounded, and its ports,
2
+ // fenced by one NetworkPolicy per role. The framework chart's own (`docker/helm/`), mirrored —
3
+ // `scaffold-helm-parity.test.ts` holds the two to each other.
4
+
5
+ import type { GeneratedFile, NameSet } from './naming';
6
+
7
+ const volumes = (app: NameSet): string => `{{/*
8
+ The writable scratch every pod mounts at /tmp — the root filesystem is read-only. Bounded, because
9
+ an emptyDir with no sizeLimit is node disk: one role filling /tmp evicts its NEIGHBOURS for disk
10
+ pressure, where with a limit the kubelet evicts that pod alone.
11
+ */}}
12
+ {{- define "${app.kebab}.tmpVolume" -}}
13
+ - name: tmp
14
+ emptyDir:
15
+ sizeLimit: {{ required "tmp.sizeLimit is unset — set it in your values (e.g. 512Mi): an unbounded /tmp is node disk" (default dict .Values.tmp).sizeLimit | quote }}
16
+ {{- end -}}
17
+ `;
18
+
19
+ const networkPolicy = (app: NameSet): string => `{{/*
20
+ One NetworkPolicy per enabled role: \`http\` (web, sync) from networkPolicy.httpFrom, \`metrics\`
21
+ (every role) from networkPolicy.metricsFrom, nothing else. A \`from\` that renders empty is omitted,
22
+ which Kubernetes reads as "any source". Egress is filtered only when networkPolicy.egress lists
23
+ rules — the database, NATS and object store are your addresses, so list DNS too. Serving roles
24
+ only: the migrate Job is a pre-install hook that runs before this exists, and opens no port.
25
+ */}}
26
+ {{- if .Values.networkPolicy.enabled }}
27
+ {{- range $role, $cfg := .Values.roles }}
28
+ {{- if $cfg.enabled }}
29
+ ---
30
+ apiVersion: networking.k8s.io/v1
31
+ kind: NetworkPolicy
32
+ metadata:
33
+ name: {{ include "${app.kebab}.fullname" $ }}-{{ $role }}
34
+ labels:
35
+ {{- include "${app.kebab}.labels" $ | nindent 4 }}
36
+ app.kubernetes.io/component: {{ $role }}
37
+ spec:
38
+ podSelector:
39
+ matchLabels:
40
+ app.kubernetes.io/instance: {{ $.Release.Name }}
41
+ app.kubernetes.io/component: {{ $role }}
42
+ policyTypes:
43
+ - Ingress
44
+ {{- if $.Values.networkPolicy.egress }}
45
+ - Egress
46
+ {{- end }}
47
+ ingress:
48
+ {{- if $cfg.port }}
49
+ - ports:
50
+ - port: http
51
+ protocol: TCP
52
+ {{- with $.Values.networkPolicy.httpFrom }}
53
+ from: {{- toYaml . | nindent 8 }}
54
+ {{- end }}
55
+ {{- end }}
56
+ - ports:
57
+ - port: metrics
58
+ protocol: TCP
59
+ {{- with $.Values.networkPolicy.metricsFrom }}
60
+ from: {{- toYaml . | nindent 8 }}
61
+ {{- end }}
62
+ {{- with $.Values.networkPolicy.egress }}
63
+ egress: {{- toYaml . | nindent 4 }}
64
+ {{- end }}
65
+ {{- end }}
66
+ {{- end }}
67
+ {{- end }}
68
+ `;
69
+
70
+ /** The bounded /tmp helper and the per-role NetworkPolicy, as two files of the chart's templates. */
71
+ export function helmFenceFiles(app: NameSet): readonly GeneratedFile[] {
72
+ return [
73
+ { path: 'docker/helm/templates/_volumes.tpl', contents: volumes(app) },
74
+ { path: 'docker/helm/templates/networkpolicy.yaml', contents: networkPolicy(app) },
75
+ ];
76
+ }
@@ -3,6 +3,7 @@
3
3
  // Split from scaffold-helm.ts, which holds the chart's INPUTS (Chart.yaml, values.yaml).
4
4
 
5
5
  import type { GeneratedFile, NameSet } from './naming';
6
+ import { helmFenceFiles } from './scaffold-helm-fences';
6
7
 
7
8
  const helpers = (
8
9
  app: NameSet,
@@ -28,11 +29,13 @@ every object would be ${app.kebab}-${app.kebab}-web.
28
29
 
29
30
  {{/*
30
31
  repository:tag, and NOT a single "image" string: x deploy --method helm passes
31
- --set image.repository=... --set image.tag=..., because replacing the map with a string makes
32
- every workload below fail on .repository and deploy nothing.
32
+ --set-string image.repository=... --set-string image.tag=..., because replacing the map with a
33
+ string makes every workload below fail on .repository and deploy nothing. toString on both halves:
34
+ a plain --set image.tag=1234567 is an int64, which printf renders as %!s(int64=1234567).
33
35
  */}}
34
36
  {{- define "${app.kebab}.image" -}}
35
- {{- printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag) -}}
37
+ {{- $repository := required "image.repository is empty — set it to the registry path your CI pushes the app image to: --set-string image.repository=ghcr.io/<org>/<app>, or x deploy --method helm --image ghcr.io/<org>/<app>:<tag>" .Values.image.repository -}}
38
+ {{- printf "%s:%s" (toString $repository) (toString (default .Chart.AppVersion .Values.image.tag)) -}}
36
39
  {{- end -}}
37
40
 
38
41
  {{- define "${app.kebab}.labels" -}}
@@ -65,7 +68,7 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
65
68
  {{- $envPort = sub (int $cfg.port) 1 -}}
66
69
  {{- end -}}
67
70
  - name: {{ $role }}
68
- image: {{ include "${app.kebab}.image" $root }}
71
+ image: {{ include "${app.kebab}.image" $root | quote }}
69
72
  imagePullPolicy: {{ $root.Values.image.pullPolicy }}
70
73
  securityContext: {{- toYaml $root.Values.securityContext | nindent 4 }}
71
74
  env:
@@ -79,13 +82,28 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
79
82
  - name: METRICS_PORT
80
83
  value: {{ $root.Values.metricsPort | quote }}
81
84
  {{- end }}
85
+ {{- /*
86
+ The sync node's page origin, from the one place this chart already knows it. Undeclared, a node
87
+ reached over plain http behind this Ingress admits BOTH spellings of its host — the plain-http
88
+ page included. env.APP_URL wins when set; an APP_URL kept in the Secret is shadowed for this
89
+ role, so pages served from another host than the Ingress's must say so in env.
90
+ */}}
91
+ {{- if and (eq $role "sync") $root.Values.ingress.enabled (not (hasKey $root.Values.env "APP_URL")) }}
92
+ - name: APP_URL
93
+ value: {{ printf "%s://%s" (ternary "https" "http" $root.Values.ingress.tls) $root.Values.ingress.host | quote }}
94
+ {{- end }}
82
95
  {{- range $key, $value := $root.Values.env }}
83
96
  - name: {{ $key }}
84
97
  value: {{ $value | quote }}
85
98
  {{- end }}
99
+ {{- /*
100
+ The Secret a role reads, per role: roles.<role>.existingSecret (or migrate.existingSecret) names a
101
+ narrower one than the release-wide existingSecret, so the migrate Job's schema-owning credential
102
+ need not sit on a web pod. Both unset is a render error, not a pod with no configuration.
103
+ */}}
86
104
  envFrom:
87
105
  - secretRef:
88
- name: {{ $root.Values.existingSecret }}
106
+ name: {{ required (printf "no Secret for role %s — set existingSecret, or roles.%s.existingSecret, in your values" $role $role) (default $root.Values.existingSecret $cfg.existingSecret) }}
89
107
  {{- if or $cfg.port $scraped }}
90
108
  ports:
91
109
  {{- if $cfg.port }}
@@ -100,8 +118,12 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
100
118
  {{/*
101
119
  Probes follow the role, because the roles do not agree on what they open. web and sync serve
102
120
  HTTP and get both. worker, scheduler and replicator open no HTTP socket at all — the scrape
103
- listener is their only port — so they take liveness on it and NO readiness: nothing routes to
104
- them, and a readiness flap would drop the pod out of the Service and so out of the scrape.
121
+ listener is their only port — so they take liveness on it, and readiness on /readyz of that same
122
+ listener: 503 until the role has started and from the first instant of a drain, so a rollout
123
+ never counts a worker that has not started its role yet. The replicator reads /readyz?deep=1:
124
+ 503 while the replication stream is down, so the pod shows 0/1 instead of a healthy pod feeding
125
+ nothing. Their headless Service publishes not-ready addresses, so a not-ready pod is still
126
+ scraped.
105
127
 
106
128
  Every one of them takes a startupProbe too, because no listener opens early: the server builds
107
129
  the app's islands before any role binds a port, and a liveness probe counting from container
@@ -134,22 +156,69 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
134
156
  httpGet: { path: /metrics, port: metrics }
135
157
  periodSeconds: 5
136
158
  failureThreshold: 30
159
+ readinessProbe:
160
+ {{- if eq $role "replicator" }}
161
+ httpGet: { path: '/readyz?deep=1', port: metrics }
162
+ periodSeconds: 10
163
+ {{- else }}
164
+ httpGet: { path: /readyz, port: metrics }
165
+ periodSeconds: 5
166
+ {{- end }}
137
167
  livenessProbe:
138
168
  httpGet: { path: /metrics, port: metrics }
139
169
  periodSeconds: 15
140
170
  failureThreshold: 4
171
+ {{- /*
172
+ The worker's retire, opt-in: roles.worker.retireSeconds > 0. The preStop sends PID 1 SIGUSR2 —
173
+ the framework's retire: stop claiming, finish every held job however long it takes, abort
174
+ nothing, then drain and exit 0 — and waits until PID 1 is gone, so the SIGTERM that follows finds
175
+ nothing left to cut off. bun -e, never kill/sleep: bun is the image's ENTRYPOINT (exec form, so it
176
+ IS PID 1). The kubelet counts the preStop against the grace period, so retireSeconds is added to
177
+ it and is the longest a retire may take.
178
+ */}}
179
+ {{- if and (eq $role "worker") (gt (int $cfg.retireSeconds) 0) }}
180
+ lifecycle:
181
+ preStop:
182
+ exec:
183
+ command:
184
+ - bun
185
+ - -e
186
+ - "process.kill(1, 'SIGUSR2'); for (;;) { try { process.kill(1, 0); } catch { break; } await Bun.sleep(1000); }"
187
+ {{- end }}
141
188
  {{- end }}
142
189
  resources: {{- toYaml $cfg.resources | nindent 4 }}
143
190
  volumeMounts:
144
191
  - name: tmp
145
192
  mountPath: /tmp
146
193
  {{- end -}}
194
+
195
+ {{/*
196
+ A role's terminationGracePeriodSeconds, in the order the kubelet's budget is spent: the preStop
197
+ sleep (web and sync, 1.30+), the worker's opt-in retire (roles.worker.retireSeconds), the
198
+ readiness grace (web and sync — every other role drains with none), the drain budget (app.config.ts drain.deadlineMs, every role) and the teardown margin.
199
+ Defaults: 45s web/sync (40s below 1.30), 35s the rest. x deploy --method helm sets
200
+ drain.deadlineSeconds and drain.readinessGraceSeconds from app.config.ts.
201
+ */}}
202
+ {{- define "${app.kebab}.terminationGracePeriodSeconds" -}}
203
+ {{- $drain := .root.Values.drain -}}
204
+ {{- $total := add (int $drain.deadlineSeconds) (int $drain.teardownMarginSeconds) -}}
205
+ {{- if eq .role "worker" -}}
206
+ {{- $total = add $total (int .cfg.retireSeconds) -}}
207
+ {{- end -}}
208
+ {{- if .cfg.port -}}
209
+ {{- $total = add $total (int $drain.readinessGraceSeconds) -}}
210
+ {{- if semverCompare ">=1.30-0" .root.Capabilities.KubeVersion.Version -}}
211
+ {{- $total = add $total (int $drain.preStopSleepSeconds) -}}
212
+ {{- end -}}
213
+ {{- end -}}
214
+ {{- $total -}}
215
+ {{- end -}}
147
216
  `;
148
217
 
149
218
  const deployments = (app: NameSet): string => `{{/*
150
219
  One Deployment per enabled role, from one image. terminationGracePeriodSeconds covers the preStop
151
- sleep, the framework's readiness grace and its SIGTERM drain: in-flight requests, open websockets
152
- and running job steps finish.
220
+ sleep, the framework's readiness grace and its SIGTERM drain — in-flight requests, open websockets
221
+ and running job steps finish — derived per role from drain in values.yaml.
153
222
  */}}
154
223
  {{- range $role, $cfg := .Values.roles }}
155
224
  {{- if $cfg.enabled }}
@@ -171,9 +240,21 @@ spec:
171
240
  matchLabels:
172
241
  app.kubernetes.io/instance: {{ $.Release.Name }}
173
242
  app.kubernetes.io/component: {{ $role }}
243
+ {{/*
244
+ The replicator is Recreate, never a rolling update: one pod per database holds the replication
245
+ slot's advisory lock, so a surged pod can only stand by, unready, and maxUnavailable: 0 never
246
+ terminates the holder it is waiting on — every rollout stalled. Recreate lets the holder go first.
247
+ */}}
248
+ {{/* A new pod must stay Ready this long before the rollout counts it and stops an old one, so a
249
+ role that crashes seconds after its first probe never replaces a working pod. */}}
250
+ minReadySeconds: {{ $.Values.minReadySeconds | int }}
174
251
  strategy:
252
+ {{- if eq $role "replicator" }}
253
+ type: Recreate
254
+ {{- else }}
175
255
  type: RollingUpdate
176
256
  rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }
257
+ {{- end }}
177
258
  template:
178
259
  metadata:
179
260
  labels:
@@ -185,17 +266,14 @@ spec:
185
266
  code that reaches the filesystem, which for a web role is one path traversal. */}}
186
267
  automountServiceAccountToken: false
187
268
  securityContext: {{- toYaml $.Values.podSecurityContext | nindent 8 }}
188
- {{/* Spent in order: the preStop sleep (drain.preStopSleepSeconds, 1.30+), the framework's
189
- readiness grace (5s outside local environments) and its drain deadline (25s, which is what
190
- X_SHUTDOWN_TIMEOUT's fix line names) — 35s, so 45 leaves 10s before SIGKILL. Raise this WITH
191
- configureLifecycle({ deadlineMs, readinessGraceMs }), never instead of it: the drain
192
- abandons its hooks at its own deadline. */}}
193
- terminationGracePeriodSeconds: 45
269
+ {{/* Derived per role from the drain values — see the helper in _helpers.tpl. Raise
270
+ drain.deadlineMs in app.config.ts, never this: the drain abandons its hooks at its own
271
+ deadline, so a longer grace period alone buys nothing. */}}
272
+ terminationGracePeriodSeconds: {{ include "${app.kebab}.terminationGracePeriodSeconds" (dict "role" $role "cfg" $cfg "root" $) }}
194
273
  containers:
195
274
  {{- include "${app.kebab}.container" (dict "role" $role "cfg" $cfg "root" $) | nindent 8 }}
196
275
  volumes:
197
- - name: tmp
198
- emptyDir: {}
276
+ {{- include "${app.kebab}.tmpVolume" $ | nindent 8 }}
199
277
  {{- end }}
200
278
  {{- end }}
201
279
  `;
@@ -219,6 +297,9 @@ spec:
219
297
  type: ClusterIP
220
298
  {{- if not $cfg.port }}
221
299
  clusterIP: None
300
+ # Scraped whether or not ready: a replicator whose stream is down (readiness 503) is the pod
301
+ # whose metrics matter most, and nothing routes to these addresses anyway.
302
+ publishNotReadyAddresses: true
222
303
  {{- end }}
223
304
  selector:
224
305
  app.kubernetes.io/instance: {{ $.Release.Name }}
@@ -267,8 +348,7 @@ spec:
267
348
  containers:
268
349
  {{- include "${app.kebab}.container" (dict "role" "migrate" "cfg" .Values.migrate "root" .) | nindent 8 }}
269
350
  volumes:
270
- - name: tmp
271
- emptyDir: {}
351
+ {{- include "${app.kebab}.tmpVolume" . | nindent 8 }}
272
352
  {{- end }}
273
353
  `;
274
354
 
@@ -382,5 +462,6 @@ export function helmTemplateFiles(app: NameSet): readonly GeneratedFile[] {
382
462
  { path: 'docker/helm/templates/migrate-job.yaml', contents: migrateJob(app) },
383
463
  { path: 'docker/helm/templates/ingress.yaml', contents: ingress(app) },
384
464
  { path: 'docker/helm/templates/hpa.yaml', contents: hpa(app) },
465
+ ...helmFenceFiles(app),
385
466
  ];
386
467
  }