create-stitchkit 0.3.3 → 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 (80) hide show
  1. package/CHANGELOG.md +218 -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 +12 -2
  25. package/template/README.md +43 -6
  26. package/template/_env.example +8 -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 -19
  31. package/template/ecosystem.dev.config.cjs +41 -21
  32. package/template/package.json +5 -4
  33. package/template/packages/backend/src/cli.ts +6 -2
  34. package/template/packages/backend/src/index.ts +21 -5
  35. package/template/packages/backend/src/surface.ts +6 -1
  36. package/template/packages/backend/src/transport/errors.ts +4 -2
  37. package/template/packages/config/package.json +3 -1
  38. package/template/packages/config/src/app-identity.generated.ts +20 -0
  39. package/template/packages/config/src/declaration.ts +30 -0
  40. package/template/packages/config/src/project-declaration.generated.ts +611 -0
  41. package/template/packages/config/src/server.ts +8 -17
  42. package/template/packages/config/src/variables.ts +89 -0
  43. package/template/packages/frontend/next.config.ts +3 -2
  44. package/template/packages/frontend/package.json +2 -2
  45. package/template/packages/frontend/scripts/serve.ts +70 -0
  46. package/template/packages/frontend/src/app/[locale]/layout.tsx +9 -8
  47. package/template/packages/frontend/src/app/[locale]/page.tsx +2 -2
  48. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
  49. package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +1 -1
  50. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +1 -1
  51. package/template/packages/frontend/src/app/robots.ts +4 -2
  52. package/template/packages/frontend/src/app/sitemap.ts +7 -19
  53. package/template/packages/frontend/src/env.ts +27 -8
  54. package/template/packages/frontend/src/lib/seo/cache-by-origin.test.ts +68 -0
  55. package/template/packages/frontend/src/lib/seo/cache-by-origin.ts +40 -0
  56. package/template/packages/frontend/src/lib/seo/metadata.ts +68 -11
  57. package/template/packages/frontend/src/lib/seo/pages.ts +2 -2
  58. package/template/packages/frontend/src/lib/seo/request-origin.ts +89 -0
  59. package/template/packages/frontend/src/theme/config.ts +1 -1
  60. package/template/packages/frontend/tsconfig.json +10 -3
  61. package/template/playwright.config.ts +1 -1
  62. package/template/project.json +169 -0
  63. package/template/scripts/build-inputs.test.ts +69 -0
  64. package/template/scripts/build-inputs.ts +57 -0
  65. package/template/scripts/check-authored.ts +18 -2
  66. package/template/scripts/declaration.test.ts +206 -0
  67. package/template/scripts/declaration.ts +268 -0
  68. package/template/scripts/dev.ts +41 -20
  69. package/template/scripts/local-env.test.ts +2 -2
  70. package/template/scripts/local-env.ts +3 -3
  71. package/template/scripts/release-steps.test.ts +87 -0
  72. package/template/scripts/release-steps.ts +108 -0
  73. package/template/scripts/release.ts +30 -0
  74. package/template/scripts/runtime-smoke.ts +7 -4
  75. package/template/scripts/serve-mode.test.ts +36 -0
  76. package/template/scripts/supervision-signal.test.ts +94 -0
  77. package/template/scripts/tooling-env.ts +5 -2
  78. package/template/scripts/web-surface-smoke.ts +70 -0
  79. package/template/app.config.json +0 -9
  80. package/template/packages/config/src/identity.ts +0 -18
