@ultimat3/cli 3.0.0 → 4.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 (52) hide show
  1. package/CLAUDE.md +69 -10
  2. package/package.json +24 -24
  3. package/src/budgets.ts +8 -5
  4. package/src/cmd-deploy.ts +42 -14
  5. package/src/cmd-docs.ts +7 -3
  6. package/src/cmd-fix.ts +15 -3
  7. package/src/cmd-generate.ts +29 -4
  8. package/src/cmd-help.ts +25 -4
  9. package/src/cmd-i18n.ts +8 -5
  10. package/src/cmd-jobs.ts +6 -5
  11. package/src/cmd-mcp.ts +16 -12
  12. package/src/cmd-new.ts +9 -13
  13. package/src/cmd-planned.ts +13 -0
  14. package/src/cmd-policy.ts +8 -6
  15. package/src/cmd-registries.ts +7 -6
  16. package/src/cmd-routes.ts +27 -4
  17. package/src/cmd-secrets.ts +6 -6
  18. package/src/cmd-verify.ts +55 -3
  19. package/src/command.ts +10 -2
  20. package/src/dev-cache.ts +9 -9
  21. package/src/dev-render.ts +6 -1
  22. package/src/dev-runtime.ts +2 -2
  23. package/src/dispatch.ts +33 -4
  24. package/src/error-codes.ts +5 -0
  25. package/src/error-contract.ts +31 -4
  26. package/src/fix-command.ts +9 -2
  27. package/src/fix-imports.ts +118 -0
  28. package/src/fix-scan.ts +251 -0
  29. package/src/flag-reads.ts +114 -0
  30. package/src/i18n-audit.ts +2 -1
  31. package/src/index.ts +8 -1
  32. package/src/jobs-drain.ts +6 -1
  33. package/src/mcp-errors.ts +5 -0
  34. package/src/mcp-host.ts +4 -2
  35. package/src/messages.ts +3 -0
  36. package/src/otlp-export.ts +14 -0
  37. package/src/parse.ts +6 -1
  38. package/src/seo-meta.ts +105 -0
  39. package/src/templates/action.ts +39 -7
  40. package/src/templates/backfill.ts +3 -1
  41. package/src/templates/index.ts +10 -1
  42. package/src/templates/job.ts +6 -2
  43. package/src/templates/query.ts +6 -1
  44. package/src/templates/route.ts +18 -9
  45. package/src/templates/scaffold-api.ts +100 -0
  46. package/src/templates/scaffold-app.ts +8 -48
  47. package/src/templates/scaffold-container.ts +44 -9
  48. package/src/templates/scaffold-helm-templates.ts +327 -0
  49. package/src/templates/scaffold-helm.ts +144 -0
  50. package/src/templates/scaffold-repo.ts +25 -8
  51. package/src/ts-scan.ts +12 -174
  52. package/src/verify-step.ts +5 -0
