create-stitchkit 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/README.md +3 -1
  3. package/UPGRADING.md +225 -0
  4. package/dist/cli.js +233 -41
  5. package/examples/repository/_env.example.append +21 -0
  6. package/examples/repository/packages/backend/src/domain/repository/github-cache.ts +2 -2
  7. package/examples/repository/packages/backend/src/surface.ts +1 -1
  8. package/examples/repository/packages/config/src/features.ts +17 -0
  9. package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +4 -4
  10. package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
  11. package/examples/repository/packages/frontend/src/app/api/[...path]/route.ts +66 -0
  12. package/examples/repository/packages/frontend/src/lib/api/client.ts +17 -12
  13. package/examples/repository/packages/frontend/src/lib/api/cross-origin.ts +87 -0
  14. package/examples/repository/packages/frontend/src/lib/api/place.ts +26 -0
  15. package/examples/repository/packages/frontend/src/lib/api/queries.ts +3 -0
  16. package/examples/repository/packages/frontend/src/lib/api/server-client.ts +11 -0
  17. package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +44 -12
  18. package/examples/repository/packages/frontend/src/providers/client-providers.tsx +30 -0
  19. package/examples/repository/packages/frontend/src/providers/index.tsx +17 -12
  20. package/examples/repository/packages/frontend/src/providers/realtime.tsx +6 -4
  21. package/examples/repository/project.json +189 -0
  22. package/examples/repository/scripts/runtime-smoke.ts +25 -5
  23. package/package.json +9 -1
  24. package/template/AGENTS.md +12 -2
  25. package/template/README.md +43 -6
  26. package/template/_env.example +8 -4
  27. package/template/biome.json +5 -1
  28. package/template/bun.lock +2 -2
  29. package/template/e2e/starter.spec.ts +5 -7
  30. package/template/ecosystem.config.cjs +42 -19
  31. package/template/ecosystem.dev.config.cjs +41 -21
  32. package/template/package.json +5 -4
  33. package/template/packages/backend/src/cli.ts +6 -2
  34. package/template/packages/backend/src/index.ts +21 -5
  35. package/template/packages/backend/src/surface.ts +6 -1
  36. package/template/packages/backend/src/transport/errors.ts +4 -2
  37. package/template/packages/config/package.json +3 -1
  38. package/template/packages/config/src/app-identity.generated.ts +20 -0
  39. package/template/packages/config/src/declaration.ts +30 -0
  40. package/template/packages/config/src/project-declaration.generated.ts +611 -0
  41. package/template/packages/config/src/server.ts +8 -17
  42. package/template/packages/config/src/variables.ts +89 -0
  43. package/template/packages/frontend/next.config.ts +3 -2
  44. package/template/packages/frontend/package.json +2 -2
  45. package/template/packages/frontend/scripts/serve.ts +70 -0
  46. package/template/packages/frontend/src/app/[locale]/layout.tsx +9 -8
  47. package/template/packages/frontend/src/app/[locale]/page.tsx +2 -2
  48. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
  49. package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +1 -1
  50. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +1 -1
  51. package/template/packages/frontend/src/app/robots.ts +4 -2
  52. package/template/packages/frontend/src/app/sitemap.ts +7 -19
  53. package/template/packages/frontend/src/env.ts +27 -8
  54. package/template/packages/frontend/src/lib/seo/cache-by-origin.test.ts +68 -0
  55. package/template/packages/frontend/src/lib/seo/cache-by-origin.ts +40 -0
  56. package/template/packages/frontend/src/lib/seo/metadata.ts +68 -11
  57. package/template/packages/frontend/src/lib/seo/pages.ts +2 -2
  58. package/template/packages/frontend/src/lib/seo/request-origin.ts +89 -0
  59. package/template/packages/frontend/src/theme/config.ts +1 -1
  60. package/template/packages/frontend/tsconfig.json +10 -3
  61. package/template/playwright.config.ts +1 -1
  62. package/template/project.json +169 -0
  63. package/template/scripts/build-inputs.test.ts +69 -0
  64. package/template/scripts/build-inputs.ts +57 -0
  65. package/template/scripts/check-authored.ts +18 -2
  66. package/template/scripts/declaration.test.ts +206 -0
  67. package/template/scripts/declaration.ts +268 -0
  68. package/template/scripts/dev.ts +41 -20
  69. package/template/scripts/local-env.test.ts +2 -2
  70. package/template/scripts/local-env.ts +3 -3
  71. package/template/scripts/release-steps.test.ts +87 -0
  72. package/template/scripts/release-steps.ts +108 -0
  73. package/template/scripts/release.ts +30 -0
  74. package/template/scripts/runtime-smoke.ts +7 -4
  75. package/template/scripts/serve-mode.test.ts +36 -0
  76. package/template/scripts/supervision-signal.test.ts +94 -0
  77. package/template/scripts/tooling-env.ts +5 -2
  78. package/template/scripts/web-surface-smoke.ts +70 -0
  79. package/template/app.config.json +0 -9
  80. package/template/packages/config/src/identity.ts +0 -18
