create-stitchkit 0.3.3 → 0.4.1

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 (99) hide show
  1. package/CHANGELOG.md +347 -0
  2. package/README.md +3 -1
  3. package/UPGRADING.md +342 -0
  4. package/dist/cli.js +238 -42
  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 +38 -6
  23. package/package.json +12 -2
  24. package/template/AGENTS.md +23 -3
  25. package/template/README.md +83 -8
  26. package/template/_env.example +16 -4
  27. package/template/_gitignore +1 -0
  28. package/template/biome.json +6 -2
  29. package/template/bun.lock +115 -98
  30. package/template/e2e/starter.spec.ts +5 -7
  31. package/template/ecosystem.config.cjs +42 -19
  32. package/template/ecosystem.dev.config.cjs +41 -21
  33. package/template/package.json +12 -10
  34. package/template/packages/backend/package.json +2 -2
  35. package/template/packages/backend/src/cleanup.ts +121 -0
  36. package/template/packages/backend/src/cli.ts +6 -2
  37. package/template/packages/backend/src/index.ts +33 -8
  38. package/template/packages/backend/src/surface.ts +6 -1
  39. package/template/packages/backend/src/transport/errors.ts +4 -2
  40. package/template/packages/config/package.json +6 -2
  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/server.ts +8 -17
  44. package/template/packages/config/src/shutdown.ts +20 -0
  45. package/template/packages/config/src/variables.ts +89 -0
  46. package/template/packages/db/package.json +2 -2
  47. package/template/packages/frontend/next.config.ts +3 -2
  48. package/template/packages/frontend/package.json +13 -13
  49. package/template/packages/frontend/scripts/serve.ts +70 -0
  50. package/template/packages/frontend/src/app/[locale]/layout.tsx +9 -8
  51. package/template/packages/frontend/src/app/[locale]/page.tsx +2 -2
  52. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
  53. package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +1 -1
  54. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +1 -1
  55. package/template/packages/frontend/src/app/robots.ts +4 -2
  56. package/template/packages/frontend/src/app/sitemap.ts +7 -19
  57. package/template/packages/frontend/src/components/ui/toaster.tsx +4 -1
  58. package/template/packages/frontend/src/env.ts +27 -8
  59. package/template/packages/frontend/src/lib/seo/cache-by-origin.test.ts +68 -0
  60. package/template/packages/frontend/src/lib/seo/cache-by-origin.ts +40 -0
  61. package/template/packages/frontend/src/lib/seo/metadata.ts +68 -11
  62. package/template/packages/frontend/src/lib/seo/pages.ts +1 -1
  63. package/template/packages/frontend/src/lib/seo/request-origin.ts +89 -0
  64. package/template/packages/frontend/src/theme/config.ts +1 -1
  65. package/template/packages/frontend/tsconfig.json +10 -3
  66. package/template/packages/shared/package.json +1 -1
  67. package/template/playwright.config.ts +1 -1
  68. package/template/project.json +169 -0
  69. package/template/scripts/acceptance-database.test.ts +73 -0
  70. package/template/scripts/acceptance-database.ts +92 -0
  71. package/template/scripts/acceptance-local.ts +144 -0
  72. package/template/scripts/build-inputs.test.ts +69 -0
  73. package/template/scripts/build-inputs.ts +58 -0
  74. package/template/scripts/build-stamp.test.ts +151 -0
  75. package/template/scripts/build-stamp.ts +169 -0
  76. package/template/scripts/check-authored.ts +18 -2
  77. package/template/scripts/client-boundary.test.ts +117 -0
  78. package/template/scripts/client-boundary.ts +148 -0
  79. package/template/scripts/declaration.test.ts +206 -0
  80. package/template/scripts/declaration.ts +271 -0
  81. package/template/scripts/deployment-preflight.ts +41 -0
  82. package/template/scripts/dev.ts +43 -20
  83. package/template/scripts/local-env.test.ts +2 -2
  84. package/template/scripts/local-env.ts +9 -3
  85. package/template/scripts/readiness.ts +92 -0
  86. package/template/scripts/release-steps.test.ts +87 -0
  87. package/template/scripts/release-steps.ts +112 -0
  88. package/template/scripts/release.ts +38 -0
  89. package/template/scripts/runtime-smoke.test.ts +178 -0
  90. package/template/scripts/runtime-smoke.ts +21 -5
  91. package/template/scripts/serve-mode.test.ts +36 -0
  92. package/template/scripts/shutdown-budget.fixture.ts +29 -0
  93. package/template/scripts/shutdown-budget.test.ts +164 -0
  94. package/template/scripts/supervision-signal.test.ts +94 -0
  95. package/template/scripts/surface-conformance.ts +8 -1
  96. package/template/scripts/tooling-env.ts +35 -3
  97. package/template/scripts/web-surface-smoke.ts +183 -2
  98. package/template/app.config.json +0 -9
  99. package/template/packages/config/src/identity.ts +0 -18
