@ultimat3/cli 21.0.0 → 22.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 (234) hide show
  1. package/CLAUDE.md +84 -1650
  2. package/README.md +40 -2
  3. package/package.json +30 -30
  4. package/src/api-registration.ts +124 -0
  5. package/src/app-artifacts.ts +39 -0
  6. package/src/app-boundaries.ts +5 -10
  7. package/src/app-load.ts +37 -5
  8. package/src/app-openapi.ts +27 -0
  9. package/src/app-permissions.ts +0 -0
  10. package/src/app-root.ts +1 -1
  11. package/src/async-pages.ts +32 -0
  12. package/src/bin.ts +7 -1
  13. package/src/boundary-findings.ts +36 -0
  14. package/src/browser-launcher-fake-html.ts +80 -0
  15. package/src/browser-launcher-fake.ts +165 -0
  16. package/src/browser-launcher-port.ts +159 -0
  17. package/src/browser-launcher.ts +26 -133
  18. package/src/budgets.ts +11 -1
  19. package/src/cdp-shot-a11y.ts +77 -0
  20. package/src/cdp-shot-clock.ts +14 -0
  21. package/src/cdp-shot-driver.ts +150 -0
  22. package/src/cdp-shot-element.ts +147 -0
  23. package/src/cdp-shot-errors.ts +62 -0
  24. package/src/cdp-shot-keys.ts +152 -0
  25. package/src/cdp-shot-page.ts +230 -0
  26. package/src/cdp-shot-watch.ts +241 -0
  27. package/src/cmd-affected-spec.ts +24 -0
  28. package/src/cmd-affected.ts +3 -19
  29. package/src/cmd-build-spec.ts +16 -0
  30. package/src/cmd-build.ts +46 -20
  31. package/src/cmd-ci-spec.ts +24 -0
  32. package/src/cmd-ci.ts +17 -21
  33. package/src/cmd-db-branch.ts +2 -2
  34. package/src/cmd-db-spec.ts +99 -0
  35. package/src/cmd-db.ts +15 -94
  36. package/src/cmd-deploy-helm.ts +136 -0
  37. package/src/cmd-deploy-spec.ts +40 -0
  38. package/src/cmd-deploy.ts +111 -43
  39. package/src/cmd-dev-spec.ts +25 -0
  40. package/src/cmd-dev.ts +55 -36
  41. package/src/cmd-docs-spec.ts +16 -0
  42. package/src/cmd-docs.ts +2 -11
  43. package/src/cmd-doctor-spec.ts +21 -0
  44. package/src/cmd-doctor.ts +4 -18
  45. package/src/cmd-env-spec.ts +18 -0
  46. package/src/cmd-env.ts +4 -13
  47. package/src/cmd-errors-spec.ts +23 -0
  48. package/src/cmd-errors.ts +3 -17
  49. package/src/cmd-fix-spec.ts +18 -0
  50. package/src/cmd-fix.ts +4 -13
  51. package/src/cmd-generate-spec.ts +31 -0
  52. package/src/cmd-generate.ts +46 -42
  53. package/src/cmd-i18n-spec.ts +16 -0
  54. package/src/cmd-i18n.ts +7 -14
  55. package/src/cmd-jobs-spec.ts +56 -0
  56. package/src/cmd-jobs.ts +3 -50
  57. package/src/cmd-manifest-spec.ts +15 -0
  58. package/src/cmd-manifest.ts +19 -20
  59. package/src/cmd-mcp-spec.ts +21 -0
  60. package/src/cmd-mcp.ts +2 -15
  61. package/src/cmd-new-spec.ts +33 -0
  62. package/src/cmd-new.ts +38 -33
  63. package/src/cmd-policy-spec.ts +13 -0
  64. package/src/cmd-policy.ts +2 -9
  65. package/src/cmd-pr-spec.ts +39 -0
  66. package/src/cmd-pr.ts +5 -65
  67. package/src/cmd-registries-spec.ts +32 -0
  68. package/src/cmd-registries.ts +4 -25
  69. package/src/cmd-routes-spec.ts +12 -0
  70. package/src/cmd-routes.ts +2 -7
  71. package/src/cmd-secrets-spec.ts +19 -0
  72. package/src/cmd-secrets.ts +22 -34
  73. package/src/cmd-shot-island.ts +12 -13
  74. package/src/cmd-shot-spec.ts +53 -0
  75. package/src/cmd-shot.ts +11 -60
  76. package/src/cmd-tasks-spec.ts +21 -0
  77. package/src/cmd-tasks.ts +2 -16
  78. package/src/cmd-test-spec.ts +54 -0
  79. package/src/cmd-test.ts +4 -49
  80. package/src/cmd-verify-spec.ts +28 -0
  81. package/src/cmd-verify.ts +3 -23
  82. package/src/db-seed.ts +4 -2
  83. package/src/dev-dashboard.ts +4 -4
  84. package/src/dev-lock.ts +18 -2
  85. package/src/dev-port.ts +21 -0
  86. package/src/dev-route-table.ts +3 -3
  87. package/src/dispatch.ts +12 -5
  88. package/src/document-styles.ts +1 -1
  89. package/src/drift.ts +4 -18
  90. package/src/error-catalog.ts +16 -9
  91. package/src/error-codes.ts +28 -30
  92. package/src/error-pages.ts +19 -7
  93. package/src/errors.ts +14 -0
  94. package/src/favicon.ts +2 -2
  95. package/src/fix-imports.ts +1 -1
  96. package/src/fix-scan.ts +2 -9
  97. package/src/flag-reads.ts +1 -2
  98. package/src/foreign-text.ts +36 -0
  99. package/src/framework-schema.ts +5 -6
  100. package/src/generate-feature.ts +42 -0
  101. package/src/generate-files.ts +7 -3
  102. package/src/generate-grants.ts +83 -0
  103. package/src/generate-kinds.ts +56 -4
  104. package/src/i18n-index.ts +59 -10
  105. package/src/icon-assets.ts +1 -1
  106. package/src/image-prepare.ts +14 -0
  107. package/src/import-scan.ts +63 -0
  108. package/src/index.ts +67 -348
  109. package/src/invocation-flags.ts +26 -0
  110. package/src/island-bundle.ts +6 -1
  111. package/src/island-capture.ts +6 -6
  112. package/src/island-harness-script.ts +6 -2
  113. package/src/island-realtime.ts +15 -8
  114. package/src/island-shot.ts +5 -0
  115. package/src/island-store.ts +131 -0
  116. package/src/island-verdict.ts +1 -1
  117. package/src/job-registration.ts +42 -0
  118. package/src/jobs-driver.ts +2 -2
  119. package/src/load-findings.ts +51 -0
  120. package/src/mcp-db-target.ts +1 -1
  121. package/src/mcp-errors.ts +28 -27
  122. package/src/mcp-host.ts +27 -12
  123. package/src/mcp-ui-diff.ts +27 -0
  124. package/src/mcp-ui-inspect.ts +4 -4
  125. package/src/mcp-ui-interact.ts +21 -15
  126. package/src/mcp-ui.ts +19 -15
  127. package/src/measure-database.ts +73 -0
  128. package/src/measure-paths.ts +74 -0
  129. package/src/measure-scope.ts +74 -0
  130. package/src/messages.ts +1 -3
  131. package/src/metrics-endpoint.ts +1 -1
  132. package/src/otlp-export.ts +1 -1
  133. package/src/output.ts +6 -0
  134. package/src/permission-grants.ts +86 -0
  135. package/src/prerender-out.ts +25 -0
  136. package/src/prerender.ts +121 -102
  137. package/src/pwa-artifacts.ts +3 -3
  138. package/src/reexport-manifest.ts +2 -1
  139. package/src/registry.ts +80 -56
  140. package/src/role-realtime.ts +36 -0
  141. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  142. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  143. package/src/role-start-types.ts +112 -0
  144. package/src/{dev-roles.ts → role-start.ts} +42 -115
  145. package/src/{dev-sync.ts → role-sync.ts} +18 -8
  146. package/src/root-env.ts +67 -0
  147. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  148. package/src/{dev-services.ts → runtime-bindings.ts} +39 -16
  149. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  150. package/src/runtime-jobs.ts +87 -0
  151. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +18 -5
  152. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  153. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  154. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  155. package/src/runtime-realtime.ts +55 -0
  156. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  157. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  158. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  159. package/src/scaffold-fixture.ts +28 -6
  160. package/src/scaffold-typecheck.ts +6 -3
  161. package/src/schema-drift.ts +7 -1
  162. package/src/script-csp.ts +5 -2
  163. package/src/secrets-rotation.ts +59 -0
  164. package/src/serve-boot.ts +192 -0
  165. package/src/serve-drain.ts +24 -0
  166. package/src/serve-entry.ts +6 -0
  167. package/src/serve-env.ts +116 -0
  168. package/src/serve-types.ts +55 -0
  169. package/src/serve.ts +44 -347
  170. package/src/shot-server.ts +2 -2
  171. package/src/shot-settle.ts +10 -1
  172. package/src/shot-theme.ts +3 -3
  173. package/src/shot-verdict.ts +16 -7
  174. package/src/signal-shred.ts +27 -0
  175. package/src/solid-loader.ts +26 -2
  176. package/src/static-report.ts +8 -1
  177. package/src/templates/action.ts +30 -16
  178. package/src/templates/entity.ts +12 -7
  179. package/src/templates/index.ts +1 -1
  180. package/src/templates/job.ts +10 -7
  181. package/src/templates/policy.ts +20 -2
  182. package/src/templates/resource-create.ts +127 -0
  183. package/src/templates/resource-form-island.ts +49 -12
  184. package/src/templates/resource.ts +10 -4
  185. package/src/templates/scaffold-app.ts +4 -1
  186. package/src/templates/scaffold-auth.ts +3 -1
  187. package/src/templates/scaffold-container-compose.ts +184 -0
  188. package/src/templates/scaffold-container.ts +24 -159
  189. package/src/templates/scaffold-dashboard-example.ts +2 -2
  190. package/src/templates/scaffold-db-package.ts +16 -3
  191. package/src/templates/scaffold-demo-org.ts +41 -0
  192. package/src/templates/scaffold-entries.ts +1 -1
  193. package/src/templates/scaffold-helm-templates.ts +66 -7
  194. package/src/templates/scaffold-helm.ts +27 -5
  195. package/src/templates/scaffold-i18n.ts +15 -10
  196. package/src/templates/scaffold-repo.ts +13 -10
  197. package/src/templates/scaffold-roles.ts +38 -10
  198. package/src/templates/slice-foundation.ts +1 -1
  199. package/src/templates/wrap.ts +4 -1
  200. package/src/test-passes.ts +2 -1
  201. package/src/test-workers.ts +26 -0
  202. package/src/ts-scan.ts +3 -6
  203. package/src/tsconfig-references.ts +1 -2
  204. package/src/verify-checks.ts +41 -39
  205. package/src/verify-e2e.ts +10 -7
  206. package/src/verify-run.ts +11 -1
  207. package/src/verify-step.ts +3 -3
  208. package/src/verify-tests.ts +1 -26
  209. package/src/verify-typecheck.ts +28 -0
  210. package/src/web-binding.ts +2 -2
  211. package/src/workspace-graph.ts +10 -33
  212. package/src/cdp-browser.ts +0 -94
  213. package/src/cdp-connection.ts +0 -247
  214. package/src/cdp-e2e-page.ts +0 -180
  215. package/src/cdp-e2e-session.ts +0 -199
  216. package/src/cdp-errors.ts +0 -56
  217. package/src/cdp-launch.ts +0 -193
  218. package/src/cdp-offline-script.ts +0 -73
  219. package/src/cdp-pipe.ts +0 -77
  220. package/src/e2e-app.ts +0 -103
  221. package/src/e2e-browser-handle.ts +0 -55
  222. package/src/e2e-dom-fixture.ts +0 -117
  223. package/src/e2e-driver.ts +0 -117
  224. package/src/e2e-errors.ts +0 -117
  225. package/src/e2e-evaluate.ts +0 -156
  226. package/src/e2e-locator.ts +0 -86
  227. package/src/e2e-page.ts +0 -153
  228. package/src/e2e-preload.ts +0 -64
  229. package/src/e2e-probe.ts +0 -23
  230. package/src/e2e-selection.ts +0 -182
  231. package/src/e2e-spawn.ts +0 -169
  232. package/src/measurement-actor.ts +0 -26
  233. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
  234. /package/src/{dev-render.ts → runtime-render.ts} +0 -0