@@ -0,0 +1,327 @@
1
+ // The Kubernetes objects the scaffolded chart renders: one Deployment and one Service per enabled
2
+ // role, the release-phase Job, an optional ingress and an optional per-role autoscaler.
3
+ // Split from scaffold-helm.ts, which holds the chart's INPUTS (Chart.yaml, values.yaml).
4
+
5
+ import type { GeneratedFile, NameSet } from './naming';
6
+
7
+ const helpers = (
8
+ app: NameSet,
9
+ ): string => `{{/* Shared naming and the one container spec every role renders. */}}
10
+
11
+ {{- define "${app.kebab}.name" -}}
12
+ {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
13
+ {{- end -}}
14
+
15
+ {{- define "${app.kebab}.fullname" -}}
16
+ {{- printf "%s-%s" .Release.Name (include "${app.kebab}.name" .) | trunc 63 | trimSuffix "-" -}}
17
+ {{- end -}}
18
+
19
+ {{/*
20
+ repository:tag, and NOT a single "image" string: x deploy --method helm passes
21
+ --set image.repository=... --set image.tag=..., because replacing the map with a string makes
22
+ every workload below fail on .repository and deploy nothing.
23
+ */}}
24
+ {{- define "${app.kebab}.image" -}}
25
+ {{- printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag) -}}
26
+ {{- end -}}
27
+
28
+ {{- define "${app.kebab}.labels" -}}
29
+ app.kubernetes.io/name: {{ include "${app.kebab}.name" . }}
30
+ app.kubernetes.io/instance: {{ .Release.Name }}
31
+ app.kubernetes.io/version: {{ default .Chart.AppVersion .Values.image.tag | quote }}
32
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
33
+ {{- end -}}
34
+
35
+ {{/*
36
+ The container spec for a role. ROLE is the only thing that varies, exactly as in
37
+ docker-compose.prod.yml: one image, one entry point, N processes.
38
+
39
+ migrate is the one role that opens no socket at all — it applies migrations and exits — so it
40
+ declares no port and no scrape target.
41
+ */}}
42
+ {{- define "${app.kebab}.container" -}}
43
+ {{- $role := .role -}}
44
+ {{- $cfg := .cfg -}}
45
+ {{- $root := .root -}}
46
+ {{- $scraped := ne $role "migrate" -}}
47
+ {{/*
48
+ roles.<role>.port is the port the role LISTENS on — the one number a Service, an ingress backend
49
+ and two probes can all use. PORT is a different number for exactly one role: the sync node binds
50
+ PORT + 1, so a sync container told PORT=3001 opens 3002 and the readiness probe polls a socket
51
+ nobody bound. Derived here rather than stated twice in values.yaml, where the two would drift.
52
+ */}}
53
+ {{- $envPort := $cfg.port -}}
54
+ {{- if and $cfg.port (eq $role "sync") -}}
55
+ {{- $envPort = sub (int $cfg.port) 1 -}}
56
+ {{- end -}}
57
+ - name: {{ $role }}
58
+ image: {{ include "${app.kebab}.image" $root }}
59
+ imagePullPolicy: {{ $root.Values.image.pullPolicy }}
60
+ securityContext: {{- toYaml $root.Values.securityContext | nindent 4 }}
61
+ env:
62
+ - name: ROLE
63
+ value: {{ $role | quote }}
64
+ {{- if $cfg.port }}
65
+ - name: PORT
66
+ value: {{ $envPort | quote }}
67
+ {{- end }}
68
+ {{- if $scraped }}
69
+ - name: METRICS_PORT
70
+ value: {{ $root.Values.metricsPort | quote }}
71
+ {{- end }}
72
+ {{- range $key, $value := $root.Values.env }}
73
+ - name: {{ $key }}
74
+ value: {{ $value | quote }}
75
+ {{- end }}
76
+ envFrom:
77
+ - secretRef:
78
+ name: {{ $root.Values.existingSecret }}
79
+ {{- if or $cfg.port $scraped }}
80
+ ports:
81
+ {{- if $cfg.port }}
82
+ - name: http
83
+ containerPort: {{ $cfg.port }}
84
+ {{- end }}
85
+ {{- if $scraped }}
86
+ - name: metrics
87
+ containerPort: {{ $root.Values.metricsPort | int }}
88
+ {{- end }}
89
+ {{- end }}
90
+ {{/*
91
+ Probes follow the role, because the roles do not agree on what they open. web and sync serve
92
+ HTTP and get both. worker, scheduler and replicator open no HTTP socket at all — the scrape
93
+ listener is their only port — so they take liveness on it and NO readiness: nothing routes to
94
+ them, and a readiness flap would drop the pod out of the Service and so out of the scrape.
95
+ */}}
96
+ {{- if $cfg.port }}
97
+ readinessProbe:
98
+ httpGet: { path: /readyz, port: http }
99
+ periodSeconds: 5
100
+ livenessProbe:
101
+ httpGet: { path: /healthz, port: http }
102
+ periodSeconds: 15
103
+ {{- else if $scraped }}
104
+ livenessProbe:
105
+ httpGet: { path: /metrics, port: metrics }
106
+ periodSeconds: 15
107
+ failureThreshold: 4
108
+ {{- end }}
109
+ resources: {{- toYaml $cfg.resources | nindent 4 }}
110
+ volumeMounts:
111
+ - name: tmp
112
+ mountPath: /tmp
113
+ {{- end -}}
114
+ `;
115
+
116
+ const deployments = (app: NameSet): string => `{{/*
117
+ One Deployment per enabled role, from one image. terminationGracePeriodSeconds matches the
118
+ framework's SIGTERM drain: in-flight requests, open websockets and running job steps finish.
119
+ */}}
120
+ {{- range $role, $cfg := .Values.roles }}
121
+ {{- if $cfg.enabled }}
122
+ ---
123
+ apiVersion: apps/v1
124
+ kind: Deployment
125
+ metadata:
126
+ name: {{ include "${app.kebab}.fullname" $ }}-{{ $role }}
127
+ labels:
128
+ {{- include "${app.kebab}.labels" $ | nindent 4 }}
129
+ app.kubernetes.io/component: {{ $role }}
130
+ spec:
131
+ {{/* Ask whether the block EXISTS before reading through it: scheduler and replicator declare
132
+ none, and without the guard the whole chart fails to render. */}}
133
+ {{- if not (and $cfg.autoscaling $cfg.autoscaling.enabled) }}
134
+ replicas: {{ $cfg.replicas }}
135
+ {{- end }}
136
+ selector:
137
+ matchLabels:
138
+ app.kubernetes.io/instance: {{ $.Release.Name }}
139
+ app.kubernetes.io/component: {{ $role }}
140
+ strategy:
141
+ type: RollingUpdate
142
+ rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }
143
+ template:
144
+ metadata:
145
+ labels:
146
+ {{- include "${app.kebab}.labels" $ | nindent 8 }}
147
+ app.kubernetes.io/component: {{ $role }}
148
+ spec:
149
+ {{/* No role in this image calls the Kubernetes API — ROLE comes from the env and everything
150
+ else from app.config.ts. Mounting the token anyway hands a JWT for this namespace to any
151
+ code that reaches the filesystem, which for a web role is one path traversal. */}}
152
+ automountServiceAccountToken: false
153
+ securityContext: {{- toYaml $.Values.podSecurityContext | nindent 8 }}
154
+ {{/* At least the framework's drain deadline (25s by default), which is what
155
+ X_SHUTDOWN_TIMEOUT's fix line tells an operator. Raise this WITH configureLifecycle({
156
+ deadlineMs }), never instead of it: the drain abandons its hooks at its own deadline. */}}
157
+ terminationGracePeriodSeconds: 45
158
+ containers:
159
+ {{- include "${app.kebab}.container" (dict "role" $role "cfg" $cfg "root" $) | nindent 8 }}
160
+ volumes:
161
+ - name: tmp
162
+ emptyDir: {}
163
+ {{- end }}
164
+ {{- end }}
165
+ `;
166
+
167
+ const service = (app: NameSet): string => `{{/*
168
+ One Service per enabled role. The roles that take no traffic still get one, HEADLESS: it exists so
169
+ a scrape target and a DNS name exist, and a headless Service allocates no ClusterIP, so nothing
170
+ about it invites traffic it would not answer.
171
+ */}}
172
+ {{- range $role, $cfg := .Values.roles }}
173
+ {{- if $cfg.enabled }}
174
+ ---
175
+ apiVersion: v1
176
+ kind: Service
177
+ metadata:
178
+ name: {{ include "${app.kebab}.fullname" $ }}-{{ $role }}
179
+ labels:
180
+ {{- include "${app.kebab}.labels" $ | nindent 4 }}
181
+ app.kubernetes.io/component: {{ $role }}
182
+ spec:
183
+ type: ClusterIP
184
+ {{- if not $cfg.port }}
185
+ clusterIP: None
186
+ {{- end }}
187
+ selector:
188
+ app.kubernetes.io/instance: {{ $.Release.Name }}
189
+ app.kubernetes.io/component: {{ $role }}
190
+ ports:
191
+ {{- if $cfg.port }}
192
+ - name: http
193
+ port: 80
194
+ targetPort: http
195
+ {{- end }}
196
+ - name: metrics
197
+ port: {{ $.Values.metricsPort | int }}
198
+ targetPort: metrics
199
+ {{- end }}
200
+ {{- end }}
201
+ `;
202
+
203
+ const migrateJob = (app: NameSet): string => `{{/*
204
+ The release phase, as a pre-install / pre-upgrade hook: ROLE=migrate applies every pending
205
+ migration under an advisory lock and exits, before any serving role starts. Same image, same entry
206
+ point — there is no second migration engine and no toolchain in the container.
207
+ */}}
208
+ {{- if .Values.migrate.enabled }}
209
+ apiVersion: batch/v1
210
+ kind: Job
211
+ metadata:
212
+ name: {{ include "${app.kebab}.fullname" . }}-migrate
213
+ labels:
214
+ {{- include "${app.kebab}.labels" . | nindent 4 }}
215
+ app.kubernetes.io/component: migrate
216
+ annotations:
217
+ helm.sh/hook: pre-install,pre-upgrade
218
+ helm.sh/hook-weight: "-5"
219
+ helm.sh/hook-delete-policy: before-hook-creation
220
+ spec:
221
+ backoffLimit: {{ .Values.migrate.backoffLimit }}
222
+ template:
223
+ metadata:
224
+ labels:
225
+ {{- include "${app.kebab}.labels" . | nindent 8 }}
226
+ app.kubernetes.io/component: migrate
227
+ spec:
228
+ restartPolicy: Never
229
+ automountServiceAccountToken: false
230
+ securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }}
231
+ containers:
232
+ {{- include "${app.kebab}.container" (dict "role" "migrate" "cfg" .Values.migrate "root" .) | nindent 8 }}
233
+ volumes:
234
+ - name: tmp
235
+ emptyDir: {}
236
+ {{- end }}
237
+ `;
238
+
239
+ const ingress = (
240
+ app: NameSet,
241
+ ): string => `{{/* Standard ingress, no vendor edge primitives. Off by default: the host is yours to name. */}}
242
+ {{- if .Values.ingress.enabled }}
243
+ apiVersion: networking.k8s.io/v1
244
+ kind: Ingress
245
+ metadata:
246
+ name: {{ include "${app.kebab}.fullname" . }}
247
+ labels:
248
+ {{- include "${app.kebab}.labels" . | nindent 4 }}
249
+ spec:
250
+ ingressClassName: {{ .Values.ingress.className }}
251
+ {{- if .Values.ingress.tls }}
252
+ tls:
253
+ - hosts: [{{ .Values.ingress.host | quote }}]
254
+ secretName: {{ include "${app.kebab}.fullname" . }}-tls
255
+ {{- end }}
256
+ rules:
257
+ - host: {{ .Values.ingress.host | quote }}
258
+ http:
259
+ paths:
260
+ {{- /* The path the sync node actually serves. Routing /_sync instead sends every
261
+ websocket to the web role, which answers no upgrade. */}}
262
+ - path: /_x/sync
263
+ pathType: Prefix
264
+ backend:
265
+ service:
266
+ name: {{ include "${app.kebab}.fullname" . }}-sync
267
+ port: { name: http }
268
+ - path: /
269
+ pathType: Prefix
270
+ backend:
271
+ service:
272
+ name: {{ include "${app.kebab}.fullname" . }}-web
273
+ port: { name: http }
274
+ {{- end }}
275
+ `;
276
+
277
+ const hpa = (app: NameSet): string => `{{/*
278
+ Per-role autoscalers, off until you turn one on. Each role scales on the signal that predicts ITS
279
+ saturation — CPU is a lagging proxy for all three and scales the wrong thing at the wrong time.
280
+ A Pods metric needs a metrics adapter in the cluster; without one the HPA reads <unknown> and
281
+ holds at minReplicas, which is why these are opt-in rather than a default nobody wired.
282
+ */}}
283
+ {{- range $role, $cfg := .Values.roles }}
284
+ {{- if and $cfg.enabled $cfg.autoscaling $cfg.autoscaling.enabled }}
285
+ ---
286
+ apiVersion: autoscaling/v2
287
+ kind: HorizontalPodAutoscaler
288
+ metadata:
289
+ name: {{ include "${app.kebab}.fullname" $ }}-{{ $role }}
290
+ labels:
291
+ {{- include "${app.kebab}.labels" $ | nindent 4 }}
292
+ app.kubernetes.io/component: {{ $role }}
293
+ spec:
294
+ scaleTargetRef:
295
+ apiVersion: apps/v1
296
+ kind: Deployment
297
+ name: {{ include "${app.kebab}.fullname" $ }}-{{ $role }}
298
+ minReplicas: {{ $cfg.autoscaling.minReplicas }}
299
+ maxReplicas: {{ $cfg.autoscaling.maxReplicas }}
300
+ metrics:
301
+ - type: Pods
302
+ pods:
303
+ metric:
304
+ name: {{ $cfg.autoscaling.metric }}
305
+ target:
306
+ type: AverageValue
307
+ averageValue: {{ $cfg.autoscaling.targetAverageValue | quote }}
308
+ behavior:
309
+ scaleUp:
310
+ stabilizationWindowSeconds: 30
311
+ scaleDown:
312
+ stabilizationWindowSeconds: 300
313
+ {{- end }}
314
+ {{- end }}
315
+ `;
316
+
317
+ /** The chart's `templates/` directory, in the order a reader meets it. */
318
+ export function helmTemplateFiles(app: NameSet): readonly GeneratedFile[] {
319
+ return [
320
+ { path: 'docker/helm/templates/_helpers.tpl', contents: helpers(app) },
321
+ { path: 'docker/helm/templates/deployments.yaml', contents: deployments(app) },
322
+ { path: 'docker/helm/templates/service.yaml', contents: service(app) },
323
+ { path: 'docker/helm/templates/migrate-job.yaml', contents: migrateJob(app) },
324
+ { path: 'docker/helm/templates/ingress.yaml', contents: ingress(app) },
325
+ { path: 'docker/helm/templates/hpa.yaml', contents: hpa(app) },
326
+ ];
327
+ }
@@ -0,0 +1,144 @@
1
+ // The chart `x deploy --method helm` runs, written by `x new`. The other method's topology file
2
+ // (`docker/docker-compose.prod.yml`) has always been scaffolded; a deploy method whose artifact
3
+ // only exists in a repository the app never had is a command that cannot run.
4
+ //
5
+ // This file is the chart's INPUTS — Chart.yaml and values.yaml. The objects they render are
6
+ // scaffold-helm-templates.ts.
7
+
8
+ import type { GeneratedFile, NameSet } from './naming';
9
+ import { helmTemplateFiles } from './scaffold-helm-templates';
10
+
11
+ /**
12
+ * `appVersion` is the default image tag (`image.tag: ""` below), so it must name a tag that can
13
+ * exist. `x deploy --image <ref>` overrides both keys, which is the path that actually ships.
14
+ */
15
+ const chart = (app: NameSet): string => `apiVersion: v2
16
+ name: ${app.kebab}
17
+ description: One image, N roles. Per-role deployments for ${app.kebab}.
18
+ type: application
19
+ version: 0.1.0
20
+ appVersion: "0.1.0"
21
+ kubeVersion: ">=1.27.0-0"
22
+ `;
23
+
24
+ const values = (
25
+ app: NameSet,
26
+ ): string => `# One image for every role; ROLE is the only difference between the workloads.
27
+ image:
28
+ repository: ${app.kebab}
29
+ tag: "" # defaults to .Chart.AppVersion; x deploy --image sets this and repository
30
+ pullPolicy: IfNotPresent
31
+
32
+ # Non-secret configuration. Secrets come from an existing Secret, never from this file:
33
+ # kubectl create secret generic ${app.kebab}-secrets --from-literal=DATABASE_URL=...
34
+ env:
35
+ NODE_ENV: production
36
+
37
+ existingSecret: ${app.kebab}-secrets # DATABASE_URL, NATS_URL, S3_*, AUTH_SECRET
38
+
39
+ # The scrape listener every serving role opens, on its own port and never the app's: the ingress
40
+ # routes / to web, so /metrics beside /healthz on 3000 is /metrics on the internet. This is
41
+ # METRICS_PORT in the container; move one and the other follows.
42
+ metricsPort: 9090
43
+
44
+ podSecurityContext:
45
+ runAsNonRoot: true
46
+ runAsUser: 65532
47
+ seccompProfile: { type: RuntimeDefault }
48
+
49
+ securityContext:
50
+ allowPrivilegeEscalation: false
51
+ readOnlyRootFilesystem: true
52
+ capabilities: { drop: [ALL] }
53
+
54
+ # The release phase. Runs to completion before any serving role starts.
55
+ migrate:
56
+ enabled: true
57
+ backoffLimit: 1
58
+ resources:
59
+ requests: { cpu: 100m, memory: 128Mi }
60
+ limits: { memory: 512Mi }
61
+
62
+ # One entry per role this chart may run. \`port\` is the port the role LISTENS on, not the value of
63
+ # PORT: they differ for sync, which binds PORT + 1, and the chart derives the env from this number
64
+ # so there is only ever one to move.
65
+ #
66
+ # Autoscaling is off until you wire a metrics adapter — each \`metric\` below is a Pods metric the
67
+ # role exports, and an HPA with no adapter behind it reads <unknown> and holds at minReplicas.
68
+ roles:
69
+ web:
70
+ enabled: true
71
+ replicas: 2
72
+ port: 3000
73
+ resources:
74
+ requests: { cpu: 200m, memory: 256Mi }
75
+ limits: { memory: 512Mi }
76
+ autoscaling:
77
+ enabled: false
78
+ minReplicas: 2
79
+ maxReplicas: 30
80
+ metric: rps # requests per second
81
+ targetAverageValue: "50"
82
+
83
+ sync:
84
+ enabled: true
85
+ replicas: 2
86
+ port: 3001
87
+ resources:
88
+ requests: { cpu: 200m, memory: 512Mi }
89
+ limits: { memory: 1Gi }
90
+ autoscaling:
91
+ enabled: false
92
+ minReplicas: 2
93
+ maxReplicas: 40
94
+ metric: connections # concurrent websockets per pod
95
+ targetAverageValue: "2000"
96
+
97
+ worker:
98
+ enabled: true
99
+ replicas: 2
100
+ resources:
101
+ requests: { cpu: 200m, memory: 256Mi }
102
+ limits: { memory: 1Gi }
103
+ autoscaling:
104
+ enabled: false
105
+ minReplicas: 1
106
+ maxReplicas: 50
107
+ metric: queue_depth # jobs waiting, exported by the worker role
108
+ targetAverageValue: "100"
109
+
110
+ scheduler:
111
+ enabled: true
112
+ # Fixed 1, and a second replica is safe but pointless: leadership is an expiring row in
113
+ # x_scheduler_leader, so the extra pod stands by.
114
+ replicas: 1
115
+ resources:
116
+ requests: { cpu: 50m, memory: 128Mi }
117
+ limits: { memory: 256Mi }
118
+
119
+ replicator:
120
+ enabled: false # exactly one per database; enable when the change feed is in use
121
+ replicas: 1
122
+ resources:
123
+ requests: { cpu: 100m, memory: 256Mi }
124
+ limits: { memory: 512Mi }
125
+
126
+ ingress:
127
+ enabled: false
128
+ className: nginx
129
+ host: ${app.kebab}.example.com
130
+ tls: true
131
+ `;
132
+
133
+ /**
134
+ * The chart for a new app: five kinds of object, all of them core Kubernetes. A ServiceMonitor and
135
+ * a PodDisruptionBudget are deliberately absent — the first needs a CRD `helm install` fails on
136
+ * where no Prometheus operator is installed, and both are cluster policy rather than app topology.
137
+ */
138
+ export function helmFiles(app: NameSet): readonly GeneratedFile[] {
139
+ return [
140
+ { path: 'docker/helm/Chart.yaml', contents: chart(app) },
141
+ { path: 'docker/helm/values.yaml', contents: values(app) },
142
+ ...helmTemplateFiles(app),
143
+ ];
144
+ }
@@ -120,6 +120,13 @@ const envDeclaration = (): string => `${envSchemaSource()}
120
120
  */
121
121
  export const env = defineEnv(envSchema);`;