@@ -0,0 +1,89 @@
1
+ import { headers } from 'next/headers';
2
+ import { env } from '@/env';
3
+
4
+ /**
5
+ * The public origin this response is being served on.
6
+ *
7
+ * Derived from the REQUEST, never from the build. An absolute address read at
8
+ * build time is frozen into the artifact — `robots.txt` and `sitemap.xml` were
9
+ * prerendered with one origin inside their bytes, so a single build could not
10
+ * serve a second address. Reading the request instead makes one artifact
11
+ * correct at every address it is ever routed to.
12
+ *
13
+ * **The request is not trusted on its own.** `x-forwarded-host` is set by
14
+ * whoever is in front of this process, and a request that reaches the role
15
+ * directly can set it too. An unchecked value would put an attacker's host into
16
+ * canonical URLs, the sitemap and OG metadata — the artifact would be portable
17
+ * and also forgeable. So a forwarded host is honoured only when the deployment
18
+ * has said which hosts it serves, and `PUBLIC_WEB_ORIGIN` remains the way to
19
+ * state a single one.
20
+ *
21
+ * Both are read at RUNTIME (no `NEXT_PUBLIC_` prefix, so nothing is substituted
22
+ * at build time).
23
+ */
24
+ export async function requestOrigin(): Promise<string> {
25
+ const configured = env.PUBLIC_WEB_ORIGIN;
26
+ if (configured) return new URL(configured).origin;
27
+
28
+ const incoming = await headers();
29
+ const host = firstValue(incoming.get('x-forwarded-host') ?? incoming.get('host'));
30
+ if (!host) {
31
+ throw new Error(
32
+ 'Cannot determine the public origin: the request carried no Host header. Set PUBLIC_WEB_ORIGIN.',
33
+ );
34
+ }
35
+ if (!isAllowedHost(host)) {
36
+ throw new Error(
37
+ `Refusing to answer for host "${host}": it is not in PUBLIC_WEB_HOSTS. Set PUBLIC_WEB_ORIGIN for a single address, or PUBLIC_WEB_HOSTS for several.`,
38
+ );
39
+ }
40
+ return new URL(`${protocolOf(incoming.get('x-forwarded-proto'))}://${host}`).origin;
41
+ }
42
+
43
+ /**
44
+ * Which hosts this deployment serves.
45
+ *
46
+ * Empty means "only what `PUBLIC_WEB_ORIGIN` says", and since that short-circuits
47
+ * above, an empty list with no origin set is a deployment that has not been told
48
+ * where it lives — which is an error, not a licence to believe the caller.
49
+ *
50
+ * There is deliberately no wildcard: serving any host is the same as having no
51
+ * canonical origin, and every answer this module gives is a canonical origin.
52
+ */
53
+ function isAllowedHost(host: string): boolean {
54
+ const allowed = env.PUBLIC_WEB_HOSTS?.split(',')
55
+ .map((entry) => entry.trim().toLowerCase())
56
+ .filter((entry) => entry.length > 0);
57
+ if (!allowed || allowed.length === 0) return false;
58
+ // No wildcard. `*` read as "serve any host" — which is the same as having no
59
+ // canonical origin at all, and therefore no meaningful answer to give for a
60
+ // canonical URL, a sitemap or an OG card. A deployment that genuinely serves
61
+ // many addresses lists them; one that does not know which it serves has a
62
+ // configuration problem, not a licence to believe the caller.
63
+ return allowed.includes(host.toLowerCase());
64
+ }
65
+
66
+ /**
67
+ * The scheme this response is being served over.
68
+ *
69
+ * Only two are possible, and anything else is a misconfigured proxy or a
70
+ * forgery — both of which must be seen, not normalised. Mapping every unknown
71
+ * value to `http` meant `ftp`, `javascript` and a truncated header all produced
72
+ * a plausible-looking origin, and the answer went into canonical URLs.
73
+ *
74
+ * A missing header is not a failure: a deployment reached directly has no
75
+ * forwarding layer, and `http` is then the truth.
76
+ */
77
+ function protocolOf(header: string | null): 'http' | 'https' {
78
+ const forwarded = firstValue(header)?.toLowerCase();
79
+ if (forwarded === undefined) return 'http';
80
+ if (forwarded === 'http' || forwarded === 'https') return forwarded;
81
+ throw new Error(
82
+ `Refusing to answer for forwarded protocol "${forwarded}": x-forwarded-proto must be http or https. Fix the proxy, or set PUBLIC_WEB_ORIGIN to state the public origin outright.`,
83
+ );
84
+ }
85
+
86
+ /** A forwarded header may carry the whole proxy chain — the first hop is ours. */
87
+ function firstValue(header: string | null): string | undefined {
88
+ return header?.split(',')[0]?.trim() || undefined;
89
+ }
@@ -1,4 +1,4 @@
1
- import { appIdentity } from '@app/config/identity';
1
+ import { appIdentity } from '@app/config/app-identity';
2
2
  import type { ThemeProviderProps } from '@wrksz/themes/next';