package/UPGRADING.md ADDED
@@ -0,0 +1,342 @@
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.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
+
180
+ ## Released migration: 0.4.0
181
+
182
+ ### the project declares itself
183
+
184
+ Everything in this section is for a project generated **before** the scaffolder
185
+ version that introduces `project.json`.
186
+
187
+ #### The declaration replaces `app.config.json`
188
+
189
+ `app.config.json` said who the project was. `project.json` says what it *is*:
190
+ identity, the roles it runs, what it builds, what it needs before it starts,
191
+ what must happen once on release, and the **names** of the variables a
192
+ deployment supplies. Identity moved under an `identity` key, and the file gained
193
+ a `schemaVersion` so a reader that does not understand the format refuses the
194
+ project instead of interpreting half of it.
195
+
196
+ ```ts
197
+ // before
198
+ import { appIdentity } from '@app/config/identity';
199
+ appIdentity.name;
200
+
201
+ // after
202
+ import { appDeclaration } from '@app/config/declaration';
203
+ appDeclaration.identity.name;
204
+ ```
205
+
206
+ A **client** component imports the generated identity-only module instead —
207
+ importing the whole declaration from the browser ships role commands, working
208
+ directories, artifact paths and the migration lockfile in the bundle:
209
+
210
+ ```ts
211
+ // before, in a 'use client' file
212
+ import { appIdentity } from '@app/config/identity';
213
+
214
+ // after
215
+ import { appIdentity } from '@app/config/app-identity';
216
+ ```
217
+
218
+ #### Operator step: delete the old supervisor processes FIRST
219
+
220
+ PM2 process names now follow the declared role names, so the new supervision
221
+ files start a **new pair beside the old one**. Under `autorestart` both then
222
+ fight for the same ports, and the symptom is an `EADDRINUSE` loop rather than a
223
+ clear error.
224
+
225
+ Before the first `bun run pm2:prod` on the new files:
226
+
227
+ ```bash
228
+ pm2 delete <slug>-backend <slug>-frontend <slug>-backend-dev <slug>-frontend-dev
229
+ ```
230
+
231
+ `<slug>` is your project's slug — `identity.slug` in `project.json`. Check what
232
+ is actually registered with `pm2 list` first; nothing here is safe to run blind.
233
+
234
+ #### Operator step: the supervisor's patience must cover the whole shutdown
235
+
236
+ The generated `ecosystem.config.cjs` is now rendered from the declaration, and
237
+ its `kill_timeout` is computed from each role's drain floor plus the force
238
+ window plus cleanup. If you kept a hand-edited supervision file, compare its
239
+ `kill_timeout` against `drainFloorMs` in `project.json`: a timeout shorter than
240
+ the full budget means the drain never finishes, every time, and the only visible
241
+ trace is a non-zero exit code.
242
+
243
+ Regenerate rather than hand-edit:
244
+
245
+ ```bash
246
+ bun run gen:declaration
247
+ ```
248
+
249
+ #### Environment: three variables changed meaning
250
+
251
+ ```sh
252
+ # before
253
+ NEXT_PUBLIC_API_URL=https://api.example
254
+ NEXT_PUBLIC_WEB_URL=https://app.example
255
+
256
+ # after — nothing. Both are gone.
257
+ ```
258
+
259
+ Anything prefixed `NEXT_PUBLIC_` is substituted at **build** time, so declaring
260
+ one froze an address into the artifact: the built `robots.txt` and `sitemap.xml`
261
+ carried one origin in their bytes and one build could not serve a second
262
+ address. The public origin now comes from the request.
263
+
264
+ A forwarded host must be claimed before it is believed. Set **one** of:
265
+
266
+ ```sh
267
+ PUBLIC_WEB_ORIGIN=https://app.example # a single address
268
+ PUBLIC_WEB_HOSTS=app.example,www.app.example # several
269
+ ```
270
+
271
+ A host outside them is refused. Setting `PUBLIC_WEB_ORIGIN` also restores static
272
+ rendering for `/robots.txt`, `/sitemap.xml` and the `[locale]` segment, which
273
+ otherwise become request-rendered.
274
+
275
+ The check and e2e addresses were renamed so no build can substitute them:
276
+
277
+ ```sh
278
+ # before
279
+ NEXT_PUBLIC_API_URL=http://127.0.0.1:3211
280
+ # after
281
+ SMOKE_API_ORIGIN=http://127.0.0.1:3211
282
+ SMOKE_WEB_ORIGIN=http://127.0.0.1:3210
283
+ ```
284
+
285
+ `CORS_ORIGIN` is now optional: a frontend that reaches the API through its own
286
+ routing layer makes same-origin requests, and requiring an origin there was
287
+ requiring knowledge of the place.
288
+
289
+ #### The repository example: calls lose their parentheses
290
+
291
+ Only for a project generated with `--example repository`. The browser now talks
292
+ to its own origin, so the API client is a module constant:
293
+
294
+ ```ts
295
+ // before
296
+ repositoryApi().read();
297
+ repositoryUrls().read();
298
+
299
+ // after
300
+ repositoryApi.read();
301
+ repositoryUrls.read();
302
+ ```
303
+
304
+ The web role forwards `/api/…` to the API role, which needs `INTERNAL_API_URL`
305
+ (already required).
306
+
307
+ **The socket's address is now its own variable.** If you use the realtime
308
+ socket and have no routing layer forwarding `/socket.io`, rename it:
309
+
310
+ ```sh
311
+ # before — read only by the socket, despite the name
312
+ PUBLIC_API_ORIGIN=https://api.example
313
+ # after
314
+ PUBLIC_REALTIME_ORIGIN=https://api.example
315
+ ```
316
+
317
+ `PUBLIC_API_ORIGIN` still exists and still means HTTP, but it is inert until you
318
+ switch: the import in `packages/frontend/src/lib/api/queries.ts` decides which
319
+ client the browser uses. Both variables are optional; a deployment behind one
320
+ routing layer sets neither, and `CORS_ORIGIN` with them.
321
+
322
+ The cross-origin form moved to `packages/frontend/src/lib/api/cross-origin.ts`
323
+ (previously `lib/api/origin.ts`); `requirePublicApiOrigin` lives there, beside
324
+ `optionalRealtimeOrigin` and `setPublicOrigins`.
325
+
326
+ #### The SEO helpers are async
327
+
328
+ They read the public origin from the request, so they cannot be constants.
329
+ TypeScript will **not** catch a missing `await` inside an inferred object
330
+ literal — a `Promise` serialises there as `{}`.
331
+
332
+ ```ts
333
+ // before
334
+ const url = absoluteSiteUrl('/en');
335
+ createPageMetadata('home', locale);
336
+
337
+ // after — and the component becomes async
338
+ const url = await absoluteSiteUrl('/en');
339
+ await createPageMetadata('home', locale);
340
+ ```
341
+
342
+ `siteOrigin` is gone.
package/dist/cli.js CHANGED
@@ -2,9 +2,207 @@
2
2
  // @bun