@@ -0,0 +1,268 @@
1
+ import { resolve } from 'node:path';
2
+ import { z } from 'zod';
3
+ import { appDeclaration } from '../packages/config/src/declaration';
4
+ import type {
5
+ ProjectDeclaration,
6
+ ProjectEnvVariable,
7
+ ProjectRole,
8
+ } from '../packages/config/src/project-declaration.generated';
9
+ import { applicationVariables } from '../packages/config/src/variables';
10
+
11
+ /**
12
+ * Everything derived from the project declaration.
13
+ *
14
+ * Two things used to be written by hand and had already drifted: the list of
15
+ * environment variables (three overlapping copies) and the two PM2 files (nine
16
+ * diverging lines, one of which killed the backend mid-drain every time). Both
17
+ * are now DERIVED — from `variables.ts` and from `project.json` — and the gate
18
+ * refuses a checked-in file that does not match what this module renders.
19
+ *
20
+ * Note what stays on which side. Roles, commands, readiness and the drain floor
21
+ * come from the declaration, because they are true of the code. Restart policy
22
+ * and the kill timeout are the place's, and for the manual path this file IS
23
+ * the place — so the policy is one visible constant below, and the rule that
24
+ * binds it to the code is checked rather than trusted.
25
+ */
26
+ const root = resolve(import.meta.dir, '..');
27
+
28
+ interface SupervisionPolicy {
29
+ restart: boolean;
30
+ /** Must cover every role's FULL termination budget — `assertSupervisionAllowsShutdown`. */
31
+ killTimeoutMs: number;
32
+ }
33
+
34
+ /**
35
+ * Local supervision policy: the place's side of the manual path.
36
+ *
37
+ * This is placement policy living in a repository, and it is here because the
38
+ * manual path has nowhere else to put it. It is not evidence that the
39
+ * declaration is placement-free — the declaration is the file next to it.
40
+ */
41
+ export const LOCAL_SUPERVISION: SupervisionPolicy = {
42
+ restart: true,
43
+ killTimeoutMs: 30_000,
44
+ };
45
+
46
+ /**
47
+ * What a role spends after its drain floor before the process can exit.
48
+ *
49
+ * The server forces for `forceTimeoutMs` (5s by default) once the grace period
50
+ * ends, and `onComplete` then closes MCP and the database. A supervisor sized to
51
+ * the drain floor alone kills the role in the middle of that tail — which is why
52
+ * the earlier check, comparing against the floor only, reported that supervision
53
+ * "allows the full shutdown" while 15s + 5s met a 20s kill timeout exactly.
54
+ */
55
+ const FORCE_BUDGET_MS = 5_000;
56
+ const CLEANUP_MARGIN_MS = 5_000;
57
+
58
+ /** The shortest time a supervisor may allow this role and still see it finish. */
59
+ export function terminationBudgetMs(role: ProjectRole): number {
60
+ return role.drainFloorMs + FORCE_BUDGET_MS + CLEANUP_MARGIN_MS;
61
+ }
62
+
63
+ /**
64
+ * JSON Schema types this projection can carry into the declaration.
65
+ *
66
+ * `number` used to be mapped to `integer`, which quietly told a deployment that
67
+ * a fractional value was an integer — the declaration and the Zod contract it
68
+ * is derived FROM would then disagree, which is the one failure the derivation
69
+ * exists to prevent. A type with no faithful shape is refused instead: the
70
+ * declaration format gains the shape, or the project stops declaring that type.
71
+ */
72
+ const SHAPE_BY_JSON_TYPE: Record<string, ProjectEnvVariable['shape']> = {
73
+ integer: 'integer',
74
+ boolean: 'boolean',
75
+ };
76
+
77
+ const JsonSchemaSchema = z.object({
78
+ properties: z.record(
79
+ z.string(),
80
+ z.object({
81
+ type: z.string().optional(),
82
+ format: z.string().optional(),
83
+ enum: z.array(z.unknown()).optional(),
84
+ }),
85
+ ),
86
+ required: z.array(z.string()).optional(),
87
+ });
88
+
89
+ /**
90
+ * The variables a deployment must supply, derived from the one Zod declaration.
91
+ *
92
+ * `required` follows the schema exactly: a variable with a default or an
93
+ * `.optional()` is not required, and nothing here restates that judgement. An
94
+ * enum carries its members, because "one of an unnamed set" tells a reader
95
+ * without a TypeScript runtime nothing — and that reader is the whole point.
96
+ */
97
+ export function renderEnvVariables(
98
+ variables: Record<string, z.ZodType> = applicationVariables,
99
+ ): ProjectEnvVariable[] {
100
+ const json = JsonSchemaSchema.parse(
101
+ z.toJSONSchema(z.object(variables), { io: 'input', unrepresentable: 'any' }),
102
+ );
103
+ const required = new Set(json.required ?? []);
104
+ return Object.entries(json.properties)
105
+ .map(([name, property]) => describeVariable(name, property, required.has(name)))
106
+ .sort((left, right) => left.name.localeCompare(right.name));
107
+ }
108
+
109
+ function describeVariable(
110
+ name: string,
111
+ property: { type?: string; format?: string; enum?: unknown[] },
112
+ required: boolean,
113
+ ): ProjectEnvVariable {
114
+ if (property.enum) {
115
+ // Members are refused rather than stringified. `String(member)` turned
116
+ // numbers and booleans into text that no longer matched the value the Zod
117
+ // schema accepts, so a deployment reading the declaration would supply
118
+ // something the application then rejects — the declaration would be derived
119
+ // and still wrong.
120
+ const members = property.enum.filter((member) => typeof member === 'string');
121
+ if (members.length !== property.enum.length) {
122
+ throw new Error(
123
+ `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.`,
124
+ );
125
+ }
126
+ return { name, shape: 'enum', required, members };
127
+ }
128
+ if (property.format === 'uri') return { name, shape: 'url', required };
129
+ if (property.type === undefined || property.type === 'string') {
130
+ return { name, shape: 'string', required };
131
+ }
132
+ const shape = SHAPE_BY_JSON_TYPE[property.type];
133
+ if (!shape) {
134
+ // Fail closed rather than describe the variable with the wrong shape: a
135
+ // reader acting on `string` when the value is something else is worse off
136
+ // than a reader told this project cannot describe it.
137
+ throw new Error(
138
+ `Cannot describe ${name}: no declaration shape for JSON Schema type "${property.type}".`,
139
+ );
140
+ }
141
+ return { name, shape, required };
142
+ }
143
+
144
+ /** The declaration as it must appear on disk: authored fields plus derived ones. */
145
+ export function renderProjectJson(): string {
146
+ const declaration: ProjectDeclaration = {
147
+ ...appDeclaration,
148
+ env: { variables: renderEnvVariables() },
149
+ };
150
+ return `${JSON.stringify(declaration, undefined, 2)}\n`;
151
+ }
152
+
153
+ /**
154
+ * The rule the drain floor exists for: a supervisor must allow at least as long
155
+ * as the role needs to finish — the drain, the force that follows it, and the
156
+ * cleanup after that. Shorter, and the process is killed mid-shutdown, which is
157
+ * what a 15s kill timeout against a 30s floor did here, every time, for as long
158
+ * as the two numbers lived in two hand-written files.
159
+ */
160
+ export function assertSupervisionAllowsShutdown(
161
+ declaration: ProjectDeclaration,
162
+ killTimeoutMs: number,
163
+ ): void {
164
+ for (const role of declaration.roles) {
165
+ const budget = terminationBudgetMs(role);
166
+ if (budget > killTimeoutMs) {
167
+ throw new Error(
168
+ `Role "${role.name}" needs up to ${budget}ms to finish shutting down (${role.drainFloorMs}ms drain + ${FORCE_BUDGET_MS}ms force + ${CLEANUP_MARGIN_MS}ms cleanup) but supervision allows ${killTimeoutMs}ms — it would be killed mid-shutdown.`,
169
+ );
170
+ }
171
+ }
172
+ }
173
+
174
+ const BANNER = `// GENERATED FILE — do not edit.
175
+ //
176
+ // Rendered from \`project.json\` by \`scripts/declaration.ts\`; run
177
+ // \`bun run gen:declaration\` after changing a role. Roles, commands and the
178
+ // drain floor come from the declaration because they are true of the code;
179
+ // restart policy and the kill timeout are this machine's, and the generator
180
+ // refuses a timeout shorter than any role's full shutdown budget.
181
+ `;
182
+
183
+ /** One PM2 file per run mode, rendered from the roles. */
184
+ export function renderEcosystem(
185
+ declaration: ProjectDeclaration,
186
+ mode: 'development' | 'production',
187
+ ): string {
188
+ assertSupervisionAllowsShutdown(declaration, LOCAL_SUPERVISION.killTimeoutMs);
189
+ const suffix = mode === 'development' ? '-dev' : '';
190
+ const apps = declaration.roles.map((role) => renderApp(role, mode, suffix)).join('\n');
191
+ return `${BANNER}const path = require('node:path');
192
+ const { config } = require('dotenv');
193
+ const declaration = require('./project.json');
194
+
195
+ // NOT \`override\`: an environment a deployment injected into this process must
196
+ // win over a file in the repository. The file fills gaps; it does not overrule
197
+ // the place.
198
+ config({ path: path.join(__dirname, '.env'), quiet: true });
199
+
200
+ module.exports = {
201
+ apps: [
202
+ ${apps}
203
+ ],
204
+ };
205
+ `;
206
+ }
207
+
208
+ function renderApp(
209
+ role: ProjectRole,
210
+ mode: 'development' | 'production',
211
+ suffix: string,
212
+ ): string {
213
+ const command = role.commands[mode];
214
+ if (!command) throw new Error(`Role "${role.name}" declares no ${mode} command.`);
215
+ const binding = role.listener ? `\`${role.listener.portVariable}\`` : 'its variables';
216
+ return ` {
217
+ name: \`\${declaration.identity.slug}-${role.name}${suffix}\`,
218
+ // The role's OWN process, in its OWN directory — no launcher in between.
219
+ // Measured: a launcher makes the role see the stop signal twice (once from
220
+ // the supervisor, once forwarded), the second press forces the shutdown,
221
+ // and a declared drain of seconds collapses to milliseconds. A workspace
222
+ // filter is worse: the signal never arrives at all.
223
+ cwd: path.join(__dirname, ${JSON.stringify(role.workingDirectory ?? '.')}),
224
+ script: ${JSON.stringify(command.executable)},
225
+ // No argv invented here: the deployment injects ${binding} and the command
226
+ // reads it. Serialised rather than concatenated — an argument with a space
227
+ // or a quote has to survive this file intact.
228
+ args: ${JSON.stringify(command.args)},
229
+ interpreter: 'none',
230
+ autorestart: ${LOCAL_SUPERVISION.restart},
231
+ // >= this role's full shutdown budget of ${terminationBudgetMs(role)}ms.
232
+ kill_timeout: ${LOCAL_SUPERVISION.killTimeoutMs},
233
+ env: { NODE_ENV: '${mode}' },
234
+ },`;
235
+ }
236
+
237
+ const IDENTITY_BANNER = `// GENERATED FILE — do not edit.
238
+ //
239
+ // Rendered from \`project.json\` by \`scripts/declaration.ts\`.
240
+ //
241
+ // Identity ONLY, inlined rather than imported, because this is the part of the
242
+ // declaration a browser may know. Importing the whole declaration from a client
243
+ // component would put role commands, working directories, build artifact paths,
244
+ // the migration lockfile and every environment variable name into the browser
245
+ // bundle — the same mistake as publishing internal topology from a status
246
+ // endpoint, made from the other side.
247
+ `;
248
+
249
+ /** Identity alone, safe for the client graph. */
250
+ export function renderAppIdentity(): string {
251
+ return `${IDENTITY_BANNER}
252
+ export const appIdentity = ${JSON.stringify(appDeclaration.identity, undefined, 2)};
253
+ `;
254
+ }
255
+
256
+ export const GENERATED_FILES = {
257
+ 'project.json': renderProjectJson,
258
+ 'packages/config/src/app-identity.generated.ts': renderAppIdentity,
259
+ 'ecosystem.config.cjs': () => renderEcosystem(appDeclaration, 'production'),
260
+ 'ecosystem.dev.config.cjs': () => renderEcosystem(appDeclaration, 'development'),
261
+ };
262
+
263
+ if (import.meta.main) {
264
+ for (const [name, render] of Object.entries(GENERATED_FILES)) {
265
+ await Bun.write(resolve(root, name), render());
266
+ console.log(`Wrote ${name}`);
267
+ }
268
+ }
@@ -1,7 +1,8 @@
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 { runDeclaredReleaseSteps } from './release-steps';
5
6
  import { inheritToolingEnvironment } from './tooling-env';
