create-stitchkit 0.3.3 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +347 -0
- package/README.md +3 -1
- package/UPGRADING.md +342 -0
- package/dist/cli.js +238 -42
- package/examples/repository/_env.example.append +21 -0
- package/examples/repository/packages/backend/src/domain/repository/github-cache.ts +2 -2
- package/examples/repository/packages/backend/src/surface.ts +1 -1
- package/examples/repository/packages/config/src/features.ts +17 -0
- package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +4 -4
- package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
- package/examples/repository/packages/frontend/src/app/api/[...path]/route.ts +66 -0
- package/examples/repository/packages/frontend/src/lib/api/client.ts +17 -12
- package/examples/repository/packages/frontend/src/lib/api/cross-origin.ts +87 -0
- package/examples/repository/packages/frontend/src/lib/api/place.ts +26 -0
- package/examples/repository/packages/frontend/src/lib/api/queries.ts +3 -0
- package/examples/repository/packages/frontend/src/lib/api/server-client.ts +11 -0
- package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +44 -12
- package/examples/repository/packages/frontend/src/providers/client-providers.tsx +30 -0
- package/examples/repository/packages/frontend/src/providers/index.tsx +17 -12
- package/examples/repository/packages/frontend/src/providers/realtime.tsx +6 -4
- package/examples/repository/project.json +189 -0
- package/examples/repository/scripts/runtime-smoke.ts +38 -6
- package/package.json +12 -2
- package/template/AGENTS.md +23 -3
- package/template/README.md +83 -8
- package/template/_env.example +16 -4
- package/template/_gitignore +1 -0
- package/template/biome.json +6 -2
- package/template/bun.lock +115 -98
- package/template/e2e/starter.spec.ts +5 -7
- package/template/ecosystem.config.cjs +42 -19
- package/template/ecosystem.dev.config.cjs +41 -21
- package/template/package.json +12 -10
- package/template/packages/backend/package.json +2 -2
- package/template/packages/backend/src/cleanup.ts +121 -0
- package/template/packages/backend/src/cli.ts +6 -2
- package/template/packages/backend/src/index.ts +33 -8
- package/template/packages/backend/src/surface.ts +6 -1
- package/template/packages/backend/src/transport/errors.ts +4 -2
- package/template/packages/config/package.json +6 -2
- package/template/packages/config/src/app-identity.generated.ts +20 -0
- package/template/packages/config/src/declaration.ts +30 -0
- package/template/packages/config/src/server.ts +8 -17
- package/template/packages/config/src/shutdown.ts +20 -0
- package/template/packages/config/src/variables.ts +89 -0
- package/template/packages/db/package.json +2 -2
- package/template/packages/frontend/next.config.ts +3 -2
- package/template/packages/frontend/package.json +13 -13
- package/template/packages/frontend/scripts/serve.ts +70 -0
- package/template/packages/frontend/src/app/[locale]/layout.tsx +9 -8
- package/template/packages/frontend/src/app/[locale]/page.tsx +2 -2
- package/template/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
- package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +1 -1
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +1 -1
- package/template/packages/frontend/src/app/robots.ts +4 -2
- package/template/packages/frontend/src/app/sitemap.ts +7 -19
- package/template/packages/frontend/src/components/ui/toaster.tsx +4 -1
- package/template/packages/frontend/src/env.ts +27 -8
- package/template/packages/frontend/src/lib/seo/cache-by-origin.test.ts +68 -0
- package/template/packages/frontend/src/lib/seo/cache-by-origin.ts +40 -0
- package/template/packages/frontend/src/lib/seo/metadata.ts +68 -11
- package/template/packages/frontend/src/lib/seo/pages.ts +1 -1
- package/template/packages/frontend/src/lib/seo/request-origin.ts +89 -0
- package/template/packages/frontend/src/theme/config.ts +1 -1
- package/template/packages/frontend/tsconfig.json +10 -3
- package/template/packages/shared/package.json +1 -1
- package/template/playwright.config.ts +1 -1
- package/template/project.json +169 -0
- package/template/scripts/acceptance-database.test.ts +73 -0
- package/template/scripts/acceptance-database.ts +92 -0
- package/template/scripts/acceptance-local.ts +144 -0
- package/template/scripts/build-inputs.test.ts +69 -0
- package/template/scripts/build-inputs.ts +58 -0
- package/template/scripts/build-stamp.test.ts +151 -0
- package/template/scripts/build-stamp.ts +169 -0
- package/template/scripts/check-authored.ts +18 -2
- package/template/scripts/client-boundary.test.ts +117 -0
- package/template/scripts/client-boundary.ts +148 -0
- package/template/scripts/declaration.test.ts +206 -0
- package/template/scripts/declaration.ts +271 -0
- package/template/scripts/deployment-preflight.ts +41 -0
- package/template/scripts/dev.ts +43 -20
- package/template/scripts/local-env.test.ts +2 -2
- package/template/scripts/local-env.ts +9 -3
- package/template/scripts/readiness.ts +92 -0
- package/template/scripts/release-steps.test.ts +87 -0
- package/template/scripts/release-steps.ts +112 -0
- package/template/scripts/release.ts +38 -0
- package/template/scripts/runtime-smoke.test.ts +178 -0
- package/template/scripts/runtime-smoke.ts +21 -5
- package/template/scripts/serve-mode.test.ts +36 -0
- package/template/scripts/shutdown-budget.fixture.ts +29 -0
- package/template/scripts/shutdown-budget.test.ts +164 -0
- package/template/scripts/supervision-signal.test.ts +94 -0
- package/template/scripts/surface-conformance.ts +8 -1
- package/template/scripts/tooling-env.ts +35 -3
- package/template/scripts/web-surface-smoke.ts +183 -2
- package/template/app.config.json +0 -9
- package/template/packages/config/src/identity.ts +0 -18
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
type ClientBoundaryScan,
|
|
7
|
+
clientEntries,
|
|
8
|
+
findDeclarationLeaks,
|
|
9
|
+
specifiers,
|
|
10
|
+
} from './client-boundary';
|
|
11
|
+
|
|
12
|
+
const root = resolve(import.meta.dir, '..');
|
|
13
|
+
const project: ClientBoundaryScan = {
|
|
14
|
+
root,
|
|
15
|
+
frontendSrc: join(root, 'packages/frontend/src'),
|
|
16
|
+
declaration: join(root, 'packages/config/src/declaration.ts'),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe('the declaration stays out of the browser', () => {
|
|
20
|
+
test('the fixture actually has client components to walk', () => {
|
|
21
|
+
expect(clientEntries(project).length).toBeGreaterThan(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('no client graph reaches the project declaration', () => {
|
|
25
|
+
expect(
|
|
26
|
+
findDeclarationLeaks(project).map((chain) =>
|
|
27
|
+
chain.map((file) => relative(root, file)).join(' → '),
|
|
28
|
+
),
|
|
29
|
+
).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('the scanner fails however the import is written', () => {
|
|
34
|
+
// A check that only recognises the one spelling the leak had the first time
|
|
35
|
+
// is a check that passes while the leak is back. These fixtures write it the
|
|
36
|
+
// two other ways it can come back.
|
|
37
|
+
const created: string[] = [];
|
|
38
|
+
afterAll(() => {
|
|
39
|
+
for (const path of created) rmSync(path, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function fixture(files: Record<string, string>): ClientBoundaryScan {
|
|
43
|
+
const base = mkdtempSync(join(tmpdir(), 'client-boundary-'));
|
|
44
|
+
created.push(base);
|
|
45
|
+
const write = (relativePath: string, content: string): void => {
|
|
46
|
+
const path = join(base, relativePath);
|
|
47
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
48
|
+
writeFileSync(path, content);
|
|
49
|
+
};
|
|
50
|
+
write(
|
|
51
|
+
'packages/config/package.json',
|
|
52
|
+
JSON.stringify({
|
|
53
|
+
name: '@app/config',
|
|
54
|
+
exports: { './declaration': './src/declaration.ts' },
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
write('packages/config/src/declaration.ts', 'export const appDeclaration = {};\n');
|
|
58
|
+
for (const [path, content] of Object.entries(files)) write(path, content);
|
|
59
|
+
return {
|
|
60
|
+
root: base,
|
|
61
|
+
frontendSrc: join(base, 'packages/frontend/src'),
|
|
62
|
+
declaration: join(base, 'packages/config/src/declaration.ts'),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test('a barrel that re-exports the declaration is a leak', () => {
|
|
67
|
+
const scan = fixture({
|
|
68
|
+
'packages/frontend/src/page.tsx':
|
|
69
|
+
"'use client';\nimport { appDeclaration } from './lib';\nexport const value = appDeclaration;\n",
|
|
70
|
+
'packages/frontend/src/lib/index.ts': "export * from '@app/config/declaration';\n",
|
|
71
|
+
});
|
|
72
|
+
expect(findDeclarationLeaks(scan)).toHaveLength(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a relative path into the config package is a leak', () => {
|
|
76
|
+
// No `@app/` prefix anywhere in the file, so a check on the specifier
|
|
77
|
+
// string sees nothing at all.
|
|
78
|
+
const scan = fixture({
|
|
79
|
+
'packages/frontend/src/page.tsx':
|
|
80
|
+
"'use client';\nimport { appDeclaration } from '../../config/src/declaration';\nexport const value = appDeclaration;\n",
|
|
81
|
+
});
|
|
82
|
+
expect(findDeclarationLeaks(scan)).toHaveLength(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a double-quoted import is a leak too', () => {
|
|
86
|
+
const scan = fixture({
|
|
87
|
+
'packages/frontend/src/page.tsx':
|
|
88
|
+
'\'use client\';\nimport { appDeclaration } from "@app/config/declaration";\nexport const value = appDeclaration;\n',
|
|
89
|
+
});
|
|
90
|
+
expect(findDeclarationLeaks(scan)).toHaveLength(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a client graph that reaches nothing forbidden is clean', () => {
|
|
94
|
+
// The control: without it every assertion above would also pass with a
|
|
95
|
+
// scanner that reports a leak for anything.
|
|
96
|
+
const scan = fixture({
|
|
97
|
+
'packages/frontend/src/page.tsx':
|
|
98
|
+
"'use client';\nimport { name } from './lib';\nexport const value = name;\n",
|
|
99
|
+
'packages/frontend/src/lib/index.ts': "export const name = 'x';\n",
|
|
100
|
+
});
|
|
101
|
+
expect(findDeclarationLeaks(scan)).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('both quote styles and side-effect imports are read', () => {
|
|
105
|
+
expect(
|
|
106
|
+
specifiers(
|
|
107
|
+
[
|
|
108
|
+
"import a from 'single';",
|
|
109
|
+
'import b from "double";',
|
|
110
|
+
"import 'side-effect';",
|
|
111
|
+
'const c = await import("dynamic");',
|
|
112
|
+
"export { d } from 'reexport';",
|
|
113
|
+
].join('\n'),
|
|
114
|
+
),
|
|
115
|
+
).toEqual(['single', 'double', 'reexport', 'side-effect', 'dynamic']);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
});
|