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
@@ -0,0 +1,271 @@
1
+ import { resolve } from 'node:path';
2
+ import type {
3
+ ProjectDeclaration,
4
+ ProjectEnvVariable,
5
+ ProjectRole,
6
+ } from 'stitchkit/declaration';
7
+ import { z } from 'zod';
8
+ import { appDeclaration } from '../packages/config/src/declaration';
9
+ import * as shutdownBudgets from '../packages/config/src/shutdown';
10
+ import { applicationVariables } from '../packages/config/src/variables';
11
+
12
+ /**
13
+ * Everything derived from the project declaration.
14
+ *
15
+ * Two things used to be written by hand and had already drifted: the list of
16
+ * environment variables (three overlapping copies) and the two PM2 files (nine
17
+ * diverging lines, one of which killed the backend mid-drain every time). Both
18
+ * are now DERIVED — from `variables.ts` and from `project.json` — and the gate
19
+ * refuses a checked-in file that does not match what this module renders.
20
+ *
21
+ * Note what stays on which side. Roles, commands, readiness and the drain floor
22
+ * come from the declaration, because they are true of the code. Restart policy
23
+ * and the kill timeout are the place's, and for the manual path this file IS
24
+ * the place — so the policy is one visible constant below, and the rule that
25
+ * binds it to the code is checked rather than trusted.
26
+ */
27
+ const root = resolve(import.meta.dir, '..');
28
+
29
+ interface SupervisionPolicy {
30
+ restart: boolean;
31
+ /** Must cover every role's FULL termination budget — `assertSupervisionAllowsShutdown`. */
32
+ killTimeoutMs: number;
33
+ }
34
+
35
+ /**
36
+ * Local supervision policy: the place's side of the manual path.
37
+ *
38
+ * This is placement policy living in a repository, and it is here because the
39
+ * manual path has nowhere else to put it. It is not evidence that the
40
+ * declaration is placement-free — the declaration is the file next to it.
41
+ */
42
+ export const LOCAL_SUPERVISION: SupervisionPolicy = {
43
+ restart: true,
44
+ killTimeoutMs: 30_000,
45
+ };
46
+
47
+ /**
48
+ * What a role spends after its drain floor before the process can exit.
49
+ *
50
+ * The server forces for `forceTimeoutMs` (5s by default) once the grace period
51
+ * ends, and `onComplete` then closes MCP and the database. A supervisor sized to
52
+ * the drain floor alone kills the role in the middle of that tail — which is why
53
+ * the earlier check, comparing against the floor only, reported that supervision
54
+ * "allows the full shutdown" while 15s + 5s met a 20s kill timeout exactly.
55
+ */
56
+ // Imported, not restated: the role enforces these, and a number in two places
57
+ // is two numbers that can disagree — which is exactly how a 15s drain met a
58
+ // 20s kill timeout.
59
+ const { FORCE_BUDGET_MS, CLEANUP_BUDGET_MS } = shutdownBudgets;
60
+
61
+ /** The shortest time a supervisor may allow this role and still see it finish. */
62
+ export function terminationBudgetMs(role: ProjectRole): number {
63
+ return role.drainFloorMs + FORCE_BUDGET_MS + CLEANUP_BUDGET_MS;
64
+ }
65
+
66
+ /**
67
+ * JSON Schema types this projection can carry into the declaration.
68
+ *
69
+ * `number` used to be mapped to `integer`, which quietly told a deployment that
70
+ * a fractional value was an integer — the declaration and the Zod contract it
71
+ * is derived FROM would then disagree, which is the one failure the derivation
72
+ * exists to prevent. A type with no faithful shape is refused instead: the
73
+ * declaration format gains the shape, or the project stops declaring that type.
74
+ */
75
+ const SHAPE_BY_JSON_TYPE: Record<string, ProjectEnvVariable['shape']> = {
76
+ integer: 'integer',
77
+ boolean: 'boolean',
78
+ };
79
+
80
+ const JsonSchemaSchema = z.object({
81
+ properties: z.record(
82
+ z.string(),
83
+ z.object({
84
+ type: z.string().optional(),
85
+ format: z.string().optional(),
86
+ enum: z.array(z.unknown()).optional(),
87
+ }),
88
+ ),
89
+ required: z.array(z.string()).optional(),
90
+ });
91
+
92
+ /**
93
+ * The variables a deployment must supply, derived from the one Zod declaration.
94
+ *
95
+ * `required` follows the schema exactly: a variable with a default or an
96
+ * `.optional()` is not required, and nothing here restates that judgement. An
97
+ * enum carries its members, because "one of an unnamed set" tells a reader
98
+ * without a TypeScript runtime nothing — and that reader is the whole point.
99
+ */
100
+ export function renderEnvVariables(
101
+ variables: Record<string, z.ZodType> = applicationVariables,
102
+ ): ProjectEnvVariable[] {
103
+ const json = JsonSchemaSchema.parse(
104
+ z.toJSONSchema(z.object(variables), { io: 'input', unrepresentable: 'any' }),
105
+ );
106
+ const required = new Set(json.required ?? []);
107
+ return Object.entries(json.properties)
108
+ .map(([name, property]) => describeVariable(name, property, required.has(name)))
109
+ .sort((left, right) => left.name.localeCompare(right.name));
110
+ }
111
+
112
+ function describeVariable(
113
+ name: string,
114
+ property: { type?: string; format?: string; enum?: unknown[] },
115
+ required: boolean,
116
+ ): ProjectEnvVariable {
117
+ if (property.enum) {
118
+ // Members are refused rather than stringified. `String(member)` turned
119
+ // numbers and booleans into text that no longer matched the value the Zod
120
+ // schema accepts, so a deployment reading the declaration would supply
121
+ // something the application then rejects — the declaration would be derived
122
+ // and still wrong.
123
+ const members = property.enum.filter((member) => typeof member === 'string');
124
+ if (members.length !== property.enum.length) {
125
+ throw new Error(
126
+ `Environment variable ${name} declares a non-string enum member. The declaration format carries enum members as strings; declare it as a string enum, or extend the format first.`,
127
+ );
128
+ }
129
+ return { name, shape: 'enum', required, members };
130
+ }
131
+ if (property.format === 'uri') return { name, shape: 'url', required };
132
+ if (property.type === undefined || property.type === 'string') {
133
+ return { name, shape: 'string', required };
134
+ }
135
+ const shape = SHAPE_BY_JSON_TYPE[property.type];
136
+ if (!shape) {
137
+ // Fail closed rather than describe the variable with the wrong shape: a
138
+ // reader acting on `string` when the value is something else is worse off
139
+ // than a reader told this project cannot describe it.
140
+ throw new Error(
141
+ `Cannot describe ${name}: no declaration shape for JSON Schema type "${property.type}".`,
142
+ );
143
+ }
144
+ return { name, shape, required };
145
+ }
146
+
147
+ /** The declaration as it must appear on disk: authored fields plus derived ones. */
148
+ export function renderProjectJson(): string {
149
+ const declaration: ProjectDeclaration = {
150
+ ...appDeclaration,
151
+ env: { variables: renderEnvVariables() },
152
+ };
153
+ return `${JSON.stringify(declaration, undefined, 2)}\n`;
154
+ }
155
+
156
+ /**
157
+ * The rule the drain floor exists for: a supervisor must allow at least as long
158
+ * as the role needs to finish — the drain, the force that follows it, and the
159
+ * cleanup after that. Shorter, and the process is killed mid-shutdown, which is
160
+ * what a 15s kill timeout against a 30s floor did here, every time, for as long
161
+ * as the two numbers lived in two hand-written files.
162
+ */
163
+ export function assertSupervisionAllowsShutdown(
164
+ declaration: ProjectDeclaration,
165
+ killTimeoutMs: number,
166
+ ): void {
167
+ for (const role of declaration.roles) {
168
+ const budget = terminationBudgetMs(role);
169
+ if (budget > killTimeoutMs) {
170
+ throw new Error(
171
+ `Role "${role.name}" needs up to ${budget}ms to finish shutting down (${role.drainFloorMs}ms drain + ${FORCE_BUDGET_MS}ms force + ${CLEANUP_BUDGET_MS}ms cleanup) but supervision allows ${killTimeoutMs}ms — it would be killed mid-shutdown.`,
172
+ );
173
+ }
174
+ }
175
+ }
176
+
177
+ const BANNER = `// GENERATED FILE — do not edit.
178
+ //
179
+ // Rendered from \`project.json\` by \`scripts/declaration.ts\`; run
180
+ // \`bun run gen:declaration\` after changing a role. Roles, commands and the
181
+ // drain floor come from the declaration because they are true of the code;
182
+ // restart policy and the kill timeout are this machine's, and the generator
183
+ // refuses a timeout shorter than any role's full shutdown budget.
184
+ `;
185
+
186
+ /** One PM2 file per run mode, rendered from the roles. */
187
+ export function renderEcosystem(
188
+ declaration: ProjectDeclaration,
189
+ mode: 'development' | 'production',
190
+ ): string {
191
+ assertSupervisionAllowsShutdown(declaration, LOCAL_SUPERVISION.killTimeoutMs);
192
+ const suffix = mode === 'development' ? '-dev' : '';
193
+ const apps = declaration.roles.map((role) => renderApp(role, mode, suffix)).join('\n');
194
+ return `${BANNER}const path = require('node:path');
195
+ const { config } = require('dotenv');
196
+ const declaration = require('./project.json');
197
+
198
+ // NOT \`override\`: an environment a deployment injected into this process must
199
+ // win over a file in the repository. The file fills gaps; it does not overrule
200
+ // the place.
201
+ config({ path: path.join(__dirname, '.env'), quiet: true });
202
+
203
+ module.exports = {
204
+ apps: [
205
+ ${apps}
206
+ ],
207
+ };
208
+ `;
209
+ }
210
+
211
+ function renderApp(
212
+ role: ProjectRole,
213
+ mode: 'development' | 'production',
214
+ suffix: string,
215
+ ): string {
216
+ const command = role.commands[mode];
217
+ if (!command) throw new Error(`Role "${role.name}" declares no ${mode} command.`);
218
+ const binding = role.listener ? `\`${role.listener.portVariable}\`` : 'its variables';
219
+ return ` {
220
+ name: \`\${declaration.identity.slug}-${role.name}${suffix}\`,
221
+ // The role's OWN process, in its OWN directory — no launcher in between.
222
+ // Measured: a launcher makes the role see the stop signal twice (once from
223
+ // the supervisor, once forwarded), the second press forces the shutdown,
224
+ // and a declared drain of seconds collapses to milliseconds. A workspace
225
+ // filter is worse: the signal never arrives at all.
226
+ cwd: path.join(__dirname, ${JSON.stringify(role.workingDirectory ?? '.')}),
227
+ script: ${JSON.stringify(command.executable)},
228
+ // No argv invented here: the deployment injects ${binding} and the command
229
+ // reads it. Serialised rather than concatenated — an argument with a space
230
+ // or a quote has to survive this file intact.
231
+ args: ${JSON.stringify(command.args)},
232
+ interpreter: 'none',
233
+ autorestart: ${LOCAL_SUPERVISION.restart},
234
+ // >= this role's full shutdown budget of ${terminationBudgetMs(role)}ms.
235
+ kill_timeout: ${LOCAL_SUPERVISION.killTimeoutMs},
236
+ env: { NODE_ENV: '${mode}' },
237
+ },`;
238
+ }
239
+
240
+ const IDENTITY_BANNER = `// GENERATED FILE — do not edit.
241
+ //
242
+ // Rendered from \`project.json\` by \`scripts/declaration.ts\`.
243
+ //
244
+ // Identity ONLY, inlined rather than imported, because this is the part of the
245
+ // declaration a browser may know. Importing the whole declaration from a client
246
+ // component would put role commands, working directories, build artifact paths,
247
+ // the migration lockfile and every environment variable name into the browser
248
+ // bundle — the same mistake as publishing internal topology from a status
249
+ // endpoint, made from the other side.
250
+ `;
251
+
252
+ /** Identity alone, safe for the client graph. */
253
+ export function renderAppIdentity(): string {
254
+ return `${IDENTITY_BANNER}
255
+ export const appIdentity = ${JSON.stringify(appDeclaration.identity, undefined, 2)};
256
+ `;
257
+ }
258
+
259
+ export const GENERATED_FILES = {
260
+ 'project.json': renderProjectJson,
261
+ 'packages/config/src/app-identity.generated.ts': renderAppIdentity,
262
+ 'ecosystem.config.cjs': () => renderEcosystem(appDeclaration, 'production'),
263
+ 'ecosystem.dev.config.cjs': () => renderEcosystem(appDeclaration, 'development'),
264
+ };
265
+
266
+ if (import.meta.main) {
267
+ for (const [name, render] of Object.entries(GENERATED_FILES)) {
268
+ await Bun.write(resolve(root, name), render());
269
+ console.log(`Wrote ${name}`);
270
+ }
271
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The deployment a runtime smoke dials has to be there.
3
+ *
4
+ * `runtime:smoke` checks a RUNNING deployment — that is what makes it a runtime
5
+ * smoke rather than a build check. Without this the first `fetch` inside some
6
+ * assertion fails with a bare `ECONNRESET`, which reads like a broken check
7
+ * instead of an absent deployment and says nothing about what to do next. The
8
+ * packed lane never saw it because the lane starts the roles itself; everyone
9
+ * following the README's gate list saw it first.
10
+ */
11
+ export async function assertDeploymentIsAnswering(
12
+ origins: Readonly<Record<string, string>>,
13
+ ): Promise<void> {
14
+ const closed: string[] = [];
15
+ for (const [role, origin] of Object.entries(origins)) {
16
+ if (!(await answers(origin))) closed.push(`${role} (${origin})`);
17
+ }
18
+ if (closed.length === 0) return;
19
+ throw new Error(
20
+ [
21
+ `Nothing is listening for ${closed.join(' and ')}.`,
22
+ '`runtime:smoke` checks a deployment that is already running, and the one it is about is',
23
+ 'the artifact `bun run build` produced: start it with `bun run pm2:prod`, then rerun.',
24
+ '(`bun run dev` serves it too, from a development build.)',
25
+ 'If the deployment is somewhere else, point SMOKE_API_ORIGIN and SMOKE_WEB_ORIGIN at it.',
26
+ ].join(' '),
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Answering, not healthy: the checks that follow are what judge health. A role
32
+ * that returns 404 for `/` has still proved the thing this asks about.
33
+ */
34
+ async function answers(origin: string): Promise<boolean> {
35
+ try {
36
+ await fetch(new URL(origin), { method: 'HEAD', signal: AbortSignal.timeout(10_000) });
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
@@ -1,7 +1,9 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { z } from 'zod';
3
- import { appIdentity } from '../packages/config/src/identity';
3
+ import { appDeclaration } from '../packages/config/src/declaration';
4
4
  import { ensureLocalEnvironment } from './local-env';
5
+ import { awaitRolesAnswering, declaredRoleReadiness } from './readiness';
6
+ import { runDeclaredReleaseSteps } from './release-steps';
5
7
  import { inheritToolingEnvironment } from './tooling-env';
6
8
 
7
9
  const root = resolve(import.meta.dir, '..');
@@ -28,7 +30,11 @@ export async function runDevelopment(environment?: Record<string, string>): Prom
28
30
  );
29
31
  }
30
32
  await assertPortsAvailable(environmentForRun);
31
- await run(['bun', 'run', 'db:setup'], environmentForRun);
33
+ // The generated client is a BUILD artifact; applying migrations is a RELEASE
34
+ // step the declaration owns. Development runs the same release step as
35
+ // production, so the two paths cannot drift on what 'up to date' means.
36
+ await run(['bun', 'run', 'db:generate'], environmentForRun);
37
+ await runDeclaredReleaseSteps(environmentForRun);
32
38
  await run(
33
39
  ['pm2', 'startOrReload', 'ecosystem.dev.config.cjs', '--update-env'],
34
40
  environmentForRun,
@@ -42,10 +48,17 @@ export async function runDevelopment(environment?: Record<string, string>): Prom
42
48
  */
43
49
  async function assertPortsAvailable(environment: Record<string, string>): Promise<void> {
44
50
  const registered = await registeredPm2Names();
45
- const managed = [`${appIdentity.slug}-backend-dev`, `${appIdentity.slug}-frontend-dev`];
51
+ const managed = appDeclaration.roles.map(
52
+ (role) => `${appDeclaration.identity.slug}-${role.name}-dev`,
53
+ );
46
54
  if (managed.some((name) => registered.has(name))) return;
47
- assertPortFree(Number(environment.API_PORT), 'API_PORT');
48
- assertPortFree(Number(environment.WEB_PORT), 'WEB_PORT');
55
+ // Which ports to probe comes from the declaration, not from a second list
56
+ // of variable names here: a new role is covered by declaring it.
57
+ for (const role of appDeclaration.roles) {
58
+ if (!role.listener) continue;
59
+ const variable = role.listener.portVariable;
60
+ assertPortFree(Number(environment[variable]), variable);
61
+ }
49
62
  }
50
63
 
51
64
  async function registeredPm2Names(): Promise<Set<string>> {
@@ -74,7 +87,7 @@ function assertPortFree(port: number, variable: string): void {
74
87
  listener.stop(true);
75
88
  } catch {
76
89
  throw new Error(
77
- `Port ${port} (${variable}) is already in use by another process. Pick a free port in .env and update the URLs that embed it.`,
90
+ `Port ${port} (${variable}) is already in use by another process. Pick a free port in .env and update the SMOKE_* origins that embed it.`,
78
91
  );
79
92
  }
80
93
  }
@@ -83,28 +96,38 @@ function assertToolAvailable(command: string, instruction: string): void {
83
96
  if (!Bun.which(command)) throw new Error(`${command} is required. ${instruction}`);
84
97
  }
85
98
 
99
+ /**
100
+ * The validated environment the development processes run with.
101
+ *
102
+ * Every variable the DECLARATION names, and no hand-written list beside it: a
103
+ * list here went stale the moment a variable was added, and a role declaring a
104
+ * port whose name was missing got `Number(undefined)` — reported as
105
+ * `Port NaN (WORKER_PORT) is already in use`, which is a false diagnosis of a
106
+ * real mistake.
107
+ */
86
108
  export async function developmentEnvironment(
87
109
  overrides: Record<string, string> = {},
88
110
  ): Promise<Record<string, string>> {
89
111
  const { env } = await import('../packages/config/src/server');
90
- return {
91
- DATABASE_URL: env.DATABASE_URL,
92
- BIND_HOST: env.BIND_HOST,
93
- API_PORT: String(env.API_PORT),
94
- WEB_PORT: String(env.WEB_PORT),
95
- CORS_ORIGIN: env.CORS_ORIGIN,
96
- NEXT_PUBLIC_API_URL: env.NEXT_PUBLIC_API_URL,
97
- NEXT_PUBLIC_WEB_URL: env.NEXT_PUBLIC_WEB_URL,
98
- INTERNAL_API_URL: env.INTERNAL_API_URL,
99
- ...overrides,
100
- };
112
+ const validated: Record<string, unknown> = env;
113
+ const declared: Record<string, string> = {};
114
+ for (const variable of appDeclaration.env.variables) {
115
+ const value = validated[variable.name];
116
+ if (value !== undefined) declared[variable.name] = String(value);
117
+ }
118
+ return { ...declared, ...overrides };
101
119
  }
102
120
 
103
121
  if (import.meta.main) {
104
122
  await runDevelopment();
105
123
 
106
124
  const environment = await developmentEnvironment();
107
- console.log(`${appIdentity.name} development processes are running`);
108
- console.log(`Web: ${environment.NEXT_PUBLIC_WEB_URL}/en`);
109
- console.log(`API: ${environment.NEXT_PUBLIC_API_URL}`);
125
+ const roles = declaredRoleReadiness(appDeclaration, environment);
126
+ // Reported only once it is TRUE. The supervisor returns at the spawn, and a
127
+ // development build needs seconds after that before it listens — so the line
128
+ // below used to be printed at a moment when nothing answered, and the next
129
+ // command in the gate list got a connection reset.
130
+ await awaitRolesAnswering(roles);
131
+ console.log(`${appDeclaration.identity.name} development processes are running`);
132
+ for (const role of roles) console.log(`${role.name}: ${role.url}`);
110
133
  }
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
2
2
  import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { appIdentity } from '../packages/config/src/identity';
5
+ import { appDeclaration } from '../packages/config/src/declaration';
6
6
  import { ensureLocalEnvironment } from './local-env';
7
7
 
8
8
  describe('ensureLocalEnvironment', () => {
@@ -19,7 +19,7 @@ describe('ensureLocalEnvironment', () => {
19
19
  // substitution is proven end-to-end by the starter lane on a renamed
20
20
  // scaffold; here we prove the file is created from the example with the
21
21
  // identity-derived database name in place.
22
- const databaseName = appIdentity.slug.replaceAll('-', '_');
22
+ const databaseName = appDeclaration.identity.slug.replaceAll('-', '_');
23
23
  expect(created).toContain(`5432/${databaseName}`);
24
24
 
25
25
  // Idempotency — a developer's local credentials survive every dev run.
@@ -1,12 +1,18 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
- import { appIdentity } from '../packages/config/src/identity';
3
+ import { appIdentity } from '../packages/config/src/app-identity.generated';
4
4
 
5
5
  /**
6
6
  * Create `.env` from `.env.example` on first run, rendering the application
7
- * identity into the database name. `.env.example` is the ONLY environment
7
+ * identity into the database name.
8
+ *
9
+ * Identity, not the whole declaration: this needs one slug, and the identity
10
+ * module carries no dependencies. That matters here more than elsewhere —
11
+ * a project scaffolded with `--no-install` renders its `.env` before anything
12
+ * is installed, and a script that reaches for the framework's schema to read a
13
+ * name cannot run in that window. `.env.example` is the ONLY environment
8
14
  * source the repository ships — the scaffolder never writes `.env`, so a
9
- * rename in `app.config.json` changes the database of the next created
15
+ * rename in `project.json` changes the database of the next created
10
16
  * environment too. Synchronous on purpose: `playwright.config.ts` and other
11
17
  * synchronous entry points must be able to self-heal before validating.
12
18
  */
@@ -0,0 +1,92 @@
1
+ import type { ProjectDeclaration } from 'stitchkit/declaration';
2
+
3
+ /** Where a declared role answers once it is ready. */
4
+ export interface RoleReadiness {
5
+ name: string;
6
+ url: string;
7
+ }
8
+
9
+ /**
10
+ * The readiness address of every role that listens — from the DECLARATION.
11
+ *
12
+ * Which variable holds the port and which holds the bind address are the
13
+ * role's own statement, so a new role is covered by declaring it rather than by
14
+ * a second list here that would go stale.
15
+ */
16
+ export function declaredRoleReadiness(
17
+ declaration: ProjectDeclaration,
18
+ environment: Record<string, string | undefined>,
19
+ ): RoleReadiness[] {
20
+ return declaration.roles.flatMap((role) => {
21
+ const listener = role.listener;
22
+ if (!listener) return [];
23
+ const port = environment[listener.portVariable];
24
+ // Not skipped: a role that declares a listener and has no port is a
25
+ // deployment that cannot have started it, and quietly waiting for nothing
26
+ // is the failure this module exists to remove.
27
+ if (!port) {
28
+ throw new Error(
29
+ `Role "${role.name}" declares a listener on ${listener.portVariable}, and this environment does not set it.`,
30
+ );
31
+ }
32
+ // `0.0.0.0` is what a role BINDS, never an address to dial: it means every
33
+ // interface, and loopback is the one this machine can always reach.
34
+ const bind = environment[listener.bindVariable];
35
+ const host = !bind || bind === '0.0.0.0' || bind === '::' ? '127.0.0.1' : bind;
36
+ return [
37
+ { name: role.name, url: `http://${authority(host, port)}${listener.readinessPath}` },
38
+ ];
39
+ });
40
+ }
41
+
42
+ /**
43
+ * An IPv6 literal is bracketed; everything else is written as it stands.
44
+ *
45
+ * `http://::1:3211/health` is not an address with a port — it is not a URL at
46
+ * all, and `fetch` refuses it. A role bound to a specific IPv6 address is a
47
+ * legitimate deployment, and it used to make the readiness wait fail on the
48
+ * spelling rather than on the role.
49
+ */
50
+ function authority(host: string, port: string): string {
51
+ const literal = host.includes(':') && !host.startsWith('[');
52
+ return `${literal ? `[${host}]` : host}:${port}`;
53
+ }
54
+
55
+ /**
56
+ * Wait until every role answers — because starting is not running.
57
+ *
58
+ * A supervisor returns as soon as it has SPAWNED a process, and a role needs
59
+ * seconds after that before it listens. Printing "running" at the moment of the
60
+ * spawn is a claim nobody checked: the next command in the gate list dialled
61
+ * the declared port and got a connection reset, which reads as a broken check
62
+ * rather than an application still booting.
63
+ */
64
+ export async function awaitRolesAnswering(
65
+ roles: readonly RoleReadiness[],
66
+ { timeoutMs = 120_000 }: { timeoutMs?: number } = {},
67
+ ): Promise<void> {
68
+ const deadline = Date.now() + timeoutMs;
69
+ const pending = [...roles];
70
+ while (pending.length > 0) {
71
+ const role = pending[0];
72
+ if (!role) break;
73
+ if (await answers(role.url)) {
74
+ pending.shift();
75
+ continue;
76
+ }
77
+ if (Date.now() >= deadline) {
78
+ throw new Error(
79
+ `${role.name} did not answer at ${role.url} within ${Math.round(timeoutMs / 1000)}s. It is under the supervisor but not serving — read its output with \`pm2 logs\`.`,
80
+ );
81
+ }
82
+ await Bun.sleep(250);
83
+ }
84
+ }
85
+
86
+ async function answers(url: string): Promise<boolean> {
87
+ try {
88
+ return (await fetch(url, { signal: AbortSignal.timeout(5_000) })).ok;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { appDeclaration } from '../packages/config/src/declaration';
3
+ import { assertBuildArtifacts, formatCommand, migrationCommandFor } from './release-steps';
4
+
5
+ describe('release steps come from the declaration', () => {
6
+ test('the declared engine resolves to this project one command', () => {
7
+ expect(migrationCommandFor()).toEqual(['bun', 'run', 'db:deploy']);
8
+ });
9
+
10
+ test('no declared migrations means no migration step, not a skipped one', () => {
11
+ expect(migrationCommandFor({ ...appDeclaration, release: {} })).toBeUndefined();
12
+ });
13
+
14
+ test('an engine this project cannot apply is refused, never skipped', () => {
15
+ // Silently not migrating is the failure that leaves a machine running
16
+ // against the wrong schema — it has to be loud.
17
+ const foreign = {
18
+ ...appDeclaration,
19
+ release: {
20
+ migrations: { engine: 'flyway', root: 'packages/db/migrations', lockfile: 'x' },
21
+ },
22
+ };
23
+ expect(() => migrationCommandFor(foreign)).toThrow(/has no command for/);
24
+ });
25
+
26
+ test('a declared migration root that does not exist is refused', () => {
27
+ const missing = {
28
+ ...appDeclaration,
29
+ release: {
30
+ migrations: { engine: 'prisma', root: 'packages/db/nowhere', lockfile: 'x' },
31
+ },
32
+ };
33
+ expect(() => migrationCommandFor(missing)).toThrow(/does not exist/);
34
+ });
35
+ });
36
+
37
+ describe('build artifacts are checked before roles start', () => {
38
+ test('a missing artifact is named, together with the command that makes it', () => {
39
+ const unbuilt = {
40
+ ...appDeclaration,
41
+ build: {
42
+ command: { executable: 'bun', args: ['run', 'build'] },
43
+ artifacts: ['packages/backend/nowhere'],
44
+ },
45
+ };
46
+ // The test used to stop at the artifact name, which is why nobody noticed
47
+ // that the second half of the sentence had become `[object Object]` when
48
+ // commands turned into `{ executable, args }`. A diagnostic exists to be
49
+ // retyped, so the assertion reads it the way an operator would.
50
+ expect(() => assertBuildArtifacts(unbuilt)).toThrow(
51
+ /Missing build artifacts: packages\/backend\/nowhere — run `bun run build` first\./,
52
+ );
53
+ });
54
+
55
+ test('a command with a space survives the diagnostic intact', () => {
56
+ expect(formatCommand({ executable: 'bun', args: ['run', 'build --out dir name'] })).toBe(
57
+ 'bun run "build --out dir name"',
58
+ );
59
+ });
60
+
61
+ test('the diagnostic never prints an object', () => {
62
+ const declared = appDeclaration.build;
63
+ if (!declared) throw new Error('the template declares a build');
64
+ expect(formatCommand(declared.command)).not.toContain('[object');
65
+ expect(formatCommand(declared.command)).toBe('bun run build');
66
+ });
67
+
68
+ test('the check covers every declared artifact, not a list kept beside it', () => {
69
+ const declared = appDeclaration.build?.artifacts ?? [];
70
+ expect(declared.length).toBeGreaterThan(1);
71
+ for (const artifact of declared) {
72
+ expect(() =>
73
+ assertBuildArtifacts({
74
+ ...appDeclaration,
75
+ build: {
76
+ command: { executable: 'bun', args: ['run', 'build'] },
77
+ artifacts: [`${artifact}-absent`],
78
+ },
79
+ }),
80
+ ).toThrow(new RegExp(`${artifact.replaceAll('/', '\\/')}-absent`));
81
+ }
82
+ });
83
+
84
+ test('a project that builds nothing passes', () => {
85
+ expect(() => assertBuildArtifacts({ ...appDeclaration, build: undefined })).not.toThrow();
86
+ });
87
+ });