6
7
 
7
8
  const root = resolve(import.meta.dir, '..');
@@ -28,7 +29,11 @@ export async function runDevelopment(environment?: Record<string, string>): Prom
28
29
  );
29
30
  }
30
31
  await assertPortsAvailable(environmentForRun);
31
- await run(['bun', 'run', 'db:setup'], environmentForRun);
32
+ // The generated client is a BUILD artifact; applying migrations is a RELEASE
33
+ // step the declaration owns. Development runs the same release step as
34
+ // production, so the two paths cannot drift on what 'up to date' means.
35
+ await run(['bun', 'run', 'db:generate'], environmentForRun);
36
+ await runDeclaredReleaseSteps(environmentForRun);
32
37
  await run(
33
38
  ['pm2', 'startOrReload', 'ecosystem.dev.config.cjs', '--update-env'],
34
39
  environmentForRun,
@@ -42,10 +47,17 @@ export async function runDevelopment(environment?: Record<string, string>): Prom
42
47
  */
43
48
  async function assertPortsAvailable(environment: Record<string, string>): Promise<void> {
44
49
  const registered = await registeredPm2Names();
45
- const managed = [`${appIdentity.slug}-backend-dev`, `${appIdentity.slug}-frontend-dev`];
50
+ const managed = appDeclaration.roles.map(
51
+ (role) => `${appDeclaration.identity.slug}-${role.name}-dev`,
52
+ );
46
53
  if (managed.some((name) => registered.has(name))) return;
47
- assertPortFree(Number(environment.API_PORT), 'API_PORT');
48
- assertPortFree(Number(environment.WEB_PORT), 'WEB_PORT');
54
+ // Which ports to probe comes from the declaration, not from a second list
55
+ // of variable names here: a new role is covered by declaring it.
56
+ for (const role of appDeclaration.roles) {
57
+ if (!role.listener) continue;
58
+ const variable = role.listener.portVariable;
59
+ assertPortFree(Number(environment[variable]), variable);
60
+ }
49
61
  }
50
62
 
51
63
  async function registeredPm2Names(): Promise<Set<string>> {
@@ -74,7 +86,7 @@ function assertPortFree(port: number, variable: string): void {
74
86
  listener.stop(true);
75
87
  } catch {
76
88
  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.`,
89
+ `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
90
  );
79
91
  }
80
92
  }
