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.
- package/CHANGELOG.md +218 -0
- package/README.md +3 -1
- package/UPGRADING.md +225 -0
- package/dist/cli.js +233 -41
- 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 +25 -5
- package/package.json +9 -1
- package/template/AGENTS.md +12 -2
- package/template/README.md +43 -6
- package/template/_env.example +8 -4
- package/template/biome.json +5 -1
- package/template/bun.lock +2 -2
- 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 +5 -4
- package/template/packages/backend/src/cli.ts +6 -2
- package/template/packages/backend/src/index.ts +21 -5
- 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 +3 -1
- 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/project-declaration.generated.ts +611 -0
- package/template/packages/config/src/server.ts +8 -17
- package/template/packages/config/src/variables.ts +89 -0
- package/template/packages/frontend/next.config.ts +3 -2
- package/template/packages/frontend/package.json +2 -2
- 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/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 +2 -2
- 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/playwright.config.ts +1 -1
- package/template/project.json +169 -0
- package/template/scripts/build-inputs.test.ts +69 -0
- package/template/scripts/build-inputs.ts +57 -0
- package/template/scripts/check-authored.ts +18 -2
- package/template/scripts/declaration.test.ts +206 -0
- package/template/scripts/declaration.ts +268 -0
- package/template/scripts/dev.ts +41 -20
- package/template/scripts/local-env.test.ts +2 -2
- package/template/scripts/local-env.ts +3 -3
- package/template/scripts/release-steps.test.ts +87 -0
- package/template/scripts/release-steps.ts +108 -0
- package/template/scripts/release.ts +30 -0
- package/template/scripts/runtime-smoke.ts +7 -4
- package/template/scripts/serve-mode.test.ts +36 -0
- package/template/scripts/supervision-signal.test.ts +94 -0
- package/template/scripts/tooling-env.ts +5 -2
- package/template/scripts/web-surface-smoke.ts +70 -0
- package/template/app.config.json +0 -9
- 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
|
+
}
|
package/template/scripts/dev.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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 { 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
|
-
|
|
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 =
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
};
|
|
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(`${
|
|
108
|
-
|
|
109
|
-
|
|
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 {
|
|
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,12 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
|
-
import {
|
|
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 `
|
|
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 =
|
|
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.
|
|
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.
|
|
31
|
+
await assertPublicWebSurface(toolingEnv.SMOKE_WEB_ORIGIN);
|
|
32
|
+
await assertArtifactIsPlacementFree(toolingEnv.SMOKE_WEB_ORIGIN);
|
|
32
33
|
|
|
33
|
-
console.log(
|
|
34
|
+
console.log(
|
|
35
|
+
'Runtime HTTP, typed client, OpenAPI, MCP, public web and placement-free artifact smoke passed',
|
|
36
|
+
);
|