create-stitchkit 0.3.2 → 0.4.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 (83) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/README.md +3 -1
  3. package/UPGRADING.md +225 -0
  4. package/dist/cli.js +233 -41
  5. package/examples/repository/_env.example.append +21 -0
  6. package/examples/repository/packages/backend/src/domain/repository/github-cache.ts +2 -2
  7. package/examples/repository/packages/backend/src/surface.ts +1 -1
  8. package/examples/repository/packages/config/src/features.ts +17 -0
  9. package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +4 -4
  10. package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
  11. package/examples/repository/packages/frontend/src/app/api/[...path]/route.ts +66 -0
  12. package/examples/repository/packages/frontend/src/lib/api/client.ts +17 -12
  13. package/examples/repository/packages/frontend/src/lib/api/cross-origin.ts +87 -0
  14. package/examples/repository/packages/frontend/src/lib/api/place.ts +26 -0
  15. package/examples/repository/packages/frontend/src/lib/api/queries.ts +3 -0
  16. package/examples/repository/packages/frontend/src/lib/api/server-client.ts +11 -0
  17. package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +44 -12
  18. package/examples/repository/packages/frontend/src/providers/client-providers.tsx +30 -0
  19. package/examples/repository/packages/frontend/src/providers/index.tsx +17 -12
  20. package/examples/repository/packages/frontend/src/providers/realtime.tsx +6 -4
  21. package/examples/repository/project.json +189 -0
  22. package/examples/repository/scripts/runtime-smoke.ts +25 -5
  23. package/package.json +9 -1
  24. package/template/AGENTS.md +15 -2
  25. package/template/README.md +51 -6
  26. package/template/_env.example +10 -4
  27. package/template/biome.json +5 -1
  28. package/template/bun.lock +2 -2
  29. package/template/e2e/starter.spec.ts +5 -7
  30. package/template/ecosystem.config.cjs +42 -13
  31. package/template/ecosystem.dev.config.cjs +41 -15
  32. package/template/package.json +5 -4
  33. package/template/packages/backend/package.json +1 -1
  34. package/template/packages/backend/scripts/ensure-built.ts +7 -0
  35. package/template/packages/backend/src/cli.ts +6 -2
  36. package/template/packages/backend/src/index.ts +23 -7
  37. package/template/packages/backend/src/surface.ts +6 -1
  38. package/template/packages/backend/src/transport/errors.ts +4 -2
  39. package/template/packages/backend/tsconfig.json +1 -1
  40. package/template/packages/config/package.json +3 -1
  41. package/template/packages/config/src/app-identity.generated.ts +20 -0
  42. package/template/packages/config/src/declaration.ts +30 -0
  43. package/template/packages/config/src/project-declaration.generated.ts +611 -0
  44. package/template/packages/config/src/server.ts +8 -14
  45. package/template/packages/config/src/variables.ts +89 -0
  46. package/template/packages/frontend/next.config.ts +3 -2
  47. package/template/packages/frontend/package.json +2 -2
  48. package/template/packages/frontend/scripts/serve.ts +70 -0
  49. package/template/packages/frontend/src/app/[locale]/layout.tsx +9 -8
  50. package/template/packages/frontend/src/app/[locale]/page.tsx +2 -2
  51. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
  52. package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +1 -1
  53. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +1 -1
  54. package/template/packages/frontend/src/app/robots.ts +4 -2
  55. package/template/packages/frontend/src/app/sitemap.ts +7 -19
  56. package/template/packages/frontend/src/env.ts +27 -8
  57. package/template/packages/frontend/src/lib/seo/cache-by-origin.test.ts +68 -0
  58. package/template/packages/frontend/src/lib/seo/cache-by-origin.ts +40 -0
  59. package/template/packages/frontend/src/lib/seo/metadata.ts +68 -11
  60. package/template/packages/frontend/src/lib/seo/pages.ts +2 -2
  61. package/template/packages/frontend/src/lib/seo/request-origin.ts +89 -0
  62. package/template/packages/frontend/src/theme/config.ts +1 -1
  63. package/template/packages/frontend/tsconfig.json +10 -3
  64. package/template/playwright.config.ts +1 -1
  65. package/template/project.json +169 -0
  66. package/template/scripts/build-inputs.test.ts +69 -0
  67. package/template/scripts/build-inputs.ts +57 -0
  68. package/template/scripts/check-authored.ts +18 -2
  69. package/template/scripts/declaration.test.ts +206 -0
  70. package/template/scripts/declaration.ts +268 -0
  71. package/template/scripts/dev.ts +84 -15
  72. package/template/scripts/local-env.test.ts +2 -2
  73. package/template/scripts/local-env.ts +3 -3
  74. package/template/scripts/release-steps.test.ts +87 -0
  75. package/template/scripts/release-steps.ts +108 -0
  76. package/template/scripts/release.ts +30 -0
  77. package/template/scripts/runtime-smoke.ts +7 -4
  78. package/template/scripts/serve-mode.test.ts +36 -0
  79. package/template/scripts/supervision-signal.test.ts +94 -0
  80. package/template/scripts/tooling-env.ts +5 -2
  81. package/template/scripts/web-surface-smoke.ts +70 -0
  82. package/template/app.config.json +0 -9
  83. package/template/packages/config/src/identity.ts +0 -18