@@ -83,28 +95,37 @@ function assertToolAvailable(command: string, instruction: string): void {
83
95
  if (!Bun.which(command)) throw new Error(`${command} is required. ${instruction}`);
84
96
  }
85
97
 
98
+ /**
99
+ * The validated environment the development processes run with.
100
+ *
101
+ * Every variable the DECLARATION names, and no hand-written list beside it: a
102
+ * list here went stale the moment a variable was added, and a role declaring a
103
+ * port whose name was missing got `Number(undefined)` — reported as
104
+ * `Port NaN (WORKER_PORT) is already in use`, which is a false diagnosis of a
105
+ * real mistake.
106
+ */
86
107
  export async function developmentEnvironment(
87
108
  overrides: Record<string, string> = {},
88
109
  ): Promise<Record<string, string>> {
89
110
  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
- };
111
+ const validated: Record<string, unknown> = env;
112
+ const declared: Record<string, string> = {};
113
+ for (const variable of appDeclaration.env.variables) {
114
+ const value = validated[variable.name];
115
+ if (value !== undefined) declared[variable.name] = String(value);
116
+ }
117
+ return { ...declared, ...overrides };
101
118
  }
102
119
 
103
120
  if (import.meta.main) {
104
121
  await runDevelopment();
105
122
 
106
123
  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}`);
124
+ console.log(`${appDeclaration.identity.name} development processes are running`);
125
+ for (const role of appDeclaration.roles) {
126
+ if (!role.listener) continue;
127
+ const port = environment[role.listener.portVariable];
128
+ const readiness = role.listener.readinessPath;
129
+ console.log(`${role.name}: http://${environment.BIND_HOST}:${port}${readiness}`);
130
+ }
110
131
  }
