create-stitchkit 0.4.0 → 0.4.2

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 (47) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/UPGRADING.md +117 -0
  3. package/dist/cli.js +5 -1
  4. package/examples/repository/scripts/runtime-smoke.ts +14 -2
  5. package/package.json +4 -2
  6. package/template/AGENTS.md +15 -5
  7. package/template/README.md +46 -8
  8. package/template/_env.example +8 -0
  9. package/template/_gitignore +1 -0
  10. package/template/biome.json +1 -1
  11. package/template/bun.lock +115 -98
  12. package/template/package.json +9 -8
  13. package/template/packages/backend/package.json +2 -2
  14. package/template/packages/backend/src/cleanup.ts +121 -0
  15. package/template/packages/backend/src/index.ts +12 -3
  16. package/template/packages/config/package.json +3 -1
  17. package/template/packages/config/src/declaration.ts +1 -1
  18. package/template/packages/config/src/shutdown.ts +20 -0
  19. package/template/packages/db/package.json +2 -2
  20. package/template/packages/frontend/package.json +11 -11
  21. package/template/packages/frontend/src/components/ui/toaster.tsx +4 -1
  22. package/template/packages/frontend/src/lib/seo/pages.ts +2 -2
  23. package/template/packages/shared/package.json +1 -1
  24. package/template/scripts/acceptance-database.test.ts +73 -0
  25. package/template/scripts/acceptance-database.ts +92 -0
  26. package/template/scripts/acceptance-local.ts +144 -0
  27. package/template/scripts/build-inputs.test.ts +1 -1
  28. package/template/scripts/build-inputs.ts +4 -3
  29. package/template/scripts/build-stamp.test.ts +151 -0
  30. package/template/scripts/build-stamp.ts +169 -0
  31. package/template/scripts/client-boundary.test.ts +117 -0
  32. package/template/scripts/client-boundary.ts +148 -0
  33. package/template/scripts/declaration.ts +10 -7
  34. package/template/scripts/deployment-preflight.ts +41 -0
  35. package/template/scripts/dev.ts +8 -6
  36. package/template/scripts/local-env.ts +9 -3
  37. package/template/scripts/readiness.ts +92 -0
  38. package/template/scripts/release-steps.ts +5 -1
  39. package/template/scripts/release.ts +8 -0
  40. package/template/scripts/runtime-smoke.test.ts +178 -0
  41. package/template/scripts/runtime-smoke.ts +15 -2
  42. package/template/scripts/shutdown-budget.fixture.ts +29 -0
  43. package/template/scripts/shutdown-budget.test.ts +164 -0
  44. package/template/scripts/surface-conformance.ts +8 -1
  45. package/template/scripts/tooling-env.ts +30 -1
  46. package/template/scripts/web-surface-smoke.ts +125 -14
  47. package/template/packages/config/src/project-declaration.generated.ts +0 -611
