@ultimat3/cli 23.0.0 → 24.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 (149) hide show
  1. package/CLAUDE.md +15 -7
  2. package/README.md +3 -3
  3. package/package.json +28 -28
  4. package/src/affected.ts +27 -35
  5. package/src/api-registration.ts +89 -0
  6. package/src/app-artifacts.ts +1 -1
  7. package/src/app-auth.ts +4 -27
  8. package/src/app-config-load.ts +51 -0
  9. package/src/app-env.ts +5 -11
  10. package/src/app-manifest.ts +2 -8
  11. package/src/app-mcp.ts +10 -23
  12. package/src/app-permissions-borrowed.ts +129 -0
  13. package/src/app-permissions-site.ts +24 -0
  14. package/src/app-permissions.ts +7 -17
  15. package/src/budgets.ts +4 -35
  16. package/src/cdp-shot-driver.ts +3 -1
  17. package/src/cmd-auth-spec.ts +16 -0
  18. package/src/cmd-auth.ts +65 -0
  19. package/src/cmd-build.ts +64 -15
  20. package/src/cmd-db-backfill.ts +25 -12
  21. package/src/cmd-db.ts +6 -5
  22. package/src/cmd-deploy-helm.ts +7 -6
  23. package/src/cmd-deploy.ts +4 -4
  24. package/src/cmd-dev.ts +9 -8
  25. package/src/cmd-doctor-spec.ts +2 -9
  26. package/src/cmd-doctor.ts +41 -14
  27. package/src/cmd-env.ts +26 -2
  28. package/src/cmd-generate.ts +79 -32
  29. package/src/cmd-help.ts +19 -6
  30. package/src/cmd-i18n.ts +10 -9
  31. package/src/cmd-jobs.ts +18 -6
  32. package/src/cmd-manifest-spec.ts +1 -1
  33. package/src/cmd-manifest.ts +6 -25
  34. package/src/cmd-mcp.ts +46 -15
  35. package/src/cmd-planned.ts +15 -8
  36. package/src/cmd-routes.ts +12 -2
  37. package/src/cmd-secrets.ts +47 -6
  38. package/src/cmd-shot-matrix.ts +1 -3
  39. package/src/cmd-shot.ts +12 -3
  40. package/src/cmd-tasks.ts +2 -2
  41. package/src/cmd-test-spec.ts +1 -7
  42. package/src/cmd-test.ts +13 -29
  43. package/src/cmd-verify.ts +4 -4
  44. package/src/db-subscribes.ts +12 -4
  45. package/src/dev-dashboard-guard.ts +76 -0
  46. package/src/dev-dashboard.ts +51 -29
  47. package/src/dev-lock.ts +4 -7
  48. package/src/dispatch.ts +3 -6
  49. package/src/doctor-app-url.ts +39 -0
  50. package/src/doctor-auth.ts +95 -0
  51. package/src/drift-replica-identity.ts +42 -0
  52. package/src/error-catalog.ts +6 -9
  53. package/src/error-codes.ts +23 -3
  54. package/src/error-unthrown.ts +29 -11
  55. package/src/errors.ts +54 -83
  56. package/src/framework-schema-apply.ts +78 -0
  57. package/src/framework-schema-stamp.ts +96 -0
  58. package/src/framework-schema.ts +13 -2
  59. package/src/generate-files.ts +4 -3
  60. package/src/generate-kinds.ts +33 -1
  61. package/src/generate-shadow.ts +46 -0
  62. package/src/generate-write.ts +47 -13
  63. package/src/gh-target.ts +12 -7
  64. package/src/i18n-audit.ts +4 -1
  65. package/src/index.ts +1 -3
  66. package/src/island-bundle.ts +3 -15
  67. package/src/jobs-drain.ts +82 -15
  68. package/src/jobs-report.ts +4 -4
  69. package/src/loopback-host.ts +13 -0
  70. package/src/manifest-staleness.ts +62 -0
  71. package/src/mcp-errors.ts +12 -3
  72. package/src/mcp-host.ts +16 -9
  73. package/src/mcp-test-run.ts +38 -0
  74. package/src/messages.ts +2 -0
  75. package/src/metrics-endpoint.ts +25 -1
  76. package/src/output.ts +6 -0
  77. package/src/page-navigation.ts +7 -23
  78. package/src/page-speculation.ts +5 -20
  79. package/src/prerender-out.ts +34 -7
  80. package/src/prerender.ts +8 -5
  81. package/src/pwa-artifacts.ts +26 -94
  82. package/src/registry.ts +2 -0
  83. package/src/revalidated-response.ts +4 -3
  84. package/src/role-replicator.ts +16 -0
  85. package/src/role-start.ts +9 -4
  86. package/src/role-sync.ts +55 -20
  87. package/src/runtime-assets.ts +11 -7
  88. package/src/runtime-bindings.ts +4 -4
  89. package/src/runtime-cache.ts +38 -47
  90. package/src/runtime-idempotency-scope.ts +23 -0
  91. package/src/runtime-isr.ts +36 -0
  92. package/src/runtime-jobs.ts +8 -33
  93. package/src/runtime-mfa-warning.ts +29 -0
  94. package/src/runtime-notify-retention.ts +10 -46
  95. package/src/runtime-overrides.ts +1 -1
  96. package/src/runtime-purge.ts +38 -16
  97. package/src/runtime-queue.ts +31 -10
  98. package/src/runtime-realtime.ts +9 -45
  99. package/src/runtime-render.ts +62 -35
  100. package/src/runtime-services.ts +20 -17
  101. package/src/runtime-storage.ts +10 -5
  102. package/src/schema-diff.ts +16 -0
  103. package/src/schema-drift.ts +45 -15
  104. package/src/seo-routes.ts +80 -9
  105. package/src/serve-boot.ts +34 -6
  106. package/src/serve-drain.ts +9 -33
  107. package/src/serve-env.ts +9 -6
  108. package/src/serve-web.ts +12 -7
  109. package/src/shot-locale.ts +5 -23
  110. package/src/site-assets.ts +3 -5
  111. package/src/site-config.ts +9 -48
  112. package/src/site-seo.ts +1 -1
  113. package/src/sitemap-lastmod.ts +5 -0
  114. package/src/solid-loader.ts +12 -3
  115. package/src/source-files.ts +6 -0
  116. package/src/stored-object-headers.ts +43 -0
  117. package/src/sync-url.ts +2 -2
  118. package/src/tasks-facts.ts +3 -29
  119. package/src/templates/action.ts +3 -0
  120. package/src/templates/naming.ts +28 -0
  121. package/src/templates/route.ts +3 -6
  122. package/src/templates/scaffold-app.ts +6 -2
  123. package/src/templates/scaffold-container-compose.ts +10 -1
  124. package/src/templates/scaffold-container.ts +4 -2
  125. package/src/templates/scaffold-db-client.ts +4 -2
  126. package/src/templates/scaffold-helm-fences.ts +76 -0
  127. package/src/templates/scaffold-helm-templates.ts +32 -7
  128. package/src/templates/scaffold-helm.ts +31 -1
  129. package/src/templates/scaffold-repo.ts +9 -1
  130. package/src/templates/scaffold-shell.ts +39 -36
  131. package/src/test-counts.ts +2 -6
  132. package/src/test-ignores.ts +25 -0
  133. package/src/test-passes.ts +0 -15
  134. package/src/test-select.ts +5 -14
  135. package/src/test-shards.ts +41 -93
  136. package/src/test-workers.ts +26 -4
  137. package/src/theme-boot.ts +6 -31
  138. package/src/trusted-hops-error.ts +17 -0
  139. package/src/unthrown-codes.ts +26 -0
  140. package/src/verify-checks.ts +17 -46
  141. package/src/verify-deadline.ts +130 -33
  142. package/src/verify-merge.ts +60 -27
  143. package/src/verify-part-step.ts +90 -0
  144. package/src/verify-run.ts +29 -8
  145. package/src/verify-shard.ts +13 -2
  146. package/src/verify-stalled.ts +152 -0
  147. package/src/verify-tests.ts +5 -2
  148. package/src/build-errors.ts +0 -54
  149. package/src/verify-errors.ts +0 -58
