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
package/CHANGELOG.md CHANGED
@@ -12,6 +12,147 @@ step is overwritten by the next release.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.4.2] — 2026-08-25
16
+
17
+ ### Fixed
18
+
19
+ - **A freshly scaffolded project resolves the framework release that exists.**
20
+ The template ships a lockfile so a scaffold is reproducible, and that lockfile
21
+ still pinned the previous Stitchkit patch — so `create-stitchkit` published
22
+ minutes after a framework release produced a project on the older one, inside
23
+ a range that already allowed the newer. The range and the lock move together
24
+ now: `catalog.stitchkit` targets `^0.60.1` and the lockfile resolves it, which
25
+ is also what the packed target lane then tests against.
26
+
27
+ ## [0.4.1] — 2026-08-25
28
+
29
+ ### Fixed
30
+
31
+ - **The `Gates` list can be run top to bottom, and it never deploys.** It
32
+ presented five commands in a row, two of which check a *running* deployment —
33
+ with nothing in the list that starts one, so on a fresh project `bun run
34
+ runtime:smoke` failed with a connection reset from inside a check. The answer
35
+ is not a deploy command: `bun run pm2:prod` applies the declared migrations
36
+ and reloads the PM2 daemon the developer is running, so a gate list carrying
37
+ it quietly means "deploy". The runtime gates now run under `bun run
38
+ acceptance:local`, which creates a deployment of its own — separate
39
+ `PM2_HOME`, ephemeral ports, its own public-host allowlist, its own
40
+ database — and destroys it by naming the declared roles. Neither guide says
41
+ `pm2 delete all` any more: it empties whichever daemon it is pointed at,
42
+ including applications that have nothing to do with the project.
43
+ - **The acceptance gate writes only to a database of its own.** It inherited
44
+ `DATABASE_URL`, and the runtime gates write — the repository example's smoke
45
+ posts `/api/repository/refresh` twice, which upserts. So a command a developer
46
+ is told to run before handing work off wrote rows into whatever `.env` named.
47
+ It runs against `ACCEPTANCE_DATABASE_URL`, applies the declared migrations
48
+ there, and refuses to start when that variable is unset or reuses the
49
+ deployment's database **name** — with the line to paste in the refusal. The
50
+ name alone decides, because a hostname is not proof of a different server:
51
+ `localhost` and `127.0.0.1` are one, and so are two DNS names for the same
52
+ PostgreSQL. The deployment's URL is not in the harness's child environment at
53
+ all.
54
+ - **A release will not start an artifact that is not this source's.**
55
+ `assertBuildArtifacts()` checked that the declared paths exist, and existence
56
+ is not freshness: `pm2:prod` run without a build applied this source's
57
+ migrations and started the previous source's `dist` and `.next`, moving the
58
+ schema ahead of the code in the one direction a code rollback does not undo.
59
+ `bun run build` now leaves a digest of the source it read, and the release
60
+ refuses when the tree no longer hashes to it — a digest rather than a
61
+ timestamp, because a checkout rewrites every mtime and a formatter rewrites
62
+ some for no change at all. Everything is source unless something names it
63
+ otherwise, and the something is the **declaration**: the digest skips
64
+ `build.artifacts`, `node_modules`/`.git`, and runtime state. It no longer
65
+ skips by kind — every `.md`, every test file, every directory called
66
+ `generated` — because a project that imports MDX or keeps checked-in source in
67
+ a directory of that name would have changed its content, kept its digest, and
68
+ been told a stale artifact was current. `.env` stays out on purpose: a binding
69
+ is not an input to this build, and hashing it would refuse a correct artifact
70
+ whenever a deployment edited its own environment.
71
+ - **The shutdown budget is an upper bound again.** `terminationBudgetMs` adds a
72
+ fixed cleanup allowance to the drain floor and refuses a supervision policy
73
+ that allows less — but the closes that run after the drain (the MCP session,
74
+ the database pool) had no deadline of their own, so a hung close ran past the
75
+ very kill timeout the budget had approved and turned an orderly shutdown into
76
+ the SIGKILL that runs no cleanup at all. The role's cleanup now shares one
77
+ bounded budget with the generator, from one constant both read, and names
78
+ whatever it stopped waiting for — and then **ends the process**. Setting
79
+ `process.exitCode` only decides the code a process reports when it exits, and
80
+ a step that ran out of time is usually still holding the handle that stops it
81
+ from exiting; a close that *threw* is reported with its cause and is no longer
82
+ counted as a clean shutdown. The shared deadline is measured with
83
+ `performance.now()` and the clock can no longer be injected: a wall clock
84
+ stepped backwards widens the very upper bound the supervisor's kill timeout
85
+ was derived from, and a frozen injected clock hands every step a full budget.
86
+ - **The project declaration stays out of the browser bundle.**
87
+ `lib/seo/pages.ts` imported `appDeclaration` for one field, and a client
88
+ component imports that module — so role commands, working directories,
89
+ artifact and migration paths and every environment variable name travelled
90
+ into the client graph along with the Zod parser. It reads `appIdentity` now,
91
+ and a test walks every `'use client'` graph and fails if one reaches the
92
+ declaration — by resolving each specifier to a file rather than matching a
93
+ string, so a barrel re-export, a relative path into the config package and a
94
+ double-quoted import are all caught.
95
+ - **A duplicated host is one address.** The portability check counted the
96
+ entries of `PUBLIC_WEB_HOSTS` without deduplicating them, so the same host
97
+ written twice passed as two addresses and the proof compared the deployment
98
+ with itself.
99
+ - **Every dial in the runtime smoke is bounded.** An endpoint that accepts the
100
+ connection and never answers used to hang the gate with no output and no
101
+ deadline instead of failing it.
102
+ - **A build output cannot be published inside the template.** What the scaffolder
103
+ copies and what npm publishes were two lists that had to agree, and only one of
104
+ them was consulted when a name was excluded. The exclusions are data now, and a
105
+ test fails until the package manifest carries the same negation.
106
+ - **A role bound to an IPv6 address gets a readiness URL that parses.**
107
+ `BIND_HOST=::1` produced `http://::1:3211/health`, which is not an address
108
+ with a port and which `fetch` refuses — so the wait failed on the spelling
109
+ rather than on the role. The literal is bracketed.
110
+ - **`bun run dev` and `bun run pm2:prod` report the roles as running only once
111
+ they answer.** A supervisor returns at the spawn, seconds before a role
112
+ listens, so both printed their address at a moment when nothing was there and
113
+ every command after them raced the application they had just started. Both now
114
+ wait on each role's declared `readinessPath`.
115
+ - **`runtime:smoke` asks the deployment on the addresses it claims.** The
116
+ portability check carried two fixture hosts of its own, so it only ever passed
117
+ where somebody had put those exact names into `PUBLIC_WEB_HOSTS` — which only
118
+ the packed lane had. Everyone else got a bare 500 from a policy working as
119
+ designed. It now reads `PUBLIC_WEB_HOSTS`, and a deployment with too few
120
+ addresses to compare is told which line to add instead of being refused a
121
+ request.
122
+ - **A closed deployment is diagnosed, not reset.** `runtime:smoke` says what is
123
+ not listening and which command starts it, before the first check runs.
124
+ - **The theme value the toaster reads narrows again.** `@wrksz/themes` 1.2
125
+ changed `useThemeValue`'s type parameter to describe the map rather than the
126
+ value, and it now infers `const` — so naming the value union at the call site
127
+ widened the result to every member of `string` and the generated project
128
+ stopped type-checking. The call site lets inference do it.
129
+ - **A green `runtime:smoke` prints one line.** The MCP surface check asked the
130
+ server to list tools even when it advertised no tool capability, which made
131
+ the vendor client log a debug warning on every successful run. It reads the
132
+ advertised capability instead.
133
+
134
+ ### Changed
135
+
136
+ - **Every dependency of the generated project is on its latest release.**
137
+ Next 16.3.2, `next-intl` 4.13.7, `@tanstack/react-query` 5.102.3,
138
+ `@tanstack/react-table` 9.1.2, `framer-motion` 13.1.1, `@wrksz/themes` 1.2.0,
139
+ `shiki` 4.4.3, `sonner` 2.0.8, `ai` 7.0.78, `pg` 8.23.0, Playwright 1.62.1,
140
+ Biome 2.5.10 and the `@types/*` that go with them. No major crossed; the
141
+ Stitchkit range is unchanged and still declared once, in the catalog.
142
+ - **The generated project imports the declaration schema instead of mirroring
143
+ it.** `packages/config/src/declaration.ts` now reads
144
+ `parseProjectDeclaration` from `stitchkit/declaration`, and the 611-line
145
+ generated copy — `packages/config/src/project-declaration.generated.ts` — is
146
+ gone with the script that maintained it. The copy existed only because the
147
+ entrypoint was not on npm yet; the template's catalog targets `^0.60.0`,
148
+ which publishes it, so "one schema, three readers" is now literally true.
149
+ Adopting it is one import and one deletion — see
150
+ [`UPGRADING.md`](./UPGRADING.md).
151
+ - **`scripts/local-env.ts` reads the identity module, not the declaration.** It
152
+ needs one slug, and a project scaffolded with `--no-install` renders its
153
+ `.env` before anything is installed — a script that reaches for the
154
+ framework's schema to read a name cannot run in that window.
155
+
15
156
  ## [0.4.0] — 2026-08-25