@@ -1,611 +0,0 @@
1
- // GENERATED FILE — do not edit.
2
- //
3
- // Copied verbatim from the framework's `stitchkit/declaration` source by
4
- // `scripts/sync-template-declaration.ts`. Edit `packages/core/src/declaration.ts`
5
- // and re-run `bun run gen:template-declaration`; the gate refuses a copy that
6
- // has fallen behind.
7
- //
8
- // This file disappears once the template's catalog targets a release that
9
- // publishes `stitchkit/declaration` — at that point the schema is imported, not
10
- // mirrored. → ADR 0104
11
-
12
- /**
13
- * The project declaration — the one machine-readable statement a repository
14
- * chooses to make about itself.
15
- *
16
- * The schema lives in the published framework, not in the repository that fills
17
- * it in, because it has readers that must never disagree: the project itself,
18
- * the scaffolder that writes the first copy, and whatever binds an artifact
19
- * of it into a deployment. A copy on any of those sides is a fork rather than a contract —
20
- * it diverges silently and nothing fails — so the declaration ships as one
21
- * versioned surface instead.
22
- *
23
- * The boundary rule the schema exists to hold:
24
- *
25
- * > A declaration must be complete and meaningful **when no machine exists**. A
26
- * > field that cannot be filled in without knowing where the code will run is a
27
- * > binding supplied by the deployment, not a declaration made by the
28
- * > repository.
29
- *
30
- * What holds it, stated at its real strength:
31
- *
32
- * 1. **Structure.** There is nowhere a value MUST go. A command is `executable`
33
- * plus an `args` array — no shell string, no pipe, no redirect — and no part
34
- * may be an absolute path or an assignment in any form, so `--port=8080` and
35
- * `--config=/srv/…` have to be written as separate arguments where the same
36
- * checks reach them. Paths are repository-relative. Bindings are named by
37
- * variable, never valued, and a listener's variables must exist in the env
38
- * contract with the right shapes.
39
- * 2. **Hygiene.** Every remaining free string is checked against
40
- * `namesAMachine` — a scheme, a protocol-relative host, an absolute or
41
- * home-relative path, a Windows drive, a `host:port` pair, a bare IPv4
42
- * literal — and a number after a port flag is refused.
43
- *
44
- * The second is a filter for known shapes, not a proof. A secret written as its
45
- * own argument (`--token`, `sk-live-…`) and a hostname written as a plain word
46
- * (`db.internal`) are indistinguishable from any other argument, and no schema
47
- * makes them distinguishable. This is not a secret scanner and does not claim to
48
- * be one: the guarantee is that nothing here REQUIRES a value of the place, so a
49
- * complete declaration can be written before any machine exists.
50
- *
51
- * **Declaring is optional, and that is a contract rather than a gap.** A
52
- * project with no declaration is a complete project: nothing else in the
53
- * framework imports this module, no build, test or start path looks for a
54
- * `project.json`, and the absence of one is never an error or a warning. A
55
- * check that demanded a declaration "for convenience" would turn a repository
56
- * into something only one tool can bring up — which is a fork, not a
57
- * dependency, and is the outcome this whole surface exists to avoid.
58
- *
59
- * Unknown keys are **refused**, not stripped. A declaration is a contract
60
- * between programs that never meet; a key one side does not recognise is a
61
- * disagreement, and silently discarding it is how a partially understood
62
- * declaration becomes a running, wrong deployment.
63
- */
64
- import { z } from 'zod';
65
-
66
- /**
67
- * The declaration format this build understands.
68
- *
69
- * A reader that does not recognise the version a repository declares refuses
70
- * the repository rather than interpreting it partially. Because unknown keys
71
- * are refused too, the version is what a reader consults when the *shape*
72
- * changed — and every added field is a version bump, not a silent widening.
73
- */
74
- export const PROJECT_DECLARATION_SCHEMA_VERSION = 1;
75
-
76
- /**
77
- * Does this string name a particular machine?
78
- *
79
- * Every pattern here is something that cannot be true of code alone. Kept in
80
- * one place because the previous version of this schema checked only `://`, in
81
- * only one of the four fields a human writes freely — and a connection string
82
- * with credentials, a secret and an absolute path all passed.
83
- */
84
- const MACHINE_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
85
- [/:\/\//, 'an absolute address'],
86
- [/^\/\//, 'a protocol-relative host'],
87
- [/^[~]/, 'a home-relative path'],
88
- [/^[A-Za-z]:[\\/]/, 'a Windows drive path'],
89
- [/\\/, 'a Windows path separator'],
90
- [/(?:^|[\s=:])\d{1,3}(?:\.\d{1,3}){3}(?![\d.])/, 'an IP address'],
91
- [/(?:^|[\s=])[A-Za-z][\w.-]*:\d{2,5}(?![\w.])/, 'a host and port'],
92
- ];
93
-
94
- /** The reason this string names a machine, or `undefined` when it does not. */
95
- export function namesAMachine(value: string): string | undefined {
96
- for (const [pattern, reason] of MACHINE_PATTERNS) {
97
- if (pattern.test(value)) return reason;
98
- }
99
- return undefined;
100
- }
101
-
102
- function refuseMachineNames(label: string) {
103
- return (schema: z.ZodString) =>
104
- schema.refine((value) => namesAMachine(value) === undefined, {
105
- error: (issue) =>
106
- `${label} names a machine — ${namesAMachine(String(issue.input)) ?? 'a value of the deployment'} is supplied by the deployment, not written in the code`,
107
- });
108
- }
109
-
110
- /**
111
- * Lowercase, hyphen-separated identity. Everything named after the project —
112
- * process names, derived resource names — is derived from it, so it is the one
113
- * identity field with a machine-checkable shape.
114
- */
115
- export const ProjectSlugSchema = z
116
- .string()
117
- .min(1)
118
- .max(64)
119
- .regex(
120
- /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
121
- 'Use lowercase letters, numbers and single hyphens (for example: talk-control)',
122
- );
123
-
124
- /**
125
- * Human description keyed by locale tag. Which locales a project speaks is the
126
- * project's own business — the framework only insists that it speaks one.
127
- *
128
- * To fix an exact set, replace this field entirely when composing a stricter
129
- * declaration; it is a record, so it has no `extend`.
130
- */
131
- export const ProjectDescriptionSchema = z
132
- .record(
133
- refuseMachineNames('A locale tag')(z.string().min(1)),
134
- refuseMachineNames('A description')(z.string().trim().min(1)),
135
- )
136
- .refine(
137
- (value) => Object.keys(value).length > 0,
138
- 'Describe the project in at least one locale',
139
- );
140
-
141
- /** Who the project is. True of the code with no machine in existence. */
142
- export const ProjectIdentitySchema = z
143
- .object({
144
- slug: ProjectSlugSchema,
145
- name: refuseMachineNames('A project name')(z.string().trim().min(1).max(80)),
146
- version: z.string().regex(/^\d+\.\d+\.\d+$/, 'Use a semantic version such as 0.1.0'),
147
- description: ProjectDescriptionSchema,
148
- })
149
- .strict();
150
-
151
- /**
152
- * A path inside the source. Repository-relative on purpose: a path is code
153
- * only while it is relative to the source — the moment it is absolute, climbs
154
- * out, or names a drive, it names a machine.
155
- */
156
- export const RepositoryPathSchema = z
157
- .string()
158
- .min(1)
159
- .refine((value) => !value.startsWith('/'), 'Use a path relative to the repository root')
160
- .refine((value) => namesAMachine(value) === undefined, {
161
- error: (issue) =>
162
- `A path may not contain ${namesAMachine(String(issue.input)) ?? 'a machine name'}`,
163
- })
164
- .refine(
165
- (value) => !value.split('/').includes('..'),
166
- 'A path may not climb out of the repository',
167
- );
168
-
169
- /** The name of a variable a deployment will fill in. A name, never a value. */
170
- export const BindingVariableSchema = z
171
- .string()
172
- .regex(/^[A-Z][A-Z0-9_]*$/, 'Name an environment variable, for example API_PORT');
173
-
174
- /**
175
- * How a role is reached over the network.
176
- *
177
- * Absent means the role has no listener at all — a queue consumer, a bot, a
178
- * scheduler. That is a legitimate role, not an incomplete one, so nothing here
179
- * is required of it.
180
- *
181
- * Both bindings are named, never valued: a deployment supplies the port and the
182
- * interface under these names. There is deliberately no way to say "the port
183
- * arrives as a command-line argument" — a reader would then have to implement
184
- * two injection forms forever, and a role that needs an argument builds it from
185
- * the variable inside its own process.
186
- */
187
- export const ProjectListenerSchema = z
188
- .object({
189
- portVariable: BindingVariableSchema,
190
- bindVariable: BindingVariableSchema,
191
- /**
192
- * Path that answers once this role is ready to serve. `/` is a real answer.
193
- * Checked against `namesAMachine` because `//host/health` is a host, not a
194
- * path, and `.startsWith('/')` alone cannot tell them apart.
195
- */
196
- readinessPath: refuseMachineNames('A readiness path')(
197
- z.string().startsWith('/', 'Readiness is a path, for example /health'),
198
- ),
199
- })
200
- .strict();
201
-
202
- /** The run modes a role declares commands for. */
203
- export const ProjectRunModeSchema = z.enum(['development', 'production']);
204
-
205
- /**
206
- * How to run a role: the program and its arguments, never a shell string.
207
- *
208
- * argv rather than a command line for two reasons that are both defects the
209
- * previous shape had. A string has to be split to be executed, and splitting on
210
- * spaces destroys quoted arguments and paths with spaces; and a string is a
211
- * place to hide `--port 8080`, `API_TOKEN=…`, a pipe or a redirect, which is
212
- * exactly what the boundary rule forbids. With argv there is nothing to split
213
- * and each member is checked on its own.
214
- *
215
- * **Start the role's process, not a launcher that starts it.** Measured under
216
- * PM2: with a launcher in between, the supervisor's signal reaches both, the
217
- * launcher forwards its copy, and the role sees two presses in two turns —
218
- * which every well-behaved shutdown treats as "stop waiting, force it now". A
219
- * declared 15s drain collapsed to 1.3ms that way, and the only visible trace
220
- * was a non-zero exit code. A workspace-filtering launcher is worse still: the
221
- * signal never arrives at all. `PROJECT_LAUNCHERS` refuses the shape.
222
- */
223
- /**
224
- * A package-script runner, as a PAIR: the executable and the verb that makes it
225
- * one.
226
- *
227
- * As a bare list of executables this refused `deno run x.ts`, which is a direct
228
- * runtime invocation and exactly the shape the rule wants. `deno task` is the
229
- * launcher; `deno run` is not. `npx` and `bunx` are launchers whatever follows.
230
- */
231
- const PROJECT_SCRIPT_LAUNCHERS: ReadonlyArray<readonly [RegExp, RegExp | null]> = [
232
- [/^(?:bun|npm|pnpm|yarn)$/, /^run$/],
233
- [/^deno$/, /^task$/],
234
- [/^(?:npx|bunx|pnpx)$/, null],
235
- ];
236
-
237
- function launchesAScript(executable: string, firstArgument: string | undefined): boolean {
238
- for (const [runner, verb] of PROJECT_SCRIPT_LAUNCHERS) {
239
- if (!runner.test(executable)) continue;
240
- if (verb === null) return true;
241
- if (firstArgument !== undefined && verb.test(firstArgument)) return true;
242
- }
243
- return false;
244
- }
245
-
246
- /**
247
- * Flags whose next argument is a port, for the one heuristic that remains.
248
- *
249
- * `-p` is included knowingly: it means "port" in almost every server CLI, and
250
- * refusing `['--port', '8080']` while accepting `['-p', '8080']` would make the
251
- * rule decorative. It costs a false refusal for the tools where `-p` means
252
- * something else and takes a number, which the message tells you how to rewrite.
253
- */
254
- const PORT_FLAG = /^(?:-p|-{1,2}(?:port|listen))$/i;
255
-
256
- /**
257
- * A command part carries neither a machine name nor an inline value.
258
- *
259
- * Two of these are STRUCTURAL and one is hygiene, and the difference matters
260
- * because the texts around this schema used to claim all three were structural.
261
- *
262
- * Structural: a part may not be an absolute path, and a part may not be an
263
- * ASSIGNMENT in any form. `--flag=value` was the hole — `--port=8080`,
264
- * `--token=…` and `--config=/srv/app/config.json` all parsed clean, because
265
- * every per-part check looks at the part and the value was hiding inside one.
266
- * Writing the value as its own argv member is what puts it back under the same
267
- * checks: `['--port', '8080']` is refused as a port, `['--config', '/srv/…']`
268
- * as an absolute path.
269
- *
270
- * Hygiene: a bare number directly after a flag that names a port. It is a
271
- * heuristic and is scoped like one — `['--workers', '12']` is a legitimate
272
- * command and used to be refused. What this cannot do is recognise a secret or
273
- * a hostname written as a plain word: `sk-live-…` and `db.internal` are
274
- * indistinguishable from any other argument, and no schema will change that.
275
- * The boundary is held by having NOWHERE for a value to be required; it is not
276
- * a proof that nobody wrote one.
277
- */
278
- const CommandPartSchema = refuseMachineNames('A command part')(z.string().min(1))
279
- .refine(
280
- (value) => !value.startsWith('/'),
281
- 'A command part may not be an absolute path — paths are relative to the source',
282
- )
283
- .refine(
284
- // Anything up to the first `=` that is not itself a `=`. The earlier
285
- // `^-{0,2}[A-Za-z][\w.-]*=` described the shapes its author thought of and
286
- // let three through — `_API_TOKEN=secret` (a leading underscore is a legal
287
- // env name), `--set:key=value` and `--opt[k]=v` — while the text beside it
288
- // said "an assignment in any form".
289
- (value) => !/^[^=\s]+=/.test(value),
290
- 'A command part may not carry an inline value — write the flag and its value as separate arguments, so the value is checked like every other one',
291
- );
292
-
293
- const CommandArgumentsSchema = z
294
- .array(CommandPartSchema)
295
- .refine(
296
- (args) =>
297
- args.every(
298
- (value, index) => !(/^\d{1,5}$/.test(value) && PORT_FLAG.test(args[index - 1] ?? '')),
299
- ),
300
- 'A number after a port flag is a port — name the variable that carries it and let the role read it',
301
- );
302
-
303
- export const ProjectCommandSchema = z
304
- .object({
305
- executable: CommandPartSchema,
306
- args: CommandArgumentsSchema,
307
- })
308
- .strict();
309
-
310
- /**
311
- * A command run under a supervisor, which additionally may not be a launcher.
312
- *
313
- * The rule applies to ROLES and not to `build`: a build is not signalled, so a
314
- * script runner in front of it costs nothing. A supervised role is signalled,
315
- * and there the launcher is the defect measured above.
316
- */
317
- export const ProjectRoleCommandSchema = ProjectCommandSchema.refine(
318
- (value) => !launchesAScript(value.executable, value.args[0]),
319
- 'Start the role process itself, not a script runner: a launcher between the supervisor and the role duplicates the shutdown signal and forces the drain',
320
- ).refine(
321
- (value) => !value.args.includes('--filter'),
322
- 'A workspace filter puts a launcher between the supervisor and the role, and the shutdown signal never reaches it',
323
- );
324
-
325
- export const ProjectRoleSchema = z
326
- .object({
327
- name: ProjectSlugSchema,
328
- /**
329
- * Where this role's commands run, relative to the repository root. Omitted
330
- * means the root itself.
331
- *
332
- * Part of the role rather than of the supervisor because a command is only
333
- * meaningful together with the directory it runs in — and because a
334
- * supervisor reaching into a workspace from the root adds the very launcher
335
- * `ProjectCommandSchema` refuses.
336
- */
337
- workingDirectory: RepositoryPathSchema.optional(),
338
- /** How to run this role, per mode. The key enum makes every mode required. */
339
- commands: z.record(ProjectRunModeSchema, ProjectRoleCommandSchema),
340
- listener: ProjectListenerSchema.optional(),
341
- /**
342
- * The FLOOR: how long this role may need to drain cleanly, in milliseconds.
343
- *
344
- * A property of the code — it follows from what the role has to finish, not
345
- * from how long a deployment is willing to wait. Whatever supervises the
346
- * process must allow at least this much, plus whatever the role spends
347
- * forcing and cleaning up, before killing it.
348
- */
349
- drainFloorMs: z.number().int().nonnegative(),
350
- })
351
- .strict();
352
-
353
- /**
354
- * Data the build is allowed to read, named and frozen.
355
- *
356
- * The boundary rule separates two things — code, and the values of a place.
357
- * There is a third that is neither: **data read while building**. Pages
358
- * prerendered from a database depend on bytes that are not in the source and
359
- * are not a binding, and no amount of moving environment variables makes such a
360
- * build portable. Left unnamed, the dependency is invisible: the build succeeds
361
- * on the machine that happens to have the database, and the artifact silently
362
- * stops being a function of the source.
363
- *
364
- * Declaring an input is what makes it legitimate. `path` points at a frozen
365
- * export inside the source, `digest` pins its bytes, and a build that reads
366
- * anything else is a build nobody declared. The other two legitimate answers
367
- * need no field here at all: render at runtime, or generate the bytes as a
368
- * release step on the way to the deployment.
369
- */
370
- export const ProjectBuildInputSchema = z
371
- .object({
372
- /** How the build refers to this input — and how a failure names it. */
373
- name: ProjectSlugSchema,
374
- /** The frozen export, inside the source. Never a live source of data. */
375
- path: RepositoryPathSchema,
376
- /**
377
- * The bytes, pinned. Without it the field records a filename and promises
378
- * nothing: the contents could change between two builds of one source and
379
- * both would look declared.
380
- */
381
- digest: z
382
- .string()
383
- .regex(/^sha256:[0-9a-f]{64}$/, 'Use a lowercase sha256 digest, as "sha256:<64 hex>"'),
384
- })
385
- .strict();
386
-
387
- export type ProjectBuildInput = z.infer<typeof ProjectBuildInputSchema>;
388
-
389
- /** What building the source produces. More than one path is the normal case. */
390
- export const ProjectBuildSchema = z
391
- .object({
392
- command: ProjectCommandSchema,
393
- artifacts: z.array(RepositoryPathSchema).min(1),
394
- /**
395
- * Absent is the normal case, and it means something exact: this build reads
396
- * no data. It does not mean "unknown".
397
- */
398
- inputs: z.array(ProjectBuildInputSchema).optional(),
399
- })
400
- .strict()
401
- .refine((build) => {
402
- const names = (build.inputs ?? []).map((input) => input.name);
403
- return new Set(names).size === names.length;
404
- }, 'Two build inputs share a name — a failure could then name either of them');
405
-
406
- /** When a requirement has to be there. */
407
- export const ProjectRequirementPhaseSchema = z.enum(['release', 'start']);
408
-
409
- /**
410
- * Something the code needs that the code does not provide.
411
- *
412
- * `phases` says when: `release` is needed once while bringing a deployment to
413
- * this source, `start` is needed by every process every time it starts. One
414
- * requirement can be both, and saying so once beats naming it twice.
415
- */
416
- export const ProjectRequirementSchema = z
417
- .object({
418
- name: ProjectSlugSchema,
419
- phases: z.array(ProjectRequirementPhaseSchema).min(1),
420
- })
421
- .strict();
422
-
423
- /**
424
- * What a migration IS, declared as a fact rather than as a command to run.
425
- *
426
- * The repository says which bytes are migrations; whatever brings a deployment
427
- * to this source reads them and decides — exact contents, admission verdict,
428
- * whether a preflight can be skipped because nothing touches the database. A
429
- * free list of shell commands would take that decision away from the side that
430
- * is able to make it, and hand it to the side that cannot see the deployment.
431
- *
432
- * `engine` is a name, checked like every other free string: a connection string
433
- * fits in a name-shaped field otherwise, credentials and all.
434
- */
435
- export const ProjectMigrationsSchema = z
436
- .object({
437
- engine: refuseMachineNames('A migration engine name')(z.string().min(1).max(64)),
438
- root: RepositoryPathSchema,
439
- lockfile: RepositoryPathSchema,
440
- })
441
- .strict();
442
-
443
- /** What must happen once, before any role starts, to reach this source. */
444
- export const ProjectReleaseSchema = z
445
- .object({ migrations: ProjectMigrationsSchema.optional() })
446
- .strict();
447
-
448
- /** The value shapes a declaration can describe without naming a value. */
449
- export const ProjectEnvShapeSchema = z.enum(['string', 'integer', 'boolean', 'url', 'enum']);
450
-
451
- /**
452
- * A variable a deployment must supply.
453
- *
454
- * `members` is required for `enum` and forbidden otherwise: a reader without a
455
- * TypeScript runtime learns nothing from "one of an unnamed set", which is the
456
- * only thing this list exists to tell it.
457
- */
458
- export const ProjectEnvVariableSchema = z
459
- .object({
460
- name: BindingVariableSchema,
461
- shape: ProjectEnvShapeSchema,
462
- required: z.boolean(),
463
- members: z
464
- .array(refuseMachineNames('An enum member')(z.string().min(1)))
465
- .min(1)
466
- .optional(),
467
- })
468
- .strict()
469
- .refine(
470
- (value) => (value.shape === 'enum') === (value.members !== undefined),
471
- 'An enum variable lists its members; every other shape has none',
472
- );
473
-
474
- /** The declaration itself. Every field here is true about the code alone. */
475
- export const ProjectDeclarationSchema = z
476
- .object({
477
- schemaVersion: z.literal(PROJECT_DECLARATION_SCHEMA_VERSION),
478
- kind: z.enum(['library', 'application']),
479
- identity: ProjectIdentitySchema,
480
- roles: z.array(ProjectRoleSchema),
481
- build: ProjectBuildSchema.optional(),
482
- requires: z.array(ProjectRequirementSchema),
483
- release: ProjectReleaseSchema,
484
- /**
485
- * The variables a deployment must supply, by name and shape.
486
- *
487
- * Names only. A default, a coercion or an error message belongs to the
488
- * project's own validation, which stays the source — this list is derived
489
- * from it so that a reader without a TypeScript runtime can still see what
490
- * the project needs.
491
- */
492
- env: z.object({ variables: z.array(ProjectEnvVariableSchema) }).strict(),
493
- })
494
- .strict()
495
- .refine(
496
- (value) =>
497
- value.kind === 'application' ? value.roles.length > 0 : value.roles.length === 0,
498
- 'An application declares at least one role; a library declares none',
499
- )
500
- .refine(
501
- (value) => new Set(value.roles.map((role) => role.name)).size === value.roles.length,
502
- 'Role names must be unique',
503
- )
504
- .refine(
505
- (value) =>
506
- new Set(value.requires.map((entry) => entry.name)).size === value.requires.length,
507
- 'Name each requirement once and list its phases',
508
- )
509
- .refine(
510
- (value) =>
511
- value.requires.every((entry) => new Set(entry.phases).size === entry.phases.length),
512
- 'List each phase of a requirement once',
513
- )
514
- .refine(
515
- (value) =>
516
- new Set(value.env.variables.map((entry) => entry.name)).size ===
517
- value.env.variables.length,
518
- 'Declare each environment variable once',
519
- )
520
- /**
521
- * A listener names two bindings. The env contract must contain both, with the
522
- * right shapes, and they must not be the same variable.
523
- *
524
- * Without this a declaration can be internally contradictory and still parse:
525
- * a reader outside the tree is told a role listens on `API_PORT`, looks for it
526
- * in the contract that lists what a deployment must supply, and does not find
527
- * it. Nothing fails — the deployment simply starts without a port. The
528
- * repository's own fixture demonstrated exactly that shape before this check.
529
- */
530
- .refine((value) => listenerBindingProblem(value) === undefined, {
531
- error: (issue) =>
532
- listenerBindingProblem(issue.input) ?? 'Listener bindings are inconsistent',
533
- });
534
-
535
- interface ListenerBindingSubject {
536
- roles: ReadonlyArray<{
537
- name: string;
538
- listener?: { portVariable: string; bindVariable: string };
539
- }>;
540
- env: { variables: ReadonlyArray<{ name: string; shape: string }> };
541
- }
542
-
543
- function isListenerBindingSubject(value: unknown): value is ListenerBindingSubject {
544
- return typeof value === 'object' && value !== null && 'roles' in value && 'env' in value;
545
- }
546
-
547
- /** Why a listener's bindings disagree with the env contract, or `undefined`. */
548
- function listenerBindingProblem(value: unknown): string | undefined {
549
- if (!isListenerBindingSubject(value)) return undefined;
550
- const shapes = new Map(value.env.variables.map((entry) => [entry.name, entry.shape]));
551
- for (const role of value.roles) {
552
- const listener = role.listener;
553
- if (!listener) continue;
554
- if (listener.portVariable === listener.bindVariable) {
555
- return `Role "${role.name}" points its port and its bind address at the same variable "${listener.portVariable}"`;
556
- }
557
- const expected: ReadonlyArray<readonly [string, string]> = [
558
- [listener.portVariable, 'integer'],
559
- [listener.bindVariable, 'string'],
560
- ];
561
- for (const [name, shape] of expected) {
562
- const declared = shapes.get(name);
563
- if (declared === undefined) {
564
- return `Role "${role.name}" listens on "${name}", which env.variables does not declare — a deployment reading this cannot know it has to supply it`;
565
- }
566
- if (declared !== shape) {
567
- return `Role "${role.name}" listens on "${name}", declared as "${declared}" where a ${shape} is needed`;
568
- }
569
- }
570
- }
571
- return undefined;
572
- }
573
-
574
- export type ProjectDeclaration = z.infer<typeof ProjectDeclarationSchema>;
575
- export type ProjectRole = z.infer<typeof ProjectRoleSchema>;
576
- export type ProjectCommand = z.infer<typeof ProjectCommandSchema>;
577
- export type ProjectRoleCommand = z.infer<typeof ProjectRoleCommandSchema>;
578
- export type ProjectEnvVariable = z.infer<typeof ProjectEnvVariableSchema>;
579
- export type ProjectIdentity = z.infer<typeof ProjectIdentitySchema>;
580
-
581
- /** The declared version, or `undefined` when the source does not carry one. */
582
- const VersionProbeSchema = z.object({ schemaVersion: z.unknown() }).loose();
583
-
584
- /**
585
- * Parse a declaration, refusing an unknown schema version **before** any field
586
- * is read.
587
- *
588
- * Order matters. The object is strict, so a newer declaration would otherwise
589
- * report as a list of unrecognised keys — which reads like a broken file rather
590
- * than a version this build is too old to serve. The version check is
591
- * fail-closed: an unrecognised version is refused, never assumed compatible.
592
- */
593
- export function parseProjectDeclaration(source: unknown): ProjectDeclaration {
594
- const probe = VersionProbeSchema.safeParse(source);
595
- const declared = probe.success ? probe.data.schemaVersion : undefined;
596
- if (declared !== undefined && declared !== PROJECT_DECLARATION_SCHEMA_VERSION) {
597
- throw new Error(
598
- `Project declaration schema version ${JSON.stringify(declared)} is not supported — ` +
599
- `this build understands version ${PROJECT_DECLARATION_SCHEMA_VERSION}.`,
600
- );
601
- }
602
- return ProjectDeclarationSchema.parse(source);
603
- }
604
-
605
- /** The role with this name, or `undefined`. */
606
- export function findProjectRole(
607
- declaration: ProjectDeclaration,
608
- name: string,
609
- ): ProjectRole | undefined {
610
- return declaration.roles.find((role) => role.name === name);
611
- }