package/CHANGELOG.md CHANGED
@@ -4,8 +4,251 @@ All notable changes to **create-stitchkit** are documented here. The scaffolder
4
4
  has its own version and release line; the Stitchkit range tested by its template
5
5
  is declared in the template root catalog.
6
6
 
7
+ A release that changes the generated project in a way an existing project must
8
+ follow leads its entry with a **`### ⚠️ Breaking changes`** section. What else
9
+ has to happen — including the steps that touch a running machine — is in
10
+ [`UPGRADING.md`](./UPGRADING.md), because a changelog entry carrying an operator
11
+ step is overwritten by the next release.
12
+
7
13
  ## [Unreleased]
8
14
 
15
+ ## [0.4.0] — 2026-08-25
16
+
17
+ ### ⚠️ Breaking changes
18
+
19
+ - **The repository example's browser talks to its OWN origin by default.** The
20
+ example is what gets copied, and it was demonstrating the hard case: the
21
+ browser dialled the API role directly, so the address had to arrive from the
22
+ server at runtime, the API client could not exist until it did, and every call
23
+ site paid for that with a lazy accessor — `repositoryApi().read()` — plus a
24
+ runtime error when something rendered outside `<Providers>`. Somebody copying
25
+ it inherited that whether or not they were cross-origin at all. The body of
26
+ the example is now the default shape: a same-origin `/api/…` path forwarded by
27
+ the web role, and a client that is a module constant.
28
+ `// before: repositoryApi().read()` → `// after: repositoryApi.read()`
29
+ The cross-origin form is not lost — it moved to a named file,
30
+ `packages/frontend/src/lib/api/cross-origin.ts`, with what it costs written
31
+ next to it, and switching to it is one import in `queries.ts`.
32
+ - **`PUBLIC_REALTIME_ORIGIN` — the socket's address has a name of its own.**
33
+ `PUBLIC_API_ORIGIN` used to carry both questions and answer only one: it read
34
+ as a mode switch while in fact nothing but the realtime socket looked at it.
35
+ The two are genuinely different — HTTP can be forwarded by the web role, and a
36
+ WebSocket upgrade cannot survive a route handler — so a deployment can be
37
+ same-origin for HTTP and still have to name the socket's origin. Both are
38
+ optional; a deployment behind one routing layer sets neither.
39
+ `// before: PUBLIC_API_ORIGIN=https://api.example # …which only the socket read` →
40
+ `// after: PUBLIC_REALTIME_ORIGIN=https://api.example`
41
+ - **`app.config.json` is now `project.json` — the project *declaration*.** It no
42
+ longer describes only identity: it states what this repository is, the roles it
43
+ runs, what it builds, what it needs before it starts, the release steps that
44
+ must happen once, and the environment variables a deployment must supply.
45
+ Identity moved under an `identity` key and the file gained a `schemaVersion`,
46
+ so a reader that does not understand the format refuses the project instead of
47
+ interpreting it partially.
48
+ `// before: import { appIdentity } from '@app/config/identity'; appIdentity.name` →
49
+ `// after: import { appDeclaration } from '@app/config/declaration'; appDeclaration.identity.name`
50
+
51
+ A client component imports `@app/config/app-identity` instead — a generated
52
+ module carrying identity alone. Importing the whole declaration from the
53
+ browser would ship role commands, working directories, build artifact paths and
54
+ the migration lockfile in the bundle.
55
+ `// before: import { appIdentity } from '@app/config/identity' // in a 'use client' file` →
56
+ `// after: import { appIdentity } from '@app/config/app-identity'`
57
+
58
+ The rule the file exists to hold: **it must be complete with no machine in
59
+ existence.** Ports, hosts, addresses, machine paths, routing shape and
60
+ supervision policy are named there by variable and never by value — and the
61
+ schema refuses them by shape rather than by review.
62
+
63
+ - **The SEO helpers are async, and `siteOrigin` is gone.** They read the public
64
+ origin from the request instead of a build-time constant, so they cannot be
65
+ constants themselves. A page that calls them must await them — and TypeScript
66
+ will *not* catch it inside an inferred object literal, where a `Promise`
67
+ silently serialises as `{}`.
68
+ `// before: const url = absoluteSiteUrl('/en'); export const siteOrigin` →
69
+ `// after: const url = await absoluteSiteUrl('/en') // and the component becomes async`
70
+ `// before: createPageMetadata('home', locale)` →
71
+ `// after: await createPageMetadata('home', locale)`
72
+
73
+ - **A forwarded host must be claimed before it is believed.** The public origin
74
+ comes from the request, which makes one artifact serve many addresses — and
75
+ would let any caller choose the canonical URL, the sitemap and the OG metadata
76
+ if it were trusted blindly. Set `PUBLIC_WEB_ORIGIN` for a single address, or
77
+ `PUBLIC_WEB_HOSTS` for several; a host outside them is refused. `x-forwarded-proto`
78
+ is narrowed to `http` or `https`.
79
+ `// before: (nothing — any x-forwarded-host was honoured)` →
80
+ `// after: PUBLIC_WEB_HOSTS=app.example,www.app.example`
81
+
82
+ - **Environment variables are declared once, and the declaration lists them as
83
+ `env.variables`.** The server schema, the frontend schema and the tooling
84
+ schema were three overlapping copies that had already diverged.
85
+ `packages/config/src/variables.ts` is now the single declaration; `server.ts`
86
+ and `frontend/src/env.ts` are projections of it, and the declaration's list is
87
+ *derived* from it rather than restated. An overlay may now **tighten** a
88
+ variable, not only add one — the repository example requires `INTERNAL_API_URL`,
89
+ `PUBLIC_API_ORIGIN` and `CORS_ORIGIN` because its frontend dereferences them on
90
+ every render.
91
+ `// before: z.url() repeated in three files; env.required with required:false entries` →
92
+ `// after: applicationVariables.INTERNAL_API_URL, referenced; env.variables`
93
+
94
+ - **`NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_WEB_URL` are gone.** Anything prefixed
95
+ `NEXT_PUBLIC_` is substituted at BUILD time, so declaring one froze a value of
96
+ the place into the artifact: the built `robots.txt` and `sitemap.xml` carried
97
+ one origin inside their bytes, and the server chunk carried
98
+ `NEXT_PUBLIC_API_URL:"http://…"` as a literal. One build could not serve a
99
+ second address.
100
+ `// before: NEXT_PUBLIC_WEB_URL=https://app.example → baked at build` →
101
+ `// after: no variable; the origin is read from the request`
102
+ **Cost, stated plainly:** `/robots.txt`, `/sitemap.xml` and — because the root
103
+ layout's `generateMetadata` reads the request — the whole `[locale]` segment
104
+ are no longer prerendered as static content. Setting `PUBLIC_WEB_ORIGIN`
105
+ short-circuits the request read and restores static rendering for a deployment
106
+ that serves exactly one address. Answers are built once per address, not once
107
+ per request: `cacheByOrigin` memoises them behind a bounded LRU so a forged
108
+ `Host` cannot grow the cache.
109
+
110
+ - **`CORS_ORIGIN` is optional.** A frontend that reaches the API through its own
111
+ routing layer makes same-origin requests, and requiring an origin there was
112
+ requiring knowledge of the place. Set it only for a genuinely cross-origin
113
+ browser.
114
+ `// before: CORS_ORIGIN=https://app.example # required` →
115
+ `// after: unset unless the browser genuinely lives elsewhere`
116
+
117
+ - **The smoke and e2e addresses are `SMOKE_API_ORIGIN` and `SMOKE_WEB_ORIGIN`.**
118
+ They are legitimately bound to a place — they name the deployment a check dials —
119
+ but must not carry a prefix that makes the build substitute them.
120
+ `// before: NEXT_PUBLIC_API_URL=http://127.0.0.1:3211` →
121
+ `// after: SMOKE_API_ORIGIN=http://127.0.0.1:3211`
122
+
123
+ - **Each role is started by its own PROCESS, in its own directory, and PM2
124
+ process names follow the declared role names.** A role's command is `executable`
125
+ plus `args` in the declaration — argv, never a shell string — and the
126
+ supervision files are rendered from it.
127
+ `// before: <slug>-backend, <slug>-frontend` → `// after: <slug>-api, <slug>-web`
128
+ **Before your first `pm2:prod` on the new files**, remove the old processes, or
129
+ `startOrReload` will start the new pair beside them and both will fight for the
130
+ same ports under `autorestart`:
131
+ ```bash
132
+ pm2 delete <slug>-backend <slug>-frontend <slug>-backend-dev <slug>-frontend-dev
133
+ ```
134
+
135
+ - **The web role reads its bindings itself.** The supervisor no longer builds an
136
+ argv for it; injecting `WEB_PORT` is now sufficient, where before a deployment
137
+ that set it and stopped there got a web role on the wrong port, silently. There
138
+ is no default port in the repository any more — a missing variable fails by
139
+ name.
140
+ `// before: "start": "next start --port ${WEB_PORT:-3210}"` →
141
+ `// after: "start": "bun scripts/serve.ts production"`
142
+
143
+ ### Fixed
144
+
145
+ - **A supervised backend now actually drains.** The supervision files started the
146
+ role through a script runner, so the stop signal arrived twice — once from PM2,
147
+ once forwarded by the launcher — and a shutdown chain treats the second signal
148
+ as "force it now". Measured on a real PM2 stop: a declared 15 s grace period
149
+ ended after **1.3 ms** with `outcome: "forced"`, `reason: "signal"`, and the
150
+ only visible trace was a non-zero exit code. The supervisor now execs the role
151
+ itself, and the same stop reports `outcome: "clean"` with exit 0.
152
+ `// before: script: 'bun', args: ['run', 'start'] // an intermediate shape, never released` →
153
+ `// after: script: 'bun', args: ['dist/index.js']`
154
+ A generated project at the previous release ran `script: 'dist/index.js'` with
155
+ `interpreter: 'bun'`, which had the same property; the defect it *did* ship is
156
+ the timeout mismatch below.
157
+ - **Supervision no longer kills the backend mid-shutdown.** The application asked
158
+ for a 30 s drain while PM2 sent `SIGKILL` after `kill_timeout: 15000` in
159
+ production and 10000 in development — so a drain longer than the supervisor's
160
+ patience never finished, every time. The check now covers the **whole**
161
+ termination budget rather than the drain alone: drain floor, plus the force
162
+ window that follows it, plus cleanup. Comparing against the floor alone let
163
+ 15 s + 5 s meet a 20 s kill timeout exactly, with no margin at all.
164
+ - **The backend says how its shutdown ended.** `onComplete` logs the outcome, the
165
+ reason, the duration and how many requests completed or were aborted. Without
166
+ it an operator saw a process that vanished and an exit code, and could not tell
167
+ a clean drain from one that was cut short — which is exactly how the defect
168
+ above stayed invisible.
169
+ - **A requested stop of the web role reports success.** Next exits `130` on
170
+ `SIGINT`; the role passed that upward, so every ordinary supervised stop looked
171
+ like a failure. A stop the role was asked to perform now exits `0`, while a code
172
+ from any other cause is still passed on unchanged.
173
+ - **A deployment's environment is no longer overruled by a file.** The production
174
+ supervision file loaded `.env` with `override: true`, so a value injected into
175
+ the process lost to a value in the repository. Bindings come from the place; the
176
+ file fills gaps.
177
+ - **Release steps come from the declaration.** `pm2:prod` hand-carried
178
+ `db:deploy` and a build preflight as a shell string beside the declaration that
179
+ already stated them. It now runs `scripts/release.ts`, which checks every
180
+ artifact `build.artifacts` declares and applies migrations for the engine
181
+ `release.migrations` declares — refusing an engine it has no command for rather
182
+ than skipping the step, because silently not migrating is what leaves a machine
183
+ running against the wrong schema. Both declared migration paths are checked,
184
+ the lockfile included. Development runs the same step.
185
+ - **`bun run test` passes on a fresh scaffold.** A test read `.env` at module
186
+ load, before `env:ensure` had created it, so the second gate the README tells a
187
+ new user to run died with `ENOENT` before a single test executed. Both CI paths
188
+ write `.env` first, so nothing saw it.
189
+
190
+ ### Changed
191
+
192
+ - **The generated build declares whether it reads data, and CI proves it does
193
+ not.** Data read while building is a third kind of input — neither code nor a
194
+ binding — and a build that reads it undeclared is a function of whichever
195
+ machine had the database. The template answers it the default way: no route
196
+ can reach a data source at all (`check-authored` refuses the import and now
197
+ says why), so the build is a function of the source alone. The packed lane
198
+ builds against a database address that accepts nothing, which is the only
199
+ check that covers every transitive path at once. A project that genuinely
200
+ needs data at build time declares a frozen export in `build.inputs` and
201
+ `bun scripts/build-inputs.ts` refuses it the moment its digest drifts.
202
+ - **One artifact is now provably portable in CI, within a stated policy.**
203
+ `runtime:smoke` asks the running web role for `/sitemap.xml` and `/robots.txt`
204
+ under two different external addresses and requires two different answers —
205
+ and requires a *third*, unclaimed host to be refused. The first half catches a
206
+ build-time address creeping back in; the second catches the portability
207
+ mechanism turning into an open redirect for metadata.
208
+ - **The drain floor has one home.** The backend passed a literal grace period
209
+ while the declaration stated another — the same two-numbers-in-two-files shape
210
+ that let a 30 s floor meet a 15 s kill timeout. It now reads
211
+ `apiRole.drainFloorMs`, the number a supervisor reads too.
212
+ - **Three files are generated from the declaration** — both supervision files and
213
+ the client-safe identity module — plus the `env.variables` block of the
214
+ declaration itself. `bun run gen:declaration` renders them and the test suite
215
+ refuses a stale copy. The example's declaration is generated from the
216
+ template's in the same way.
217
+ - **The generated application targets Stitchkit `^0.59.0`.** The template's
218
+ catalog pointed at `^0.52.0`, so a project scaffolded today started seven
219
+ minors behind the framework — without neutral client-disconnect handling,
220
+ managed files, composed auth, async operation contracts or the CLI presentation
221
+ policy. Both the packed target lane and the packed HEAD lane pass on the new
222
+ target.
223
+ - **The scaffolder prints the addresses the generated project will actually
224
+ use**, read back from its declaration and example environment rather than
225
+ restated as constants.
226
+
227
+ ## [0.3.3] — 2026-08-18
228
+
229
+ ### Changed
230
+
231
+ - **Generated applications bind loopback by default.** Both processes listen on
232
+ `BIND_HOST` (default `127.0.0.1`) instead of a hardcoded `0.0.0.0` in the
233
+ backend server and both PM2 configs. Exposing the app to the network is now a
234
+ single conscious opt-in (`BIND_HOST=0.0.0.0` in `.env`) rather than the state
235
+ a forgotten edit leaves behind. Reported from a production deployment of a
236
+ generated app.
237
+ - **The template targets Stitchkit `^0.52.0`** — new applications get
238
+ `implement.declare`, keyed registries and hook-derived scope maps out of the
239
+ box. Purely additive relative to 0.50.
240
+ - **`bun run dev` reports honest URLs and fails fast on occupied ports.** The
241
+ final `Web:`/`API:` lines are rendered from the validated environment instead
242
+ of hardcoded ports, and before starting fresh PM2 processes the script probes
243
+ `API_PORT`/`WEB_PORT` and names the offending variable when a foreign process
244
+ holds one. Reloads of the app's own processes are unaffected.
245
+ - **`start` without a build says what to do.** A missing `dist/index.js` now
246
+ fails with “run `bun run build` first” (also preflighted in `pm2:prod`)
247
+ instead of a bare module-resolution error.
248
+ - **README and AGENTS.md pin the Prisma entry point.** Database commands go
249
+ through the root `bun run db:*` scripts; the `prisma` CLI invoked directly has
250
+ no datasource URL by design.
251
+
9
252
  ## [0.3.2] — 2026-08-17