16
157
 
17
158
  ### ⚠️ Breaking changes
package/UPGRADING.md CHANGED
@@ -60,6 +60,123 @@ the first scaffolder release with a migration channel of its own.
60
60
 
61
61
  ---
62
62
 
63
+ ## Released migration: 0.4.1
64
+
65
+ ### a release refuses a stale artifact, and cleanup is bounded
66
+
67
+ Two changes an existing project should take, both about a shutdown or a start
68
+ that looked safe and was not.
69
+
70
+ 1. **Stamp the build.** Copy `scripts/build-stamp.ts` from a fresh scaffold,
71
+ append `&& bun scripts/build-stamp.ts` to your root `build` script, add
72
+ `.build-stamp.json` to `.gitignore`, and call `assertArtifactMatchesSource()`
73
+ at the end of `assertBuildArtifacts()` in `scripts/release-steps.ts`. Until
74
+ you do, `bun run pm2:prod` without a build applies your migrations and starts
75
+ the previous build.
76
+
77
+ 2. **Bound the cleanup, and let it end the process.** Copy
78
+ `packages/config/src/shutdown.ts` and `packages/backend/src/cleanup.ts`, add
79
+ the `./shutdown` export to `packages/config/package.json`, and replace the
80
+ bare `await mcp.close(); await prisma.$disconnect();` in your API role's
81
+ `onComplete` with `closeWithinBudget([...])` followed by
82
+ `concludeShutdown(cleanup, result.outcome === 'clean')`. Then have
83
+ `scripts/declaration.ts` import `FORCE_BUDGET_MS` and `CLEANUP_BUDGET_MS`
84
+ from the new module instead of declaring its own copies — the number a
85
+ supervisor is told to allow and the number the role enforces have to be one
86
+ number. `concludeShutdown` is the half that makes the budget real: setting
87
+ `process.exitCode` decides the code a process reports *when it exits*, and a
88
+ step that ran out of time is usually still holding the handle that stops it
89
+ from exiting at all.
90
+
91
+ No operator step: nothing about a running machine changes.
92
+
93
+ ### the gate list runs, and never deploys
94
+
95
+ The `Gates` list in the generated guides presented five commands in a row, two
96
+ of which check a **running** deployment — with nothing in the list that starts
97
+ one. The fix is not to add a deploy command: `bun run pm2:prod` applies your
98
+ declared migrations and reloads the deployment you are running, so a list that
99
+ contains it means "run these before handing work off" quietly says "deploy".
100
+
101
+ 1. **Take `pm2:prod` out of your gate list**, in `README.md` and `AGENTS.md`
102
+ alike, and delete any advice to run `pm2 delete all` — that empties whichever
103
+ daemon it is pointed at, including applications with nothing to do with this
104
+ project.
105
+
106
+ 2. **Add the harness that brings up a deployment of its own.** Copy
107
+ `scripts/acceptance-local.ts` and `scripts/acceptance-database.ts` from a
108
+ fresh scaffold and add `"acceptance:local": "bun scripts/acceptance-local.ts"`
109
+ to your root scripts. It creates and destroys its own deployment — separate
110
+ `PM2_HOME`, ephemeral ports, its own public-host allowlist — and runs
111
+ `runtime:smoke` and `e2e` against that.
112
+
113
+ 3. **Give it a database of its own.** Add `ACCEPTANCE_DATABASE_URL` to `.env`
114
+ and `.env.example`, naming a throwaway database — not the one `DATABASE_URL`
115
+ names. The runtime gates WRITE (the repository example's smoke posts a
116
+ refresh, and that upserts), so a harness borrowing `DATABASE_URL` writes rows
117
+ into whatever your `.env` points at. The harness refuses to start when the
118
+ variable is unset or names the same database, and prints the line to add.
119
+
120
+ 4. **Wait for readiness before reporting it.** Copy `scripts/readiness.ts` from
121
+ a fresh scaffold, then in `scripts/dev.ts` and `scripts/release.ts` await
122
+ `awaitRolesAnswering(declaredRoleReadiness(appDeclaration, environment))`
123
+ before printing that the roles are running. Until you do, anything you run
124
+ after `bun run dev` or `bun run pm2:prod` races the roles they started.
125
+
126
+ 5. **Let the smoke read your allowlist.** In `scripts/web-surface-smoke.ts` the
127
+ portability check named two hosts of its own; take the version that reads
128
+ `PUBLIC_WEB_HOSTS` and pass it from `scripts/runtime-smoke.ts`. If you kept
129
+ the old one, your `.env` must claim `alpha.example` and `beta.example:8443`
130
+ or the check fails on a host your deployment correctly refuses. With the new
131
+ one, `_env.example` drops those example hosts — the harness supplies its own.
132
+
133
+ 6. **Keep the declaration out of the browser.**
134
+ `packages/frontend/src/lib/seo/pages.ts` must import `appIdentity` from
135
+ `@app/config/app-identity` rather than `appDeclaration`: a client component
136
+ reaches that module, so the whole declaration was going into your browser
137
+ bundle.
138
+
139
+ No operator step: nothing about a running machine changes.
140
+
141
+ ### the declaration schema is imported
142
+
143
+ For a project generated **before** the scaffolder version that drops the schema
144
+ mirror. Nothing in your tree stops working if you skip this — the copy keeps
145
+ parsing. What you lose by skipping is the guarantee: your copy no longer moves
146
+ when the framework's schema does, and nothing tells you.
147
+
148
+ 1. **Point the config package at the framework.** In
149
+ `packages/config/src/declaration.ts`:
150
+
151
+ ```ts
152
+ // before
153
+ import { findProjectRole, parseProjectDeclaration } from './project-declaration.generated';
154
+ // after
155
+ import { findProjectRole, parseProjectDeclaration } from 'stitchkit/declaration';
156
+ ```
157
+
158
+ Then the same for every `import type { ProjectDeclaration, … }` in
159
+ `scripts/` — they pointed at the same file.
160
+
161
+ 2. **Declare the dependency.** `packages/config/package.json` gains
162
+ `"stitchkit": "catalog:"`, and `bun install` refreshes the lockfile.
163
+
164
+ 3. **Delete `packages/config/src/project-declaration.generated.ts`.**
165
+
166
+ 4. **Check your `stitchkit` range.** The entrypoint ships from **0.60.0**. If
167
+ your catalog targets less than that, raise it first — otherwise the import
168
+ resolves to nothing.
169
+
170
+ 5. **Read `.env` without the schema.** If your `scripts/local-env.ts` imports
171
+ `appDeclaration`, switch it to `appIdentity` from
172
+ `packages/config/src/app-identity.generated`. It needs only the slug, and a
173
+ project scaffolded with `--no-install` renders `.env` before anything is
174
+ installed — in that window the framework is not there to import.
175
+
176
+ No operator step: nothing about a running machine changes.
177
+
178
+ ---
179
+
63
180
  ## Released migration: 0.4.0