3
3
 
4
4
  export type AppTheme = 'light' | 'dark';
@@ -5,15 +5,22 @@
5
5
  "jsx": "react-jsx",
6
6
  "incremental": true,
7
7
  "types": ["bun"],
8
- "plugins": [{ "name": "next" }],
9
- "paths": { "@/*": ["./src/*"] }
8
+ "plugins": [
9
+ {
10
+ "name": "next"
11
+ }
12
+ ],
13
+ "paths": {
14
+ "@/*": ["./src/*"]
15
+ }
10
16
  },
11
17
  "include": [
12
18
  "next-env.d.ts",
13
19
  "next.config.ts",
14
20
  "src/**/*.ts",
15
21
  "src/**/*.tsx",
16
- ".next/types/**/*.ts"
22
+ ".next/types/**/*.ts",
23
+ "scripts/**/*.ts"
17
24
  ],
18
25
  "exclude": ["node_modules"]
19
26
  }
@@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test';
2
2
  import { loadToolingEnv } from './scripts/tooling-env';
3
3
 
4
4
  const toolingEnv = loadToolingEnv();
5
- const baseURL = toolingEnv.PLAYWRIGHT_BASE_URL ?? toolingEnv.NEXT_PUBLIC_WEB_URL;
5
+ const baseURL = toolingEnv.PLAYWRIGHT_BASE_URL ?? toolingEnv.SMOKE_WEB_ORIGIN;
6
6
 