@@ -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,12 @@
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 { appDeclaration } from '../packages/config/src/declaration';
4
4
 
5
5
  /**
6
6
  * Create `.env` from `.env.example` on first run, rendering the application
7
7
  * identity into the database name. `.env.example` is the ONLY environment
8
8
  * source the repository ships — the scaffolder never writes `.env`, so a
9
- * rename in `app.config.json` changes the database of the next created
9
+ * rename in `project.json` changes the database of the next created
10
10
  * environment too. Synchronous on purpose: `playwright.config.ts` and other
11
11
  * synchronous entry points must be able to self-heal before validating.
12
12
  */
@@ -14,7 +14,7 @@ export function ensureLocalEnvironment(root: string): void {
14
14
  const destination = resolve(root, '.env');
15
15
  if (existsSync(destination)) return;
16
16
  const example = readFileSync(resolve(root, '.env.example'), 'utf8');
17
- const databaseName = appIdentity.slug.replaceAll('-', '_');
17
+ const databaseName = appDeclaration.identity.slug.replaceAll('-', '_');
18
18
  writeFileSync(destination, example.replaceAll('stitchkit_starter', databaseName));
19
19
  }
20
20
 
@@ -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
+ });
@@ -0,0 +1,108 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { appDeclaration } from '../packages/config/src/declaration';
4
+ import type { ProjectDeclaration } from '../packages/config/src/project-declaration.generated';
5
+ import { inheritToolingEnvironment } from './tooling-env';
6
+
7
+ const root = resolve(import.meta.dir, '..');
8
+
9
+ /**
10
+ * Bringing this deployment to this source — the steps the DECLARATION says
11
+ * must happen once, before any role starts.
12
+ *
13
+ * The declaration says what a migration *is* (engine, root, lockfile), not what
14
+ * command to run for it. That split is the point: an outside deployment tool
15
+ * reads the bytes and decides for itself — exact contents, admission verdict,
16
+ * whether a preflight is needed at all — while the project keeps the one command
17
+ * that applies them here. Neither side has to learn the other's vocabulary.
18
+ */
19
+ const MIGRATION_COMMANDS: Record<string, string[]> = {
20
+ prisma: ['bun', 'run', 'db:deploy'],
21
+ };
22
+
23
+ /**
24
+ * The command that applies this project's declared migrations, or `undefined`
25
+ * when it declares none — absent means "there are none", not "we forgot to say".
26
+ *
27
+ * An engine with no command here is refused rather than skipped: silently not
28
+ * migrating is the failure that leaves a deployment running against the wrong
29
+ * schema.
30
+ */
31
+ /**
32
+ * A declared command as an operator can retype it.
33
+ *
34
+ * `${command}` on a `{ executable, args }` object prints `[object Object]`, so
35
+ * the one diagnostic that exists to tell an operator what to run told them
36
+ * nothing. Quoting is deliberate: an argument with a space has to survive being
37
+ * read back.
38
+ */
39
+ export function formatCommand(command: { executable: string; args: string[] }): string {
40
+ return [command.executable, ...command.args]
41
+ .map((part) => (/[\s"']/.test(part) ? JSON.stringify(part) : part))
42
+ .join(' ');
43
+ }
44
+
45
+ export function migrationCommandFor(
46
+ declaration: ProjectDeclaration = appDeclaration,
47
+ ): string[] | undefined {
48
+ const { migrations } = declaration.release;
49
+ if (!migrations) return undefined;
50
+
51
+ const command = MIGRATION_COMMANDS[migrations.engine];
52
+ if (!command) {
53
+ throw new Error(
54
+ `project.json declares migrations for "${migrations.engine}", which this project has no command for.`,
55
+ );
56
+ }
57
+ // Both declared paths are checked. The lockfile is what tells a reader the
58
+ // migrations belong to one lineage; declaring it and then not looking at it
59
+ // is how a declaration starts describing a tree that is not there.
60
+ const declaredPaths: Array<[string, string]> = [
61
+ ['root', migrations.root],
62
+ ['lockfile', migrations.lockfile],
63
+ ];
64
+ for (const [label, path] of declaredPaths) {
65
+ if (!existsSync(resolve(root, path))) {
66
+ throw new Error(`Declared migration ${label} ${path} does not exist.`);
67
+ }
68
+ }
69
+ return command;
70
+ }
71
+
72
+ /**
73
+ * Everything the declaration says building produces must exist before roles
74
+ * start.
75
+ *
76
+ * Derived from `build.artifacts` rather than from a list kept here, so an
77
+ * artifact added to the declaration is covered without a second edit — and the
78
+ * failure names the missing path instead of surfacing as a module-not-found
79
+ * inside a supervised process, where nobody reads it.
80
+ */
81
+ export function assertBuildArtifacts(declaration: ProjectDeclaration = appDeclaration): void {
82
+ const build = declaration.build;
83
+ if (!build) return;
84
+ const missing = build.artifacts.filter((artifact) => !existsSync(resolve(root, artifact)));
85
+ if (missing.length > 0) {
86
+ throw new Error(
87
+ `Missing build artifacts: ${missing.join(', ')} — run \`${formatCommand(build.command)}\` first.`,
88
+ );
89
+ }
90
+ }
91
+
92
+ export async function runDeclaredReleaseSteps(
93
+ environment?: Record<string, string>,
94
+ ): Promise<void> {
95
+ const command = migrationCommandFor();
96
+ if (!command) return;
97
+
98
+ const child = Bun.spawn(command, {
99
+ cwd: root,
100
+ env: environment ? inheritToolingEnvironment(environment) : undefined,
101
+ stdin: 'inherit',
102
+ stdout: 'inherit',
103
+ stderr: 'inherit',
104
+ });
105
+ if ((await child.exited) !== 0) {
106
+ throw new Error(`${command.join(' ')} failed`);
107
+ }
108
+ }
@@ -0,0 +1,30 @@
1
+ import { resolve } from 'node:path';
2
+ import { appDeclaration } from '../packages/config/src/declaration';
3
+ import { assertBuildArtifacts, runDeclaredReleaseSteps } from './release-steps';
4
+
5
+ /**
6
+ * Bring this deployment to this source, then hand the roles to the supervisor.
7
+ *
8
+ * The order is the declaration's, not this file's: build artifacts must exist,
9
+ * declared release steps run once, and only then do roles start. Nothing here
10
+ * repeats what `project.json` already says — the migration engine, the artifact
11
+ * paths and the roles all come from it, and the supervision file this ends with
12
+ * is generated from it too.
13
+ */
14
+ const root = resolve(import.meta.dir, '..');
15
+
16
+ assertBuildArtifacts();
17
+ await runDeclaredReleaseSteps();
18
+
19
+ const supervisor = Bun.spawn(['pm2', 'startOrReload', 'ecosystem.config.cjs', '--update-env'], {
20
+ cwd: root,
21
+ stdin: 'inherit',
22
+ stdout: 'inherit',
23
+ stderr: 'inherit',
24
+ });
25
+ const exitCode = await supervisor.exited;
26
+ if (exitCode !== 0) process.exit(exitCode);
27
+
28
+ for (const role of appDeclaration.roles) {
29
+ console.log(`${appDeclaration.identity.slug}-${role.name} is under supervision`);
30
+ }
@@ -3,10 +3,10 @@ import { createClient, createHttpClient } from 'stitchkit';
3
3
  import { z } from 'zod';
4
4
  import { runSurfaceConformance } from './surface-conformance';
5
5
  import { loadToolingEnv } from './tooling-env';
6
- import { assertPublicWebSurface } from './web-surface-smoke';
6
+ import { assertArtifactIsPlacementFree, assertPublicWebSurface } from './web-surface-smoke';
7
7
 
8
8
  const toolingEnv = loadToolingEnv();
9
- const apiOrigin = toolingEnv.NEXT_PUBLIC_API_URL;
9
+ const apiOrigin = toolingEnv.SMOKE_API_ORIGIN;
10
10
 
11
11
  async function json(path: string): Promise<unknown> {
12
12
  const response = await fetch(`${apiOrigin}${path}`);
@@ -28,6 +28,9 @@ if (!Object.keys(openApi.paths).includes('/api/system/status')) {
28
28
  }
29
29
 
30
30
  await runSurfaceConformance({ apiOrigin });
31
- await assertPublicWebSurface(toolingEnv.NEXT_PUBLIC_WEB_URL);
31
+ await assertPublicWebSurface(toolingEnv.SMOKE_WEB_ORIGIN);
32
+ await assertArtifactIsPlacementFree(toolingEnv.SMOKE_WEB_ORIGIN);
32
33
 
33
- console.log('Runtime HTTP, typed client, OpenAPI, MCP and public web smoke passed');
34
+ console.log(
35
+ 'Runtime HTTP, typed client, OpenAPI, MCP, public web and placement-free artifact smoke passed',
36
+ );