@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
@@ -22,7 +22,7 @@ export const routeConfig = (load: string): string => `export const config = defi
22
22
  // toggle, provider, the ui runtime they share and the error registry are the rest. It was
23
23
  // 60.9kb before the framework stopped shipping solid-js twice and the i18n catalog with it
24
24
  // (issue #490), which is what put this at 64kb; 60kb is the figure the scaffold budgets held
25
- // before that, kept rather than tightened so a Bun patch cannot red a first \`bin/check\`.
25
+ // before that, kept rather than tightened so a Bun patch cannot red a first \`bun run check\`.
26
26
  budget: { js: '60kb' },${load}
27
27
  meta: ({ t }) => ({
28
28
  title: t('app.dashboard.title'),
@@ -55,4 +55,65 @@ export const dashboardStyle = (): string => `@use '@ultimat3/ui/tokens' as token
55
55
  margin-inline: auto;
56
56
  color: tokens.role('fg');
57
57
  }
58
+
59
+ // The trend beside the ring and the dial: side by side once the main column is wide enough for the
60
+ // area chart to keep its 14 day labels legible, stacked before that.
61
+ .charts {
62
+ display: grid;
63
+ gap: tokens.space(6);
64
+ min-inline-size: 0;
65
+
66
+ @include tokens.respond-to('lg') {
67
+ grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
68
+ align-items: start;
69
+ }
70
+ }
71
+
72
+ // The ring and the dial share a column: two across on a tablet, where one alone would leave half
73
+ // the row empty, one above the other on a phone and in the narrow side column of a monitor.
74
+ .side {
75
+ display: grid;
76
+ gap: tokens.space(4);
77
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
78
+ }
79
+ `;
80
+
81
+ /**
82
+ * `apps/web/shared/theme.ts`: the app's brand, which the boot reads by this path and inlines in every
83
+ * document (`theme-brand.ts`). Emitted as the default theme — `defineTheme({})` renders no CSS, so
84
+ * no tag and no CSP entry — with the preset one edit away: the look is the app's choice (axiom 8).
85
+ */
86
+ export const themeFile =
87
+ (): string => `// This app's brand: the boot reads \`brand\` from this file and inlines it in every document,
88
+ // after global.scss, with its hash in the CSP. \`defineTheme({})\` is @ultimat3/ui's default theme
89
+ // and renders nothing, so today no tag is emitted.
90
+ //
91
+ // \`defineTheme()\` is the one seam for restyling — colours, radii, fonts and shadows by role,
92
+ // validated at the call. The dashboard look (near-black panels, a cyan accent, glowing edges, its
93
+ // own eight chart series) is one edit:
94
+ //
95
+ // export const brand = defineTheme({ preset: 'scifi' });
96
+ //
97
+ // Every other slot layers onto the preset role by role:
98
+ // defineTheme({ preset: 'scifi', colors: { dark: { 'chart-1': '120 220 255' } } })
99
+ // A preset never forces a theme: to open dark whatever the OS says, set \`theme.defaultMode\` in
100
+ // app.config.ts.
101
+ import { defineTheme } from '@ultimat3/ui';
102
+
103
+ export const brand = defineTheme({});
104
+ `;
105
+
106
+ /** Its test: what the brand renders is a fact the app pins, so opting in is a visible diff. */
107
+ export const themeTest =
108
+ (): string => `// The brand the boot inlines. The default theme renders no CSS — no \`<style>\`, no CSP entry —
109
+ // and the preset is a whole palette; either way \`defineTheme()\` validated it at import.
110
+ import { expect, unitTest } from '@ultimat3/testing';
111
+ import { defineTheme } from '@ultimat3/ui';
112
+ import { brand } from './theme';
113
+
114
+ unitTest('the app ships the default theme until it opts into a preset', () => {
115
+ expect(brand.css).toBe('');
116
+ // The opt-in is one edit, and what it would ship is real CSS the boot inlines.
117
+ expect(defineTheme({ preset: 'scifi' }).css).toContain('--color-');
118
+ });
58
119
  `;
@@ -2,25 +2,29 @@
2
2
  // stopped being an `<h1>` in a panel. Two shapes, one per `x new` invocation, and both show only
3
3
  // what is true of the app that was just written:
4
4
  //
5
- // --example the seeded `post` slice: a stat row, a bar chart of posts per day, the latest
6
- // posts in a table. Real rows from the app's own repo, aggregated by a pure view
7
- // module this file also emits.
5
+ // --example the seeded `post` slice: a stat row, an area chart of posts per day, a ring of
6
+ // free against paid, a dial of this week's share, the latest posts in a table.
7
+ // Real rows from the app's own repo, aggregated by a pure view module this file
8
+ // also emits.
8
9
  // --no-example no entity, so no rows to count and nothing honest to chart. Framework facts
9
10
  // instead — routes, locales, roles, version — and the route table. A chart of
10
11
  // invented numbers would teach the wrong thing to every app that starts here.
11
12
  //
12
- // One island on the page, the theme toggle (`scaffold-shell.ts`); the tiles, chart and table are
13
+ // One island on the page, the theme toggle (`scaffold-shell.ts`); the tiles, charts and table are
13
14
  // server markup, which is what keeps the route inside its 60kb budget.
14
15
 
15
16
  import type { GeneratedFile, NameSet } from './naming';
16
17
  import { bareDashboardFiles } from './scaffold-dashboard-bare';
17
18
  import { exampleDashboardFiles } from './scaffold-dashboard-example';
18
- import { DASHBOARD_DIR, dashboardStyle } from './scaffold-dashboard-shared';
19
+ import { DASHBOARD_DIR, dashboardStyle, themeFile, themeTest } from './scaffold-dashboard-shared';
19
20
 
20
21
  /** `apps/web/app/dashboard/`, in the shape the invocation earns. */
21
22
  export function dashboardFiles(app: NameSet, example: boolean): readonly GeneratedFile[] {
22
23
  return [
23
24
  ...(example ? exampleDashboardFiles(app) : bareDashboardFiles(app)),
24
25
  { path: `${DASHBOARD_DIR}/page.module.scss`, contents: dashboardStyle() },
26
+ // Both shapes: the look is the app's to choose whether or not it has an entity yet.
27
+ { path: 'apps/web/shared/theme.ts', contents: themeFile() },
28
+ { path: 'apps/web/shared/theme.test.ts', contents: themeTest() },
25
29
  ];
26
30
  }
@@ -165,7 +165,7 @@ const dbSeedTest = (app: NameSet, example: boolean): string =>
165
165
  // development viewer's tenant policy refuses, and the dashboard then counts zero.
166
166
  ${sortedImports([
167
167
  `import { DEMO_ORG_ID } from '@${app.kebab}/web/shared/demo-org';`,
168
- "import { createContext, runWithContext } from '@ultimat3/core';",
168
+ "import { ctxOf, runWithContext } from '@ultimat3/core';",
169
169
  "import { testActor } from '@ultimat3/policy';",
170
170
  "import { afterEach, expect, unitTest } from '@ultimat3/testing';",
171
171
  ])}
@@ -174,7 +174,7 @@ import { ${app.camel}Seed } from './seed';
174
174
 
175
175
  /** The development viewer's org: every read below runs as an actor inside it. */
176
176
  const asViewer = <T>(run: () => Promise<T>): Promise<T> =>
177
- runWithContext(createContext({ actor: testActor('viewer', { orgId: DEMO_ORG_ID }).actor }), run);
177
+ runWithContext(ctxOf({ actor: testActor('viewer', { orgId: DEMO_ORG_ID }).actor }), run);
178
178
 
179
179
  // One store per process: without this, one test's rows are the next test's fixtures.
180
180
  afterEach(() => {
@@ -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
+ });
@@ -29,11 +29,13 @@ every object would be ${app.kebab}-${app.kebab}-web.
29
29
 
30
30
  {{/*
31
31
  repository:tag, and NOT a single "image" string: x deploy --method helm passes
32
- --set image.repository=... --set image.tag=..., because replacing the map with a string makes
33
- 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).
34
35
  */}}
35
36
  {{- define "${app.kebab}.image" -}}
36
- {{- 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)) -}}
37
39
  {{- end -}}
38
40
 
39
41
  {{- define "${app.kebab}.labels" -}}
@@ -66,7 +68,7 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
66
68
  {{- $envPort = sub (int $cfg.port) 1 -}}
67
69
  {{- end -}}
68
70
  - name: {{ $role }}
69
- image: {{ include "${app.kebab}.image" $root }}
71
+ image: {{ include "${app.kebab}.image" $root | quote }}
70
72
  imagePullPolicy: {{ $root.Values.image.pullPolicy }}
71
73
  securityContext: {{- toYaml $root.Values.securityContext | nindent 4 }}
72
74
  env:
@@ -116,10 +118,12 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
116
118
  {{/*
117
119
  Probes follow the role, because the roles do not agree on what they open. web and sync serve
118
120
  HTTP and get both. worker, scheduler and replicator open no HTTP socket at all — the scrape
119
- listener is their only port — so they take liveness on it. Readiness for replicator alone, on
120
- /readyz of that listener: 503 while the replication stream is down, so the pod shows 0/1 instead
121
- of a healthy pod feeding nothing. Their headless Service publishes not-ready addresses, so a
122
- not-ready pod is still scraped.
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.
123
127
 
124
128
  Every one of them takes a startupProbe too, because no listener opens early: the server builds
125
129
  the app's islands before any role binds a port, and a liveness probe counting from container
@@ -152,27 +156,69 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
152
156
  httpGet: { path: /metrics, port: metrics }
153
157
  periodSeconds: 5
154
158
  failureThreshold: 30
155
- {{- if eq $role "replicator" }}
156
159
  readinessProbe:
160
+ {{- if eq $role "replicator" }}
157
161
  httpGet: { path: '/readyz?deep=1', port: metrics }
158
162
  periodSeconds: 10
159
- {{- end }}
163
+ {{- else }}
164
+ httpGet: { path: /readyz, port: metrics }
165
+ periodSeconds: 5
166
+ {{- end }}
160
167
  livenessProbe:
161
168
  httpGet: { path: /metrics, port: metrics }
162
169
  periodSeconds: 15
163
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 }}
164
188
  {{- end }}
165
189
  resources: {{- toYaml $cfg.resources | nindent 4 }}
166
190
  volumeMounts:
167
191
  - name: tmp
168
192
  mountPath: /tmp
169
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 -}}
170
216
  `;