64
181
 
65
182
  ### the project declares itself
package/dist/cli.js CHANGED
@@ -292,6 +292,8 @@ var IGNORED_DIRECTORIES = new Set([
292
292
  "playwright-report",
293
293
  "test-results"
294
294
  ]);
295
+ var IGNORED_FILE_NAMES = new Set([".env", ".build-stamp.json", "next-env.d.ts"]);
296
+ var IGNORED_FILE_SUFFIXES = [".log", ".tsbuildinfo"];
295
297
  function isTemplateSourcePathIncluded(sourcePath) {
296
298
  const normalized = sourcePath.replaceAll("\\", "/").replace(/^\.\//, "");
297
299
  if (!normalized)
@@ -302,7 +304,9 @@ function isTemplateSourcePathIncluded(sourcePath) {
302
304
  if (normalized === "packages/db/src/generated" || normalized.startsWith("packages/db/src/generated/"))
303
305
  return false;
304
306
  const name = basename2(normalized);
305
- return name !== ".env" && name !== "next-env.d.ts" && !name.endsWith(".log") && !name.endsWith(".tsbuildinfo");
307
+ if (IGNORED_FILE_NAMES.has(name))
308
+ return false;
309
+ return !IGNORED_FILE_SUFFIXES.some((suffix) => name.endsWith(suffix));
306
310
  }
307
311
  function shouldIncludeTemplatePath(templateDirectory, sourcePath) {
308
312
  return isTemplateSourcePathIncluded(relative(templateDirectory, sourcePath));
@@ -1,6 +1,7 @@
1
1
  import { RepositorySnapshotSchema, repositoryRealtimeContract } from '@app/shared';
2
2
  import { createRealtimeClient, defineRealtimeContract } from 'stitchkit';
3
3
  import { z } from 'zod';
4
+ import { assertDeploymentIsAnswering } from './deployment-preflight';
4
5
  import { defineSurfaceProbe, runSurfaceConformance } from './surface-conformance';
5
6
  import { loadToolingEnv } from './tooling-env';
6
7
  import { assertArtifactIsPlacementFree, assertPublicWebSurface } from './web-surface-smoke';
@@ -8,8 +9,16 @@ import { assertArtifactIsPlacementFree, assertPublicWebSurface } from './web-sur
8
9
  const toolingEnv = loadToolingEnv();
9
10
  const apiOrigin = toolingEnv.SMOKE_API_ORIGIN;
10
11
 
12
+ await assertDeploymentIsAnswering({
13
+ 'the API role': apiOrigin,
14
+ 'the web role': toolingEnv.SMOKE_WEB_ORIGIN,
15
+ });
16
+
11
17
  async function json(path: string, init?: RequestInit): Promise<unknown> {
12
- const response = await fetch(`${apiOrigin}${path}`, init);
18
+ const response = await fetch(`${apiOrigin}${path}`, {
19
+ ...init,
20
+ signal: AbortSignal.timeout(30_000),
21
+ });
13
22
  if (!response.ok)
14
23
  throw new Error(`${init?.method ?? 'GET'} ${path} returned ${response.status}`);
15
24
  return response.json();
@@ -185,7 +194,10 @@ await runSurfaceConformance({
185
194
  }
186
195
 
187
196
  await assertPublicWebSurface(toolingEnv.SMOKE_WEB_ORIGIN);
188
- await assertArtifactIsPlacementFree(toolingEnv.SMOKE_WEB_ORIGIN);
197
+ await assertArtifactIsPlacementFree(toolingEnv.SMOKE_WEB_ORIGIN, {
198
+ origin: toolingEnv.PUBLIC_WEB_ORIGIN,
199
+ hosts: toolingEnv.PUBLIC_WEB_HOSTS,
200
+ });
189
201
 
190
202
  console.log(
191
203
  'Runtime HTTP (same-origin and direct), OpenAPI, Socket.IO, MCP and public web smoke passed',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-stitchkit",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Create a production-shaped Stitchkit application",
5
5
  "license": "MIT",
6
6
  "author": "Max Listov <maxlistov@gmail.com>",
@@ -18,6 +18,7 @@
18
18
  "template/**/*",
19
19
  "examples/**/*",
20
20
  "!template/**/.env",
21
+ "!template/**/.build-stamp.json",
21
22
  "!template/**/node_modules/**",
22
23
  "!template/**/.next/**",
23
24
  "!template/**/dist/**",
@@ -29,6 +30,7 @@
29
30
  "!template/**/*.tsbuildinfo",
30
31
  "!template/**/coverage/**",
31
32
  "!examples/**/.env",
33
+ "!examples/**/.build-stamp.json",
32
34
  "!examples/**/node_modules/**",
33
35
  "!examples/**/.next/**",
34
36
  "!examples/**/dist/**",
@@ -57,7 +59,7 @@
57
59
  "zod": "^4.4.3"
58
60
  },
59
61
  "devDependencies": {
60
- "@types/bun": "^1.3.14",
62
+ "@types/bun": "^1.4.0",
61
63
  "typescript": "^7.0.2"
62
64
  },
63
65
  "engines": {
@@ -19,10 +19,11 @@ framework source repository.
19
19
  by machine — the scaffolder stamps the identity, `bun run gen:declaration`
20
20
  derives `env.variables` — so the formatter leaves it alone and
21
21
  `scripts/declaration.test.ts` is what checks it.
22
- - Three files are generated from it and must not be hand-edited:
23
- `ecosystem.config.cjs`, `ecosystem.dev.config.cjs` and the `env.variables`
24
- block of `project.json`. Run `bun run gen:declaration` after changing a role. It holds
25
- nothing that differs between two deployments; those are named there by
22
+ - Four things are generated from it and must not be hand-edited:
23
+ `ecosystem.config.cjs`, `ecosystem.dev.config.cjs`,
24
+ `packages/config/src/app-identity.generated.ts` and the `env.variables` block
25
+ of `project.json`. Run `bun run gen:declaration` after changing a role. It
26
+ holds nothing that differs between two deployments; those are named there by
26
27
  variable and supplied by the place.
27
28
 
28
29
  Dependencies point inward: frontend/backend → shared; backend → db/config.
@@ -58,6 +59,15 @@ vertical path. Before handing work off, run:
58
59
  bun run check
59
60
  bun run test
60
61
  bun run build
61
- bun run runtime:smoke
62
+ bun run acceptance:local
62
63
  ```
63
64
 
65
+ `acceptance:local` is part of the list because `runtime:smoke` and `e2e` check a
66
+ running deployment: it creates one of its own — separate PM2 home, ephemeral
67
+ ports, and its own database from `ACCEPTANCE_DATABASE_URL` — runs both against
68
+ it, and destroys it. The separate database is not tidiness: the gates write, so
69
+ one borrowing `DATABASE_URL` writes rows wherever `.env` points. **Never put
70
+ `pm2:prod` in this list.** It applies the declared migrations to *your* database
71
+ and reloads the running deployment; deploying is its own command, asked for on
72
+ purpose, and no gate performs it.
73
+
@@ -9,14 +9,17 @@ before it starts, and the environment variables a deployment must supply.
9
9
  The declaration is true **with no machine in existence**. A field you cannot
10
10
  fill in without knowing where the code will run is a *binding*, not a
11
11
  declaration: ports, hosts, addresses, machine paths and supervision policy are
12
- named there by variable and never by value, and the schema has nowhere to put
13
- them. Change the slug, display name, version or description there and package
12
+ named there by variable and never by value. The schema has no field that asks
13
+ for one, so nothing in it ever requires a value of the place; where a value
14
+ could still be written into a free-text field, a filter refuses the known
15
+ shapes of a machine name. Change the slug, display name, version or description there and package
14
16
  names, process names, MCP/OpenAPI identity, UI copy and SEO follow.
15
17
 
16
- Three files are **generated** from it — `ecosystem.config.cjs`,
17
- `ecosystem.dev.config.cjs` and the `env.variables` block of the declaration
18
- itself. Run `bun run gen:declaration` after changing a role; the test suite
19
- refuses a stale copy.
18
+ Four things are **generated** from it — `ecosystem.config.cjs`,
19
+ `ecosystem.dev.config.cjs`, `packages/config/src/app-identity.generated.ts` and
20
+ the `env.variables` block of the declaration itself. Run
21
+ `bun run gen:declaration` after changing a role; the test suite refuses a stale
22
+ copy.
20
23
 
21
24
  ## Start
22
25
 
@@ -87,10 +90,45 @@ Unsupported browsers and users requesting reduced motion switch immediately.
87
90
  bun run check
88
91
  bun run test
89
92
  bun run build
90
- bun run runtime:smoke
91
- bun run e2e
93
+ bun run acceptance:local
92
94
  ```
93
95
 
96
+ Top to bottom, in one terminal, and none of it touches a deployment. They
97
+ assume your development database is already set up — see [Start](#start).
98
+
99
+ `acceptance:local` is the last one because `runtime:smoke` and `e2e` check a
100
+ **running** deployment rather than a source tree — so it creates one and
101
+ destroys it: its own PM2 home, ephemeral ports, its own public-host allowlist,
102
+ and a stop that names the roles the declaration declares. It reloads nothing
103
+ you are running.
104
+
105
+ It also brings its own database, `ACCEPTANCE_DATABASE_URL`, and applies this
106
+ project's migrations to that — not to yours. The gates WRITE (the repository
107
+ example's smoke posts a refresh, and that upserts), so borrowing `DATABASE_URL`
108
+ would make a gate a writer in whatever database your `.env` names. It refuses to
109
+ start when the variable is unset or names the same database, and says what to
110
+ add. It needs `pm2` (see [Requirements](#requirements)).
111
+
112
+ Deploying is a separate, deliberate command — `bun run pm2:prod` under
113
+ [Production](#production) — and it is not a gate.
114
+
115
+ To run the two runtime gates against a deployment that already exists somewhere,
116
+ point `SMOKE_API_ORIGIN` / `SMOKE_WEB_ORIGIN` at it and call `bun run
117
+ runtime:smoke` / `bun run e2e` directly. `runtime:smoke` asks the web role to
118
+ answer as two of the addresses `PUBLIC_WEB_HOSTS` claims, to prove one artifact
119
+ serves many; a deployment claiming fewer than two addresses besides the one
120
+ being dialled is told exactly what to add rather than failing on a refused host.
121
+
122
+ ## Requirements
123
+
124
+ - **Bun** and **Node ≥ 22**.
125
+ - **PostgreSQL** — external infrastructure, in development and production
126
+ alike. This project owns its schema and migrations, never the database
127
+ process.
128
+ - **PM2** on `PATH` (`bun add --global pm2`) for `bun run dev`,
129
+ `bun run acceptance:local` and `bun run pm2:prod`.
130
+ - **Playwright browsers** (`bunx playwright install`) for `bun run e2e`.
131
+
94
132
  ## Production
95
133
 
96
134
  Provide a production `.env`, then:
@@ -1,5 +1,9 @@
1
1
  NODE_ENV=development
2
2
  DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:5432/stitchkit_starter
3
+ # The throwaway database `bun run acceptance:local` creates and writes to. The
4
+ # runtime gates WRITE, so they get one of their own: the harness refuses to
5
+ # start if this is unset or names the database above.
6
+ ACCEPTANCE_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:5432/stitchkit_starter_acceptance
3
7
  # 0.0.0.0 exposes the app to every network interface — opt in consciously.
4
8
  BIND_HOST=127.0.0.1
5
9
  API_PORT=3211
@@ -9,6 +13,10 @@ SMOKE_WEB_ORIGIN=http://127.0.0.1:3210
9
13
  # Hosts this deployment answers for, comma-separated. One built artifact can
10
14
  # serve several addresses; a forwarded host outside this list is refused rather
11
15
  # than believed. Leave unset and set PUBLIC_WEB_ORIGIN instead for a single one.
16
+ #
17
+ # `bun run acceptance:local` supplies its own list to the deployment it creates,
18
+ # so the addresses the portability check needs are not policy this project
19
+ # carries. List here only the hosts this deployment really answers for.
12
20
  PUBLIC_WEB_HOSTS=127.0.0.1:3210
13
21
  LOG_FORMAT=pretty
14
22
  # CORS_ORIGIN is only needed for a genuinely cross-origin browser.
@@ -3,6 +3,7 @@ dist/
3
3
  .next/
4
4
  next-env.d.ts
5
5
  .env
6
+ .build-stamp.json
6
7
  coverage/
7
8
  *.log
8
9
  packages/db/src/generated/
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "https://biomejs.dev/schemas/2.5.7/schema.json",
2
+ "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
3
3
  "files": {
4
4
  "includes": [
5
5
  "**",