create-stitchkit 0.3.3 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +347 -0
  2. package/README.md +3 -1
  3. package/UPGRADING.md +342 -0
  4. package/dist/cli.js +238 -42
  5. package/examples/repository/_env.example.append +21 -0
  6. package/examples/repository/packages/backend/src/domain/repository/github-cache.ts +2 -2
  7. package/examples/repository/packages/backend/src/surface.ts +1 -1
  8. package/examples/repository/packages/config/src/features.ts +17 -0
  9. package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +4 -4
  10. package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
  11. package/examples/repository/packages/frontend/src/app/api/[...path]/route.ts +66 -0
  12. package/examples/repository/packages/frontend/src/lib/api/client.ts +17 -12
  13. package/examples/repository/packages/frontend/src/lib/api/cross-origin.ts +87 -0
  14. package/examples/repository/packages/frontend/src/lib/api/place.ts +26 -0
  15. package/examples/repository/packages/frontend/src/lib/api/queries.ts +3 -0
  16. package/examples/repository/packages/frontend/src/lib/api/server-client.ts +11 -0
  17. package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +44 -12
  18. package/examples/repository/packages/frontend/src/providers/client-providers.tsx +30 -0
  19. package/examples/repository/packages/frontend/src/providers/index.tsx +17 -12
  20. package/examples/repository/packages/frontend/src/providers/realtime.tsx +6 -4
  21. package/examples/repository/project.json +189 -0
  22. package/examples/repository/scripts/runtime-smoke.ts +38 -6
  23. package/package.json +12 -2
  24. package/template/AGENTS.md +23 -3
  25. package/template/README.md +83 -8
  26. package/template/_env.example +16 -4
  27. package/template/_gitignore +1 -0
  28. package/template/biome.json +6 -2
  29. package/template/bun.lock +115 -98
  30. package/template/e2e/starter.spec.ts +5 -7
  31. package/template/ecosystem.config.cjs +42 -19
  32. package/template/ecosystem.dev.config.cjs +41 -21
  33. package/template/package.json +12 -10
  34. package/template/packages/backend/package.json +2 -2
  35. package/template/packages/backend/src/cleanup.ts +121 -0
  36. package/template/packages/backend/src/cli.ts +6 -2
  37. package/template/packages/backend/src/index.ts +33 -8
  38. package/template/packages/backend/src/surface.ts +6 -1
  39. package/template/packages/backend/src/transport/errors.ts +4 -2
  40. package/template/packages/config/package.json +6 -2
  41. package/template/packages/config/src/app-identity.generated.ts +20 -0
  42. package/template/packages/config/src/declaration.ts +30 -0
  43. package/template/packages/config/src/server.ts +8 -17
  44. package/template/packages/config/src/shutdown.ts +20 -0
  45. package/template/packages/config/src/variables.ts +89 -0
  46. package/template/packages/db/package.json +2 -2
  47. package/template/packages/frontend/next.config.ts +3 -2
  48. package/template/packages/frontend/package.json +13 -13
  49. package/template/packages/frontend/scripts/serve.ts +70 -0
  50. package/template/packages/frontend/src/app/[locale]/layout.tsx +9 -8
  51. package/template/packages/frontend/src/app/[locale]/page.tsx +2 -2
  52. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
  53. package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +1 -1
  54. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +1 -1
  55. package/template/packages/frontend/src/app/robots.ts +4 -2
  56. package/template/packages/frontend/src/app/sitemap.ts +7 -19
  57. package/template/packages/frontend/src/components/ui/toaster.tsx +4 -1
  58. package/template/packages/frontend/src/env.ts +27 -8
  59. package/template/packages/frontend/src/lib/seo/cache-by-origin.test.ts +68 -0
  60. package/template/packages/frontend/src/lib/seo/cache-by-origin.ts +40 -0
  61. package/template/packages/frontend/src/lib/seo/metadata.ts +68 -11
  62. package/template/packages/frontend/src/lib/seo/pages.ts +1 -1
  63. package/template/packages/frontend/src/lib/seo/request-origin.ts +89 -0
  64. package/template/packages/frontend/src/theme/config.ts +1 -1
  65. package/template/packages/frontend/tsconfig.json +10 -3
  66. package/template/packages/shared/package.json +1 -1
  67. package/template/playwright.config.ts +1 -1
  68. package/template/project.json +169 -0
  69. package/template/scripts/acceptance-database.test.ts +73 -0
  70. package/template/scripts/acceptance-database.ts +92 -0
  71. package/template/scripts/acceptance-local.ts +144 -0
  72. package/template/scripts/build-inputs.test.ts +69 -0
  73. package/template/scripts/build-inputs.ts +58 -0
  74. package/template/scripts/build-stamp.test.ts +151 -0
  75. package/template/scripts/build-stamp.ts +169 -0
  76. package/template/scripts/check-authored.ts +18 -2
  77. package/template/scripts/client-boundary.test.ts +117 -0
  78. package/template/scripts/client-boundary.ts +148 -0
  79. package/template/scripts/declaration.test.ts +206 -0
  80. package/template/scripts/declaration.ts +271 -0
  81. package/template/scripts/deployment-preflight.ts +41 -0
  82. package/template/scripts/dev.ts +43 -20
  83. package/template/scripts/local-env.test.ts +2 -2
  84. package/template/scripts/local-env.ts +9 -3
  85. package/template/scripts/readiness.ts +92 -0
  86. package/template/scripts/release-steps.test.ts +87 -0
  87. package/template/scripts/release-steps.ts +112 -0
  88. package/template/scripts/release.ts +38 -0
  89. package/template/scripts/runtime-smoke.test.ts +178 -0
  90. package/template/scripts/runtime-smoke.ts +21 -5
  91. package/template/scripts/serve-mode.test.ts +36 -0
  92. package/template/scripts/shutdown-budget.fixture.ts +29 -0
  93. package/template/scripts/shutdown-budget.test.ts +164 -0
  94. package/template/scripts/supervision-signal.test.ts +94 -0
  95. package/template/scripts/surface-conformance.ts +8 -1
  96. package/template/scripts/tooling-env.ts +35 -3
  97. package/template/scripts/web-surface-smoke.ts +183 -2
  98. package/template/app.config.json +0 -9
  99. package/template/packages/config/src/identity.ts +0 -18