171
217
 
172
218
  const deployments = (app: NameSet): string => `{{/*
173
219
  One Deployment per enabled role, from one image. terminationGracePeriodSeconds covers the preStop
174
- sleep, the framework's readiness grace and its SIGTERM drain: in-flight requests, open websockets
175
- 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.
176
222
  */}}
177
223
  {{- range $role, $cfg := .Values.roles }}
178
224
  {{- if $cfg.enabled }}
@@ -194,9 +240,21 @@ spec:
194
240
  matchLabels:
195
241
  app.kubernetes.io/instance: {{ $.Release.Name }}
196
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 }}
197
251
  strategy:
252
+ {{- if eq $role "replicator" }}
253
+ type: Recreate
254
+ {{- else }}
198
255
  type: RollingUpdate
199
256
  rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }
257
+ {{- end }}
200
258
  template:
201
259
  metadata:
202
260
  labels:
@@ -208,12 +266,10 @@ spec:
208
266
  code that reaches the filesystem, which for a web role is one path traversal. */}}
209
267
  automountServiceAccountToken: false
210
268
  securityContext: {{- toYaml $.Values.podSecurityContext | nindent 8 }}
211
- {{/* Spent in order: the preStop sleep (drain.preStopSleepSeconds, 1.30+), the framework's
212
- readiness grace (5s outside local environments) and its drain deadline (25s, which is what
213
- X_SHUTDOWN_TIMEOUT's fix line names) — 35s, so 45 leaves 10s before SIGKILL. Raise this WITH
214
- configureLifecycle({ deadlineMs, readinessGraceMs }), never instead of it: the drain
215
- abandons its hooks at its own deadline. */}}
216
- 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" $) }}
217
273
  containers:
218
274
  {{- include "${app.kebab}.container" (dict "role" $role "cfg" $cfg "root" $) | nindent 8 }}
219
275
  volumes: