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.
- package/CHANGELOG.md +347 -0
- package/README.md +3 -1
- package/UPGRADING.md +342 -0
- package/dist/cli.js +238 -42
- package/examples/repository/_env.example.append +21 -0
- package/examples/repository/packages/backend/src/domain/repository/github-cache.ts +2 -2
- package/examples/repository/packages/backend/src/surface.ts +1 -1
- package/examples/repository/packages/config/src/features.ts +17 -0
- package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +4 -4
- package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
- package/examples/repository/packages/frontend/src/app/api/[...path]/route.ts +66 -0
- package/examples/repository/packages/frontend/src/lib/api/client.ts +17 -12
- package/examples/repository/packages/frontend/src/lib/api/cross-origin.ts +87 -0
- package/examples/repository/packages/frontend/src/lib/api/place.ts +26 -0
- package/examples/repository/packages/frontend/src/lib/api/queries.ts +3 -0
- package/examples/repository/packages/frontend/src/lib/api/server-client.ts +11 -0
- package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +44 -12
- package/examples/repository/packages/frontend/src/providers/client-providers.tsx +30 -0
- package/examples/repository/packages/frontend/src/providers/index.tsx +17 -12
- package/examples/repository/packages/frontend/src/providers/realtime.tsx +6 -4
- package/examples/repository/project.json +189 -0
- package/examples/repository/scripts/runtime-smoke.ts +38 -6
- package/package.json +12 -2
- package/template/AGENTS.md +23 -3
- package/template/README.md +83 -8
- package/template/_env.example +16 -4
- package/template/_gitignore +1 -0
- package/template/biome.json +6 -2
- package/template/bun.lock +115 -98
- package/template/e2e/starter.spec.ts +5 -7
- package/template/ecosystem.config.cjs +42 -19
- package/template/ecosystem.dev.config.cjs +41 -21
- package/template/package.json +12 -10
- package/template/packages/backend/package.json +2 -2
- package/template/packages/backend/src/cleanup.ts +121 -0
- package/template/packages/backend/src/cli.ts +6 -2
- package/template/packages/backend/src/index.ts +33 -8
- package/template/packages/backend/src/surface.ts +6 -1
- package/template/packages/backend/src/transport/errors.ts +4 -2
- package/template/packages/config/package.json +6 -2
- package/template/packages/config/src/app-identity.generated.ts +20 -0
- package/template/packages/config/src/declaration.ts +30 -0
- package/template/packages/config/src/server.ts +8 -17
- package/template/packages/config/src/shutdown.ts +20 -0
- package/template/packages/config/src/variables.ts +89 -0
- package/template/packages/db/package.json +2 -2
- package/template/packages/frontend/next.config.ts +3 -2
- package/template/packages/frontend/package.json +13 -13
- package/template/packages/frontend/scripts/serve.ts +70 -0
- package/template/packages/frontend/src/app/[locale]/layout.tsx +9 -8
- package/template/packages/frontend/src/app/[locale]/page.tsx +2 -2
- package/template/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
- package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +1 -1
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +1 -1
- package/template/packages/frontend/src/app/robots.ts +4 -2
- package/template/packages/frontend/src/app/sitemap.ts +7 -19
- package/template/packages/frontend/src/components/ui/toaster.tsx +4 -1
- package/template/packages/frontend/src/env.ts +27 -8
- package/template/packages/frontend/src/lib/seo/cache-by-origin.test.ts +68 -0
- package/template/packages/frontend/src/lib/seo/cache-by-origin.ts +40 -0
- package/template/packages/frontend/src/lib/seo/metadata.ts +68 -11
- package/template/packages/frontend/src/lib/seo/pages.ts +1 -1
- package/template/packages/frontend/src/lib/seo/request-origin.ts +89 -0
- package/template/packages/frontend/src/theme/config.ts +1 -1
- package/template/packages/frontend/tsconfig.json +10 -3
- package/template/packages/shared/package.json +1 -1
- package/template/playwright.config.ts +1 -1
- package/template/project.json +169 -0
- package/template/scripts/acceptance-database.test.ts +73 -0
- package/template/scripts/acceptance-database.ts +92 -0
- package/template/scripts/acceptance-local.ts +144 -0
- package/template/scripts/build-inputs.test.ts +69 -0
- package/template/scripts/build-inputs.ts +58 -0
- package/template/scripts/build-stamp.test.ts +151 -0
- package/template/scripts/build-stamp.ts +169 -0
- package/template/scripts/check-authored.ts +18 -2
- package/template/scripts/client-boundary.test.ts +117 -0
- package/template/scripts/client-boundary.ts +148 -0
- package/template/scripts/declaration.test.ts +206 -0
- package/template/scripts/declaration.ts +271 -0
- package/template/scripts/deployment-preflight.ts +41 -0
- package/template/scripts/dev.ts +43 -20
- package/template/scripts/local-env.test.ts +2 -2
- package/template/scripts/local-env.ts +9 -3
- package/template/scripts/readiness.ts +92 -0
- package/template/scripts/release-steps.test.ts +87 -0
- package/template/scripts/release-steps.ts +112 -0
- package/template/scripts/release.ts +38 -0
- package/template/scripts/runtime-smoke.test.ts +178 -0
- package/template/scripts/runtime-smoke.ts +21 -5
- package/template/scripts/serve-mode.test.ts +36 -0
- package/template/scripts/shutdown-budget.fixture.ts +29 -0
- package/template/scripts/shutdown-budget.test.ts +164 -0
- package/template/scripts/supervision-signal.test.ts +94 -0
- package/template/scripts/surface-conformance.ts +8 -1
- package/template/scripts/tooling-env.ts +35 -3
- package/template/scripts/web-surface-smoke.ts +183 -2
- package/template/app.config.json +0 -9
- 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
|
+
}
|
package/template/scripts/dev.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 {
|
|
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 =
|
|
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.
|
|
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 `
|
|
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
|
+
});
|