create-stitchkit 0.4.0 → 0.4.2

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 (47) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/UPGRADING.md +117 -0
  3. package/dist/cli.js +5 -1
  4. package/examples/repository/scripts/runtime-smoke.ts +14 -2
  5. package/package.json +4 -2
  6. package/template/AGENTS.md +15 -5
  7. package/template/README.md +46 -8
  8. package/template/_env.example +8 -0
  9. package/template/_gitignore +1 -0
  10. package/template/biome.json +1 -1
  11. package/template/bun.lock +115 -98
  12. package/template/package.json +9 -8
  13. package/template/packages/backend/package.json +2 -2
  14. package/template/packages/backend/src/cleanup.ts +121 -0
  15. package/template/packages/backend/src/index.ts +12 -3
  16. package/template/packages/config/package.json +3 -1
  17. package/template/packages/config/src/declaration.ts +1 -1
  18. package/template/packages/config/src/shutdown.ts +20 -0
  19. package/template/packages/db/package.json +2 -2
  20. package/template/packages/frontend/package.json +11 -11
  21. package/template/packages/frontend/src/components/ui/toaster.tsx +4 -1
  22. package/template/packages/frontend/src/lib/seo/pages.ts +2 -2
  23. package/template/packages/shared/package.json +1 -1
  24. package/template/scripts/acceptance-database.test.ts +73 -0
  25. package/template/scripts/acceptance-database.ts +92 -0
  26. package/template/scripts/acceptance-local.ts +144 -0
  27. package/template/scripts/build-inputs.test.ts +1 -1
  28. package/template/scripts/build-inputs.ts +4 -3
  29. package/template/scripts/build-stamp.test.ts +151 -0
  30. package/template/scripts/build-stamp.ts +169 -0
  31. package/template/scripts/client-boundary.test.ts +117 -0
  32. package/template/scripts/client-boundary.ts +148 -0
  33. package/template/scripts/declaration.ts +10 -7
  34. package/template/scripts/deployment-preflight.ts +41 -0
  35. package/template/scripts/dev.ts +8 -6
  36. package/template/scripts/local-env.ts +9 -3
  37. package/template/scripts/readiness.ts +92 -0
  38. package/template/scripts/release-steps.ts +5 -1
  39. package/template/scripts/release.ts +8 -0
  40. package/template/scripts/runtime-smoke.test.ts +178 -0
  41. package/template/scripts/runtime-smoke.ts +15 -2
  42. package/template/scripts/shutdown-budget.fixture.ts +29 -0
  43. package/template/scripts/shutdown-budget.test.ts +164 -0
  44. package/template/scripts/surface-conformance.ts +8 -1
  45. package/template/scripts/tooling-env.ts +30 -1
  46. package/template/scripts/web-surface-smoke.ts +125 -14
  47. package/template/packages/config/src/project-declaration.generated.ts +0 -611