122
122
 
123
+ /**
124
+ * No `installPrompt`. `PwaConfig` declares it, `defineConfig` defaults it, and NO file reads it —
125
+ * `packages/core/src/config.ts` carries the marker saying so. Scaffolding it wrote a switch with
126
+ * no wire into every generated app, and the note belongs HERE rather than in the emitted file: an
127
+ * app author has no use for a comment about a framework key their config does not name. Deleting
128
+ * the key itself is core's edit; not writing it is this template's half.
129
+ */
123
130
  const appConfig = (
124
131
  app: NameSet,
125
132
  ): string => `// The one config file. Everything the app needs to boot is here, typed and validated at startup —
@@ -142,7 +149,7 @@ export const config = defineConfig({
142
149
  jobs: { driver: 'postgres', queues: ['${app.kebab}-default'], concurrency: 4 },
143
150
  // In-process transport by default; set urlEnv and transport: 'nats' to scale past one node.
144
151
  realtime: { enabled: true, tier: 'live-queries', transport: 'memory' },
145
- pwa: { enabled: true, offline: 'runtime', installPrompt: true },
152
+ pwa: { enabled: true, offline: 'runtime' },
146
153
  ai: { mcp: { expose: true, path: '/mcp' } },
147
154
  });
148
155
  `;
