create-stitchkit 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/README.md +11 -4
- package/dist/cli.js +93 -15
- package/examples/repository/_env.example.append +4 -0
- package/examples/repository/e2e/repository.spec.ts +69 -0
- package/{template → examples/repository}/packages/backend/src/domain/repository/github-cache.test.ts +8 -0
- package/{template → examples/repository}/packages/backend/src/domain/repository/github-cache.ts +2 -1
- package/examples/repository/packages/backend/src/surface.snapshot.json +58 -0
- package/examples/repository/packages/backend/src/surface.ts +16 -0
- package/examples/repository/packages/config/src/features.ts +7 -0
- package/examples/repository/packages/db/schema.prisma +31 -0
- package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +45 -0
- package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +151 -0
- package/{template → examples/repository}/packages/frontend/src/components/repository-summary.tsx +5 -1
- package/{template → examples/repository}/packages/frontend/src/lib/realtime/repository.ts +5 -6
- package/examples/repository/packages/frontend/src/providers/index.tsx +18 -0
- package/examples/repository/packages/shared/src/index.ts +5 -0
- package/examples/repository/packages/shared/src/realtime/repository.ts +10 -0
- package/{template → examples/repository}/packages/shared/src/schemas/repository.ts +4 -2
- package/examples/repository/scripts/runtime-smoke.ts +169 -0
- package/package.json +10 -1
- package/template/AGENTS.md +50 -0
- package/template/README.md +17 -8
- package/template/_env.example +1 -4
- package/template/app.config.json +9 -0
- package/template/bun.lock +6 -4
- package/template/docs/ADDING_A_FEATURE.md +102 -0
- package/template/e2e/starter.spec.ts +34 -22
- package/template/ecosystem.config.cjs +3 -2
- package/template/ecosystem.dev.config.cjs +7 -4
- package/template/package.json +6 -2
- package/template/packages/backend/src/cli.ts +2 -1
- package/template/packages/backend/src/index.ts +4 -3
- package/template/packages/backend/src/surface-manifest.test.ts +237 -0
- package/template/packages/backend/src/surface-manifest.ts +220 -0
- package/template/packages/backend/src/surface.snapshot.json +18 -0
- package/template/packages/backend/src/surface.ts +4 -9
- package/template/packages/backend/src/tools.ts +4 -1
- package/template/packages/backend/src/transport/errors.ts +1 -0
- package/template/packages/backend/src/transport/system-service.ts +8 -0
- package/template/packages/config/package.json +2 -1
- package/template/packages/config/src/features.ts +1 -0
- package/template/packages/config/src/identity.ts +18 -0
- package/template/packages/config/src/server.ts +10 -3
- package/template/packages/db/schema.prisma +0 -23
- package/template/packages/frontend/messages/en.json +0 -1
- package/template/packages/frontend/messages/ru.json +0 -1
- package/template/packages/frontend/package.json +1 -1
- package/template/packages/frontend/src/app/[locale]/page.tsx +8 -19
- package/template/packages/frontend/src/app/[locale]/starter-page.tsx +3 -4
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +3 -2
- package/template/packages/frontend/src/lib/query-client.test.ts +22 -0
- package/template/packages/frontend/src/lib/query-client.ts +10 -2
- package/template/packages/frontend/src/lib/seo/pages.ts +4 -5
- package/template/packages/frontend/src/providers/index.tsx +1 -4
- package/template/packages/frontend/src/theme/config.ts +2 -1
- package/template/packages/frontend/tsconfig.json +7 -1
- package/template/packages/shared/package.json +0 -1
- package/template/packages/shared/src/contracts/system.ts +19 -0
- package/template/packages/shared/src/index.ts +2 -3
- package/template/packages/shared/src/schemas/system.ts +4 -0
- package/template/playwright.config.ts +2 -1
- package/template/scripts/check-authored.ts +20 -6
- package/template/scripts/dev.ts +46 -7
- package/template/scripts/local-env.test.ts +33 -0
- package/template/scripts/local-env.ts +16 -5
- package/template/scripts/runtime-smoke.ts +22 -61
- package/template/scripts/surface-conformance.ts +166 -0
- package/template/scripts/surface-snapshot.ts +21 -0
- package/template/scripts/tooling-env.ts +16 -3
- package/template/tsconfig.json +3 -1
- package/template/_env +0 -11
- package/template/packages/shared/src/events/repository.ts +0 -9
- /package/{template → examples/repository}/packages/backend/src/domain/errors.ts +0 -0
- /package/{template → examples/repository}/packages/backend/src/transport/repository-service.ts +0 -0
- /package/{template → examples/repository}/packages/db/migrations/20260808000000_init/migration.sql +0 -0
- /package/{template → examples/repository}/packages/db/migrations/20260808170000_repository_visibility/migration.sql +0 -0
- /package/{template → examples/repository}/packages/frontend/src/lib/api/client.ts +0 -0
- /package/{template → examples/repository}/packages/frontend/src/lib/api/queries.ts +0 -0
- /package/{template → examples/repository}/packages/frontend/src/providers/realtime.tsx +0 -0
- /package/{template → examples/repository}/packages/shared/src/contracts/repository.ts +0 -0
- /package/{template → examples/repository}/packages/shared/src/schemas/repository.test.ts +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineConfig, devices } from '@playwright/test';
|
|
2
|
-
import {
|
|
2
|
+
import { loadToolingEnv } from './scripts/tooling-env';
|
|
3
3
|
|
|
4
|
+
const toolingEnv = loadToolingEnv();
|
|
4
5
|
const baseURL = toolingEnv.PLAYWRIGHT_BASE_URL ?? toolingEnv.NEXT_PUBLIC_WEB_URL;
|
|
5
6
|
|
|
6
7
|
export default defineConfig({
|
|
@@ -2,7 +2,8 @@ import { readdir, readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { extname, join } from 'node:path';
|
|
3
3
|
import { parseSync, Visitor } from 'oxc-parser';
|
|
4
4
|
|
|
5
|
-
const roots = ['packages', 'scripts'];
|
|
5
|
+
const roots = ['packages', 'scripts', 'e2e'];
|
|
6
|
+
const rootFiles = ['playwright.config.ts', 'ecosystem.config.cjs', 'ecosystem.dev.config.cjs'];
|
|
6
7
|
const processEnvMarker = ['process', 'env'].join('.');
|
|
7
8
|
const replacedThemePackage = ['next', 'themes'].join('-');
|
|
8
9
|
const generatedDirectories = new Set(['.git', '.next', 'dist', 'node_modules']);
|
|
@@ -10,20 +11,26 @@ const processEnvBoundaries = new Set([
|
|
|
10
11
|
'packages/frontend/src/env.ts',
|
|
11
12
|
'packages/config/src/server.ts',
|
|
12
13
|
'scripts/tooling-env.ts',
|
|
14
|
+
'ecosystem.config.cjs',
|
|
15
|
+
'ecosystem.dev.config.cjs',
|
|
13
16
|
]);
|
|
14
17
|
|
|
18
|
+
function lineAt(source: string, offset: number): number {
|
|
19
|
+
return source.slice(0, offset).split('\n').length;
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
function inspect(path: string, source: string): string[] {
|
|
16
23
|
const result = parseSync(path, source);
|
|
17
24
|
const failures = result.errors.map((error) => `${path}: parse error: ${error.message}`);
|
|
18
25
|
const visitor = new Visitor({
|
|
19
26
|
TSAsExpression(node) {
|
|
20
|
-
failures.push(`${path}:${node.
|
|
27
|
+
failures.push(`${path}:${lineAt(source, node.start)}: type assertion`);
|
|
21
28
|
},
|
|
22
29
|
TSTypeAssertion(node) {
|
|
23
|
-
failures.push(`${path}:${node.
|
|
30
|
+
failures.push(`${path}:${lineAt(source, node.start)}: type assertion`);
|
|
24
31
|
},
|
|
25
32
|
TSAnyKeyword(node) {
|
|
26
|
-
failures.push(`${path}:${node.
|
|
33
|
+
failures.push(`${path}:${lineAt(source, node.start)}: explicit any`);
|
|
27
34
|
},
|
|
28
35
|
});
|
|
29
36
|
visitor.visit(result.program);
|
|
@@ -62,13 +69,20 @@ async function visitDirectory(directory: string): Promise<string[]> {
|
|
|
62
69
|
failures.push(...(await visitDirectory(path)));
|
|
63
70
|
continue;
|
|
64
71
|
}
|
|
65
|
-
if (!['.ts', '.tsx'].includes(extname(path))) continue;
|
|
72
|
+
if (!['.cjs', '.ts', '.tsx'].includes(extname(path))) continue;
|
|
66
73
|
failures.push(...inspect(path, await readFile(path, 'utf8')));
|
|
67
74
|
}
|
|
68
75
|
return failures;
|
|
69
76
|
}
|
|
70
77
|
|
|
71
|
-
const failures =
|
|
78
|
+
const failures = [
|
|
79
|
+
...(await Promise.all(roots.map(visitDirectory))).flat(),
|
|
80
|
+
...(
|
|
81
|
+
await Promise.all(
|
|
82
|
+
rootFiles.map(async (path) => inspect(path, await readFile(path, 'utf8'))),
|
|
83
|
+
)
|
|
84
|
+
).flat(),
|
|
85
|
+
];
|
|
72
86
|
const webPackage = await readFile('packages/frontend/package.json', 'utf8');
|
|
73
87
|
if (webPackage.includes(replacedThemePackage)) {
|
|
74
88
|
failures.push(
|
package/template/scripts/dev.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
|
+
import { appIdentity } from '../packages/config/src/identity';
|
|
2
3
|
import { ensureLocalEnvironment } from './local-env';
|
|
4
|
+
import { inheritToolingEnvironment } from './tooling-env';
|
|
3
5
|
|
|
4
6
|
const root = resolve(import.meta.dir, '..');
|
|
5
|
-
async function run(command: string[]): Promise<void> {
|
|
7
|
+
async function run(command: string[], environment?: Record<string, string>): Promise<void> {
|
|
6
8
|
const child = Bun.spawn(command, {
|
|
7
9
|
cwd: root,
|
|
10
|
+
env: environment ? inheritToolingEnvironment(environment) : undefined,
|
|
8
11
|
stdin: 'inherit',
|
|
9
12
|
stdout: 'inherit',
|
|
10
13
|
stderr: 'inherit',
|
|
@@ -13,11 +16,47 @@ async function run(command: string[]): Promise<void> {
|
|
|
13
16
|
if (exitCode !== 0) throw new Error(`${command.join(' ')} failed with exit code ${exitCode}`);
|
|
14
17
|
}
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
export async function runDevelopment(environment?: Record<string, string>): Promise<void> {
|
|
20
|
+
ensureLocalEnvironment(root);
|
|
21
|
+
assertToolAvailable('pm2', 'Install PM2 with `bun add --global pm2`, then rerun.');
|
|
22
|
+
await run(['pm2', 'ping']);
|
|
23
|
+
const environmentForRun = await developmentEnvironment(environment);
|
|
24
|
+
if (environmentForRun.DATABASE_URL?.includes('USER:PASSWORD')) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'DATABASE_URL still contains the starter placeholder. Create a PostgreSQL database, update DATABASE_URL in .env, then rerun `bun run dev`.',
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
await run(['bun', 'run', 'db:setup'], environmentForRun);
|
|
30
|
+
await run(
|
|
31
|
+
['pm2', 'startOrReload', 'ecosystem.dev.config.cjs', '--update-env'],
|
|
32
|
+
environmentForRun,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assertToolAvailable(command: string, instruction: string): void {
|
|
37
|
+
if (!Bun.which(command)) throw new Error(`${command} is required. ${instruction}`);
|
|
38
|
+
}
|
|
17
39
|
|
|
18
|
-
|
|
19
|
-
|
|
40
|
+
export async function developmentEnvironment(
|
|
41
|
+
overrides: Record<string, string> = {},
|
|
42
|
+
): Promise<Record<string, string>> {
|
|
43
|
+
const { env } = await import('../packages/config/src/server');
|
|
44
|
+
return {
|
|
45
|
+
DATABASE_URL: env.DATABASE_URL,
|
|
46
|
+
API_PORT: String(env.API_PORT),
|
|
47
|
+
WEB_PORT: String(env.WEB_PORT),
|
|
48
|
+
CORS_ORIGIN: env.CORS_ORIGIN,
|
|
49
|
+
NEXT_PUBLIC_API_URL: env.NEXT_PUBLIC_API_URL,
|
|
50
|
+
NEXT_PUBLIC_WEB_URL: env.NEXT_PUBLIC_WEB_URL,
|
|
51
|
+
INTERNAL_API_URL: env.INTERNAL_API_URL,
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (import.meta.main) {
|
|
57
|
+
await runDevelopment();
|
|
20
58
|
|
|
21
|
-
console.log(
|
|
22
|
-
console.log('Web: http://localhost:3210/en');
|
|
23
|
-
console.log('API: http://localhost:3211');
|
|
59
|
+
console.log(`${appIdentity.name} development processes are running`);
|
|
60
|
+
console.log('Web: http://localhost:3210/en');
|
|
61
|
+
console.log('API: http://localhost:3211');
|
|
62
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { appIdentity } from '../packages/config/src/identity';
|
|
6
|
+
import { ensureLocalEnvironment } from './local-env';
|
|
7
|
+
|
|
8
|
+
describe('ensureLocalEnvironment', () => {
|
|
9
|
+
test('renders the application identity into a fresh .env and never touches an existing one', async () => {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), 'sk-env-'));
|
|
11
|
+
try {
|
|
12
|
+
await writeFile(
|
|
13
|
+
join(root, '.env.example'),
|
|
14
|
+
'DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:5432/stitchkit_starter\n',
|
|
15
|
+
);
|
|
16
|
+
ensureLocalEnvironment(root);
|
|
17
|
+
const created = await readFile(join(root, '.env'), 'utf8');
|
|
18
|
+
// In the neutral dev workspace the slug IS the neutral identity, so the
|
|
19
|
+
// substitution is proven end-to-end by the starter lane on a renamed
|
|
20
|
+
// scaffold; here we prove the file is created from the example with the
|
|
21
|
+
// identity-derived database name in place.
|
|
22
|
+
const databaseName = appIdentity.slug.replaceAll('-', '_');
|
|
23
|
+
expect(created).toContain(`5432/${databaseName}`);
|
|
24
|
+
|
|
25
|
+
// Idempotency — a developer's local credentials survive every dev run.
|
|
26
|
+
await writeFile(join(root, '.env'), 'DATABASE_URL=postgresql://real-creds@db/mine\n');
|
|
27
|
+
ensureLocalEnvironment(root);
|
|
28
|
+
expect(await readFile(join(root, '.env'), 'utf8')).toContain('real-creds');
|
|
29
|
+
} finally {
|
|
30
|
+
await rm(root, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
|
+
import { appIdentity } from '../packages/config/src/identity';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Create `.env` from `.env.example` on first run, rendering the application
|
|
7
|
+
* identity into the database name. `.env.example` is the ONLY environment
|
|
8
|
+
* source the repository ships — the scaffolder never writes `.env`, so a
|
|
9
|
+
* rename in `app.config.json` changes the database of the next created
|
|
10
|
+
* environment too. Synchronous on purpose: `playwright.config.ts` and other
|
|
11
|
+
* synchronous entry points must be able to self-heal before validating.
|
|
12
|
+
*/
|
|
13
|
+
export function ensureLocalEnvironment(root: string): void {
|
|
5
14
|
const destination = resolve(root, '.env');
|
|
6
|
-
if (
|
|
7
|
-
|
|
15
|
+
if (existsSync(destination)) return;
|
|
16
|
+
const example = readFileSync(resolve(root, '.env.example'), 'utf8');
|
|
17
|
+
const databaseName = appIdentity.slug.replaceAll('-', '_');
|
|
18
|
+
writeFileSync(destination, example.replaceAll('stitchkit_starter', databaseName));
|
|
8
19
|
}
|
|
9
20
|
|
|
10
21
|
if (import.meta.main) {
|
|
11
|
-
|
|
22
|
+
ensureLocalEnvironment(resolve(import.meta.dir, '..'));
|
|
12
23
|
}
|
|
@@ -1,69 +1,30 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { io } from 'socket.io-client';
|
|
1
|
+
import { systemContract } from '@app/shared';
|
|
2
|
+
import { createClient, createHttpClient } from 'stitchkit';
|
|
4
3
|
import { z } from 'zod';
|
|
5
|
-
import {
|
|
4
|
+
import { runSurfaceConformance } from './surface-conformance';
|
|
5
|
+
import { loadToolingEnv } from './tooling-env';
|
|
6
6
|
|
|
7
|
-
const apiOrigin =
|
|
7
|
+
const apiOrigin = loadToolingEnv().NEXT_PUBLIC_API_URL;
|
|
8
8
|
|
|
9
|
-
async function json(path: string
|
|
10
|
-
const response = await fetch(`${apiOrigin}${path}
|
|
11
|
-
if (!response.ok)
|
|
12
|
-
throw new Error(`${init?.method ?? 'GET'} ${path} returned ${response.status}`);
|
|
9
|
+
async function json(path: string): Promise<unknown> {
|
|
10
|
+
const response = await fetch(`${apiOrigin}${path}`);
|
|
11
|
+
if (!response.ok) throw new Error(`GET ${path} returned ${response.status}`);
|
|
13
12
|
return response.json();
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
try {
|
|
29
|
-
z.object({ status: z.literal('ok') }).parse(await json('/health'));
|
|
30
|
-
const corsResponse = await fetch(`${apiOrigin}/api/repository/`, {
|
|
31
|
-
headers: { Origin: 'http://localhost:58302' },
|
|
32
|
-
});
|
|
33
|
-
if (corsResponse.headers.get('access-control-allow-origin') !== '*') {
|
|
34
|
-
throw new Error('Public API does not allow a forwarded browser origin');
|
|
35
|
-
}
|
|
36
|
-
const openApi = z
|
|
37
|
-
.object({ paths: z.record(z.string(), z.unknown()) })
|
|
38
|
-
.parse(await json('/openapi.json'));
|
|
39
|
-
if (!Object.keys(openApi.paths).some((path) => path.startsWith('/api/repository'))) {
|
|
40
|
-
throw new Error('OpenAPI repository path is missing');
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const refreshed = RepositorySnapshotSchema.parse(
|
|
44
|
-
await json('/api/repository/refresh', { method: 'POST' }),
|
|
45
|
-
);
|
|
46
|
-
if ((await refreshedEvent) !== refreshed.fullName) {
|
|
47
|
-
throw new Error('Socket.IO repository identity differs');
|
|
48
|
-
}
|
|
49
|
-
const cached = RepositorySnapshotSchema.parse(await json('/api/repository/'));
|
|
50
|
-
if (cached.fullName !== refreshed.fullName) throw new Error('Repository cache read differs');
|
|
51
|
-
|
|
52
|
-
const client = new Client(
|
|
53
|
-
{ name: 'starter-lane', version: '1.0.0' },
|
|
54
|
-
{ versionNegotiation: { mode: { pin: '2026-07-28' } } },
|
|
55
|
-
);
|
|
56
|
-
const transport = new StreamableHTTPClientTransport(new URL(`${apiOrigin}/mcp`));
|
|
57
|
-
await client.connect(transport);
|
|
58
|
-
const tools = await client.listTools();
|
|
59
|
-
if (!tools.tools.some((tool) => tool.name === 'repository_read')) {
|
|
60
|
-
throw new Error('MCP repository tool is absent');
|
|
61
|
-
}
|
|
62
|
-
const result = await client.callTool({ name: 'repository_read', arguments: {} });
|
|
63
|
-
if (result.isError) throw new Error('MCP repository read failed');
|
|
64
|
-
await client.close();
|
|
65
|
-
} finally {
|
|
66
|
-
socket.close();
|
|
15
|
+
z.object({ status: z.literal('ok') }).parse(await json('/health'));
|
|
16
|
+
const system = createClient(
|
|
17
|
+
systemContract,
|
|
18
|
+
createHttpClient({ baseUrl: `${apiOrigin}/api`, credentials: 'omit' }),
|
|
19
|
+
);
|
|
20
|
+
z.object({ status: z.literal('ok') }).parse(await system.status());
|
|
21
|
+
const openApi = z
|
|
22
|
+
.object({ paths: z.record(z.string(), z.unknown()) })
|
|
23
|
+
.parse(await json('/openapi.json'));
|
|
24
|
+
if (!Object.keys(openApi.paths).includes('/api/system/status')) {
|
|
25
|
+
throw new Error('System status contract is missing from OpenAPI');
|
|
67
26
|
}
|
|
68
27
|
|
|
69
|
-
|
|
28
|
+
await runSurfaceConformance({ apiOrigin });
|
|
29
|
+
|
|
30
|
+
console.log('Runtime HTTP, typed client, OpenAPI and MCP smoke passed');
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
|
4
|
+
import { mountAgent } from 'stitchkit/tools';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { createSurface } from '../packages/backend/src/surface';
|
|
7
|
+
import {
|
|
8
|
+
assertManifestMatchesSnapshot,
|
|
9
|
+
assertSurfaceConformance,
|
|
10
|
+
buildSurfaceManifest,
|
|
11
|
+
type SurfaceManifestOperation,
|
|
12
|
+
} from '../packages/backend/src/surface-manifest';
|
|
13
|
+
|
|
14
|
+
export const SURFACE_SNAPSHOT_PATH = 'packages/backend/src/surface.snapshot.json';
|
|
15
|
+
|
|
16
|
+
export interface SurfaceProbe {
|
|
17
|
+
name: string;
|
|
18
|
+
run: () => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function defineSurfaceProbe<TInput, TOutput>({
|
|
22
|
+
name,
|
|
23
|
+
input,
|
|
24
|
+
fixture,
|
|
25
|
+
output,
|
|
26
|
+
run,
|
|
27
|
+
}: {
|
|
28
|
+
name: string;
|
|
29
|
+
input: z.ZodType<TInput>;
|
|
30
|
+
fixture: unknown;
|
|
31
|
+
output?: z.ZodType<TOutput>;
|
|
32
|
+
run: (input: TInput) => Promise<TOutput>;
|
|
33
|
+
}): SurfaceProbe {
|
|
34
|
+
return {
|
|
35
|
+
name,
|
|
36
|
+
run: async () => {
|
|
37
|
+
const result = await run(input.parse(fixture));
|
|
38
|
+
output?.parse(result);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface SurfaceConformanceOptions {
|
|
44
|
+
apiOrigin: string;
|
|
45
|
+
probes?: readonly SurfaceProbe[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readJson(url: string): Promise<unknown> {
|
|
49
|
+
const response = await fetch(url);
|
|
50
|
+
if (!response.ok) throw new Error(`GET ${url} returned ${response.status}`);
|
|
51
|
+
return response.json();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Observe the CLI surface EXTERNALLY — spawn the real CLI process and parse
|
|
56
|
+
* its command table, instead of re-deriving the list from the same in-process
|
|
57
|
+
* `services` the manifest was built from (which could only ever agree).
|
|
58
|
+
*/
|
|
59
|
+
export async function discoverCliCommands(root = process.cwd()): Promise<string[]> {
|
|
60
|
+
const child = Bun.spawn({
|
|
61
|
+
cmd: ['bun', 'packages/backend/src/cli.ts', '--help'],
|
|
62
|
+
cwd: root,
|
|
63
|
+
stdout: 'pipe',
|
|
64
|
+
stderr: 'pipe',
|
|
65
|
+
});
|
|
66
|
+
const [output, errors, exitCode] = await Promise.all([
|
|
67
|
+
new Response(child.stdout).text(),
|
|
68
|
+
new Response(child.stderr).text(),
|
|
69
|
+
child.exited,
|
|
70
|
+
]);
|
|
71
|
+
if (exitCode !== 0) {
|
|
72
|
+
throw new Error(`CLI --help exited with ${exitCode}: ${errors || output}`);
|
|
73
|
+
}
|
|
74
|
+
const lines = output.split('\n');
|
|
75
|
+
const start = lines.findIndex((line) => line.trim() === 'Commands:');
|
|
76
|
+
if (start === -1) throw new Error('CLI --help output carries no "Commands:" section');
|
|
77
|
+
const commands: string[] = [];
|
|
78
|
+
for (const line of lines.slice(start + 1)) {
|
|
79
|
+
if (!line.startsWith(' ')) break;
|
|
80
|
+
const name = line.trim().split(/\s+/)[0];
|
|
81
|
+
if (name) commands.push(name);
|
|
82
|
+
}
|
|
83
|
+
return commands;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function discoverMcpTools(
|
|
87
|
+
apiOrigin: string,
|
|
88
|
+
_manifest: readonly SurfaceManifestOperation[],
|
|
89
|
+
): Promise<string[]> {
|
|
90
|
+
const client = new Client(
|
|
91
|
+
{ name: 'surface-conformance', version: '1.0.0' },
|
|
92
|
+
{ versionNegotiation: { mode: { pin: '2026-07-28' } } },
|
|
93
|
+
);
|
|
94
|
+
const transport = new StreamableHTTPClientTransport(new URL(`${apiOrigin}/mcp`));
|
|
95
|
+
try {
|
|
96
|
+
await client.connect(transport);
|
|
97
|
+
return (await client.listTools()).tools.map((tool) => tool.name);
|
|
98
|
+
} finally {
|
|
99
|
+
await client.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function runSurfaceConformance({
|
|
104
|
+
apiOrigin,
|
|
105
|
+
probes = [],
|
|
106
|
+
}: SurfaceConformanceOptions): Promise<void> {
|
|
107
|
+
const { services, socket } = await createSurface();
|
|
108
|
+
try {
|
|
109
|
+
const manifest = buildSurfaceManifest(services);
|
|
110
|
+
// The committed snapshot is the anchor no source edit can move along with
|
|
111
|
+
// itself: removing a transport from `expose` changes the live manifest but
|
|
112
|
+
// not the snapshot, so the lane fails until the snapshot is deliberately
|
|
113
|
+
// regenerated and reviewed.
|
|
114
|
+
const snapshot = SurfaceSnapshotSchema.parse(
|
|
115
|
+
JSON.parse(await readFile(join(process.cwd(), SURFACE_SNAPSHOT_PATH), 'utf8')),
|
|
116
|
+
);
|
|
117
|
+
assertManifestMatchesSnapshot(manifest, snapshot);
|
|
118
|
+
const openApi = await readJson(`${apiOrigin}/openapi.json`);
|
|
119
|
+
const mcpToolNames = await discoverMcpTools(apiOrigin, manifest);
|
|
120
|
+
assertSurfaceConformance({
|
|
121
|
+
manifest,
|
|
122
|
+
openApi: OpenApiSnapshotSchema.parse(openApi),
|
|
123
|
+
mcpToolNames,
|
|
124
|
+
// AGENT has no external process to observe — it is anchored by the
|
|
125
|
+
// snapshot above; CLI is observed by spawning the real CLI.
|
|
126
|
+
agentToolNames: Object.keys(mountAgent(services)),
|
|
127
|
+
cliToolNames: await discoverCliCommands(),
|
|
128
|
+
});
|
|
129
|
+
for (const probe of probes) {
|
|
130
|
+
try {
|
|
131
|
+
await probe.run();
|
|
132
|
+
} catch (cause) {
|
|
133
|
+
throw new Error(`Surface probe "${probe.name}" failed`, { cause });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
await socket.io.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const OpenApiSnapshotSchema = z.object({
|
|
142
|
+
paths: z.record(z.string(), z.record(z.string(), z.unknown())),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const SurfaceSnapshotSchema: z.ZodType<SurfaceManifestOperation[]> = z.array(
|
|
146
|
+
z.object({
|
|
147
|
+
service: z.string(),
|
|
148
|
+
action: z.string(),
|
|
149
|
+
scope: z.string(),
|
|
150
|
+
hasInput: z.boolean(),
|
|
151
|
+
hasOutput: z.boolean(),
|
|
152
|
+
inputShape: z.string().nullable(),
|
|
153
|
+
outputShape: z.string().nullable(),
|
|
154
|
+
http: z.array(
|
|
155
|
+
z.object({
|
|
156
|
+
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']),
|
|
157
|
+
path: z.string(),
|
|
158
|
+
}),
|
|
159
|
+
),
|
|
160
|
+
tools: z.object({
|
|
161
|
+
MCP: z.string().optional(),
|
|
162
|
+
AGENT: z.string().optional(),
|
|
163
|
+
CLI: z.string().optional(),
|
|
164
|
+
}),
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Regenerate the committed surface snapshot — the anchor `surface-conformance`
|
|
4
|
+
* compares the live contract surface against. Run it deliberately after an
|
|
5
|
+
* intended surface change and review the diff like any other contract change.
|
|
6
|
+
*/
|
|
7
|
+
import { writeFile } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { createSurface } from '../packages/backend/src/surface';
|
|
10
|
+
import { buildSurfaceManifest } from '../packages/backend/src/surface-manifest';
|
|
11
|
+
import { SURFACE_SNAPSHOT_PATH } from './surface-conformance';
|
|
12
|
+
|
|
13
|
+
const { services, socket } = await createSurface();
|
|
14
|
+
try {
|
|
15
|
+
const manifest = buildSurfaceManifest(services);
|
|
16
|
+
const target = join(process.cwd(), SURFACE_SNAPSHOT_PATH);
|
|
17
|
+
await writeFile(target, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
18
|
+
console.log(`Wrote ${manifest.length} operation(s) to ${SURFACE_SNAPSHOT_PATH}`);
|
|
19
|
+
} finally {
|
|
20
|
+
await socket.io.close();
|
|
21
|
+
}
|
|
@@ -2,14 +2,27 @@ import path from 'node:path';
|
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { config as dotenvConfig } from 'dotenv';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
import { ensureLocalEnvironment } from './local-env';
|
|
5
6
|
|
|
6
7
|
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
-
dotenvConfig({ path: path.resolve(scriptDirectory, '..', '.env'), quiet: true });
|
|
8
|
-
|
|
9
8
|
const ToolingEnvSchema = z.object({
|
|
10
9
|
NEXT_PUBLIC_API_URL: z.url(),
|
|
11
10
|
NEXT_PUBLIC_WEB_URL: z.url(),
|
|
12
11
|
PLAYWRIGHT_BASE_URL: z.url().optional(),
|
|
13
12
|
});
|
|
14
13
|
|
|
15
|
-
export
|
|
14
|
+
export function loadToolingEnv(root = path.resolve(scriptDirectory, '..')) {
|
|
15
|
+
// Self-heal FIRST — a fresh clone has no `.env`, and validation must not
|
|
16
|
+
// fire before the environment can be created (the second-developer path:
|
|
17
|
+
// `runtime:smoke` and `e2e` both start here).
|
|
18
|
+
ensureLocalEnvironment(root);
|
|
19
|
+
dotenvConfig({ path: path.resolve(root, '.env'), quiet: true });
|
|
20
|
+
return ToolingEnvSchema.parse(process.env);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Process inheritance is isolated to this tooling environment boundary. */
|
|
24
|
+
export function inheritToolingEnvironment(
|
|
25
|
+
overrides: Record<string, string>,
|
|
26
|
+
): Record<string, string | undefined> {
|
|
27
|
+
return { ...process.env, ...overrides };
|
|
28
|
+
}
|
package/template/tsconfig.json
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
"moduleResolution": "bundler",
|
|
6
6
|
"strict": true,
|
|
7
7
|
"skipLibCheck": true,
|
|
8
|
+
"types": ["bun", "node"],
|
|
8
9
|
"allowImportingTsExtensions": true,
|
|
9
10
|
"noEmit": true,
|
|
10
11
|
"resolveJsonModule": true,
|
|
11
12
|
"verbatimModuleSyntax": true
|
|
12
|
-
}
|
|
13
|
+
},
|
|
14
|
+
"include": ["scripts/**/*.ts", "e2e/**/*.ts", "playwright.config.ts"]
|
|
13
15
|
}
|
package/template/_env
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
NODE_ENV=development
|
|
2
|
-
DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:5432/stitchkit_starter
|
|
3
|
-
API_PORT=3211
|
|
4
|
-
WEB_PORT=3210
|
|
5
|
-
NEXT_PUBLIC_API_URL=http://127.0.0.1:3211
|
|
6
|
-
INTERNAL_API_URL=http://127.0.0.1:3211
|
|
7
|
-
NEXT_PUBLIC_WEB_URL=http://127.0.0.1:3210
|
|
8
|
-
LOG_FORMAT=pretty
|
|
9
|
-
GITHUB_REPOSITORY=max-listov/stitchkit
|
|
10
|
-
GITHUB_CACHE_TTL_SECONDS=900
|
|
11
|
-
# GITHUB_TOKEN=github_pat_...
|
|
File without changes
|
/package/{template → examples/repository}/packages/backend/src/transport/repository-service.ts
RENAMED
|
File without changes
|
/package/{template → examples/repository}/packages/db/migrations/20260808000000_init/migration.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|