@@ -0,0 +1,148 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+
4
+ /**
5
+ * What a `'use client'` graph is allowed to reach.
6
+ *
7
+ * The project declaration is not application data — it is what the repository
8
+ * says about how it is built and run: role commands, working directories,
9
+ * artifact and migration paths, and the name of every environment variable a
10
+ * deployment supplies. One `import` from a module a client component happens to
11
+ * use puts all of it, plus the Zod schema that parses it, into the browser
12
+ * bundle. That is the exact mistake `app-identity.generated.ts` exists to
13
+ * prevent, made from the other side — and it shipped, because nothing checked
14
+ * the graph, only the file that imports directly.
15
+ *
16
+ * The check is a resolved FILE, not a spelling. Matching the string
17
+ * `@app/config/declaration` catches the one way the leak was written the first
18
+ * time and misses the two ways it comes back: a barrel that re-exports it, and
19
+ * a relative path into the config package. Both end at the same file, so that
20
+ * is what this follows.
21
+ */
22
+ export interface ClientBoundaryScan {
23
+ /** Repository root — where `packages/<name>` lives. */
24
+ root: string;
25
+ /** The client graph's entry directory. */
26
+ frontendSrc: string;
27
+ /** The module no client graph may reach. */
28
+ declaration: string;
29
+ }
30
+
31
+ export function sourceFiles(directory: string): string[] {
32
+ const found: string[] = [];
33
+ for (const entry of readdirSync(directory)) {
34
+ const path = join(directory, entry);
35
+ if (statSync(path).isDirectory()) {
36
+ if (entry === 'node_modules' || entry === 'generated') continue;
37
+ found.push(...sourceFiles(path));
38
+ } else if (/\.tsx?$/.test(entry)) {
39
+ found.push(path);
40
+ }
41
+ }
42
+ return found;
43
+ }
44
+
45
+ /**
46
+ * Every module specifier in a source file, in either quote style.
47
+ *
48
+ * Formatting is enforced elsewhere, which is exactly why this must not depend
49
+ * on it: a check that only reads the house style stops being a check the day
50
+ * someone pastes a line from somewhere else.
51
+ */
52
+ export function specifiers(source: string): string[] {
53
+ const quoted = `'([^']+)'|"([^"]+)"`;
54
+ return [
55
+ // `import … from 'x'` and `export … from 'x'`.
56
+ ...[...source.matchAll(new RegExp(String.raw`from\s+(?:${quoted})`, 'g'))],
57
+ // `import 'x'` and `import('x')`. Written out because a side-effect import
58
+ // has no `from` — and a module pulled in for its side effects is in the
59
+ // bundle exactly as much as one whose value is used.
60
+ ...[...source.matchAll(new RegExp(String.raw`import\s*\(?\s*(?:${quoted})`, 'g'))],
61
+ ].flatMap((match) => {
62
+ const specifier = match[1] ?? match[2];
63
+ return specifier ? [specifier] : [];
64
+ });
65
+ }
66
+
67
+ function firstExistingFile(base: string): string | undefined {
68
+ for (const candidate of [
69
+ base,
70
+ `${base}.ts`,
71
+ `${base}.tsx`,
72
+ join(base, 'index.ts'),
73
+ join(base, 'index.tsx'),
74
+ ]) {
75
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ /** `@app/config/declaration` → the file its package's `exports` map names. */
81
+ function resolveWorkspace(scan: ClientBoundaryScan, specifier: string): string | undefined {
82
+ const parts = specifier.split('/');
83
+ if (parts[0] !== '@app' || !parts[1]) return undefined;
84
+ const packageRoot = join(scan.root, 'packages', parts[1]);
85
+ const manifestPath = join(packageRoot, 'package.json');
86
+ if (!existsSync(manifestPath)) return undefined;
87
+ const subpath = parts.length > 2 ? `./${parts.slice(2).join('/')}` : '.';
88
+ const manifest: unknown = JSON.parse(readFileSync(manifestPath, 'utf8'));
89
+ const exportsField =
90
+ typeof manifest === 'object' && manifest !== null
91
+ ? Reflect.get(manifest, 'exports')
92
+ : undefined;
93
+ const target =
94
+ typeof exportsField === 'object' && exportsField !== null
95
+ ? Reflect.get(exportsField, subpath)
96
+ : undefined;
97
+ if (typeof target !== 'string') return undefined;
98
+ return firstExistingFile(resolve(packageRoot, target));
99
+ }
100
+
101
+ export function resolveSpecifier(
102
+ scan: ClientBoundaryScan,
103
+ from: string,
104
+ specifier: string,
105
+ ): string | undefined {
106
+ if (specifier.startsWith('@app/')) return resolveWorkspace(scan, specifier);
107
+ const base = specifier.startsWith('@/')
108
+ ? join(scan.frontendSrc, specifier.slice(2))
109
+ : specifier.startsWith('.')
110
+ ? resolve(dirname(from), specifier)
111
+ : undefined;
112
+ return base ? firstExistingFile(base) : undefined;
113
+ }
114
+
115
+ /** The import chain from a client entry to the declaration, if one exists. */
116
+ export function pathToDeclaration(
117
+ scan: ClientBoundaryScan,
118
+ entry: string,
119
+ ): string[] | undefined {
120
+ const seen = new Set<string>();
121
+ const queue: Array<{ file: string; chain: string[] }> = [{ file: entry, chain: [entry] }];
122
+ while (queue.length > 0) {
123
+ const step = queue.shift();
124
+ if (!step || seen.has(step.file)) continue;
125
+ seen.add(step.file);
126
+ const source = readFileSync(step.file, 'utf8');
127
+ for (const specifier of specifiers(source)) {
128
+ const next = resolveSpecifier(scan, step.file, specifier);
129
+ if (next === scan.declaration) return [...step.chain, next];
130
+ if (next && !seen.has(next)) queue.push({ file: next, chain: [...step.chain, next] });
131
+ }
132
+ }
133
+ return undefined;
134
+ }
135
+
136
+ export function clientEntries(scan: ClientBoundaryScan): string[] {
137
+ return sourceFiles(scan.frontendSrc).filter((file) =>
138
+ /^\s*['"]use client['"]/m.test(readFileSync(file, 'utf8')),
139
+ );
140
+ }
141
+
142
+ /** Every client entry whose graph reaches the declaration, as a readable chain. */
143
+ export function findDeclarationLeaks(scan: ClientBoundaryScan): string[][] {
144
+ return clientEntries(scan).flatMap((entry) => {
145
+ const chain = pathToDeclaration(scan, entry);
146
+ return chain ? [chain] : [];
147
+ });
148
+ }
@@ -1,11 +1,12 @@
1
1
  import { resolve } from 'node:path';
2
- import { z } from 'zod';
3
- import { appDeclaration } from '../packages/config/src/declaration';
4
2
  import type {
5
3
  ProjectDeclaration,
6
4
  ProjectEnvVariable,
7
5
  ProjectRole,
8
- } from '../packages/config/src/project-declaration.generated';
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';
9
10
  import { applicationVariables } from '../packages/config/src/variables';
10
11
 
11
12
  /**
@@ -52,12 +53,14 @@ export const LOCAL_SUPERVISION: SupervisionPolicy = {
52
53
  * the earlier check, comparing against the floor only, reported that supervision
53
54
  * "allows the full shutdown" while 15s + 5s met a 20s kill timeout exactly.
54
55
  */
55
- const FORCE_BUDGET_MS = 5_000;
56
- const CLEANUP_MARGIN_MS = 5_000;
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;
57
60
 
58
61
  /** The shortest time a supervisor may allow this role and still see it finish. */
59
62
  export function terminationBudgetMs(role: ProjectRole): number {
60
- return role.drainFloorMs + FORCE_BUDGET_MS + CLEANUP_MARGIN_MS;
63
+ return role.drainFloorMs + FORCE_BUDGET_MS + CLEANUP_BUDGET_MS;
61
64
  }
62
65
 
63
66
  /**
@@ -165,7 +168,7 @@ export function assertSupervisionAllowsShutdown(
165
168
  const budget = terminationBudgetMs(role);
166
169
  if (budget > killTimeoutMs) {
167
170
  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.`,
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.`,
169
172
  );
170
173
  }
171
174
  }
@@ -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
+ }
@@ -2,6 +2,7 @@ import { resolve } from 'node:path';
2
2
  import { z } from 'zod';
3
3
  import { appDeclaration } from '../packages/config/src/declaration';
4
4
  import { ensureLocalEnvironment } from './local-env';
5
+ import { awaitRolesAnswering, declaredRoleReadiness } from './readiness';
5
6
  import { runDeclaredReleaseSteps } from './release-steps';
6
7
  import { inheritToolingEnvironment } from './tooling-env';
7
8
 
@@ -121,11 +122,12 @@ if (import.meta.main) {
121
122
  await runDevelopment();
122
123
 
123
124
  const environment = await developmentEnvironment();
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);
124
131
  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
- }
132
+ for (const role of roles) console.log(`${role.name}: ${role.url}`);
131
133
  }
@@ -1,10 +1,16 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
- import { appDeclaration } from '../packages/config/src/declaration';
3
+ import { appIdentity } from '../packages/config/src/app-identity.generated';
4
4
 
5
5
  /**
6
6
  * Create `.env` from `.env.example` on first run, rendering the application
7
- * identity into the database name. `.env.example` is the ONLY environment
7
+ * identity into the database name.
8
+ *
9
+ * Identity, not the whole declaration: this needs one slug, and the identity
10
+ * module carries no dependencies. That matters here more than elsewhere —
11
+ * a project scaffolded with `--no-install` renders its `.env` before anything
12
+ * is installed, and a script that reaches for the framework's schema to read a
13
+ * name cannot run in that window. `.env.example` is the ONLY environment
8
14
  * source the repository ships — the scaffolder never writes `.env`, so a
9
15
  * rename in `project.json` changes the database of the next created
10
16
  * environment too. Synchronous on purpose: `playwright.config.ts` and other
@@ -14,7 +20,7 @@ export function ensureLocalEnvironment(root: string): void {
14
20
  const destination = resolve(root, '.env');
15
21
  if (existsSync(destination)) return;
16
22
  const example = readFileSync(resolve(root, '.env.example'), 'utf8');
17
- const databaseName = appDeclaration.identity.slug.replaceAll('-', '_');
23
+ const databaseName = appIdentity.slug.replaceAll('-', '_');
18
24
  writeFileSync(destination, example.replaceAll('stitchkit_starter', databaseName));
19
25
  }
20
26
 
@@ -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
+ }
@@ -1,7 +1,8 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
+ import type { ProjectDeclaration } from 'stitchkit/declaration';
3
4
  import { appDeclaration } from '../packages/config/src/declaration';
4
- import type { ProjectDeclaration } from '../packages/config/src/project-declaration.generated';
5
+ import { assertArtifactMatchesSource } from './build-stamp';
5
6
  import { inheritToolingEnvironment } from './tooling-env';
6
7
 
7
8
  const root = resolve(import.meta.dir, '..');
@@ -87,6 +88,9 @@ export function assertBuildArtifacts(declaration: ProjectDeclaration = appDeclar
87
88
  `Missing build artifacts: ${missing.join(', ')} — run \`${formatCommand(build.command)}\` first.`,
88
89
  );
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);
90
94
  }
91
95
 
92
96
  export async function runDeclaredReleaseSteps(
@@ -1,6 +1,8 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { appDeclaration } from '../packages/config/src/declaration';
3
+ import { awaitRolesAnswering, declaredRoleReadiness } from './readiness';
3
4
  import { assertBuildArtifacts, runDeclaredReleaseSteps } from './release-steps';
5
+ import { deploymentEnvironment } from './tooling-env';
4
6
 
5
7
  /**
6
8
  * Bring this deployment to this source, then hand the roles to the supervisor.
@@ -25,6 +27,12 @@ const supervisor = Bun.spawn(['pm2', 'startOrReload', 'ecosystem.config.cjs', '-
25
27
  const exitCode = await supervisor.exited;
26
28
  if (exitCode !== 0) process.exit(exitCode);
27
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);
28
35
  for (const role of appDeclaration.roles) {
29
36
  console.log(`${appDeclaration.identity.slug}-${role.name} is under supervision`);
30
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,6 +1,7 @@
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
7
  import { assertArtifactIsPlacementFree, assertPublicWebSurface } from './web-surface-smoke';
@@ -8,8 +9,17 @@ import { assertArtifactIsPlacementFree, assertPublicWebSurface } from './web-sur
8
9
  const toolingEnv = loadToolingEnv();
9
10
  const apiOrigin = toolingEnv.SMOKE_API_ORIGIN;
10
11
 
12
+ await assertDeploymentIsAnswering({
13
+ 'the API role': apiOrigin,
14
+ 'the web role': toolingEnv.SMOKE_WEB_ORIGIN,
15
+ });
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
  }
@@ -29,7 +39,10 @@ if (!Object.keys(openApi.paths).includes('/api/system/status')) {
29
39
 
30
40
  await runSurfaceConformance({ apiOrigin });
31
41
  await assertPublicWebSurface(toolingEnv.SMOKE_WEB_ORIGIN);
32
- await assertArtifactIsPlacementFree(toolingEnv.SMOKE_WEB_ORIGIN);
42
+ await assertArtifactIsPlacementFree(toolingEnv.SMOKE_WEB_ORIGIN, {
43
+ origin: toolingEnv.PUBLIC_WEB_ORIGIN,
44
+ hosts: toolingEnv.PUBLIC_WEB_HOSTS,
45
+ });
33
46
 
34
47
  console.log(
35
48
  'Runtime HTTP, typed client, OpenAPI, MCP, public web and placement-free artifact smoke passed',
@@ -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);