3
3
 
4
4
  // src/cli.ts
5
- import { basename as basename3, resolve as resolve2 } from "path";
5
+ import { readFile as readFile2 } from "fs/promises";
6
+ import { basename as basename3, join as join2, resolve as resolve2 } from "path";
6
7
  var {spawn } = globalThis.Bun;
7
8
 
9
+ // src/identity.ts
10
+ import { basename } from "path";
11
+
12
+ // ../core/src/declaration.ts
13
+ import { z } from "zod";
14
+ var PROJECT_DECLARATION_SCHEMA_VERSION = 1;
15
+ var MACHINE_PATTERNS = [
16
+ [/:\/\//, "an absolute address"],
17
+ [/^\/\//, "a protocol-relative host"],
18
+ [/^[~]/, "a home-relative path"],
19
+ [/^[A-Za-z]:[\\/]/, "a Windows drive path"],
20
+ [/\\/, "a Windows path separator"],
21
+ [/(?:^|[\s=:])\d{1,3}(?:\.\d{1,3}){3}(?![\d.])/, "an IP address"],
22
+ [/(?:^|[\s=])[A-Za-z][\w.-]*:\d{2,5}(?![\w.])/, "a host and port"]
23
+ ];
24
+ function namesAMachine(value) {
25
+ for (const [pattern, reason] of MACHINE_PATTERNS) {
26
+ if (pattern.test(value))
27
+ return reason;
28
+ }
29
+ return;
30
+ }
31
+ function refuseMachineNames(label) {
32
+ return (schema) => schema.refine((value) => namesAMachine(value) === undefined, {
33
+ error: (issue) => `${label} names a machine \u2014 ${namesAMachine(String(issue.input)) ?? "a value of the deployment"} is supplied by the deployment, not written in the code`
34
+ });
35
+ }
36
+ var ProjectSlugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers and single hyphens (for example: talk-control)");
37
+ var ProjectDescriptionSchema = z.record(refuseMachineNames("A locale tag")(z.string().min(1)), refuseMachineNames("A description")(z.string().trim().min(1))).refine((value) => Object.keys(value).length > 0, "Describe the project in at least one locale");
38
+ var ProjectIdentitySchema = z.object({
39
+ slug: ProjectSlugSchema,
40
+ name: refuseMachineNames("A project name")(z.string().trim().min(1).max(80)),
41
+ version: z.string().regex(/^\d+\.\d+\.\d+$/, "Use a semantic version such as 0.1.0"),
42
+ description: ProjectDescriptionSchema
43
+ }).strict();
44
+ var RepositoryPathSchema = z.string().min(1).refine((value) => !value.startsWith("/"), "Use a path relative to the repository root").refine((value) => namesAMachine(value) === undefined, {
45
+ error: (issue) => `A path may not contain ${namesAMachine(String(issue.input)) ?? "a machine name"}`
46
+ }).refine((value) => !value.split("/").includes(".."), "A path may not climb out of the repository");
47
+ var BindingVariableSchema = z.string().regex(/^[A-Z][A-Z0-9_]*$/, "Name an environment variable, for example API_PORT");
48
+ var ProjectListenerSchema = z.object({
49
+ portVariable: BindingVariableSchema,
50
+ bindVariable: BindingVariableSchema,
51
+ readinessPath: refuseMachineNames("A readiness path")(z.string().startsWith("/", "Readiness is a path, for example /health"))
52
+ }).strict();
53
+ var ProjectRunModeSchema = z.enum(["development", "production"]);
54
+ var PROJECT_SCRIPT_LAUNCHERS = [
55
+ [/^(?:bun|npm|pnpm|yarn)$/, /^run$/],
56
+ [/^deno$/, /^task$/],
57
+ [/^(?:npx|bunx|pnpx)$/, null]
58
+ ];
59
+ function launchesAScript(executable, firstArgument) {
60
+ for (const [runner, verb] of PROJECT_SCRIPT_LAUNCHERS) {
61
+ if (!runner.test(executable))
62
+ continue;
63
+ if (verb === null)
64
+ return true;
65
+ if (firstArgument !== undefined && verb.test(firstArgument))
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+ var PORT_FLAG = /^(?:-p|-{1,2}(?:port|listen))$/i;
71
+ var CommandPartSchema = refuseMachineNames("A command part")(z.string().min(1)).refine((value) => !value.startsWith("/"), "A command part may not be an absolute path \u2014 paths are relative to the source").refine((value) => !/^[^=\s]+=/.test(value), "A command part may not carry an inline value \u2014 write the flag and its value as separate arguments, so the value is checked like every other one");
72
+ var CommandArgumentsSchema = z.array(CommandPartSchema).refine((args) => args.every((value, index) => !(/^\d{1,5}$/.test(value) && PORT_FLAG.test(args[index - 1] ?? ""))), "A number after a port flag is a port \u2014 name the variable that carries it and let the role read it");
73
+ var ProjectCommandSchema = z.object({
74
+ executable: CommandPartSchema,
75
+ args: CommandArgumentsSchema
76
+ }).strict();
77
+ var ProjectRoleCommandSchema = ProjectCommandSchema.refine((value) => !launchesAScript(value.executable, value.args[0]), "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").refine((value) => !value.args.includes("--filter"), "A workspace filter puts a launcher between the supervisor and the role, and the shutdown signal never reaches it");
78
+ var ProjectRoleSchema = z.object({
79
+ name: ProjectSlugSchema,
80
+ workingDirectory: RepositoryPathSchema.optional(),
81
+ commands: z.record(ProjectRunModeSchema, ProjectRoleCommandSchema),
82
+ listener: ProjectListenerSchema.optional(),
83
+ drainFloorMs: z.number().int().nonnegative()
84
+ }).strict();
85
+ var ProjectBuildInputSchema = z.object({
86
+ name: ProjectSlugSchema,
87
+ path: RepositoryPathSchema,
88
+ digest: z.string().regex(/^sha256:[0-9a-f]{64}$/, 'Use a lowercase sha256 digest, as "sha256:<64 hex>"')
89
+ }).strict();
90
+ var ProjectBuildSchema = z.object({
91
+ command: ProjectCommandSchema,
92
+ artifacts: z.array(RepositoryPathSchema).min(1),
93
+ inputs: z.array(ProjectBuildInputSchema).optional()
94
+ }).strict().refine((build) => {
95
+ const names = (build.inputs ?? []).map((input) => input.name);
96
+ return new Set(names).size === names.length;
97
+ }, "Two build inputs share a name \u2014 a failure could then name either of them");
98
+ var ProjectRequirementPhaseSchema = z.enum(["release", "start"]);
99
+ var ProjectRequirementSchema = z.object({
100
+ name: ProjectSlugSchema,
101
+ phases: z.array(ProjectRequirementPhaseSchema).min(1)
102
+ }).strict();
103
+ var ProjectMigrationsSchema = z.object({
104
+ engine: refuseMachineNames("A migration engine name")(z.string().min(1).max(64)),
105
+ root: RepositoryPathSchema,
106
+ lockfile: RepositoryPathSchema
107
+ }).strict();
108
+ var ProjectReleaseSchema = z.object({ migrations: ProjectMigrationsSchema.optional() }).strict();
109
+ var ProjectEnvShapeSchema = z.enum(["string", "integer", "boolean", "url", "enum"]);
110
+ var ProjectEnvVariableSchema = z.object({
111
+ name: BindingVariableSchema,
112
+ shape: ProjectEnvShapeSchema,
113
+ required: z.boolean(),
114
+ members: z.array(refuseMachineNames("An enum member")(z.string().min(1))).min(1).optional()
115
+ }).strict().refine((value) => value.shape === "enum" === (value.members !== undefined), "An enum variable lists its members; every other shape has none");
116
+ var ProjectDeclarationSchema = z.object({
117
+ schemaVersion: z.literal(PROJECT_DECLARATION_SCHEMA_VERSION),
118
+ kind: z.enum(["library", "application"]),
119
+ identity: ProjectIdentitySchema,
120
+ roles: z.array(ProjectRoleSchema),
121
+ build: ProjectBuildSchema.optional(),
122
+ requires: z.array(ProjectRequirementSchema),
123
+ release: ProjectReleaseSchema,
124
+ env: z.object({ variables: z.array(ProjectEnvVariableSchema) }).strict()
125
+ }).strict().refine((value) => value.kind === "application" ? value.roles.length > 0 : value.roles.length === 0, "An application declares at least one role; a library declares none").refine((value) => new Set(value.roles.map((role) => role.name)).size === value.roles.length, "Role names must be unique").refine((value) => new Set(value.requires.map((entry) => entry.name)).size === value.requires.length, "Name each requirement once and list its phases").refine((value) => value.requires.every((entry) => new Set(entry.phases).size === entry.phases.length), "List each phase of a requirement once").refine((value) => new Set(value.env.variables.map((entry) => entry.name)).size === value.env.variables.length, "Declare each environment variable once").refine((value) => listenerBindingProblem(value) === undefined, {
126
+ error: (issue) => listenerBindingProblem(issue.input) ?? "Listener bindings are inconsistent"
127
+ });
128
+ function isListenerBindingSubject(value) {
129
+ return typeof value === "object" && value !== null && "roles" in value && "env" in value;
130
+ }
131
+ function listenerBindingProblem(value) {
132
+ if (!isListenerBindingSubject(value))
133
+ return;
134
+ const shapes = new Map(value.env.variables.map((entry) => [entry.name, entry.shape]));
135
+ for (const role of value.roles) {
136
+ const listener = role.listener;
137
+ if (!listener)
138
+ continue;
139
+ if (listener.portVariable === listener.bindVariable) {
140
+ return `Role "${role.name}" points its port and its bind address at the same variable "${listener.portVariable}"`;
141
+ }
142
+ const expected = [
143
+ [listener.portVariable, "integer"],
144
+ [listener.bindVariable, "string"]
145
+ ];
146
+ for (const [name, shape] of expected) {
147
+ const declared = shapes.get(name);
148
+ if (declared === undefined) {
149
+ return `Role "${role.name}" listens on "${name}", which env.variables does not declare \u2014 a deployment reading this cannot know it has to supply it`;
150
+ }
151
+ if (declared !== shape) {
152
+ return `Role "${role.name}" listens on "${name}", declared as "${declared}" where a ${shape} is needed`;
153
+ }
154
+ }
155
+ }
156
+ return;
157
+ }
158
+ var VersionProbeSchema = z.object({ schemaVersion: z.unknown() }).loose();
159
+ function parseProjectDeclaration(source) {
160
+ const probe = VersionProbeSchema.safeParse(source);
161
+ const declared = probe.success ? probe.data.schemaVersion : undefined;
162
+ if (declared !== undefined && declared !== PROJECT_DECLARATION_SCHEMA_VERSION) {
163
+ throw new Error(`Project declaration schema version ${JSON.stringify(declared)} is not supported \u2014 ` + `this build understands version ${PROJECT_DECLARATION_SCHEMA_VERSION}.`);
164
+ }
165
+ return ProjectDeclarationSchema.parse(source);
166
+ }
167
+
168
+ // src/identity.ts
169
+ var APP_IDENTITY_PATH = "packages/config/src/app-identity.generated.ts";
170
+ function renderAppIdentityModule(identity) {
171
+ return `// GENERATED FILE \u2014 do not edit.
172
+ //
173
+ // Rendered from \`project.json\` by \`scripts/declaration.ts\`.
174
+ //
175
+ // Identity ONLY, inlined rather than imported, because this is the part of the
176
+ // declaration a browser may know. Importing the whole declaration from a client
177
+ // component would put role commands, working directories, build artifact paths,
178
+ // the migration lockfile and every environment variable name into the browser
179
+ // bundle \u2014 the same mistake as publishing internal topology from a status
180
+ // endpoint, made from the other side.
181
+
182
+ export const appIdentity = ${JSON.stringify(identity, undefined, 2)};
183
+ `;
184
+ }
185
+ function displayNameFromSlug(slug) {
186
+ return slug.split("-").map((part) => `${part[0]?.toUpperCase()}${part.slice(1)}`).join(" ");
187
+ }
188
+ function createApplicationIdentity(destination, displayName) {
189
+ const slug = ProjectSlugSchema.parse(basename(destination));
190
+ const name = displayName?.trim() || displayNameFromSlug(slug);
191
+ return ProjectIdentitySchema.parse({
192
+ slug,
193
+ name,
194
+ version: "0.1.0",
195
+ description: {
196
+ en: `${name} is a production application built with Stitchkit.`,
197
+ ru: `${name} \u2014 production-\u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u0435 \u043D\u0430 Stitchkit.`
198
+ }
199
+ });
200
+ }
201
+ function withIdentity(declaration, identity) {
202
+ const copied = parseProjectDeclaration(declaration);
203
+ return parseProjectDeclaration({ ...copied, identity });
204
+ }
205
+
8
206
  // src/options.ts
9
207
  var HELP = `Create a production-shaped Stitchkit application.
10
208
 
@@ -62,38 +260,6 @@ import { lstat, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
62
260
  import { homedir } from "os";
63
261
  import { basename as basename2, dirname, extname, join, parse, relative, resolve, sep } from "path";
64
262
  import { z as z2 } from "zod";
65
-
66
- // src/identity.ts
67
- import { basename } from "path";
68
- import { z } from "zod";
69
- var ApplicationSlugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers and single hyphens (for example: talk-control)");
70
- var ApplicationIdentitySchema = z.object({
71
- slug: ApplicationSlugSchema,
72
- name: z.string().trim().min(1).max(80),
73
- version: z.string().regex(/^\d+\.\d+\.\d+$/, "Use a semantic version such as 0.1.0"),
74
- description: z.object({
75
- en: z.string().trim().min(1),
76
- ru: z.string().trim().min(1)
77
- })
78
- });
79
- function displayNameFromSlug(slug) {
80
- return slug.split("-").map((part) => `${part[0]?.toUpperCase()}${part.slice(1)}`).join(" ");
81
- }
82
- function createApplicationIdentity(destination, displayName) {
83
- const slug = ApplicationSlugSchema.parse(basename(destination));
84
- const name = displayName?.trim() || displayNameFromSlug(slug);
85
- return ApplicationIdentitySchema.parse({
86
- slug,
87
- name,
88
- version: "0.1.0",
89
- description: {
90
- en: `${name} is a production application built with Stitchkit.`,
91
- ru: `${name} \u2014 production-\u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u0435 \u043D\u0430 Stitchkit.`
92
- }
93
- });
94
- }
95
-
96
- // src/scaffold.ts
97
263
  var TEXT_EXTENSIONS = new Set([
98
264
  ".cjs",
99
265
  ".css",
@@ -126,6 +292,8 @@ var IGNORED_DIRECTORIES = new Set([
126
292
  "playwright-report",
127
293
  "test-results"
128
294
  ]);
295
+ var IGNORED_FILE_NAMES = new Set([".env", ".build-stamp.json", "next-env.d.ts"]);
296
+ var IGNORED_FILE_SUFFIXES = [".log", ".tsbuildinfo"];
129
297
  function isTemplateSourcePathIncluded(sourcePath) {
130
298
  const normalized = sourcePath.replaceAll("\\", "/").replace(/^\.\//, "");
131
299
  if (!normalized)
@@ -136,7 +304,9 @@ function isTemplateSourcePathIncluded(sourcePath) {
136
304
  if (normalized === "packages/db/src/generated" || normalized.startsWith("packages/db/src/generated/"))
137
305
  return false;
138
306
  const name = basename2(normalized);
139
- 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));
140
310
  }
141
311
  function shouldIncludeTemplatePath(templateDirectory, sourcePath) {
142
312
  return isTemplateSourcePathIncluded(relative(templateDirectory, sourcePath));
@@ -231,8 +401,13 @@ async function scaffoldProject(templateDirectory, destination, options = {}) {
231
401
  if (options.overlayDirectory) {
232
402
  await writeMaterialisedFiles(resolvedDestination, await materialiseTemplateFiles(options.overlayDirectory));
233
403
  }
234
- await writeFile(join(resolvedDestination, "app.config.json"), `${JSON.stringify(identity, undefined, 2)}
404
+ const declarationPath = join(resolvedDestination, "project.json");
405
+ const declaration = withIdentity(JSON.parse(await readFile(declarationPath, "utf8")), identity);
406
+ await writeFile(declarationPath, `${JSON.stringify(declaration, undefined, 2)}
235
407
  `);
408
+ const identityPath = join(resolvedDestination, APP_IDENTITY_PATH);
409
+ await mkdir(dirname(identityPath), { recursive: true });
410
+ await writeFile(identityPath, renderAppIdentityModule(declaration.identity));
236
411
  const manifestPath = join(resolvedDestination, "package.json");
237
412
  const manifest = RootManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8")));
238
413
  await writeFile(manifestPath, `${JSON.stringify({ ...manifest, name: identity.slug }, undefined, 2)}
@@ -284,14 +459,10 @@ Created ${options.displayName ?? basename3(destination)}${mode}
284
459
  process.stdout.write(` bun run dev
285
460
 
286
461
  `);
287
- process.stdout.write(`Web: http://localhost:3210
288
- `);
289
- process.stdout.write(`API: http://localhost:3211
290
- `);
291
- process.stdout.write(`MCP: http://localhost:3211/mcp
292
- `);
293
- process.stdout.write(`OpenAPI: http://localhost:3211/openapi.json
462
+ for (const line of await roleAddresses(destination)) {
463
+ process.stdout.write(`${line}
294
464
  `);
465
+ }
295
466
  return 0;
296
467
  } catch (error) {
297
468
  const message = error instanceof Error ? error.message : String(error);
@@ -303,6 +474,31 @@ Created ${options.displayName ?? basename3(destination)}${mode}
303
474
  if (import.meta.main) {
304
475
  process.exitCode = await run(Bun.argv.slice(2));
305
476
  }
477
+ async function roleAddresses(destination) {
478
+ try {
479
+ const declaration = parseProjectDeclaration(JSON.parse(await readFile2(join2(destination, "project.json"), "utf8")));
480
+ const environment = readEnvironmentExample(await readFile2(join2(destination, ".env.example"), "utf8"));
481
+ const host = environment.BIND_HOST ?? "127.0.0.1";
482
+ return declaration.roles.flatMap((role) => {
483
+ const port = role.listener && environment[role.listener.portVariable];
484
+ if (!role.listener || !port)
485
+ return [];
486
+ return [`${role.name}: http://${host}:${port}${role.listener.readinessPath}`];
487
+ });
488
+ } catch {
489
+ return [];
490
+ }
491
+ }
492
+ function readEnvironmentExample(source) {
493
+ const values = {};
494
+ for (const line of source.split(`
495
+ `)) {
496
+ const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line.trim());
497
+ if (match?.[1])
498
+ values[match[1]] = match[2] ?? "";
499
+ }
500
+ return values;
501
+ }
306
502
  export {
307
503
  run
308
504
  };