@@ -0,0 +1,184 @@
1
+ // The production topology `x new` writes: one compose service per role, one image. Split from
2
+ // scaffold-container.ts, which holds the image and the page that explains it; this file is what
3
+ // `x deploy --method compose` runs, and the single-node rung of the scale ladder.
4
+
5
+ import type { NameSet } from './naming';
6
+
7
+ /**
8
+ * The production env file, relative to the app root. Named once because two readers must agree:
9
+ * the compose file's \`env_file:\` (what the containers see) and \`x deploy\`'s \`--env-file\` (what
10
+ * compose interpolates \`\${VAR:?…}\` from). Two spellings would let them drift apart silently.
11
+ */
12
+ export const PROD_ENV_FILE = '.env.production';
13
+
14
+ /** The production topology `x new` writes at `docker/docker-compose.prod.yml`. */
15
+ export const composeProdFile = (
16
+ app: NameSet,
17
+ ): string => `# The production topology: one service per role, one image, differing only by ROLE and replicas.
18
+ # What \`x deploy --method compose\` runs. migrate runs to completion before anything serves.
19
+ #
20
+ # IMAGE=ghcr.io/you/${app.kebab}:1.2.3 x deploy --image ghcr.io/you/${app.kebab}:1.2.3
21
+ #
22
+ # By hand, always with \`--env-file ${PROD_ENV_FILE}\`: Compose fills \`\${VAR:?…}\` below from the shell
23
+ # and \`--env-file\` only, NEVER from \`env_file:\`, so without it a value set only in that file is
24
+ # "missing" and the parse fails. \`x deploy\` passes it on every step.
25
+ #
26
+ # docker compose --env-file ${PROD_ENV_FILE} -f docker/docker-compose.prod.yml up -d
27
+ #
28
+ # A published host port has exactly one binder, so \`web\` and \`sync\` run at 1 here. Compose is one
29
+ # box; horizontal scaling of those two belongs to an orchestrator — \`docker/helm\`, beside this
30
+ # file, is the chart \`x deploy --method helm\` installs. To scale them on one box anyway, drop
31
+ # \`ports:\` and put your own proxy on this network — the service name resolves to every replica
32
+ # over the compose DNS round robin.
33
+ name: ${app.kebab}
34
+
35
+ # Every service's logs, rotated. Docker's default json-file driver never rotates, so a chatty role
36
+ # on a long-lived box fills the disk the database lives on; 3 x 10 MB per container is the ceiling.
37
+ x-logging: &logging
38
+ driver: json-file
39
+ options: { max-size: 10m, max-file: '3' }
40
+
41
+ x-image: &image
42
+ image: \${IMAGE:-${app.kebab}:dev}
43
+ env_file: [../${PROD_ENV_FILE}]
44
+ restart: unless-stopped
45
+ logging: *logging
46
+ # SIGTERM → /readyz answers 503 for the readiness grace (5s outside local environments), then the
47
+ # drain of in-flight requests, jobs and sockets (25s). 40s leaves 10s before Docker SIGKILLs.
48
+ stop_grace_period: 40s
49
+ # What the chart's securityContext applies, on the rung that had none: a read-only root
50
+ # filesystem, no Linux capabilities (nothing here binds below 1024), no setuid escalation, and a
51
+ # memory ceiling so one runaway role takes down its container rather than the box. /tmp and .x/
52
+ # are tmpfs — .x/ is where a still-embedded binding keeps state, which production never relies on.
53
+ read_only: true
54
+ tmpfs: [/tmp, /app/.x]
55
+ cap_drop: [ALL]
56
+ security_opt: ['no-new-privileges:true']
57
+ mem_limit: 1g
58
+ depends_on:
59
+ db: { condition: service_healthy }
60
+
61
+ # The image's own HEALTHCHECK fetches \`/readyz\` on $PORT, and only \`web\` and \`sync\` open an HTTP
62
+ # socket — every other role gets the scrape listener and nothing else. A service that inherits that
63
+ # probe is fetching a port it never binds: it reports \`unhealthy\` for its whole life and anything
64
+ # gated on it never starts. Probes follow the role here, exactly as they do in \`docker/helm\`.
65
+ x-metrics-probe: &metrics-probe
66
+ test: ['CMD', 'bun', '--eval', "fetch('http://127.0.0.1:'+(process.env.METRICS_PORT||9090)+'/metrics').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
67
+ interval: 10s
68
+ timeout: 3s
69
+ start_period: 30s
70
+ retries: 3
71
+
72
+ # Run-once services exit. A probe against an exited container reports \`unhealthy\` forever, and
73
+ # nothing waits on their health — \`service_completed_successfully\` is what the others gate on.
74
+ x-run-once-probe: &run-once-probe
75
+ disable: true
76
+
77
+ services:
78
+ db:
79
+ image: postgres:17-alpine
80
+ environment:
81
+ POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
82
+ POSTGRES_DB: ${app.kebab}
83
+ volumes: ['pgdata:/var/lib/postgresql/data']
84
+ healthcheck:
85
+ test: ['CMD-SHELL', 'pg_isready -U postgres']
86
+ interval: 5s
87
+ restart: unless-stopped
88
+ logging: *logging
89
+
90
+ # The release phase. Applies pending migrations under an advisory lock and exits; every serving
91
+ # role waits for it to complete, so no replica ever serves against a schema it does not ship.
92
+ migrate:
93
+ <<: *image
94
+ environment: [ROLE=migrate]
95
+ restart: 'no'
96
+ healthcheck: *run-once-probe
97
+
98
+ # Run-once, AFTER the new version serves. Deliberately NOT part of the release gate: a slow
99
+ # UPDATE there holds the deploy open against a database still serving the previous version.
100
+ # Dry run is the default, so \`--write\` is explicit.
101
+ backfill:
102
+ <<: *image
103
+ # The image's ENTRYPOINT is \`bun apps/web/server.ts\`, and that entry reads ROLE and PORT and
104
+ # NOTHING ELSE — argv never reaches a parser. A bare \`command:\` is appended to it and silently
105
+ # discarded, so this service used to serve HTTP as ROLE=web under a name that said otherwise.
106
+ # Overriding the entrypoint is what makes the words below a command. The file path, not
107
+ # \`node_modules/.bin/x\`: it needs no bin symlink and no executable bit inside the image.
108
+ entrypoint: ['bun', 'node_modules/@ultimat3/cli/src/bin.ts']
109
+ command: ['db', 'backfill', '--all', '--write', '--json']
110
+ depends_on:
111
+ db: { condition: service_healthy }
112
+ migrate: { condition: service_completed_successfully }
113
+ # The barrier, not the ordering. \`docker compose up -d\` returns when a container STARTS, so
114
+ # listing this last would only look like "after". The image's HEALTHCHECK is what makes it true.
115
+ web: { condition: service_healthy }
116
+ restart: 'no'
117
+ healthcheck: *run-once-probe
118
+
119
+ web:
120
+ <<: *image
121
+ # The page dials SYNC_URL; unset, it dials /_x/sync on :3000, which web does not serve here.
122
+ # Set it in ${PROD_ENV_FILE}: \`--env-file\` (header) is what lets this line read it there.
123
+ environment: [ROLE=web, 'SYNC_URL=\${SYNC_URL:?set SYNC_URL=ws://<host>:3001/_x/sync, see wiki/Deployment.md}']
124
+ depends_on:
125
+ db: { condition: service_healthy }
126
+ migrate: { condition: service_completed_successfully }
127
+ deploy: { replicas: 1 } # stateless, scales on RPS — pinned by the published port
128
+ ports: ['3000:3000']
129
+
130
+ sync:
131
+ <<: *image
132
+ environment: [ROLE=sync]
133
+ depends_on:
134
+ db: { condition: service_healthy }
135
+ migrate: { condition: service_completed_successfully }
136
+ # OFF by default (zero replicas). A sync node on this database hears committed changes only
137
+ # from a replicator it can reach, and this file runs none: booted anyway it is refused
138
+ # (X_REALTIME_TOPOLOGY). To turn live queries and channels on: declare realtime: { enabled:
139
+ # true, transport: 'nats', urlEnv: 'NATS_URL' } in app.config.ts, add a NATS service and set the
140
+ # SAME NATS_URL for web, sync and a replicator service (ROLE=replicator, exactly one), start
141
+ # Postgres with wal_level=logical and a publication for the entity tables, then set replicas: 1.
142
+ # A NATS_URL under transport 'memory' refuses the boot (X_CONFIG_INVALID), and so does 'nats'
143
+ # without one.
144
+ deploy: { replicas: 0 } # when on: scales on concurrent websockets — pinned to 1 by the port
145
+ # The sync role binds PORT + 1. PORT is unset here, so it is 3000 and this listens on 3001.
146
+ ports: ['3001:3001']
147
+ # ...which is why the image's own HEALTHCHECK cannot be inherited here. It fetches $PORT —
148
+ # 3000 — and this role never binds it, so the container reports \`unhealthy\` from
149
+ # \`start_period\` onward and never recovers, and anything gated on \`sync: service_healthy\`
150
+ # would never start. Literal 3001 rather than an expression, for the same reason \`ports:\`
151
+ # above is literal: PORT is unset in this file, and two ways of saying one number drift.
152
+ # \`docker/helm\` states the same rule as \`PORT = .port - 1\`.
153
+ healthcheck:
154
+ test: ['CMD', 'bun', '--eval', "fetch('http://127.0.0.1:3001/readyz').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
155
+ interval: 10s
156
+ timeout: 3s
157
+ start_period: 30s
158
+ retries: 3
159
+
160
+ worker:
161
+ <<: *image
162
+ environment: [ROLE=worker]
163
+ depends_on:
164
+ db: { condition: service_healthy }
165
+ migrate: { condition: service_completed_successfully }
166
+ healthcheck: *metrics-probe
167
+ deploy: { replicas: 1 } # scales on queue depth
168
+
169
+ scheduler:
170
+ <<: *image
171
+ environment: [ROLE=scheduler]
172
+ depends_on:
173
+ db: { condition: service_healthy }
174
+ migrate: { condition: service_completed_successfully }
175
+ # Fixed 1. Leadership is an EXPIRING LEASE ROW in \`x_scheduler_leader\` (role-start.ts,
176
+ # driver-pg-ddl.ts), NOT an advisory lock: that grant belongs to the session, not to the
177
+ # process — it outlives every transaction and no pooled node can renew it or prove it still
178
+ # holds one. A second instance is harmless but idle.
179
+ healthcheck: *metrics-probe
180
+ deploy: { replicas: 1 }
181
+
182
+ volumes:
183
+ pgdata:
184
+ `;
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { SECRETS_KEY_FILE } from '@ultimat3/core';
10
10
  import type { GeneratedFile, NameSet } from './naming';