@@ -189,11 +196,16 @@ const biome = (): string => `{
189
196
  * to any root with an `app.config.ts`. Typed as `VerifyStepName`, so a name the gate does not run
190
197
  * is a compile error rather than a floor that covers nothing.
191
198
  *
192
- * Four are deliberately absent. `contract`, `live` and `job` have no scaffolded file; `e2e` has
193
- * one, and it is an `e2eTest` — `test.skip` until the app registers a browser driver, so the step
194
- * would run zero tests and fail the ratchet on the scaffold's own placeholder. `contract-diff`
195
- * needs a committed `x.manifest.json`, which `x manifest` writes later. Each joins the list in the
196
- * commit that makes the app's own gate run it.
199
+ * Plus `contract`, which every variant now ships a file for — `apps/web/api/health.contract.test.ts`
200
+ * — and, with the example slice, `live` and `job`: its query is a `liveTest` and its job a
201
+ * `jobTest`, and both were written into plain `<name>.test.ts` files that the `unit` step ran while
202
+ * `x test live` and `x test job` answered X_TEST_NO_FILES. This is the commit that makes the app's
203
+ * own gate run them, which is what the paragraph below always said the condition was.
204
+ *
205
+ * Two remain absent. `e2e` has a scaffolded file and it is an `e2eTest` — `test.skip` until the app
206
+ * registers a browser driver, so the step would run zero tests and fail the ratchet on the
207
+ * scaffold's own placeholder. `contract-diff` needs a committed `x.manifest.json`, which
208
+ * `x manifest` writes later. Each joins the list in the commit that makes the app's gate run it.
197
209
  */
198
210
  const SCAFFOLD_FLOOR: readonly VerifyStepName[] = [
199
211
  'typecheck',
@@ -203,13 +215,18 @@ const SCAFFOLD_FLOOR: readonly VerifyStepName[] = [
203
215
  'package-shape',
204
216
  'errors',
205
217
  'unit',
218
+ 'contract',
206
219
  'eval',
207
220
  'drift',
208
221
  'budgets',
209
222
  'manifest',
210
223
  ];
211
224
 
212
- const verifyFloor = (): string => `${JSON.stringify({ steps: SCAFFOLD_FLOOR }, null, 2)}\n`;
225
+ /** The two suites only the example slice writes a file for — a floor step with no file is red. */
226
+ const EXAMPLE_FLOOR: readonly VerifyStepName[] = ['live', 'job'];
227
+
228
+ const verifyFloor = (example: boolean): string =>
229
+ `${JSON.stringify({ steps: example ? [...SCAFFOLD_FLOOR, ...EXAMPLE_FLOOR] : SCAFFOLD_FLOOR }, null, 2)}\n`;
213
230
 
214
231
  const bunfig = (): string => `[test]
215
232
  root = "."
@@ -276,7 +293,7 @@ export function repoFiles(
276
293
  { path: 'biome.json', contents: biome() },
277
294
  { path: 'bunfig.toml', contents: bunfig() },
278
295
  { path: 'app.config.ts', contents: appConfig(app) },
279
- { path: VERIFY_FLOOR_FILE, contents: verifyFloor() },
296
+ { path: VERIFY_FLOOR_FILE, contents: verifyFloor(example) },
280
297
  { path: 'types/scss.d.ts', contents: scssTypes() },
281
298
  { path: '.gitignore', contents: gitignore() },
282
299
  { path: '.env.development', contents: envDevelopment() },