7
7
  export default defineConfig({
8
8
  testDir: './e2e',
@@ -0,0 +1,169 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "kind": "application",
4
+ "identity": {
5
+ "slug": "stitchkit-starter",
6
+ "name": "Stitchkit Starter",
7
+ "version": "0.1.0",
8
+ "description": {
9
+ "en": "Stitchkit Starter is a production application built with Stitchkit.",
10
+ "ru": "Stitchkit Starter — production-приложение на Stitchkit."
11
+ }
12
+ },
13
+ "roles": [
14
+ {
15
+ "name": "api",
16
+ "workingDirectory": "packages/backend",
17
+ "commands": {
18
+ "development": {
19
+ "executable": "bun",
20
+ "args": [
21
+ "--watch",
22
+ "src/index.ts"
23
+ ]
24
+ },
25
+ "production": {
26
+ "executable": "bun",
27
+ "args": [
28
+ "dist/index.js"
29
+ ]
30
+ }
31
+ },
32
+ "listener": {
33
+ "portVariable": "API_PORT",
34
+ "bindVariable": "BIND_HOST",
35
+ "readinessPath": "/health"
36
+ },
37
+ "drainFloorMs": 15000
38
+ },
39
+ {
40
+ "name": "web",
41
+ "workingDirectory": "packages/frontend",
42
+ "commands": {
43
+ "development": {
44
+ "executable": "bun",
45
+ "args": [
46
+ "scripts/serve.ts",
47
+ "development"
48
+ ]
49
+ },
50
+ "production": {
51
+ "executable": "bun",
52
+ "args": [
53
+ "scripts/serve.ts",
54
+ "production"
55
+ ]
56
+ }
57
+ },
58
+ "listener": {
59
+ "portVariable": "WEB_PORT",
60
+ "bindVariable": "BIND_HOST",
61
+ "readinessPath": "/"
62
+ },
63
+ "drainFloorMs": 5000
64
+ }
65
+ ],
66
+ "build": {
67
+ "command": {
68
+ "executable": "bun",
69
+ "args": [
70
+ "run",
71
+ "build"
72
+ ]
73
+ },
74
+ "artifacts": [
75
+ "packages/backend/dist",
76
+ "packages/frontend/.next",
77
+ "packages/db/src/generated"
78
+ ]
79
+ },
80
+ "requires": [
81
+ {
82
+ "name": "postgres",
83
+ "phases": [
84
+ "release",
85
+ "start"
86
+ ]
87
+ }
88
+ ],
89
+ "release": {
90
+ "migrations": {
91
+ "engine": "prisma",
92
+ "root": "packages/db/migrations",
93
+ "lockfile": "packages/db/migrations/migration_lock.toml"
94
+ }
95
+ },
96
+ "env": {
97
+ "variables": [
98
+ {
99
+ "name": "API_PORT",
100
+ "shape": "integer",
101
+ "required": true
102
+ },
103
+ {
104
+ "name": "BIND_HOST",
105
+ "shape": "string",
106
+ "required": false
107
+ },
108
+ {
109
+ "name": "CORS_ORIGIN",
110
+ "shape": "url",
111
+ "required": false
112
+ },
113
+ {
114
+ "name": "DATABASE_URL",
115
+ "shape": "url",
116
+ "required": true
117
+ },
118
+ {
119
+ "name": "INTERNAL_API_URL",
120
+ "shape": "url",
121
+ "required": false
122
+ },
123
+ {
124
+ "name": "LOG_FORMAT",
125
+ "shape": "enum",
126
+ "required": false,
127
+ "members": [
128
+ "pretty",
129
+ "json"
130
+ ]
131
+ },
132
+ {
133
+ "name": "NODE_ENV",
134
+ "shape": "enum",
135
+ "required": false,
136
+ "members": [
137
+ "development",
138
+ "test",
139
+ "production"
140
+ ]
141
+ },
142
+ {
143
+ "name": "PUBLIC_API_ORIGIN",
144
+ "shape": "url",
145
+ "required": false
146
+ },
147
+ {
148
+ "name": "PUBLIC_REALTIME_ORIGIN",
149
+ "shape": "url",
150
+ "required": false
151
+ },
152
+ {
153
+ "name": "PUBLIC_WEB_HOSTS",
154
+ "shape": "string",
155
+ "required": false
156
+ },
157
+ {
158
+ "name": "PUBLIC_WEB_ORIGIN",
159
+ "shape": "url",
160
+ "required": false
161
+ },
162
+ {
163
+ "name": "WEB_PORT",
164
+ "shape": "integer",
165
+ "required": true
166
+ }
167
+ ]
168
+ }
169
+ }
@@ -0,0 +1,69 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { createHash } from 'node:crypto';
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { appDeclaration } from '../packages/config/src/declaration';
7
+ import type { ProjectDeclaration } from '../packages/config/src/project-declaration.generated';
8
+ import { assertDeclaredBuildInputs } from './build-inputs';
9
+
10
+ function digestOf(text: string): string {
11
+ return `sha256:${createHash('sha256').update(text).digest('hex')}`;
12
+ }
13
+
14
+ function withInput(path: string, digest: string): ProjectDeclaration {
15
+ const build = appDeclaration.build;
16
+ if (!build) throw new Error('the template declares a build');
17
+ return {
18
+ ...appDeclaration,
19
+ build: { ...build, inputs: [{ name: 'catalogue', path, digest }] },
20
+ };
21
+ }
22
+
23
+ test('this template declares no build inputs, and that is the answer', () => {
24
+ // Not "we forgot to say": the frontend cannot reach a data source at all
25
+ // (`check-authored` refuses the import), so the build is a function of the
26
+ // source alone. If a route ever needs data at build time, it declares it.
27
+ expect(appDeclaration.build?.inputs).toBeUndefined();
28
+ expect(() => assertDeclaredBuildInputs()).not.toThrow();
29
+ });
30
+
31
+ test('a declared input whose bytes changed is refused by name', async () => {
32
+ const directory = await mkdtemp(join(tmpdir(), 'starter-inputs-'));
33
+ try {
34
+ await writeFile(join(directory, 'catalogue.json'), '{"items":2}');
35
+ // The digest of what the author froze — not of what is on disk now. This is
36
+ // the whole failure mode: the filename still resolves, so nothing else in
37
+ // the build notices that two builds of one source now differ.
38
+ const stale = digestOf('{"items":1}');
39
+ expect(() =>
40
+ assertDeclaredBuildInputs(withInput('catalogue.json', stale), directory),
41
+ ).toThrow(/"catalogue".*no longer matches its digest/s);
42
+ } finally {
43
+ await rm(directory, { recursive: true, force: true });
44
+ }
45
+ });
46
+
47
+ test('a declared input that matches its digest passes', async () => {
48
+ const directory = await mkdtemp(join(tmpdir(), 'starter-inputs-'));
49
+ try {
50
+ const frozen = '{"items":2}';
51
+ await writeFile(join(directory, 'catalogue.json'), frozen);
52
+ expect(() =>
53
+ assertDeclaredBuildInputs(withInput('catalogue.json', digestOf(frozen)), directory),
54
+ ).not.toThrow();
55
+ } finally {
56
+ await rm(directory, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ test('a declared input that is not there names itself', async () => {
61
+ const directory = await mkdtemp(join(tmpdir(), 'starter-inputs-'));
62
+ try {
63
+ expect(() =>
64
+ assertDeclaredBuildInputs(withInput('catalogue.json', digestOf('{}')), directory),
65
+ ).toThrow(/Declared build input "catalogue" is missing/);
66
+ } finally {
67
+ await rm(directory, { recursive: true, force: true });
68
+ }
69
+ });
@@ -0,0 +1,57 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { appDeclaration } from '../packages/config/src/declaration';
5
+ import type { ProjectDeclaration } from '../packages/config/src/project-declaration.generated';
6
+
7
+ const root = resolve(import.meta.dir, '..');
8
+
9
+ /**
10
+ * The third kind of input, checked before the build reads it.
11
+ *
12
+ * The boundary rule separates code from the values of a place. Data read while
13
+ * building is neither: it is not in the source and it is not a binding, so a
14
+ * build that reads it is a function of something nobody declared. That is the
15
+ * dependency that never announces itself — it works on the machine that happens
16
+ * to have the database, and the artifact quietly stops being a function of the
17
+ * source.
18
+ *
19
+ * Three answers are legitimate, per route rather than per project: render at
20
+ * runtime (the default, and what this template does), read a frozen export
21
+ * whose digest is declared here, or generate the bytes as a release step. This
22
+ * file owns the second one. It is deliberately a no-op for a project that
23
+ * declares no inputs — absent means "this build reads no data", which is an
24
+ * answer, not a gap.
25
+ */
26
+ export function assertDeclaredBuildInputs(
27
+ declaration: ProjectDeclaration = appDeclaration,
28
+ from: string = root,
29
+ ): void {
30
+ for (const input of declaration.build?.inputs ?? []) {
31
+ const path = resolve(from, input.path);
32
+ let bytes: Buffer;
33
+ try {
34
+ bytes = readFileSync(path);
35
+ } catch {
36
+ throw new Error(
37
+ `Declared build input "${input.name}" is missing: ${input.path}. A build input is a frozen export inside the source — restore it, or stop declaring it and read the data at runtime.`,
38
+ );
39
+ }
40
+ const actual = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
41
+ if (actual !== input.digest) {
42
+ throw new Error(
43
+ `Declared build input "${input.name}" (${input.path}) no longer matches its digest.\n declared: ${input.digest}\n actual: ${actual}\nTwo builds of one source would produce different bytes. Re-freeze the export and update the digest in project.json, or drop the input and render the data at runtime.`,
44
+ );
45
+ }
46
+ }
47
+ }
48
+
49
+ if (import.meta.main) {
50
+ assertDeclaredBuildInputs();
51
+ const declared = appDeclaration.build?.inputs ?? [];
52
+ console.log(
53
+ declared.length === 0
54
+ ? 'This build reads no declared data.'
55
+ : `${declared.length} declared build input(s) match their digests.`,
56
+ );
57
+ }
@@ -7,12 +7,13 @@ const rootFiles = ['playwright.config.ts', 'ecosystem.config.cjs', 'ecosystem.de
7
7
  const processEnvMarker = ['process', 'env'].join('.');
8
8
  const replacedThemePackage = ['next', 'themes'].join('-');
9
9
  const generatedDirectories = new Set(['.git', '.next', 'dist', 'node_modules']);
10
+ // The supervision files used to read the environment directly to build an argv
11
+ // for the web role. They no longer do — a role reads its own bindings — so they
12
+ // are no longer environment boundaries, and this list is smaller by two.
10
13
  const processEnvBoundaries = new Set([
11
14
  'packages/frontend/src/env.ts',
12
15
  'packages/config/src/server.ts',
13
16
  'scripts/tooling-env.ts',
14
- 'ecosystem.config.cjs',
15
- 'ecosystem.dev.config.cjs',
16
17
  ]);
17
18
 
18
19
  function lineAt(source: string, offset: number): number {
@@ -52,6 +53,21 @@ function inspect(path: string, source: string): string[] {
52
53
  failures.push(
53
54
  `${path}: server-only dependency ${dependency} crossed the browser boundary`,
54
55
  );
56
+ // Two separate reasons meet on this line, and the second one is the
57
+ // quiet one. Shipping a database client to the browser is the obvious
58
+ // failure; the other is that a route reaching a data source makes the
59
+ // BUILD depend on data — bytes that are neither code nor a binding, so
60
+ // the artifact stops being a function of the source and starts being a
61
+ // function of whichever machine had the database. Three answers are
62
+ // legitimate, chosen per route: render at runtime (what this template
63
+ // does), declare a frozen export as `build.inputs` in `project.json`
64
+ // and let `scripts/build-inputs.ts` pin its digest, or generate the
65
+ // bytes as a release step.
66
+ if (path.startsWith('packages/frontend/src/app/')) {
67
+ failures.push(
68
+ `${path}: a route reading data makes the build depend on it — render at runtime, declare the export in build.inputs, or generate it as a release step`,
69
+ );
70
+ }
55
71
  }
56
72
  }
57
73
  }
@@ -0,0 +1,206 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { readFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { parse } from 'dotenv';
5
+ import { appDeclaration } from '../packages/config/src/declaration';
6
+ import {
7
+ assertSupervisionAllowsShutdown,
8
+ LOCAL_SUPERVISION,
9
+ renderAppIdentity,
10
+ renderEcosystem,
11
+ renderEnvVariables,
12
+ terminationBudgetMs,
13
+ } from './declaration';
14
+ import { ensureLocalEnvironment } from './local-env';
15
+
16
+ const root = resolve(import.meta.dir, '..');
17
+ const read = (name: string) => readFileSync(resolve(root, name), 'utf8');
18
+
19
+ describe('the declaration is the source of what is derived from it', () => {
20
+ test('the checked-in supervision files are exactly what the generator renders', () => {
21
+ // Byte-for-byte: nothing in these files is authored, so a difference is
22
+ // always a hand edit or a stale file — never a formatting opinion.
23
+ expect(read('ecosystem.config.cjs')).toBe(renderEcosystem(appDeclaration, 'production'));
24
+ expect(read('ecosystem.dev.config.cjs')).toBe(
25
+ renderEcosystem(appDeclaration, 'development'),
26
+ );
27
+ expect(read('packages/config/src/app-identity.generated.ts')).toBe(renderAppIdentity());
28
+ });
29
+
30
+ test('env.variables in the declaration matches the one environment schema', () => {
31
+ // Content, not bytes: roles are authored in this file and the formatter
32
+ // owns its shape — only the derived block has to agree.
33
+ expect(appDeclaration.env.variables).toEqual(renderEnvVariables());
34
+ });
35
+
36
+ test('every declared variable appears once, with a shape a reader can act on', () => {
37
+ const derived = renderEnvVariables();
38
+ expect(new Set(derived.map((entry) => entry.name)).size).toBe(derived.length);
39
+ expect(derived).toContainEqual({ name: 'DATABASE_URL', shape: 'url', required: true });
40
+ // A variable with a default is NOT required — the schema decides, not this list.
41
+ expect(derived).toContainEqual({ name: 'BIND_HOST', shape: 'string', required: false });
42
+ // An enum names its members: "one of an unnamed set" is useless to the
43
+ // reader this list exists for.
44
+ expect(derived).toContainEqual({
45
+ name: 'LOG_FORMAT',
46
+ shape: 'enum',
47
+ required: false,
48
+ members: ['pretty', 'json'],
49
+ });
50
+ });
51
+ });
52
+
53
+ describe('supervision may not be shorter than the code needs', () => {
54
+ test('the budget is the whole shutdown, not just the drain', () => {
55
+ for (const role of appDeclaration.roles) {
56
+ // Drain, then the force that follows it, then cleanup. Comparing against
57
+ // the drain alone is what let 15s + 5s meet a 20s kill timeout exactly.
58
+ expect(terminationBudgetMs(role)).toBeGreaterThan(role.drainFloorMs);
59
+ }
60
+ });
61
+
62
+ test('the local policy allows every role its full shutdown', () => {
63
+ expect(() =>
64
+ assertSupervisionAllowsShutdown(appDeclaration, LOCAL_SUPERVISION.killTimeoutMs),
65
+ ).not.toThrow();
66
+ });
67
+
68
+ test('a timeout that only covers the drain is refused', () => {
69
+ const floor = Math.max(...appDeclaration.roles.map((role) => role.drainFloorMs));
70
+ expect(() => assertSupervisionAllowsShutdown(appDeclaration, floor)).toThrow(
71
+ /killed mid-shutdown/,
72
+ );
73
+ });
74
+
75
+ test('a timeout one millisecond under the budget is refused', () => {
76
+ const budget = Math.max(...appDeclaration.roles.map(terminationBudgetMs));
77
+ expect(() => assertSupervisionAllowsShutdown(appDeclaration, budget - 1)).toThrow();
78
+ expect(() => assertSupervisionAllowsShutdown(appDeclaration, budget)).not.toThrow();
79
+ });
80
+
81
+ test('rendering a supervision file cannot bypass the rule', () => {
82
+ const impatient = {
83
+ ...appDeclaration,
84
+ roles: appDeclaration.roles.map((role) => ({ ...role, drainFloorMs: 10 ** 9 })),
85
+ };
86
+ expect(() => renderEcosystem(impatient, 'production')).toThrow(/mid-shutdown/);
87
+ });
88
+ });
89
+
90
+ describe('no repository file carries a value of the place', () => {
91
+ // The real property, checked the same way the frontend build is checked: no
92
+ // value that differs between two deployments may appear in a repository file.
93
+ // A kill timeout may — supervision policy is the place's, and for the manual
94
+ // path this repository IS the place, which is why it is one visible constant.
95
+ ensureLocalEnvironment(root);
96
+ const environment = parse(read('.env'));
97
+ const placementValues = Object.values(environment).filter(
98
+ (value) => /^\d+$/.test(value) || value.includes('://') || /\d+\.\d+\.\d+\.\d+/.test(value),
99
+ );
100
+
101
+ test('the fixture actually contains ports and addresses to look for', () => {
102
+ expect(placementValues.length).toBeGreaterThan(2);
103
+ });
104
+
105
+ const repositoryFiles = [
106
+ 'ecosystem.config.cjs',
107
+ 'ecosystem.dev.config.cjs',
108
+ 'project.json',
109
+ 'packages/config/src/app-identity.generated.ts',
110
+ ];
111
+ for (const name of repositoryFiles) {
112
+ test(`${name} names no port and no address`, () => {
113
+ const rendered = read(name);
114
+ for (const value of placementValues) expect(rendered).not.toInclude(value);
115
+ });
116
+ }
117
+
118
+ for (const name of ['ecosystem.config.cjs', 'ecosystem.dev.config.cjs']) {
119
+ test(`${name} says it is generated`, () => {
120
+ expect(read(name)).toStartWith('// GENERATED FILE — do not edit.');
121
+ });
122
+ }
123
+ });
124
+
125
+ describe('a role starts its own process, never a launcher', () => {
126
+ // Measured, not assumed: under PM2 a `bun run <script>` command made the API
127
+ // role receive the stop signal twice — once from the supervisor, once
128
+ // forwarded by the launcher — and the second press forced the shutdown, which
129
+ // turned a declared 15s drain into 1.3ms. Direct exec: `Shutdown clean`.
130
+ // The schema refuses the shape; this checks what actually reaches PM2.
131
+ const modes: Array<'production' | 'development'> = ['production', 'development'];
132
+
133
+ for (const mode of modes) {
134
+ test(`the ${mode} supervision file execs the role directly`, () => {
135
+ const rendered = renderEcosystem(appDeclaration, mode)
136
+ .split('\n')
137
+ .filter((line) => !line.trimStart().startsWith('//'))
138
+ .join('\n');
139
+ expect(rendered).not.toMatch(/args: \["run"/);
140
+ expect(rendered).not.toContain('--filter');
141
+ // And the deployment's environment is not overruled by a repository file.
142
+ expect(rendered).not.toContain('override');
143
+ });
144
+ }
145
+ });
146
+
147
+ describe('the drain floor has one home', () => {
148
+ test('the API role reads its grace period from the declaration', () => {
149
+ const source = read('packages/backend/src/index.ts');
150
+ // A literal here would be a second number able to disagree with the one a
151
+ // supervisor reads, which is exactly how a 30s floor met a 15s kill timeout.
152
+ expect(source).toContain('gracePeriodMs: apiRole.drainFloorMs');
153
+ expect(source).not.toMatch(/gracePeriodMs:\s*\d/);
154
+ });
155
+ });
156
+
157
+ describe('an argument survives the generator intact', () => {
158
+ test('a space and a quote reach the supervision file unmangled', () => {
159
+ // The reason commands are argv and the generator serialises rather than
160
+ // concatenates: `split(' ')` destroyed quoted arguments, and building
161
+ // `'${part}'` by hand emitted invalid JavaScript for an argument
162
+ // containing a quote.
163
+ const awkward = {
164
+ ...appDeclaration,
165
+ roles: appDeclaration.roles.map((role) => ({
166
+ ...role,
167
+ commands: {
168
+ ...role.commands,
169
+ production: { executable: 'bun', args: ['run me.ts', "it's fine"] },
170
+ },
171
+ })),
172
+ };
173
+
174
+ const rendered = renderEcosystem(awkward, 'production');
175
+ expect(rendered).toContain('["run me.ts","it\'s fine"]');
176
+
177
+ // And it is still valid JavaScript: the file is `require`d by PM2.
178
+ expect(
179
+ () =>
180
+ new Function(
181
+ `return (${rendered.slice(rendered.indexOf('args: [')).slice(6).split(']')[0]}])`,
182
+ ),
183
+ ).not.toThrow();
184
+ });
185
+ });
186
+
187
+ describe('the guidance a next agent reads names the keys that exist', () => {
188
+ // `AGENTS.md` is not documentation about the past — it is the instruction the
189
+ // next agent follows. It kept pointing at `env.required` for a whole release
190
+ // after the key became `env.variables`, which is worse than a stale comment:
191
+ // the agent goes looking for something that is not there.
192
+ const guidance = ['AGENTS.md', 'README.md'];
193
+
194
+ for (const file of guidance) {
195
+ test(`${file} names no key the declaration does not have`, () => {
196
+ const text = readFileSync(resolve(import.meta.dir, '..', file), 'utf8');
197
+ const referenced = [...text.matchAll(/`(env|build|release|roles|identity)\.(\w+)`/g)];
198
+ const unknown = referenced.filter(([, root, key]) => {
199
+ const branch: unknown = Reflect.get(appDeclaration, root ?? '');
200
+ if (typeof branch !== 'object' || branch === null) return true;
201
+ return !Object.hasOwn(branch, key ?? '');
202
+ });
203
+ expect(unknown.map(([match]) => match)).toEqual([]);
204
+ });
205
+ }
206
+ });