11
+ import { composeProdFile, PROD_ENV_FILE } from './scaffold-container-compose';
11
12
  import { helmFiles } from './scaffold-helm';
12
13
 
13
14
  const dockerfile = (
@@ -21,13 +22,24 @@ const dockerfile = (
21
22
  #
22
23
  # syntax=docker/dockerfile:1
23
24
 
24
- # ---------- deps: runtime dependencies only, cached on the workspace manifests ----------
25
+ # ---------- manifests: every workspace package.json, and nothing else ----------
26
+ # The install below must be keyed on the MANIFESTS, not on the source: copying \`apps\` and
27
+ # \`packages\` ahead of \`bun install\` made every edit to a page a full reinstall. The set is derived
28
+ # from the context rather than listed, so a workspace glob added later is not a lockfile the frozen
29
+ # install refuses. \`bunfig.toml\` survives because its \`[install]\` section is input to the install,
30
+ # and so does a \`.tgz\`: a \`file:\` tarball dependency (a vendored or private package) is resolved
31
+ # from the file itself, and without it the frozen install cannot find what the lockfile names.
32
+ FROM oven/bun:1.4-alpine AS manifests
33
+ WORKDIR /app
34
+ COPY . .
35
+ RUN find . ! -type d ! -name package.json ! -name bunfig.toml ! -name '*.tgz' -delete \\
36
+ && find . -type d -empty -delete
37
+
38
+ # ---------- deps: runtime dependencies only, cached on the lockfile and the manifests ----------
25
39
  FROM oven/bun:1.4-alpine AS deps
26
40
  WORKDIR /app
27
- COPY package.json bun.lock ./
28
- # The workspace members' manifests are what \`bun install\` resolves against; their sources are not.
29
- COPY apps ./apps
30
- COPY packages ./packages
41
+ COPY --from=manifests /app ./
42
+ COPY bun.lock ./
31
43
  RUN bun install --frozen-lockfile --production
32
44
 
33
45
  # ---------- runtime ----------
@@ -35,9 +47,13 @@ RUN bun install --frozen-lockfile --production
35
47
  # before it ever calls \`docker build\`. Re-running typecheck and lint here would need the
36
48
  # devDependencies the \`--production\` install above deliberately leaves out — which is exactly how
37
49
  # a build stage came to run \`tsc\` and \`biome\` against a tree that had neither.
38
- FROM oven/bun:1.4-alpine AS runtime
50
+ #
51
+ # Built ON the install stage, so the WHOLE installed tree comes with it — Bun's isolated linker
52
+ # links a workspace's dependencies under that workspace's own \`node_modules\`, and copying only the
53
+ # root one left \`apps/web\`'s behind — and the source lands on top (the ignore file drops every host
54
+ # \`node_modules\`, so the two merge). The layer cache is the same as a COPY from it would be.
55
+ FROM deps AS runtime
39
56
  WORKDIR /app
40
- COPY --from=deps /app/node_modules ./node_modules
41
57
  COPY . .
42
58
 
43
59
  # The immutable content hash this image serves. Stamped by CI (\`--build-arg BUILD_ID=$(git rev-parse HEAD)\`);
@@ -112,157 +128,6 @@ coverage
112
128
  **/playwright-report
113
129
  `;
114
130
 
115
- /**
116
- * The production env file, relative to the app root. Named once because two readers must agree:
117
- * the compose file's \`env_file:\` (what the containers see) and \`x deploy\`'s \`--env-file\` (what
118
- * compose interpolates \`\${VAR:?…}\` from). Two spellings would let them drift apart silently.
119
- */
120
- export const PROD_ENV_FILE = '.env.production';
121
-
122
- const composeProd = (
123
- app: NameSet,
124
- ): string => `# The production topology: one service per role, one image, differing only by ROLE and replicas.
125
- # What \`x deploy --method compose\` runs. migrate runs to completion before anything serves.
126
- #
127
- # IMAGE=ghcr.io/you/${app.kebab}:1.2.3 x deploy --image ghcr.io/you/${app.kebab}:1.2.3
128
- #
129
- # By hand, always with \`--env-file ${PROD_ENV_FILE}\`: Compose fills \`\${VAR:?…}\` below from the shell
130
- # and \`--env-file\` only, NEVER from \`env_file:\`, so without it a value set only in that file is
131
- # "missing" and the parse fails. \`x deploy\` passes it on every step.
132
- #
133
- # docker compose --env-file ${PROD_ENV_FILE} -f docker/docker-compose.prod.yml up -d
134
- #
135
- # A published host port has exactly one binder, so \`web\` and \`sync\` run at 1 here. Compose is one
136
- # box; horizontal scaling of those two belongs to an orchestrator — \`docker/helm\`, beside this
137
- # file, is the chart \`x deploy --method helm\` installs. To scale them on one box anyway, drop
138
- # \`ports:\` and put your own proxy on this network — the service name resolves to every replica
139
- # over the compose DNS round robin.
140
- name: ${app.kebab}
141
-
142
- x-image: &image
143
- image: \${IMAGE:-${app.kebab}:dev}
144
- env_file: [../${PROD_ENV_FILE}]
145
- restart: unless-stopped
146
- stop_grace_period: 30s # SIGTERM → drain in-flight requests, jobs and sockets
147
- depends_on:
148
- db: { condition: service_healthy }
149
-
150
- # The image's own HEALTHCHECK fetches \`/readyz\` on $PORT, and only \`web\` and \`sync\` open an HTTP
151
- # socket — every other role gets the scrape listener and nothing else. A service that inherits that
152
- # probe is fetching a port it never binds: it reports \`unhealthy\` for its whole life and anything
153
- # gated on it never starts. Probes follow the role here, exactly as they do in \`docker/helm\`.
154
- x-metrics-probe: &metrics-probe
155
- test: ['CMD', 'bun', '--eval', "fetch('http://127.0.0.1:'+(process.env.METRICS_PORT||9090)+'/metrics').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
156
- interval: 10s
157
- timeout: 3s
158
- start_period: 30s
159
- retries: 3
160
-
161
- # Run-once services exit. A probe against an exited container reports \`unhealthy\` forever, and
162
- # nothing waits on their health — \`service_completed_successfully\` is what the others gate on.
163
- x-run-once-probe: &run-once-probe
164
- disable: true
165
-
166
- services:
167
- db:
168
- image: postgres:17-alpine
169
- environment:
170
- POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
171
- POSTGRES_DB: ${app.kebab}
172
- volumes: ['pgdata:/var/lib/postgresql/data']
173
- healthcheck:
174
- test: ['CMD-SHELL', 'pg_isready -U postgres']
175
- interval: 5s
176
- restart: unless-stopped
177
-
178
- # The release phase. Applies pending migrations under an advisory lock and exits; every serving
179
- # role waits for it to complete, so no replica ever serves against a schema it does not ship.
180
- migrate:
181
- <<: *image
182
- environment: [ROLE=migrate]
183
- restart: 'no'
184
- healthcheck: *run-once-probe
185
-
186
- # Run-once, AFTER the new version serves. Deliberately NOT part of the release gate: a slow
187
- # UPDATE there holds the deploy open against a database still serving the previous version.
188
- # Dry run is the default, so \`--write\` is explicit.
189
- backfill:
190
- <<: *image
191
- # The image's ENTRYPOINT is \`bun apps/web/server.ts\`, and that entry reads ROLE and PORT and
192
- # NOTHING ELSE — argv never reaches a parser. A bare \`command:\` is appended to it and silently
193
- # discarded, so this service used to serve HTTP as ROLE=web under a name that said otherwise.
194
- # Overriding the entrypoint is what makes the words below a command. The file path, not
195
- # \`node_modules/.bin/x\`: it needs no bin symlink and no executable bit inside the image.
196
- entrypoint: ['bun', 'node_modules/@ultimat3/cli/src/bin.ts']
197
- command: ['db', 'backfill', '--all', '--write', '--json']
198
- depends_on:
199
- db: { condition: service_healthy }
200
- migrate: { condition: service_completed_successfully }
201
- # The barrier, not the ordering. \`docker compose up -d\` returns when a container STARTS, so
202
- # listing this last would only look like "after". The image's HEALTHCHECK is what makes it true.
203
- web: { condition: service_healthy }
204
- restart: 'no'
205
- healthcheck: *run-once-probe
206
-
207
- web:
208
- <<: *image
209
- # The page dials SYNC_URL; unset, it dials /_x/sync on :3000, which web does not serve here.
210
- # Set it in ${PROD_ENV_FILE}: \`--env-file\` (header) is what lets this line read it there.
211
- environment: [ROLE=web, 'SYNC_URL=\${SYNC_URL:?set SYNC_URL=ws://<host>:3001/_x/sync, see wiki/Deployment.md}']
212
- depends_on:
213
- db: { condition: service_healthy }
214
- migrate: { condition: service_completed_successfully }
215
- deploy: { replicas: 1 } # stateless, scales on RPS — pinned by the published port
216
- ports: ['3000:3000']
217
-
218
- sync:
219
- <<: *image
220
- environment: [ROLE=sync]
221
- depends_on:
222
- db: { condition: service_healthy }
223
- migrate: { condition: service_completed_successfully }
224
- deploy: { replicas: 1 } # scales on concurrent websockets, no sticky sessions — pinned by the port
225
- # The sync role binds PORT + 1. PORT is unset here, so it is 3000 and this listens on 3001.
226
- ports: ['3001:3001']
227
- # ...which is why the image's own HEALTHCHECK cannot be inherited here. It fetches $PORT —
228
- # 3000 — and this role never binds it, so the container reports \`unhealthy\` from
229
- # \`start_period\` onward and never recovers, and anything gated on \`sync: service_healthy\`
230
- # would never start. Literal 3001 rather than an expression, for the same reason \`ports:\`
231
- # above is literal: PORT is unset in this file, and two ways of saying one number drift.
232
- # \`docker/helm\` states the same rule as \`PORT = .port - 1\`.
233
- healthcheck:
234
- test: ['CMD', 'bun', '--eval', "fetch('http://127.0.0.1:3001/readyz').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
235
- interval: 10s
236
- timeout: 3s
237
- start_period: 30s
238
- retries: 3
239
-
240
- worker:
241
- <<: *image
242
- environment: [ROLE=worker]
243
- depends_on:
244
- db: { condition: service_healthy }
245
- migrate: { condition: service_completed_successfully }
246
- healthcheck: *metrics-probe
247
- deploy: { replicas: 1 } # scales on queue depth
248
-
249
- scheduler:
250
- <<: *image
251
- environment: [ROLE=scheduler]
252
- depends_on:
253
- db: { condition: service_healthy }
254
- migrate: { condition: service_completed_successfully }
255
- # Fixed 1. Leadership is an EXPIRING LEASE ROW in \`x_scheduler_leader\` (dev-roles.ts,
256
- # driver-pg-ddl.ts), NOT an advisory lock: that grant belongs to the session, not to the
257
- # process — it outlives every transaction and no pooled node can renew it or prove it still
258
- # holds one. A second instance is harmless but idle.
259
- healthcheck: *metrics-probe
260
- deploy: { replicas: 1 }
261
-
262
- volumes:
263
- pgdata:
264
- `;
265
-
266
131
  const readme = (app: NameSet): string => `# docker
267
132
 
268
133
  One image, every role. \`ROLE\` selects behaviour at start, so there is one artifact to promote and
@@ -398,7 +263,7 @@ export function containerFiles(app: NameSet): readonly GeneratedFile[] {
398
263
  return [
399
264
  { path: 'docker/Dockerfile', contents: dockerfile(app) },
400
265
  { path: 'docker/Dockerfile.dockerignore', contents: dockerignore() },
401
- { path: 'docker/docker-compose.prod.yml', contents: composeProd(app) },
266
+ { path: 'docker/docker-compose.prod.yml', contents: composeProdFile(app) },
402
267
  { path: 'docker/README.md', contents: readme(app) },
403
268
  // The other deploy method's topology, on the same terms as the compose file above: `x deploy`
404
269
  // is one `helm upgrade --install` against this directory, and a chart that shipped in no npm
@@ -19,7 +19,6 @@ const examplePage = (
19
19
  ${sortedImports([
20
20
  `import { useT } from '@${app.kebab}/i18n';`,
21
21
  "import { isUltimateError } from '@ultimat3/core';",
22
- "import { seedId } from '@ultimat3/entity';",
23
22
  "import { currentLocale } from '@ultimat3/i18n';",
24
23
  "import { defineRoute, island } from '@ultimat3/render';",
25
24
  [
@@ -35,6 +34,7 @@ ${sortedImports([
35
34
  "} from '@ultimat3/ui';",
36
35
  ].join('\n'),
37
36
  ])}
37
+ import { DEMO_ORG_ID } from '../../shared/demo-org';
38
38
  import { Shell } from '../../shared/shell';
39
39
  import * as repo from '../post/repo';
40
40
  import {
@@ -53,7 +53,7 @@ import styles from './page.module.scss';
53
53
  * one \`packages/db/src/seed.ts\` writes. The day sessions exist, this becomes the actor's org and
54
54
  * the read becomes \`postList.as(actor, …)\` — the query already declares the tenancy rule.
55
55
  */
56
- const DEMO_ORG = seedId('org:demo');
56
+ const DEMO_ORG = DEMO_ORG_ID;
57
57
 
58
58
  /** The stat row counts what it can see. Past this many posts, write an aggregate query. */
59
59
  const ROW_LIMIT = 500;
@@ -94,6 +94,19 @@ export {};
94
94
  *
95
95
  * `x db seed` owns the connection, the tier and the per-seed transaction. One runner, one answer.
96
96
  */
97
+ /**
98
+ * The seed's two package imports in the order Biome sorts them: the app's own scope can sort on
99
+ * either side of \`@ultimat3\`, and a fixed order is a lint failure for half of all app names.
100
+ */
101
+ const seedImports = (app: NameSet): string =>
102
+ [
103
+ [`@${app.kebab}/web/shared/demo-org`, 'DEMO_ORG_LABEL'],
104
+ ['@ultimat3/entity', 'defineSeed'],
105
+ ]
106
+ .sort(([a = ''], [b = '']) => (a < b ? -1 : a > b ? 1 : 0))
107
+ .map(([from, name]) => `import { ${name} } from '${from}';`)
108
+ .join('\n');
109
+
97
110
  const dbSeed = (app: NameSet, example: boolean): string =>
98
111
  example
99
112
  ? `// Deterministic fixtures: the same rows every time, so a test, a demo and a branch database
@@ -104,7 +117,7 @@ const dbSeed = (app: NameSet, example: boolean): string =>
104
117
  // included), and wraps each seed in its own transaction. Never a plain \`bun run\` script: that
105
118
  // reaches the database through \`db()\`, which needs a \`postgres:\` \`DATABASE_URL\` and so cannot
106
119
  // see the embedded database at all.
107
- import { defineSeed } from '@ultimat3/entity';
120
+ ${seedImports(app)}
108
121
  import { post } from './schema';
109
122
 
110
123
  /** Stable across runs: \`id('post:hello')\` is a UUID v5 of the label, not a random one. */
@@ -112,13 +125,13 @@ export const ${app.camel}Seed = defineSeed('${app.kebab}', async ({ insert, id }
112
125
  await insert(post, [
113
126
  {
114
127
  id: id('post:hello'),
115
- orgId: id('org:demo'),
128
+ orgId: id(DEMO_ORG_LABEL),
116
129
  title: 'Hello ${app.pascal}',
117
130
  price: { minor: 0, currency: 'USD' },
118
131
  },
119
132
  {
120
133
  id: id('post:second'),
121
- orgId: id('org:demo'),
134
+ orgId: id(DEMO_ORG_LABEL),
122
135
  title: 'Second post',
123
136
  price: { minor: 1900, currency: 'USD' },
124
137
  },
@@ -0,0 +1,41 @@
1
+ // `apps/web/shared/demo-org.ts`: the ONE org this app has until it issues sessions. The dev actor
2
+ // named `'dev-org'`, the seed wrote `id('org:demo')` and the dashboard computed a third copy — so a
3
+ // generated tenant policy (`actor.orgId === input.orgId`, a `t.uuid`) could never pass for the dev
4
+ // actor, and every generated write answered 403 under a green gate.
5
+
6
+ import type { GeneratedFile } from './naming';
7
+
8
+ const demoOrg =
9
+ (): string => `// The org every development-only fact belongs to: the dev actor's \`orgId\`, the org the seed
10
+ // writes its rows under, and the org the dashboard aggregates. One constant, so the three cannot
11
+ // disagree — a policy deciding \`actor.orgId === input.orgId\` passes for the dev actor on the rows
12
+ // the seed wrote. Replace its readers when this app issues real sessions.
13
+ import { seedId } from '@ultimat3/entity';
14
+
15
+ /** The label \`defineSeed\`'s \`id()\` derives the uuid from — the same uuid on every run. */
16
+ export const DEMO_ORG_LABEL = 'org:demo';
17
+
18
+ /** \`seedId(DEMO_ORG_LABEL)\`: a uuid v5, exactly what the seed's \`id(DEMO_ORG_LABEL)\` answers. */
19
+ export const DEMO_ORG_ID = seedId(DEMO_ORG_LABEL);
20
+ `;
21
+
22
+ const demoOrgTest =
23
+ (): string => `// The dev actor, the seed and the dashboard all read DEMO_ORG_ID. If it stops being the uuid the
24
+ // seed's \`id()\` derives, a generated tenant policy denies the dev actor again.
25
+ import { seedId } from '@ultimat3/entity';
26
+ import { expect, unitTest } from '@ultimat3/testing';
27
+ import { DEMO_ORG_ID, DEMO_ORG_LABEL } from './demo-org';
28
+
29
+ const UUID_V5 = /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
30
+
31
+ unitTest('the demo org is the uuid the seed writes', () => {
32
+ expect(DEMO_ORG_ID).toBe(seedId(DEMO_ORG_LABEL));
33
+ expect(DEMO_ORG_ID).toMatch(UUID_V5);
34
+ });
35
+ `;
36
+
37
+ /** Written by `x new`, with or without the example slice: the dev actor always reads it. */
38
+ export const demoOrgFiles = (): readonly GeneratedFile[] => [
39
+ { path: 'apps/web/shared/demo-org.ts', contents: demoOrg() },
40
+ { path: 'apps/web/shared/demo-org.test.ts', contents: demoOrgTest() },
41
+ ];
@@ -15,7 +15,7 @@ const server =
15
15
  // shares the host's network namespace (\`--network host\`), or through a sidecar and \`ssh -L\`.
16
16
 
17
17
  import { join } from 'node:path';
18
- import { runRole } from '@ultimat3/cli';
18
+ import { runRole } from '@ultimat3/cli/serve';
19
19
 
20
20
  // MORE THAN ONE REPLICA? Add these two lines, above \`runRole\`:
21
21
  //