10
253
 
11
254
  ### Changed
package/README.md CHANGED
@@ -16,7 +16,9 @@ complete production UI system.
16
16
  It uses one conventional `packages/*` namespace: `backend`, `frontend`,
17
17
  `config`, `db` and `shared`. The destination name becomes the generated slug;
18
18
  `--display-name` sets the human title. Both are recorded once in
19
- `app.config.json` and drive package, process, transport, UI and SEO identity.
19
+ `project.json` — the generated project's **declaration**, the single
20
+ machine-readable statement it makes about itself — and drive package, process,
21
+ transport, UI and SEO identity.
20
22
 
21
23
  The default scaffold is domain-free. To add the runnable repository example:
22
24
 
package/UPGRADING.md ADDED
@@ -0,0 +1,225 @@
1
+ # Upgrading a generated project
2
+
3
+ How to move a project generated by `create-stitchkit` from one scaffolder
4
+ version to another. It is a different move from upgrading the framework: your
5
+ project is a **copy** of the template, not a dependency on it, so nothing here
6
+ happens by installing anything. What a new scaffolder version brings is a set of
7
+ edits you apply to a tree you already own, plus — and this is the part that bites
8
+ — **operator steps**: things a machine has to be told before the new shape will
9
+ start at all.
10
+
11
+ The framework's own guide is
12
+ [`docs/guide/upgrading.md`](../../docs/guide/upgrading.md); it covers the
13
+ `stitchkit` dependency. This one covers the generated project.
14
+
15
+ ## The one rule that makes this work
16
+
17
+ A scaffolder release that changes the generated project in a way an existing
18
+ project must follow leads its [`CHANGELOG.md`](./CHANGELOG.md) entry with a
19
+ **`### ⚠️ Breaking changes`** section (exact heading), each item carrying a
20
+ **before → after** snippet. A version with **no** such section changes nothing
21
+ you are obliged to adopt.
22
+
23
+ The changelog says *what* changed. This file says what else has to happen —
24
+ including the steps that touch a running machine, which have no place in a
25
+ changelog because the next release overwrites the entry that carried them.
26
+
27
+ ## Flow
28
+
29
+ 1. **Find your project's origin.** The scaffolder version that generated it is
30
+ not recorded in the tree — check the changelog of the version you scaffolded
31
+ with, or diff your tree against a fresh scaffold of your current target.
32
+ 2. **Read every `### ⚠️ Breaking changes` in range** in
33
+ [`CHANGELOG.md`](./CHANGELOG.md), from the version above yours up to your
34
+ target.
35
+ 3. **Apply the code edits**, then the **`## Released migration: X.Y.Z`** section
36
+ below for the same versions — that is where the operator steps live.
37
+
38
+ > This channel starts at **0.4.0**. Versions below it have breaking changelog
39
+ > entries and no migration section here, because the file did not exist yet;
40
+ > for those, the changelog entry is all there is.
41
+ 4. **Verify** with your project's own gates: `bun run check`, `bun run test`,
42
+ `bun run build`, then a real start.
43
+
44
+ ## Where a migration section goes while the version has no number
45
+
46
+ Write it here as **`## Unreleased migration: <short slug>`**. The slug matters:
47
+ several may sit side by side, and each belongs to whoever wrote it. Do **not**
48
+ reuse another author's heading — that is how a migration gets overwritten before
49
+ anyone promotes it.
50
+
51
+ At release, the release commit promotes every `Unreleased migration` heading
52
+ into one `## Released migration: X.Y.Z`, each former heading becoming a `###`
53
+ subsection under it — the same move the changelog makes when `[Unreleased]`
54
+ becomes `## [X.Y.Z]`, in the same commit.
55
+
56
+ A release carrying `### ⚠️ Breaking changes` and no matching
57
+ `## Released migration: X.Y.Z` is refused by `bun scripts/release-plan.ts`, in
58
+ `pre-push` and again in the publishing workflow. The check starts at `0.4.0` —
59
+ the first scaffolder release with a migration channel of its own.
60
+
61
+ ---
62
+
63
+ ## Released migration: 0.4.0
64
+
65
+ ### the project declares itself
66
+
67
+ Everything in this section is for a project generated **before** the scaffolder
68
+ version that introduces `project.json`.
69
+
70
+ #### The declaration replaces `app.config.json`
71
+
72
+ `app.config.json` said who the project was. `project.json` says what it *is*:
73
+ identity, the roles it runs, what it builds, what it needs before it starts,
74
+ what must happen once on release, and the **names** of the variables a
75
+ deployment supplies. Identity moved under an `identity` key, and the file gained
76
+ a `schemaVersion` so a reader that does not understand the format refuses the
77
+ project instead of interpreting half of it.
78
+
79
+ ```ts
80
+ // before
81
+ import { appIdentity } from '@app/config/identity';
82
+ appIdentity.name;
83
+
84
+ // after
85
+ import { appDeclaration } from '@app/config/declaration';
86
+ appDeclaration.identity.name;
87
+ ```
88
+
89
+ A **client** component imports the generated identity-only module instead —
90
+ importing the whole declaration from the browser ships role commands, working
91
+ directories, artifact paths and the migration lockfile in the bundle:
92
+
93
+ ```ts
94
+ // before, in a 'use client' file
95
+ import { appIdentity } from '@app/config/identity';
96
+
97
+ // after
98
+ import { appIdentity } from '@app/config/app-identity';
99
+ ```
100
+
101
+ #### Operator step: delete the old supervisor processes FIRST
102
+
103
+ PM2 process names now follow the declared role names, so the new supervision
104
+ files start a **new pair beside the old one**. Under `autorestart` both then
105
+ fight for the same ports, and the symptom is an `EADDRINUSE` loop rather than a
106
+ clear error.
107
+
108
+ Before the first `bun run pm2:prod` on the new files:
109
+
110
+ ```bash
111
+ pm2 delete <slug>-backend <slug>-frontend <slug>-backend-dev <slug>-frontend-dev
112
+ ```
113
+
114
+ `<slug>` is your project's slug — `identity.slug` in `project.json`. Check what
115
+ is actually registered with `pm2 list` first; nothing here is safe to run blind.
116
+
117
+ #### Operator step: the supervisor's patience must cover the whole shutdown
118
+
119
+ The generated `ecosystem.config.cjs` is now rendered from the declaration, and
120
+ its `kill_timeout` is computed from each role's drain floor plus the force
121
+ window plus cleanup. If you kept a hand-edited supervision file, compare its
122
+ `kill_timeout` against `drainFloorMs` in `project.json`: a timeout shorter than
123
+ the full budget means the drain never finishes, every time, and the only visible
124
+ trace is a non-zero exit code.
125
+
126
+ Regenerate rather than hand-edit:
127
+
128
+ ```bash
129
+ bun run gen:declaration
130
+ ```
131
+
132
+ #### Environment: three variables changed meaning
133
+
134
+ ```sh
135
+ # before
136
+ NEXT_PUBLIC_API_URL=https://api.example
137
+ NEXT_PUBLIC_WEB_URL=https://app.example
138
+
139
+ # after — nothing. Both are gone.
140
+ ```
141
+
142
+ Anything prefixed `NEXT_PUBLIC_` is substituted at **build** time, so declaring
143
+ one froze an address into the artifact: the built `robots.txt` and `sitemap.xml`
144
+ carried one origin in their bytes and one build could not serve a second
145
+ address. The public origin now comes from the request.
146
+
147
+ A forwarded host must be claimed before it is believed. Set **one** of:
148
+
149
+ ```sh
150
+ PUBLIC_WEB_ORIGIN=https://app.example # a single address
151
+ PUBLIC_WEB_HOSTS=app.example,www.app.example # several
152
+ ```
153
+
154
+ A host outside them is refused. Setting `PUBLIC_WEB_ORIGIN` also restores static
155
+ rendering for `/robots.txt`, `/sitemap.xml` and the `[locale]` segment, which
156
+ otherwise become request-rendered.
157
+
158
+ The check and e2e addresses were renamed so no build can substitute them:
159
+
160
+ ```sh
161
+ # before
162
+ NEXT_PUBLIC_API_URL=http://127.0.0.1:3211
163
+ # after
164
+ SMOKE_API_ORIGIN=http://127.0.0.1:3211
165
+ SMOKE_WEB_ORIGIN=http://127.0.0.1:3210
166
+ ```
167
+
168
+ `CORS_ORIGIN` is now optional: a frontend that reaches the API through its own
169
+ routing layer makes same-origin requests, and requiring an origin there was
170
+ requiring knowledge of the place.
171
+
172
+ #### The repository example: calls lose their parentheses
173
+
174
+ Only for a project generated with `--example repository`. The browser now talks
175
+ to its own origin, so the API client is a module constant:
176
+
177
+ ```ts
178
+ // before
179
+ repositoryApi().read();
180
+ repositoryUrls().read();
181
+
182
+ // after
183
+ repositoryApi.read();
184
+ repositoryUrls.read();
185
+ ```
186
+
187
+ The web role forwards `/api/…` to the API role, which needs `INTERNAL_API_URL`
188
+ (already required).
189
+
190
+ **The socket's address is now its own variable.** If you use the realtime
191
+ socket and have no routing layer forwarding `/socket.io`, rename it:
192
+
193
+ ```sh
194
+ # before — read only by the socket, despite the name
195
+ PUBLIC_API_ORIGIN=https://api.example
196
+ # after
197
+ PUBLIC_REALTIME_ORIGIN=https://api.example
198
+ ```
199
+
200
+ `PUBLIC_API_ORIGIN` still exists and still means HTTP, but it is inert until you
201
+ switch: the import in `packages/frontend/src/lib/api/queries.ts` decides which
202
+ client the browser uses. Both variables are optional; a deployment behind one
203
+ routing layer sets neither, and `CORS_ORIGIN` with them.
204
+
205
+ The cross-origin form moved to `packages/frontend/src/lib/api/cross-origin.ts`
206
+ (previously `lib/api/origin.ts`); `requirePublicApiOrigin` lives there, beside
207
+ `optionalRealtimeOrigin` and `setPublicOrigins`.
208
+
209
+ #### The SEO helpers are async
210
+
211
+ They read the public origin from the request, so they cannot be constants.
212
+ TypeScript will **not** catch a missing `await` inside an inferred object
213
+ literal — a `Promise` serialises there as `{}`.
214
+
215
+ ```ts
216
+ // before
217
+ const url = absoluteSiteUrl('/en');
218
+ createPageMetadata('home', locale);
219
+
220
+ // after — and the component becomes async
221
+ const url = await absoluteSiteUrl('/en');
222
+ await createPageMetadata('home', locale);
223
+ ```
224
+
225
+ `siteOrigin` is gone.