@@ -33,12 +33,9 @@ const OFFLINE: Record<Surface, string> = { site: 'precache', app: 'runtime' };
33
33
  /**
34
34
  * Structured, not a literal string: the route and the test that pins it read the same fact.
35
35
  *
36
- * `lcp` is deliberately NOT here. `RouteBudget.lcp` is accepted by `defineRoute`, and nothing in
37
- * the framework can produce the `lcpMs` that `checkBudgets` compares it against — `prerender.ts`
38
- * emits static HTML and there is no browser in the build to observe a paint. A scaffolded `lcp`
39
- * was therefore a budget that reads as declared, is never weighed, and passes silently the moment
40
- * a `build-stats.json` row exists: the false green `budgets.ts`'s own header forbids. Scaffolding
41
- * only what the build measures is the half of "delete it or thread it" that `x new` can act on.
36
+ * `js` is the one budget a route can declare: it is the one the build weighs. `lcp` was scaffolded
37
+ * here once and then deleted from `RouteBudget` itself — nothing in the framework observes a
38
+ * paint, so it read as declared and was never weighed.
42
39
  */
43
40
  const BUDGET: Record<Surface, { readonly js: string }> = {
44
41
  site: { js: '0kb' },
@@ -16,6 +16,10 @@ import { rolesFiles } from './scaffold-roles';
16
16
  import { shellFiles } from './scaffold-shell';
17
17
  import { siteFiles } from './scaffold-site';
18
18
 
19
+ // `typecheck` is the ROOT program, as in every workspace `x new` writes: a type extension declared in
20
+ // one workspace (`PermissionRegistry` in `app/*/policy.ts`) is in force only where its file is in
21
+ // the program, and the app's own `tsconfig.json` passed in `apps/admin` what the gate refuses.
22
+ //
19
23
  // The one dependency this manifest names, and it is not decoration: every page below reads its
20
24
  // strings through `@<app>/i18n`'s `useT()`, so the surface that renders a string DEPENDS on the
21
25
  // module that registers the catalogs. An undeclared workspace dependency resolves through the root
@@ -36,7 +40,7 @@ const webPackage = (app: NameSet, example: boolean): string => {
36
40
  "./*.tsx": "./*.tsx"
37
41
  },
38
42
  "scripts": {
39
- "typecheck": "tsc --noEmit -p tsconfig.json"
43
+ "typecheck": "tsc --noEmit -p ../../tsconfig.json"
40
44
  },
41
45
  "dependencies": {${db}
42
46
  "@${app.kebab}/i18n": "0.0.0"
@@ -260,7 +264,7 @@ const adminPackage = (app: NameSet): string => `{
260
264
  "./*.tsx": "./*.tsx"
261
265
  },
262
266
  "scripts": {
263
- "typecheck": "tsc --noEmit -p tsconfig.json"
267
+ "typecheck": "tsc --noEmit -p ../../tsconfig.json"
264
268
  },
265
269
  "dependencies": {
266
270
  "@${app.kebab}/db": "0.0.0",
@@ -25,6 +25,11 @@ export const composeProdFile = (
25
25
  #
26
26
  # docker compose --env-file ${PROD_ENV_FILE} -f docker/docker-compose.prod.yml up -d
27
27
  #
28
+ # Two origins the file cannot know, so ${PROD_ENV_FILE} states them: SYNC_URL, where a page dials
29
+ # the socket (ws://<host>:3001/_x/sync), and APP_URL, where the pages are served
30
+ # (http://<host>:3000) — the sync node admits a socket from APP_URL's origin and no other once
31
+ # declared; undeclared, from the origin it was reached on.
32
+ #
28
33
  # A published host port has exactly one binder, so \`web\` and \`sync\` run at 1 here. Compose is one
29
34
  # box; horizontal scaling of those two belongs to an orchestrator — \`docker/helm\`, beside this
30
35
  # file, is the chart \`x deploy --method helm\` installs. To scale them on one box anyway, drop
@@ -129,7 +134,11 @@ services:
129
134
 
130
135
  sync:
131
136
  <<: *image
132
- environment: [ROLE=sync]
137
+ # The page is :3000 and this socket :3001 — two origins — and the node admits an upgrade from
138
+ # APP_URL's origin and no other once declared (scheme, host and port, exactly); undeclared, from
139
+ # the origin it was reached on. Unset, every websocket is 403 X_SOCKET_ORIGIN_REFUSED, so it is
140
+ # required: the same value web builds its links from.
141
+ environment: [ROLE=sync, 'APP_URL=\${APP_URL:?set APP_URL=http://<host>:3000 — the origin the pages are served on, see wiki/Deployment.md}']
133
142
  depends_on:
134
143
  db: { condition: service_healthy }
135
144
  migrate: { condition: service_completed_successfully }
@@ -30,14 +30,16 @@ const dockerfile = (
30
30
  # install refuses. \`bunfig.toml\` survives because its \`[install]\` section is input to the install,
31
31
  # and so does a \`.tgz\`: a \`file:\` tarball dependency (a vendored or private package) is resolved
32
32
  # from the file itself, and without it the frozen install cannot find what the lockfile names.
33
- FROM oven/bun:1.4-alpine AS manifests
33
+ # The tag names the series for the reader; the digest is what builds — a tag is a pointer its
34
+ # publisher can move. Dependabot's docker ecosystem moves the digest together with the tag.
35
+ FROM oven/bun:1.4-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f AS manifests
34
36
  WORKDIR /app
35
37
  COPY . .
36
38
  RUN find . ! -type d ! -name package.json ! -name bunfig.toml ! -name '*.tgz' -delete \\
37
39
  && find . -type d -empty -delete
38
40
 
39
41
  # ---------- deps: runtime dependencies only, cached on the lockfile and the manifests ----------
40
- FROM oven/bun:1.4-alpine AS deps
42
+ FROM oven/bun:1.4-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f AS deps
41
43
  WORKDIR /app
42
44
  COPY --from=manifests /app ./
43
45
  COPY bun.lock ./
@@ -74,7 +74,7 @@ export const dbClientSource = (
74
74
  // same two lines: its import, and \`<tableName>: <entity>,\` in the set.
75
75
 
76
76
  ${sortedImports([
77
- "import { resolveEnvironment } from '@ultimat3/core';",
77
+ "import { storeMode } from '@ultimat3/core';",
78
78
  "import { type Driver, database, memoryDriver, postgresDriver } from '@ultimat3/entity';",
79
79
  ...entries.map(handleImportLine),
80
80
  ])}
@@ -86,9 +86,11 @@ ${sortedImports([
86
86
  * \`postgresDriver()\` takes no connection: it resolves \`@ultimat3/db\`'s process client, which is
87
87
  * the embedded PGlite under \`x dev\` and the \`DATABASE_URL\` pool in a container. \`bun test\`
88
88
  * installs no client, so a test gets the in-memory driver — the same contract, no database.
89
+ * \`storeMode\` is core's one answer to that question, shared with every other seam that has a
90
+ * memory and a database store.
89
91
  */
90
92
  export const selectDriver = (env: Readonly<Record<string, string | undefined>>): Driver =>
91
- resolveEnvironment({ env }) === 'test' ? memoryDriver() : postgresDriver();
93
+ storeMode(env) === 'memory' ? memoryDriver() : postgresDriver();
92
94
 
93
95
  export const driver = selectDriver(Bun.env);
94
96
 
@@ -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,
@@ -79,13 +80,28 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
79
80
  - name: METRICS_PORT
80
81
  value: {{ $root.Values.metricsPort | quote }}
81
82
  {{- end }}
83
+ {{- /*
84
+ The sync node's page origin, from the one place this chart already knows it. Undeclared, a node
85
+ reached over plain http behind this Ingress admits BOTH spellings of its host — the plain-http
86
+ page included. env.APP_URL wins when set; an APP_URL kept in the Secret is shadowed for this
87
+ role, so pages served from another host than the Ingress's must say so in env.
88
+ */}}
89
+ {{- if and (eq $role "sync") $root.Values.ingress.enabled (not (hasKey $root.Values.env "APP_URL")) }}
90
+ - name: APP_URL
91
+ value: {{ printf "%s://%s" (ternary "https" "http" $root.Values.ingress.tls) $root.Values.ingress.host | quote }}
92
+ {{- end }}
82
93
  {{- range $key, $value := $root.Values.env }}
83
94
  - name: {{ $key }}
84
95
  value: {{ $value | quote }}
85
96
  {{- end }}
97
+ {{- /*
98
+ The Secret a role reads, per role: roles.<role>.existingSecret (or migrate.existingSecret) names a
99
+ narrower one than the release-wide existingSecret, so the migrate Job's schema-owning credential
100
+ need not sit on a web pod. Both unset is a render error, not a pod with no configuration.
101
+ */}}
86
102
  envFrom:
87
103
  - secretRef:
88
- name: {{ $root.Values.existingSecret }}
104
+ 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
105
  {{- if or $cfg.port $scraped }}
90
106
  ports:
91
107
  {{- if $cfg.port }}
@@ -100,8 +116,10 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
100
116
  {{/*
101
117
  Probes follow the role, because the roles do not agree on what they open. web and sync serve
102
118
  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.
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.
105
123
 
106
124
  Every one of them takes a startupProbe too, because no listener opens early: the server builds
107
125
  the app's islands before any role binds a port, and a liveness probe counting from container
@@ -134,6 +152,11 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
134
152
  httpGet: { path: /metrics, port: metrics }
135
153
  periodSeconds: 5
136
154
  failureThreshold: 30
155
+ {{- if eq $role "replicator" }}
156
+ readinessProbe:
157
+ httpGet: { path: '/readyz?deep=1', port: metrics }
158
+ periodSeconds: 10
159
+ {{- end }}
137
160
  livenessProbe:
138
161
  httpGet: { path: /metrics, port: metrics }
139
162
  periodSeconds: 15
@@ -194,8 +217,7 @@ spec:
194
217
  containers:
195
218
  {{- include "${app.kebab}.container" (dict "role" $role "cfg" $cfg "root" $) | nindent 8 }}
196
219
  volumes:
197
- - name: tmp
198
- emptyDir: {}
220
+ {{- include "${app.kebab}.tmpVolume" $ | nindent 8 }}
199
221
  {{- end }}
200
222
  {{- end }}
201
223
  `;
@@ -219,6 +241,9 @@ spec:
219
241
  type: ClusterIP
220
242
  {{- if not $cfg.port }}
221
243
  clusterIP: None
244
+ # Scraped whether or not ready: a replicator whose stream is down (readiness 503) is the pod
245
+ # whose metrics matter most, and nothing routes to these addresses anyway.
246
+ publishNotReadyAddresses: true
222
247
  {{- end }}
223
248
  selector:
224
249
  app.kubernetes.io/instance: {{ $.Release.Name }}
@@ -267,8 +292,7 @@ spec:
267
292
  containers:
268
293
  {{- include "${app.kebab}.container" (dict "role" "migrate" "cfg" .Values.migrate "root" .) | nindent 8 }}
269
294
  volumes:
270
- - name: tmp
271
- emptyDir: {}
295
+ {{- include "${app.kebab}.tmpVolume" . | nindent 8 }}
272
296
  {{- end }}
273
297
  `;
274
298
 
@@ -382,5 +406,6 @@ export function helmTemplateFiles(app: NameSet): readonly GeneratedFile[] {
382
406
  { path: 'docker/helm/templates/migrate-job.yaml', contents: migrateJob(app) },
383
407
  { path: 'docker/helm/templates/ingress.yaml', contents: ingress(app) },
384
408
  { path: 'docker/helm/templates/hpa.yaml', contents: hpa(app) },
409
+ ...helmFenceFiles(app),
385
410
  ];
386
411
  }
@@ -31,11 +31,41 @@ image:
31
31
 
32
32
  # Non-secret configuration. Secrets come from an existing Secret, never from this file:
33
33
  # kubectl create secret generic ${app.kebab}-secrets --from-literal=DATABASE_URL=...
34
+ #
35
+ # APP_URL — the pages' public origin — is what the sync node admits a websocket from. With the
36
+ # ingress below enabled the chart SETS it on the sync role from ingress.host (https when
37
+ # ingress.tls), because an undeclared node behind a TLS ingress also admits the plain-http page.
38
+ # Set it here when the pages are served from another host or port than that (a CDN domain, a
39
+ # SYNC_URL of its own): this value wins, and without it every websocket from those pages is
40
+ # 403 X_SOCKET_ORIGIN_REFUSED.
34
41
  env:
35
42
  NODE_ENV: production
36
43
 
37
44
  existingSecret: ${app.kebab}-secrets # DATABASE_URL, NATS_URL, S3_*, AUTH_SECRET, ULTIMATE_CURSOR_SECRET
38
45
 
46
+ # The release-wide Secret above is every role's DEFAULT: \`existingSecret\` under a role
47
+ # (roles.web.existingSecret, migrate.existingSecret) names that role's own, so the migrate Job's
48
+ # schema-owning DATABASE_URL need not sit on a web pod.
49
+
50
+ # The /tmp every pod mounts (the root filesystem is read-only), bounded: past this the kubelet
51
+ # evicts the pod that filled it, never its neighbours for node disk pressure.
52
+ tmp:
53
+ sizeLimit: 512Mi
54
+
55
+ # One NetworkPolicy per serving role (templates/networkpolicy.yaml): \`http\` from httpFrom,
56
+ # \`metrics\` from metricsFrom, nothing else. Inert on a CNI that does not enforce NetworkPolicy.
57
+ # httpFrom empty = any source; narrow it to your ingress controller's namespace, e.g.
58
+ # [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: ingress-nginx } } }]
59
+ # metricsFrom default: any pod in the cluster and no address outside it
60
+ # egress OFF while empty; a list turns on egress filtering with exactly those rules —
61
+ # the database, NATS, the object store and DNS are your addresses, so list all of them
62
+ networkPolicy:
63
+ enabled: true
64
+ httpFrom: []
65
+ metricsFrom:
66
+ - namespaceSelector: {}
67
+ egress: []
68
+
39
69
  # The scrape listener every serving role opens, on its own port and never the app's: the ingress
40
70
  # routes / to web, so /metrics beside /healthz on 3000 is /metrics on the internet. This is
41
71
  # METRICS_PORT in the container; move one and the other follows.
@@ -153,7 +183,7 @@ ingress:
153
183
  `;
154
184
 
155
185
  /**
156
- * The chart for a new app: five kinds of object, all of them core Kubernetes. A ServiceMonitor and
186
+ * The chart for a new app: six kinds of object, all of them core Kubernetes. A ServiceMonitor and
157
187
  * a PodDisruptionBudget are deliberately absent — the first needs a CRD `helm install` fails on
158
188
  * where no Prometheus operator is installed, and both are cluster policy rather than app topology.
159
189
  */
@@ -41,6 +41,14 @@ const BIOME_VERSION = '2.5.8';
41
41
  */
42
42
  const TYPESCRIPT_VERSION = '^7.0.2';
43
43
 
44
+ /**
45
+ * The Solid runtime every island and `@ultimat3/ui` component renders with, exact for the reason
46
+ * `BIOME_VERSION` is: the CLI compiles islands with `babel-preset-solid ^1.9.15`, and a scaffold
47
+ * that installed an older runtime than the compiler targets drifted as a buried literal (1.9.14
48
+ * beside the repository's 1.9.15). `scaffold-repo.test.ts` holds it equal to the root manifest's.
49
+ */
50
+ const SOLID_VERSION = '1.9.15';
51
+
44
52
  // `engines.bun` is `REQUIRED_BUN`, the floor the SHIPPED `x` enforces
45
53
  // (`packages/cli/src/app-root.ts`), and not a second literal. It was one: `>=1.3.0`, a whole minor
46
54
  // BELOW the CLI the app then runs, so on a box with Bun 1.3.x `bun install` succeeded and the very
@@ -99,7 +107,7 @@ const rootPackage = (app: NameSet, version: string): string => `{
99
107
  "@ultimat3/schema": "^${version}",
100
108
  "@ultimat3/seo": "^${version}",
101
109
  "@ultimat3/ui": "^${version}",
102
- "solid-js": "1.9.14"
110
+ "solid-js": "${SOLID_VERSION}"
103
111
  },
104
112
  "engines": {
105
113
  "bun": ">=${REQUIRED_BUN}"
@@ -272,17 +272,11 @@ const toggleIsland =
272
272
  // browser downloads. Named by SPECIFIER from the page, never by import:
273
273
  // const ThemeSwitch = island({ src: '../../shared/theme-toggle.island.tsx', props: ['locale'] });
274
274
  //
275
- // The control itself is the catalog's \`ThemeToggle\`; this file is the runtime it needs and the
276
- // one decision the framework cannot make for it — see \`bootedEnv\` below.
275
+ // The control itself is the catalog's \`ThemeToggle\`; this file is only the runtime it needs. It
276
+ // reads the browser on its own: the theme the boot stamped on \`<html>\`, then \`theme.defaultMode\`
277
+ // (stamped beside it), then the OS.
277
278
 
278
- import {
279
- browserThemeEnv,
280
- setSolidRuntime,
281
- THEME_ATTRIBUTE,
282
- type ThemeEnv,
283
- ThemeToggle,
284
- UiProvider,
285
- } from '@ultimat3/ui';
279
+ import { setSolidRuntime, ThemeToggle, UiProvider } from '@ultimat3/ui';
286
280
  import {
287
281
  createContext,
288
282
  createEffect,
@@ -298,17 +292,6 @@ export interface ThemeToggleIslandProps {
298
292
  readonly locale: string;
299
293
  }
300
294
 
301
- /**
302
- * With no stored choice the catalog's \`resolveTheme\` asks the OS — but the document already wears
303
- * the theme the framework's boot script stamped from \`theme.defaultMode\` (dark, in this app), so
304
- * a toggle that asked the OS would show one theme while the page wore another. The boot's verdict
305
- * is on \`<html>\` before this runs: read it once, and let a stored choice win as it does there.
306
- */
307
- export const bootedEnv = (): ThemeEnv => {
308
- const booted = document.documentElement.getAttribute(THEME_ATTRIBUTE) === 'dark';
309
- return { ...browserThemeEnv(), prefersDark: () => booted };
310
- };
311
-
312
295
  /**
313
296
  * The one export the hydration runtime calls. \`setSolidRuntime\` comes FIRST and is not optional:
314
297
  * \`@ultimat3/ui\` imports types from solid-js and never a runtime, so the reactive graph a
@@ -318,12 +301,11 @@ export const bootedEnv = (): ThemeEnv => {
318
301
  */
319
302
  export function mount(el: HTMLElement, props: ThemeToggleIslandProps): void {
320
303
  setSolidRuntime({ createContext, useContext, createSignal, createMemo, createEffect, onCleanup });
321
- const env = bootedEnv();
322
304
  el.textContent = '';
323
305
  render(
324
306
  () => (
325
307
  <UiProvider locale={props.locale}>
326
- <ThemeToggle mode="toggle" env={env} />
308
+ <ThemeToggle mode="toggle" />
327
309
  </UiProvider>
328
310
  ),
329
311
  el,
@@ -353,15 +335,13 @@ export const themeToggleStates = defineIslandStates({
353
335
 
354
336
  const toggleTest =
355
337
  (): string => `// The toggle the browser actually runs: built with the same \`buildIslands\` as \`x build\`, mounted
356
- // against a DOM small enough to read, and clicked. What it pins is the one decision this island
357
- // makes — the boot's verdict on \`<html>\` is what the control starts from, and a click writes the
358
- // key the boot script reads back on the next load.
338
+ // against a DOM small enough to read, and clicked. What it pins: the boot's verdict on \`<html>\` is
339
+ // what the control starts from, and a click writes the key the boot script reads back next load.
359
340
 
360
341
  import { join } from 'node:path';
361
342
  import { buildIslands } from '@ultimat3/cli';
362
343
  import { describeIslandState, expect, test } from '@ultimat3/testing';
363
- import { THEME_ATTRIBUTE, THEME_STORAGE_KEY } from '@ultimat3/ui';
364
- import { bootedEnv } from './theme-toggle.island';
344
+ import { THEME_ATTRIBUTE, THEME_DEFAULT_ATTRIBUTE, THEME_STORAGE_KEY } from '@ultimat3/ui';
365
345
  import { themeToggleStates } from './theme-toggle.island.states';
366
346
 
367
347
  /** What the island reads through \`browserThemeEnv\`: storage and the OS query, both fakes. */
@@ -371,11 +351,19 @@ const localStorage = {
371
351
  setItem: (key: string, value: string): void => void stored.set(key, value),
372
352
  removeItem: (key: string): void => void stored.delete(key),
373
353
  };
354
+ /** One OS query, shared by every \`matchMedia\` call: \`flipOs\` changes it and tells the listeners. */
355
+ const os = { dark: false, listeners: new Set<() => void>() };
374
356
  const matchMedia = (): Record<string, unknown> => ({
375
- matches: false,
376
- addEventListener: () => {},
377
- removeEventListener: () => {},
357
+ get matches(): boolean {
358
+ return os.dark;
359
+ },
360
+ addEventListener: (_type: string, listener: () => void) => void os.listeners.add(listener),
361
+ removeEventListener: (_type: string, listener: () => void) => void os.listeners.delete(listener),
378
362
  });
363
+ const flipOs = (dark: boolean): void => {
364
+ os.dark = dark;
365
+ for (const listener of os.listeners) listener();
366
+ };
379
367
 
380
368
  // The state \`x shot --island theme-toggle\` photographs is the state this mounts: one manifest,
381
369
  // so the picture and the assertion cannot be of two different components.
@@ -404,12 +392,27 @@ describeIslandState(themeToggleStates, 'default', island, (mounted) => {
404
392
  expect(mounted().documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('dark');
405
393
  });
406
394
 
407
- test('the env the island builds starts from the theme stamped on <html>', () => {
408
- // The fake document is still installed here, so this reads exactly what \`mount\` read.
395
+ test('with nothing stored, a click flips the theme the boot stamped — not the OS', () => {
396
+ // The OS fake says light. The boot stamped this app's \`defaultMode\`, dark, so the visible
397
+ // theme is dark and the first click must go light; a toggle that asked the OS would write the
398
+ // dark already on screen and look dead.
399
+ stored.clear();
400
+ mounted().documentElement.setAttribute(THEME_DEFAULT_ATTRIBUTE, 'dark');
409
401
  mounted().documentElement.setAttribute(THEME_ATTRIBUTE, 'dark');
410
- expect(bootedEnv().prefersDark()).toBe(true);
411
- mounted().documentElement.setAttribute(THEME_ATTRIBUTE, 'light');
412
- expect(bootedEnv().prefersDark()).toBe(false);
402
+ expect(mounted().fire('button', 'click')).toBe(true);
403
+ expect(stored.get(THEME_STORAGE_KEY)).toBe('light');
404
+ expect(mounted().documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('light');
405
+ });
406
+
407
+ test("an OS flip is followed while nothing is stored and the app's default is 'system'", () => {
408
+ stored.clear();
409
+ mounted().documentElement.setAttribute(THEME_DEFAULT_ATTRIBUTE, 'system');
410
+ flipOs(true);
411
+ expect(mounted().documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('dark');
412
+ // A fixed default is the app's own opinion: the OS does not override it.
413
+ mounted().documentElement.setAttribute(THEME_DEFAULT_ATTRIBUTE, 'dark');
414
+ flipOs(false);
415
+ expect(mounted().documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('dark');
413
416
  });
414
417
  });
415
418
  `;
@@ -5,6 +5,8 @@
5
5
  import type { ExecResult } from './exec';
6
6
  import { execOutput } from './exec';
7
7
  import { parseBunTest } from './mcp-test-output';
8
+ // One reader of bun's `N errors` line, shared with MCP `tests.run` — never a second regex.
9
+ import { errorsIn } from './mcp-test-run';
8
10
 
9
11
  export interface TestCounts {
10
12
  /** Tests that executed — passed plus failed. A failed test is a test that ran. */
@@ -20,12 +22,6 @@ export interface TestCounts {
20
22
  readonly errors?: number;
21
23
  }
22
24
 
23
- /** Bun's own `N error` / `N errors` summary line: the last one, as `parseBunTest` reads the rest. */
24
- const errorsIn = (output: string): number => {
25
- const last = [...output.matchAll(/^\s*(\d+)\s+errors?\s*$/gm)].at(-1);
26
- return last === undefined ? 0 : Number.parseInt(last[1] ?? '0', 10);
27
- };
28
-
29
25
  /**
30
26
  * Summed across every process the step spawned, because a step is one line in the gate whether it
31
27
  * ran on one worker or eight.
@@ -0,0 +1,25 @@
1
+ // The ONE policy for which paths are never a test the gate runs here, projected twice: the path
2
+ // predicate `discoverTests` filters by and the `--path-ignore-patterns` the serial steps pass to
3
+ // `bun test`. A leaf, so `test-select.ts` and `verify-tests.ts` (which import each other) share it.
4
+
5
+ /** Build output and installed packages: never a test, at ANY depth — a workspace's `dist/` too. */
6
+ const IGNORED_ANYWHERE = ['dist', 'node_modules'] as const;
7
+
8
+ /**
9
+ * Nested projects with their own gate (`examples/`, `dummy/`, run by
10
+ * `scripts/reference-app-gate.ts`) and the root `build/` — skipped AT THE ROOT only. Matched at any
11
+ * depth they swallowed an app's own slice: `x g resource build` lost 14 of its 16 test files and
12
+ * `x g resource example` its `examples/` page tests, from `x test` and the gate alike.
13
+ */
14
+ const IGNORED_AT_ROOT = ['build', 'examples', 'dummy'] as const;
15
+
16
+ /** Whether a root-relative POSIX path is excluded from every test selection. */
17
+ export const isIgnoredTestPath = (path: string): boolean =>
18
+ IGNORED_ANYWHERE.some((dir) => `/${path}`.includes(`/${dir}/`)) ||
19
+ IGNORED_AT_ROOT.some((dir) => path.startsWith(`${dir}/`));
20
+
21
+ /** The same policy as `bun test --path-ignore-patterns` globs, relative to the run's root. */
22
+ export const IGNORED_TEST_PATTERNS: readonly string[] = [
23
+ ...IGNORED_ANYWHERE.map((dir) => `**/${dir}/**`),
24
+ ...IGNORED_AT_ROOT.map((dir) => `${dir}/**`),
25
+ ];
@@ -24,8 +24,6 @@ export interface PassInput {
24
24
  readonly workers: number;
25
25
  /** The positional, when there was one. A pass over one type keeps it; the split never adds one. */
26
26
  readonly type?: TestType;
27
- /** Set for a `--worker I` rerun, which is one process by construction. */
28
- readonly shard?: number;
29
27
  }
30
28
 
31
29
  const widthFor = (files: readonly TestFile[], workers: number): number =>
@@ -45,22 +43,9 @@ const widthFor = (files: readonly TestFile[], workers: number): number =>
45
43
  *
46
44
  * Each serial type is its own pass rather than one pass over both, because the pass's `type` is
47
45
  * what its failure reproduces with: `x test live --workers 1` selects exactly the files that ran.
48
- *
49
- * A `--worker I` rerun is left whole: it is a single `bun test --isolate --shard=i/N` process, so
50
- * nothing inside it runs beside anything else, and splitting it would make shard i of the rerun a
51
- * different set of files from shard i of the run it reproduces.
52
46
  */
53
47
  export function testPasses(input: PassInput): readonly TestPass[] {
54
48
  const serialTypes = new Set<TestType>(SERIAL_TYPES);
55
- if (input.shard !== undefined) {
56
- return [
57
- {
58
- files: input.files,
59
- workers: widthFor(input.files, input.workers),
60
- ...(input.type === undefined ? {} : { type: input.type }),
61
- },
62
- ];
63
- }
64
49
  const shared = input.files.filter((file) => !serialTypes.has(ownerOf(file.path)));
65
50
  const passes: TestPass[] = [];
66
51
  // Cheapest first, and the widest first: the pool run is most of the corpus and most of the
@@ -9,6 +9,7 @@ import { nearestName, renderFixShellArg } from '@ultimat3/core';
9
9
  import { BadFlagError } from './errors';
10
10
  import type { ParsedArgs } from './parse';
11
11
  import { flagString } from './parse';
12
+ import { isIgnoredTestPath } from './test-ignores';
12
13
  import type { TestType } from './verify-tests';
13
14
  import { ownerOf, TEST_TYPES } from './verify-tests';
14
15
 
@@ -27,20 +28,10 @@ const TEST_GLOB = '**/*.test.{ts,tsx}';
27
28
 
28
29
  /**
29
30
  * The root `test` script's ignore list, kept identical so `x test` and `bun run test` see one
30
- * suite. `e2e/` is NOT on it: an opt-in suite that the gate runs but `x test` silently drops is
31
- * a suite nobody runs until CI says so. `examples/` is, because the reference app is a separate
32
- * project with its own gate — `x verify` there, not `x test` here.
33
- *
34
- * `dummy/` and `build/` complete the list `verify-tests.ts` already excluded (`NEVER_A_TEST`). The
35
- * comment above claimed the two agreed and they did not: `x test unit` discovered 464 files where
36
- * the gate's `unit` step ran 441, so the gate's own test steps — which now select through this
37
- * function — would have started running a nested demo app's suite on the framework's gate.
38
- *
39
- * Both directories are gated where they belong, by `scripts/reference-app-gate.ts` running
40
- * `x verify` inside each app — see `NEVER_A_TEST` for why that is the only place they run.
31
+ * suite — `test-ignores.ts` holds it, and the serial steps' argv projects the same policy. `e2e/`
32
+ * is NOT on it: an opt-in suite that the gate runs but `x test` silently drops is a suite nobody
33
+ * runs until CI says so.
41
34
  */
42
- const IGNORED = ['/dist/', '/build/', '/node_modules/', '/examples/', '/dummy/'];
43
-
44
35
  /**
45
36
  * File size stands in for duration: cheap to read, and it correlates far better than file count.
46
37
  * `type`, when given, narrows to exactly the files verify-tests.ts would run for that suite — one
@@ -58,7 +49,7 @@ export async function discoverTests(
58
49
  const files: TestFile[] = [];
59
50
  for await (const found of new Bun.Glob(TEST_GLOB).scan({ cwd: root, absolute: false })) {
60
51
  const path = found.split('\\').join('/');
61
- if (IGNORED.some((part) => `/${path}`.includes(part))) continue;
52
+ if (isIgnoredTestPath(path)) continue;
62
53
  if (filters !== undefined && !filters.some((part) => path.includes(part))) continue;
63
54
  if (type !== undefined && !belongsToType(path, type)) continue;
64
55
  files.push({ path, bytes: Bun.file(join(root, path)).size });