@@ -0,0 +1,112 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import type { ProjectDeclaration } from 'stitchkit/declaration';
4
+ import { appDeclaration } from '../packages/config/src/declaration';
5
+ import { assertArtifactMatchesSource } from './build-stamp';
6
+ import { inheritToolingEnvironment } from './tooling-env';
7
+
8
+ const root = resolve(import.meta.dir, '..');
9
+
10
+ /**
11
+ * Bringing this deployment to this source — the steps the DECLARATION says
12
+ * must happen once, before any role starts.
13
+ *
14
+ * The declaration says what a migration *is* (engine, root, lockfile), not what
15
+ * command to run for it. That split is the point: an outside deployment tool
16
+ * reads the bytes and decides for itself — exact contents, admission verdict,
17
+ * whether a preflight is needed at all — while the project keeps the one command
18
+ * that applies them here. Neither side has to learn the other's vocabulary.
19
+ */
20
+ const MIGRATION_COMMANDS: Record<string, string[]> = {
21
+ prisma: ['bun', 'run', 'db:deploy'],
22
+ };
23
+
24
+ /**
25
+ * The command that applies this project's declared migrations, or `undefined`
26
+ * when it declares none — absent means "there are none", not "we forgot to say".
27
+ *
28
+ * An engine with no command here is refused rather than skipped: silently not
29
+ * migrating is the failure that leaves a deployment running against the wrong
30
+ * schema.
31
+ */
32
+ /**
33
+ * A declared command as an operator can retype it.
34
+ *
35
+ * `${command}` on a `{ executable, args }` object prints `[object Object]`, so
36
+ * the one diagnostic that exists to tell an operator what to run told them
37
+ * nothing. Quoting is deliberate: an argument with a space has to survive being
38
+ * read back.
39
+ */
40
+ export function formatCommand(command: { executable: string; args: string[] }): string {
41
+ return [command.executable, ...command.args]
42
+ .map((part) => (/[\s"']/.test(part) ? JSON.stringify(part) : part))
43
+ .join(' ');
44
+ }
45
+
46
+ export function migrationCommandFor(
47
+ declaration: ProjectDeclaration = appDeclaration,
48
+ ): string[] | undefined {
49
+ const { migrations } = declaration.release;
50
+ if (!migrations) return undefined;
51
+
52
+ const command = MIGRATION_COMMANDS[migrations.engine];
53
+ if (!command) {
54
+ throw new Error(
55
+ `project.json declares migrations for "${migrations.engine}", which this project has no command for.`,
56
+ );
57
+ }
58
+ // Both declared paths are checked. The lockfile is what tells a reader the
59
+ // migrations belong to one lineage; declaring it and then not looking at it
60
+ // is how a declaration starts describing a tree that is not there.
61
+ const declaredPaths: Array<[string, string]> = [
62
+ ['root', migrations.root],
63
+ ['lockfile', migrations.lockfile],
64
+ ];
65
+ for (const [label, path] of declaredPaths) {
66
+ if (!existsSync(resolve(root, path))) {
67
+ throw new Error(`Declared migration ${label} ${path} does not exist.`);
68
+ }
69
+ }
70
+ return command;
71
+ }
72
+
73
+ /**
74
+ * Everything the declaration says building produces must exist before roles
75
+ * start.
76
+ *
77
+ * Derived from `build.artifacts` rather than from a list kept here, so an
78
+ * artifact added to the declaration is covered without a second edit — and the
79
+ * failure names the missing path instead of surfacing as a module-not-found
80
+ * inside a supervised process, where nobody reads it.
81
+ */
82
+ export function assertBuildArtifacts(declaration: ProjectDeclaration = appDeclaration): void {
83
+ const build = declaration.build;
84
+ if (!build) return;
85
+ const missing = build.artifacts.filter((artifact) => !existsSync(resolve(root, artifact)));
86
+ if (missing.length > 0) {
87
+ throw new Error(
88
+ `Missing build artifacts: ${missing.join(', ')} — run \`${formatCommand(build.command)}\` first.`,
89
+ );
90
+ }
91
+ // Present is not current. Without this, a release applies THIS source's
92
+ // migrations and then starts the previous source's artifact.
93
+ assertArtifactMatchesSource(root);
94
+ }
95
+
96
+ export async function runDeclaredReleaseSteps(
97
+ environment?: Record<string, string>,
98
+ ): Promise<void> {
99
+ const command = migrationCommandFor();
100
+ if (!command) return;
101
+
102
+ const child = Bun.spawn(command, {
103
+ cwd: root,
104
+ env: environment ? inheritToolingEnvironment(environment) : undefined,
105
+ stdin: 'inherit',
106
+ stdout: 'inherit',
107
+ stderr: 'inherit',
108
+ });
109
+ if ((await child.exited) !== 0) {
110
+ throw new Error(`${command.join(' ')} failed`);
111
+ }
112
+ }
@@ -0,0 +1,38 @@
1
+ import { resolve } from 'node:path';
2
+ import { appDeclaration } from '../packages/config/src/declaration';
3
+ import { awaitRolesAnswering, declaredRoleReadiness } from './readiness';
4
+ import { assertBuildArtifacts, runDeclaredReleaseSteps } from './release-steps';
5
+ import { deploymentEnvironment } from './tooling-env';
6
+
7
+ /**
8
+ * Bring this deployment to this source, then hand the roles to the supervisor.
9
+ *
10
+ * The order is the declaration's, not this file's: build artifacts must exist,
11
+ * declared release steps run once, and only then do roles start. Nothing here
12
+ * repeats what `project.json` already says — the migration engine, the artifact
13
+ * paths and the roles all come from it, and the supervision file this ends with
14
+ * is generated from it too.
15
+ */
16
+ const root = resolve(import.meta.dir, '..');
17
+
18
+ assertBuildArtifacts();
19
+ await runDeclaredReleaseSteps();
20
+
21
+ const supervisor = Bun.spawn(['pm2', 'startOrReload', 'ecosystem.config.cjs', '--update-env'], {
22
+ cwd: root,
23
+ stdin: 'inherit',
24
+ stdout: 'inherit',
25
+ stderr: 'inherit',
26
+ });
27
+ const exitCode = await supervisor.exited;
28
+ if (exitCode !== 0) process.exit(exitCode);
29
+
30
+ // Under supervision is not yet serving. A release that returns before the
31
+ // roles answer makes every command after it — a smoke, a health check, a
32
+ // rollout step — race the application it just started.
33
+ const roles = declaredRoleReadiness(appDeclaration, deploymentEnvironment(root));
34
+ await awaitRolesAnswering(roles);
35
+ for (const role of appDeclaration.roles) {
36
+ console.log(`${appDeclaration.identity.slug}-${role.name} is under supervision`);
37
+ }
38
+ for (const role of roles) console.log(`${role.name}: ${role.url}`);
@@ -0,0 +1,178 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { readFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { appDeclaration } from '../packages/config/src/declaration';
5
+ import { declaredRoleReadiness } from './readiness';
6
+ import { planPortabilityCheck } from './web-surface-smoke';
7
+
8
+ const dialled = 'http://127.0.0.1:3210';
9
+
10
+ describe('the portability check dials addresses the deployment claims', () => {
11
+ test('two claimed addresses besides the dialled one become the two probes', () => {
12
+ const plan = planPortabilityCheck(dialled, {
13
+ hosts: '127.0.0.1:3210,alpha.example,beta.example:8443',
14
+ });
15
+ expect(plan.kind).toBe('check');
16
+ if (plan.kind !== 'check') return;
17
+ expect(plan.addresses).toEqual([
18
+ { host: 'alpha.example', proto: 'https' },
19
+ { host: 'beta.example:8443', proto: 'http' },
20
+ ]);
21
+ });
22
+
23
+ test('a deployment with nothing but its own address is told what to add', () => {
24
+ // The reported failure, from the other side: the check used to carry two
25
+ // fixture hosts of its own, so a deployment that had never been told about
26
+ // them refused the request and the gate showed a bare 500.
27
+ const plan = planPortabilityCheck(dialled, { hosts: '127.0.0.1:3210' });
28
+ expect(plan.kind).toBe('refuse');
29
+ if (plan.kind !== 'refuse') return;
30
+ expect(plan.reason).toContain(
31
+ 'PUBLIC_WEB_HOSTS=127.0.0.1:3210,alpha.example,beta.example:8443',
32
+ );
33
+ expect(plan.reason).toContain('bun run dev');
34
+ });
35
+
36
+ test('a deployment that claims nothing at all is refused the same way', () => {
37
+ const plan = planPortabilityCheck(dialled, {});
38
+ expect(plan.kind).toBe('refuse');
39
+ if (plan.kind !== 'refuse') return;
40
+ expect(plan.reason).toContain('no host at all');
41
+ });
42
+
43
+ test('a pinned public origin is a skip with a reason, not a failure', () => {
44
+ // PUBLIC_WEB_ORIGIN short-circuits the request-derived origin, so the
45
+ // deployment answers with one address whatever it is asked on. That is a
46
+ // legitimate configuration; failing it would be a false diagnosis.
47
+ const plan = planPortabilityCheck(dialled, {
48
+ origin: 'https://example.com',
49
+ hosts: '127.0.0.1:3210,alpha.example,beta.example:8443',
50
+ });
51
+ expect(plan.kind).toBe('skip');
52
+ if (plan.kind !== 'skip') return;
53
+ expect(plan.reason).toContain('https://example.com');
54
+ });
55
+
56
+ test('the forged host is never one the deployment claims', () => {
57
+ // Otherwise the refusal the check exists to provoke never happens, and an
58
+ // assertion that cannot fail is worse than no assertion.
59
+ const plan = planPortabilityCheck(dialled, {
60
+ hosts: '127.0.0.1:3210,alpha.example,beta.example:8443,attacker.example',
61
+ });
62
+ expect(plan.kind).toBe('check');
63
+ if (plan.kind !== 'check') return;
64
+ expect(plan.forgedHost).not.toBe('attacker.example');
65
+ expect(plan.forgedHost).toBe('attacker-1.invalid');
66
+ });
67
+
68
+ test('one host written twice is one address, not two', () => {
69
+ // The count decides whether the property can be proved at all. Without
70
+ // deduplication a duplicate entry passed as a second address and the check
71
+ // compared the deployment with itself — a proof that cannot fail.
72
+ const plan = planPortabilityCheck(dialled, {
73
+ hosts: '127.0.0.1:3210,alpha.example,alpha.example',
74
+ });
75
+ expect(plan.kind).toBe('refuse');
76
+ });
77
+
78
+ test('the claimed list is matched case-insensitively, like the policy that reads it', () => {
79
+ const plan = planPortabilityCheck('http://127.0.0.1:3210', {
80
+ hosts: ' 127.0.0.1:3210 , Alpha.Example , BETA.example:8443 ',
81
+ });
82
+ expect(plan.kind).toBe('check');
83
+ if (plan.kind !== 'check') return;
84
+ expect(plan.addresses.map((address) => address.host)).toEqual([
85
+ 'alpha.example',
86
+ 'beta.example:8443',
87
+ ]);
88
+ });
89
+ });
90
+
91
+ describe('a gate list is not a deploy instruction', () => {
92
+ // `runtime:smoke` and `e2e` check a running deployment, so the list has to
93
+ // bring one up — and the wrong way to do that shipped once already:
94
+ // `pm2:prod` applies the declared migrations and reloads the deployment the
95
+ // developer is running. A gate creates and destroys its OWN deployment, and
96
+ // that is `acceptance:local`.
97
+ const root = resolve(import.meta.dir, '..');
98
+ const guidance = ['README.md', 'AGENTS.md'];
99
+
100
+ for (const file of guidance) {
101
+ const text = readFileSync(resolve(root, file), 'utf8');
102
+ const blocks = [...text.matchAll(/```bash\n([\s\S]*?)```/g)].map(([, body]) => body ?? '');
103
+ const gates = blocks.filter((body) => body.includes('bun run check'));
104
+
105
+ test(`${file} lists gates`, () => {
106
+ expect(gates.length).toBeGreaterThan(0);
107
+ });
108
+
109
+ test(`${file} puts no deployment command in the gate list`, () => {
110
+ for (const body of gates) {
111
+ expect(body).not.toContain('pm2:prod');
112
+ }
113
+ });
114
+
115
+ test(`${file} brings a deployment up before the checks that dial one`, () => {
116
+ for (const body of gates) {
117
+ const lines = body.split('\n').map((line) => line.trim());
118
+ const dialling = ['bun run runtime:smoke', 'bun run e2e'].filter((command) =>
119
+ lines.includes(command),
120
+ );
121
+ if (dialling.length === 0) {
122
+ expect(lines).toContain('bun run acceptance:local');
123
+ continue;
124
+ }
125
+ const start = lines.indexOf('bun run acceptance:local');
126
+ expect(start).toBeGreaterThanOrEqual(0);
127
+ for (const command of dialling) expect(start).toBeLessThan(lines.indexOf(command));
128
+ }
129
+ });
130
+
131
+ test(`${file} never tells anyone to delete every supervised application`, () => {
132
+ // `pm2 delete all` empties the daemon it is pointed at, including
133
+ // applications that have nothing to do with this project.
134
+ expect(text).not.toContain('pm2 delete all');
135
+ });
136
+ }
137
+ });
138
+
139
+ describe('a role is ready when it answers, not when it is spawned', () => {
140
+ const environment = { API_PORT: '3211', WEB_PORT: '3210', BIND_HOST: '127.0.0.1' };
141
+
142
+ test('every listening role gets its declared readiness address', () => {
143
+ expect(declaredRoleReadiness(appDeclaration, environment)).toEqual([
144
+ { name: 'api', url: 'http://127.0.0.1:3211/health' },
145
+ { name: 'web', url: 'http://127.0.0.1:3210/' },
146
+ ]);
147
+ });
148
+
149
+ test('a bind address of every interface is dialled on loopback', () => {
150
+ // `0.0.0.0` is what a role BINDS. Dialling it as an address is a different
151
+ // question, and loopback is the one this machine can always answer.
152
+ const addresses = declaredRoleReadiness(appDeclaration, {
153
+ ...environment,
154
+ BIND_HOST: '0.0.0.0',
155
+ });
156
+ expect(addresses.every((role) => role.url.startsWith('http://127.0.0.1:'))).toBe(true);
157
+ });
158
+
159
+ test('an IPv6 bind address is bracketed, or it is not a URL at all', () => {
160
+ // `http://::1:3211/health` cannot be parsed as an address with a port, so
161
+ // the wait failed on the spelling instead of on the role.
162
+ const addresses = declaredRoleReadiness(appDeclaration, {
163
+ ...environment,
164
+ BIND_HOST: '::1',
165
+ });
166
+ expect(addresses.map((role) => role.url)).toEqual([
167
+ 'http://[::1]:3211/health',
168
+ 'http://[::1]:3210/',
169
+ ]);
170
+ for (const role of addresses) expect(() => new URL(role.url)).not.toThrow();
171
+ });
172
+
173
+ test('a declared listener with no port is an error, not a silent skip', () => {
174
+ expect(() =>
175
+ declaredRoleReadiness(appDeclaration, { ...environment, WEB_PORT: undefined }),
176
+ ).toThrow(/WEB_PORT/);
177
+ });
178
+ });
@@ -1,15 +1,25 @@
1
1
  import { systemContract } from '@app/shared';
2
2
  import { createClient, createHttpClient } from 'stitchkit';
3
3
  import { z } from 'zod';
4
+ import { assertDeploymentIsAnswering } from './deployment-preflight';
4
5
  import { runSurfaceConformance } from './surface-conformance';
5
6
  import { loadToolingEnv } from './tooling-env';
6
- import { assertPublicWebSurface } from './web-surface-smoke';
7
+ import { assertArtifactIsPlacementFree, assertPublicWebSurface } from './web-surface-smoke';
7
8
 
8
9
  const toolingEnv = loadToolingEnv();
9
- const apiOrigin = toolingEnv.NEXT_PUBLIC_API_URL;
10
+ const apiOrigin = toolingEnv.SMOKE_API_ORIGIN;
11
+
12
+ await assertDeploymentIsAnswering({
13
+ 'the API role': apiOrigin,
14
+ 'the web role': toolingEnv.SMOKE_WEB_ORIGIN,
15
+ });
10
16
 
11
17
  async function json(path: string): Promise<unknown> {
12
- const response = await fetch(`${apiOrigin}${path}`);
18
+ // Bounded: an endpoint that accepts the connection and never answers must
19
+ // turn this gate red, not hang it with no output and no deadline.
20
+ const response = await fetch(`${apiOrigin}${path}`, {
21
+ signal: AbortSignal.timeout(30_000),
22
+ });
13
23
  if (!response.ok) throw new Error(`GET ${path} returned ${response.status}`);
14
24
  return response.json();
15
25
  }
@@ -28,6 +38,12 @@ if (!Object.keys(openApi.paths).includes('/api/system/status')) {
28
38
  }
29
39
 
30
40
  await runSurfaceConformance({ apiOrigin });
31
- await assertPublicWebSurface(toolingEnv.NEXT_PUBLIC_WEB_URL);
41
+ await assertPublicWebSurface(toolingEnv.SMOKE_WEB_ORIGIN);
42
+ await assertArtifactIsPlacementFree(toolingEnv.SMOKE_WEB_ORIGIN, {
43
+ origin: toolingEnv.PUBLIC_WEB_ORIGIN,
44
+ hosts: toolingEnv.PUBLIC_WEB_HOSTS,
45
+ });
32
46
 
33
- console.log('Runtime HTTP, typed client, OpenAPI, MCP and public web smoke passed');
47
+ console.log(
48
+ 'Runtime HTTP, typed client, OpenAPI, MCP, public web and placement-free artifact smoke passed',
49
+ );
@@ -0,0 +1,36 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * The web role refuses a mode it does not understand.
6
+ *
7
+ * `argv[2] === 'development' ? 'dev' : 'start'` meant a typo, an empty string
8
+ * and a missing argument all became production in silence — the one decision
9
+ * that changes whether the role serves a build or compiles on demand. Run as a
10
+ * real process, because the failure has to happen before Next is spawned.
11
+ */
12
+ const serve = join(import.meta.dir, '../packages/frontend/scripts/serve.ts');
13
+
14
+ async function refusal(argv: string[]): Promise<{ code: number; stderr: string }> {
15
+ const child = Bun.spawn(['bun', serve, ...argv], {
16
+ cwd: join(import.meta.dir, '../packages/frontend'),
17
+ env: { ...Bun.env, WEB_PORT: '3210', BIND_HOST: '127.0.0.1' },
18
+ stdout: 'ignore',
19
+ stderr: 'pipe',
20
+ });
21
+ const [stderr, code] = await Promise.all([new Response(child.stderr).text(), child.exited]);
22
+ return { code, stderr };
23
+ }
24
+
25
+ test('a misspelled mode is refused, not treated as production', async () => {
26
+ const { code, stderr } = await refusal(['produciton']);
27
+ expect(code).not.toBe(0);
28
+ expect(stderr).toContain('Run mode must be "development" or "production"');
29
+ expect(stderr).toContain('produciton');
30
+ }, 30_000);
31
+
32
+ test('a missing mode is refused', async () => {
33
+ const { code, stderr } = await refusal([]);
34
+ expect(code).not.toBe(0);
35
+ expect(stderr).toContain('received nothing');
36
+ }, 30_000);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A role whose cleanup will not complete, as a real process.
3
+ *
4
+ * The regression this serves cannot be written against a promise. The defect
5
+ * was never "the helper resolved late" — it was that the role set
6
+ * `process.exitCode` and then waited for an event loop something was still
7
+ * holding, so the process lived until the supervisor's SIGKILL: exactly the
8
+ * ending the cleanup budget exists to prevent. Only a process can show that.
9
+ *
10
+ * The interval below is the held handle. Nothing ever clears it, so this
11
+ * process ends only if something ends it — which is the assertion.
12
+ */
13
+ import { closeWithinBudget, concludeShutdown } from '../packages/backend/src/cleanup';
14
+
15
+ setInterval(() => undefined, 1_000);
16
+
17
+ const budgetMs = Number(Bun.argv[2] ?? '200');
18
+ const mode = Bun.argv[3] ?? 'hang';
19
+
20
+ const steps = {
21
+ hang: { name: 'database', close: () => new Promise<void>(() => undefined) },
22
+ throw: { name: 'database', close: () => Promise.reject(new Error('pool already gone')) },
23
+ clean: { name: 'database', close: () => Promise.resolve() },
24
+ };
25
+ const step = steps[mode === 'throw' ? 'throw' : mode === 'clean' ? 'clean' : 'hang'];
26
+
27
+ const result = await closeWithinBudget([step], budgetMs);
28
+ console.log(`FIXTURE ${JSON.stringify({ unfinished: result.unfinished })}`);
29
+ concludeShutdown(result, true);
@@ -0,0 +1,164 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { readFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import {
5
+ type CleanupResult,
6
+ closeWithinBudget,
7
+ concludeShutdown,
8
+ } from '../packages/backend/src/cleanup';
9
+ import { appDeclaration } from '../packages/config/src/declaration';
10
+ import { CLEANUP_BUDGET_MS, FORCE_BUDGET_MS } from '../packages/config/src/shutdown';
11
+ import { terminationBudgetMs } from './declaration';
12
+
13
+ describe('the termination budget is an upper bound, not an estimate', () => {
14
+ test('the budget is exactly the three bounded parts', () => {
15
+ for (const role of appDeclaration.roles) {
16
+ expect(terminationBudgetMs(role)).toBe(
17
+ role.drainFloorMs + FORCE_BUDGET_MS + CLEANUP_BUDGET_MS,
18
+ );
19
+ }
20
+ });
21
+
22
+ test('a cleanup that finishes reports nothing unfinished', async () => {
23
+ const closed: string[] = [];
24
+ const result = await closeWithinBudget([
25
+ { name: 'MCP', close: async () => closed.push('MCP') },
26
+ { name: 'database', close: async () => closed.push('database') },
27
+ ]);
28
+ expect(closed).toEqual(['MCP', 'database']);
29
+ expect(result.unfinished).toEqual([]);
30
+ });
31
+
32
+ test('a close that hangs stops being waited for, and is named', async () => {
33
+ // The defect this exists for: the drain had a deadline, the closes after it
34
+ // had none, so a hung MCP session or database pool ran past the very kill
35
+ // timeout the budget had told the supervisor to allow — turning an orderly
36
+ // shutdown into the SIGKILL that runs no cleanup at all.
37
+ const result = await closeWithinBudget(
38
+ [{ name: 'MCP', close: () => new Promise<void>(() => undefined) }],
39
+ 25,
40
+ );
41
+ expect(result.unfinished).toEqual(['MCP']);
42
+ expect(result.durationMs).toBeLessThan(CLEANUP_BUDGET_MS);
43
+ });
44
+
45
+ test('the steps share one budget rather than each getting a full one', async () => {
46
+ const result = await closeWithinBudget(
47
+ [
48
+ { name: 'MCP', close: () => new Promise<void>(() => undefined) },
49
+ { name: 'database', close: () => new Promise<void>(() => undefined) },
50
+ ],
51
+ 25,
52
+ );
53
+ expect(result.unfinished).toEqual(['MCP', 'database']);
54
+ // Two steps, one budget: about one budget of wall clock, not two.
55
+ expect(result.durationMs).toBeLessThan(50);
56
+ });
57
+
58
+ test('a close that fails is a failure, and keeps its cause', async () => {
59
+ // It used to be swallowed into `undefined` with the note that the budget is
60
+ // about time. The budget is — but a close that throws is a shutdown that
61
+ // did not happen, and reporting it as a clean exit with the reason gone is
62
+ // how a broken shutdown looks exactly like a working one.
63
+ const result = await closeWithinBudget([
64
+ { name: 'database', close: () => Promise.reject(new Error('pool already gone')) },
65
+ ]);
66
+ expect(result.unfinished).toEqual([]);
67
+ expect(result.failed.map((failure) => failure.name)).toEqual(['database']);
68
+ expect(result.failed[0]?.cause).toBeInstanceOf(Error);
69
+ });
70
+
71
+ test('a cleanup that completed is a zero exit and no forced ending', () => {
72
+ let exited: number | undefined;
73
+ concludeShutdown({ unfinished: [], failed: [], durationMs: 3 }, true, (code) => {
74
+ exited = code;
75
+ });
76
+ expect(process.exitCode).toBe(0);
77
+ expect(exited).toBeUndefined();
78
+ process.exitCode = 0;
79
+ });
80
+
81
+ test('an unfinished step and a failed one both end the process non-zero', () => {
82
+ const endings: Array<[string, CleanupResult]> = [
83
+ ['unfinished', { unfinished: ['database'], failed: [], durationMs: 5 }],
84
+ [
85
+ 'failed',
86
+ {
87
+ unfinished: [],
88
+ failed: [{ name: 'database', cause: new Error('pool already gone') }],
89
+ durationMs: 5,
90
+ },
91
+ ],
92
+ ];
93
+ for (const [label, result] of endings) {
94
+ let exited: number | undefined;
95
+ concludeShutdown(result, true, (code) => {
96
+ exited = code;
97
+ });
98
+ expect(`${label}:${exited}`).toBe(`${label}:1`);
99
+ }
100
+ process.exitCode = 0;
101
+ });
102
+
103
+ test('the role bounds its own cleanup rather than awaiting it bare', () => {
104
+ // One number, two readers — and the reader that matters is the role. An
105
+ // `await mcp.close()` here with nothing around it is the whole defect.
106
+ const source = readFileSync(
107
+ resolve(import.meta.dir, '../packages/backend/src/index.ts'),
108
+ 'utf8',
109
+ );
110
+ expect(source).toContain('closeWithinBudget');
111
+ expect(source).not.toMatch(/await mcp\.close\(\);/);
112
+ expect(source).not.toMatch(/await prisma\.\$disconnect\(\);/);
113
+ });
114
+ });
115
+
116
+ describe('a spent budget ends the process itself', () => {
117
+ const fixture = resolve(import.meta.dir, 'shutdown-budget.fixture.ts');
118
+
119
+ async function runFixture(mode: string, budgetMs: number, waitMs: number) {
120
+ const child = Bun.spawn(['bun', fixture, String(budgetMs), mode], {
121
+ cwd: resolve(import.meta.dir, '..'),
122
+ stdout: 'pipe',
123
+ stderr: 'pipe',
124
+ });
125
+ const startedAt = Date.now();
126
+ const timeout = Bun.sleep(waitMs).then(() => 'timeout');
127
+ const finished = await Promise.race([child.exited, timeout]);
128
+ const elapsed = Date.now() - startedAt;
129
+ if (finished === 'timeout') {
130
+ child.kill('SIGKILL');
131
+ await child.exited;
132
+ return { exitCode: undefined, elapsed, stdout: '', stderr: '' };
133
+ }
134
+ const [stdout, stderr] = await Promise.all([
135
+ new Response(child.stdout).text(),
136
+ new Response(child.stderr).text(),
137
+ ]);
138
+ return { exitCode: finished, elapsed, stdout, stderr };
139
+ }
140
+
141
+ test('a role whose close never finishes exits anyway, and says so', async () => {
142
+ const run = await runFixture('hang', 200, 8_000);
143
+ expect(run.exitCode).toBe(1);
144
+ expect(run.stderr).toContain('the cleanup budget is spent, exiting anyway');
145
+ // Well inside a supervisor's kill timeout, which is what the declared
146
+ // termination budget promises this stays inside.
147
+ expect(run.elapsed).toBeLessThan(5_000);
148
+ }, 20_000);
149
+
150
+ test('a role whose close throws exits non-zero, with the cause', async () => {
151
+ const run = await runFixture('throw', 200, 8_000);
152
+ expect(run.exitCode).toBe(1);
153
+ expect(run.stderr).toContain('Shutdown could not close database');
154
+ expect(run.stderr).toContain('pool already gone');
155
+ }, 20_000);
156
+
157
+ test('the held handle really does hold — without it the test proves nothing', async () => {
158
+ // Falsification. If this process ended on its own, the two assertions above
159
+ // would pass with `concludeShutdown` deleted, and the regression would be
160
+ // measuring the absence of work rather than the presence of an ending.
161
+ const run = await runFixture('clean', 200, 1_500);
162
+ expect(run.exitCode).toBeUndefined();
163
+ }, 20_000);
164
+ });