create-stitchkit 0.2.0 → 0.3.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 +77 -0
- package/dist/cli.js +7 -16
- package/examples/repository/_env.example.append +2 -0
- package/examples/repository/e2e/repository.spec.ts +76 -2
- package/examples/repository/packages/backend/src/domain/repository/github-cache.test.ts +8 -0
- package/examples/repository/packages/backend/src/domain/repository/github-cache.ts +2 -2
- package/examples/repository/packages/backend/src/surface.snapshot.json +58 -0
- package/examples/repository/packages/backend/src/surface.ts +7 -5
- package/examples/repository/packages/config/src/features.ts +1 -0
- package/examples/repository/packages/frontend/src/components/repository-summary.tsx +7 -3
- package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +5 -6
- package/examples/repository/packages/shared/src/index.ts +3 -1
- package/examples/repository/packages/shared/src/realtime/repository.ts +10 -0
- package/examples/repository/packages/shared/src/schemas/repository.ts +4 -2
- package/examples/repository/scripts/runtime-smoke.ts +100 -19
- package/package.json +1 -1
- package/template/README.md +6 -10
- package/template/_env.example +1 -1
- package/template/bun.lock +4 -4
- package/template/docs/ADDING_A_FEATURE.md +3 -2
- package/template/e2e/starter.spec.ts +21 -13
- package/template/ecosystem.dev.config.cjs +0 -12
- package/template/package.json +5 -3
- package/template/packages/backend/src/cli.ts +1 -1
- package/template/packages/backend/src/index.ts +27 -24
- package/template/packages/backend/src/surface-manifest.test.ts +183 -8
- package/template/packages/backend/src/surface-manifest.ts +108 -5
- package/template/packages/backend/src/surface.snapshot.json +18 -0
- package/template/packages/backend/src/surface.ts +2 -1
- package/template/packages/backend/src/tools.ts +5 -2
- 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/src/server.ts +1 -4
- package/template/packages/frontend/package.json +0 -1
- 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/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 -1
- 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 +19 -10
- package/template/scripts/local-env.test.ts +33 -0
- package/template/scripts/local-env.ts +16 -5
- package/template/scripts/runtime-smoke.ts +14 -4
- package/template/scripts/surface-conformance.ts +77 -16
- package/template/scripts/surface-snapshot.ts +21 -0
- package/template/scripts/tooling-env.ts +16 -3
- package/template/scripts/web-surface-smoke.ts +28 -0
- package/template/tsconfig.json +3 -1
- package/examples/repository/_env.append +0 -3
- package/examples/repository/packages/shared/src/events/repository.ts +0 -9
- package/template/_env +0 -9
- package/template/docs/LAN_HTTPS.md +0 -28
- package/template/packages/backend/src/transport/lan-onboarding.ts +0 -29
- package/template/scripts/dev-lan.test.ts +0 -58
- package/template/scripts/dev-lan.ts +0 -176
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"service": "system",
|
|
4
|
+
"action": "status",
|
|
5
|
+
"scope": "public",
|
|
6
|
+
"hasInput": false,
|
|
7
|
+
"hasOutput": true,
|
|
8
|
+
"inputShape": null,
|
|
9
|
+
"outputShape": "58b078ade3ee6167",
|
|
10
|
+
"http": [
|
|
11
|
+
{
|
|
12
|
+
"method": "GET",
|
|
13
|
+
"path": "/api/system/status"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"tools": {}
|
|
17
|
+
}
|
|
18
|
+
]
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { env } from '@app/config';
|
|
2
2
|
import { createSocketIOServer } from 'stitchkit/server';
|
|
3
|
+
import { createSystemService } from './transport/system-service';
|
|
3
4
|
|
|
4
5
|
export async function createSurface() {
|
|
5
6
|
const socket = await createSocketIOServer({ cors: { origin: env.CORS_ORIGIN } });
|
|
6
|
-
return { socket, services: [] };
|
|
7
|
+
return { socket, services: [createSystemService()] };
|
|
7
8
|
}
|
|
@@ -5,11 +5,14 @@ const { services, socket } = await createSurface();
|
|
|
5
5
|
|
|
6
6
|
try {
|
|
7
7
|
const surface = { services };
|
|
8
|
+
const toolNames = listToolNames(surface)
|
|
9
|
+
.filter((tool) => tool.transports.some((transport) => transport !== 'HTTP'))
|
|
10
|
+
.map((tool) => tool.name);
|
|
8
11
|
console.log(
|
|
9
12
|
JSON.stringify(
|
|
10
13
|
{
|
|
11
14
|
manifest: buildToolManifest({ services, transport: 'AGENT' }),
|
|
12
|
-
names:
|
|
15
|
+
names: toolNames,
|
|
13
16
|
transports: summarizeTransports(surface),
|
|
14
17
|
},
|
|
15
18
|
null,
|
|
@@ -17,5 +20,5 @@ try {
|
|
|
17
20
|
),
|
|
18
21
|
);
|
|
19
22
|
} finally {
|
|
20
|
-
await socket.
|
|
23
|
+
await socket.close();
|
|
21
24
|
}
|
|
@@ -16,10 +16,7 @@ export const env = createEnv({
|
|
|
16
16
|
INTERNAL_API_URL: z.url(),
|
|
17
17
|
NEXT_PUBLIC_WEB_URL: z.url(),
|
|
18
18
|
LOG_FORMAT: z.enum(['pretty', 'json']).default('pretty'),
|
|
19
|
-
CORS_ORIGIN: z.
|
|
20
|
-
DEV_HTTPS_CERT: z.string().min(1).optional(),
|
|
21
|
-
DEV_HTTPS_KEY: z.string().min(1).optional(),
|
|
22
|
-
DEV_HTTPS_CA: z.string().min(1).optional(),
|
|
19
|
+
CORS_ORIGIN: z.url(),
|
|
23
20
|
...featureServerSchema,
|
|
24
21
|
},
|
|
25
22
|
runtimeEnv: process.env,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { getQueryClient } from './query-client';
|
|
3
|
+
|
|
4
|
+
describe('query dehydration policy', () => {
|
|
5
|
+
test('includes successful prefetched data and pending streamed queries', () => {
|
|
6
|
+
const queryClient = getQueryClient();
|
|
7
|
+
queryClient.setQueryData(['prefetched-project'], { id: 'project-1' });
|
|
8
|
+
void queryClient.prefetchQuery({
|
|
9
|
+
queryKey: ['pending-project'],
|
|
10
|
+
queryFn: () => new Promise(() => undefined),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const shouldDehydrate = queryClient.getDefaultOptions().dehydrate?.shouldDehydrateQuery;
|
|
14
|
+
if (!shouldDehydrate) throw new Error('dehydration policy is required');
|
|
15
|
+
const successful = queryClient.getQueryCache().find({ queryKey: ['prefetched-project'] });
|
|
16
|
+
const pending = queryClient.getQueryCache().find({ queryKey: ['pending-project'] });
|
|
17
|
+
if (!successful || !pending) throw new Error('test queries were not created');
|
|
18
|
+
|
|
19
|
+
expect(shouldDehydrate(successful)).toBe(true);
|
|
20
|
+
expect(shouldDehydrate(pending)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -1,11 +1,19 @@
|
|
|
1
|
-
import { isServer, QueryClient } from '@tanstack/react-query';
|
|
1
|
+
import { defaultShouldDehydrateQuery, isServer, QueryClient } from '@tanstack/react-query';
|
|
2
2
|
import { cache } from 'react';
|
|
3
3
|
|
|
4
4
|
function createQueryClient(): QueryClient {
|
|
5
5
|
return new QueryClient({
|
|
6
6
|
defaultOptions: {
|
|
7
7
|
queries: { staleTime: 30_000, retry: 1 },
|
|
8
|
-
dehydrate: {
|
|
8
|
+
dehydrate: {
|
|
9
|
+
// `pending` queries dehydrate too: a server component may kick off a
|
|
10
|
+
// prefetch without awaiting it, and streaming SSR hands the in-flight
|
|
11
|
+
// promise to the client, which resumes it instead of refetching. The
|
|
12
|
+
// default predicate would drop exactly those queries and reintroduce
|
|
13
|
+
// the client-side loading flash.
|
|
14
|
+
shouldDehydrateQuery: (query) =>
|
|
15
|
+
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
|
|
16
|
+
},
|
|
9
17
|
},
|
|
10
18
|
});
|
|
11
19
|
}
|
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
"plugins": [{ "name": "next" }],
|
|
9
9
|
"paths": { "@/*": ["./src/*"] }
|
|
10
10
|
},
|
|
11
|
-
"include": [
|
|
11
|
+
"include": [
|
|
12
|
+
"next-env.d.ts",
|
|
13
|
+
"next.config.ts",
|
|
14
|
+
"src/**/*.ts",
|
|
15
|
+
"src/**/*.tsx",
|
|
16
|
+
".next/types/**/*.ts"
|
|
17
|
+
],
|
|
12
18
|
"exclude": ["node_modules"]
|
|
13
19
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createContractFactory } from 'stitchkit';
|
|
2
|
+
import { SystemStatusSchema } from '../schemas/system';
|
|
3
|
+
|
|
4
|
+
const { defineContract } = createContractFactory<'public'>({
|
|
5
|
+
toolExposure: 'explicit',
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const systemContract = defineContract(
|
|
9
|
+
{ prefix: 'system', scope: 'public' },
|
|
10
|
+
{
|
|
11
|
+
status: {
|
|
12
|
+
method: 'GET',
|
|
13
|
+
path: '/status',
|
|
14
|
+
desc: 'Read application readiness',
|
|
15
|
+
output: SystemStatusSchema,
|
|
16
|
+
expose: ['HTTP'],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
);
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export
|
|
1
|
+
export * from './contracts/system';
|
|
2
|
+
export * from './schemas/system';
|
|
@@ -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,13 +1,13 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { appIdentity } from '../packages/config/src/identity';
|
|
3
|
-
import { childEnvironment, env } from '../packages/config/src/server';
|
|
4
3
|
import { ensureLocalEnvironment } from './local-env';
|
|
4
|
+
import { inheritToolingEnvironment } from './tooling-env';
|
|
5
5
|
|
|
6
6
|
const root = resolve(import.meta.dir, '..');
|
|
7
7
|
async function run(command: string[], environment?: Record<string, string>): Promise<void> {
|
|
8
8
|
const child = Bun.spawn(command, {
|
|
9
9
|
cwd: root,
|
|
10
|
-
env: environment ?
|
|
10
|
+
env: environment ? inheritToolingEnvironment(environment) : undefined,
|
|
11
11
|
stdin: 'inherit',
|
|
12
12
|
stdout: 'inherit',
|
|
13
13
|
stderr: 'inherit',
|
|
@@ -17,8 +17,15 @@ async function run(command: string[], environment?: Record<string, string>): Pro
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export async function runDevelopment(environment?: Record<string, string>): Promise<void> {
|
|
20
|
-
|
|
21
|
-
|
|
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
|
+
}
|
|
22
29
|
await run(['bun', 'run', 'db:setup'], environmentForRun);
|
|
23
30
|
await run(
|
|
24
31
|
['pm2', 'startOrReload', 'ecosystem.dev.config.cjs', '--update-env'],
|
|
@@ -26,20 +33,22 @@ export async function runDevelopment(environment?: Record<string, string>): Prom
|
|
|
26
33
|
);
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
|
|
36
|
+
function assertToolAvailable(command: string, instruction: string): void {
|
|
37
|
+
if (!Bun.which(command)) throw new Error(`${command} is required. ${instruction}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function developmentEnvironment(
|
|
30
41
|
overrides: Record<string, string> = {},
|
|
31
|
-
): Record<string, string
|
|
42
|
+
): Promise<Record<string, string>> {
|
|
43
|
+
const { env } = await import('../packages/config/src/server');
|
|
32
44
|
return {
|
|
45
|
+
DATABASE_URL: env.DATABASE_URL,
|
|
33
46
|
API_PORT: String(env.API_PORT),
|
|
34
47
|
WEB_PORT: String(env.WEB_PORT),
|
|
35
48
|
CORS_ORIGIN: env.CORS_ORIGIN,
|
|
36
49
|
NEXT_PUBLIC_API_URL: env.NEXT_PUBLIC_API_URL,
|
|
37
50
|
NEXT_PUBLIC_WEB_URL: env.NEXT_PUBLIC_WEB_URL,
|
|
38
51
|
INTERNAL_API_URL: env.INTERNAL_API_URL,
|
|
39
|
-
DEV_HTTPS_CERT: '',
|
|
40
|
-
DEV_HTTPS_KEY: '',
|
|
41
|
-
DEV_HTTPS_CA: '',
|
|
42
|
-
NODE_EXTRA_CA_CERTS: '',
|
|
43
52
|
...overrides,
|
|
44
53
|
};
|
|
45
54
|
}
|
|
@@ -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,7 +1,11 @@
|
|
|
1
|
+
import { systemContract } from '@app/shared';
|
|
2
|
+
import { createClient, createHttpClient } from 'stitchkit';
|
|
1
3
|
import { z } from 'zod';
|
|
2
4
|
import { runSurfaceConformance } from './surface-conformance';
|
|
3
|
-
import {
|
|
5
|
+
import { loadToolingEnv } from './tooling-env';
|
|
6
|
+
import { assertPublicWebSurface } from './web-surface-smoke';
|
|
4
7
|
|
|
8
|
+
const toolingEnv = loadToolingEnv();
|
|
5
9
|
const apiOrigin = toolingEnv.NEXT_PUBLIC_API_URL;
|
|
6
10
|
|
|
7
11
|
async function json(path: string): Promise<unknown> {
|
|
@@ -11,13 +15,19 @@ async function json(path: string): Promise<unknown> {
|
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
z.object({ status: z.literal('ok') }).parse(await json('/health'));
|
|
18
|
+
const system = createClient(
|
|
19
|
+
systemContract,
|
|
20
|
+
createHttpClient({ baseUrl: `${apiOrigin}/api`, credentials: 'omit' }),
|
|
21
|
+
);
|
|
22
|
+
z.object({ status: z.literal('ok') }).parse(await system.status());
|
|
14
23
|
const openApi = z
|
|
15
24
|
.object({ paths: z.record(z.string(), z.unknown()) })
|
|
16
25
|
.parse(await json('/openapi.json'));
|
|
17
|
-
if (Object.keys(openApi.paths).
|
|
18
|
-
throw new Error('
|
|
26
|
+
if (!Object.keys(openApi.paths).includes('/api/system/status')) {
|
|
27
|
+
throw new Error('System status contract is missing from OpenAPI');
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
await runSurfaceConformance({ apiOrigin });
|
|
31
|
+
await assertPublicWebSurface(toolingEnv.NEXT_PUBLIC_WEB_URL);
|
|
22
32
|
|
|
23
|
-
console.log('
|
|
33
|
+
console.log('Runtime HTTP, typed client, OpenAPI, MCP and public web smoke passed');
|
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
1
3
|
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
|
4
|
+
import { mountAgent } from 'stitchkit/tools';
|
|
2
5
|
import { z } from 'zod';
|
|
3
6
|
import { createSurface } from '../packages/backend/src/surface';
|
|
4
7
|
import {
|
|
8
|
+
assertManifestMatchesSnapshot,
|
|
5
9
|
assertSurfaceConformance,
|
|
6
10
|
buildSurfaceManifest,
|
|
7
11
|
type SurfaceManifestOperation,
|
|
8
12
|
} from '../packages/backend/src/surface-manifest';
|
|
9
13
|
|
|
14
|
+
export const SURFACE_SNAPSHOT_PATH = 'packages/backend/src/surface.snapshot.json';
|
|
15
|
+
|
|
10
16
|
export interface SurfaceProbe {
|
|
11
17
|
name: string;
|
|
12
18
|
run: () => Promise<void>;
|
|
@@ -45,11 +51,42 @@ async function readJson(url: string): Promise<unknown> {
|
|
|
45
51
|
return response.json();
|
|
46
52
|
}
|
|
47
53
|
|
|
48
|
-
|
|
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(
|
|
49
87
|
apiOrigin: string,
|
|
50
|
-
|
|
88
|
+
_manifest: readonly SurfaceManifestOperation[],
|
|
51
89
|
): Promise<string[]> {
|
|
52
|
-
const expectedCount = manifest.filter((operation) => operation.tools.MCP).length;
|
|
53
90
|
const client = new Client(
|
|
54
91
|
{ name: 'surface-conformance', version: '1.0.0' },
|
|
55
92
|
{ versionNegotiation: { mode: { pin: '2026-07-28' } } },
|
|
@@ -57,18 +94,7 @@ async function discoverMcpTools(
|
|
|
57
94
|
const transport = new StreamableHTTPClientTransport(new URL(`${apiOrigin}/mcp`));
|
|
58
95
|
try {
|
|
59
96
|
await client.connect(transport);
|
|
60
|
-
|
|
61
|
-
return (await client.listTools()).tools.map((tool) => tool.name);
|
|
62
|
-
} catch (error) {
|
|
63
|
-
if (
|
|
64
|
-
expectedCount === 0 &&
|
|
65
|
-
error instanceof Error &&
|
|
66
|
-
error.message.includes('not supported by the negotiated protocol version')
|
|
67
|
-
) {
|
|
68
|
-
return [];
|
|
69
|
-
}
|
|
70
|
-
throw error;
|
|
71
|
-
}
|
|
97
|
+
return (await client.listTools()).tools.map((tool) => tool.name);
|
|
72
98
|
} finally {
|
|
73
99
|
await client.close();
|
|
74
100
|
}
|
|
@@ -81,12 +107,24 @@ export async function runSurfaceConformance({
|
|
|
81
107
|
const { services, socket } = await createSurface();
|
|
82
108
|
try {
|
|
83
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);
|
|
84
118
|
const openApi = await readJson(`${apiOrigin}/openapi.json`);
|
|
85
119
|
const mcpToolNames = await discoverMcpTools(apiOrigin, manifest);
|
|
86
120
|
assertSurfaceConformance({
|
|
87
121
|
manifest,
|
|
88
122
|
openApi: OpenApiSnapshotSchema.parse(openApi),
|
|
89
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(),
|
|
90
128
|
});
|
|
91
129
|
for (const probe of probes) {
|
|
92
130
|
try {
|
|
@@ -96,10 +134,33 @@ export async function runSurfaceConformance({
|
|
|
96
134
|
}
|
|
97
135
|
}
|
|
98
136
|
} finally {
|
|
99
|
-
await socket.
|
|
137
|
+
await socket.close();
|
|
100
138
|
}
|
|
101
139
|
}
|
|
102
140
|
|
|
103
141
|
const OpenApiSnapshotSchema = z.object({
|
|
104
142
|
paths: z.record(z.string(), z.record(z.string(), z.unknown())),
|
|
105
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.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
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const OG_IMAGE_PATH = '/api/og/en/themes';
|
|
2
|
+
const SITEMAP_PATH = '/sitemap.xml';
|
|
3
|
+
|
|
4
|
+
function publicUrl(origin: string, path: string): URL {
|
|
5
|
+
return new URL(path, new URL(origin).origin);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function assertPublicWebSurface(webOrigin: string): Promise<void> {
|
|
9
|
+
const image = await fetch(publicUrl(webOrigin, OG_IMAGE_PATH));
|
|
10
|
+
if (image.status !== 200) {
|
|
11
|
+
throw new Error(`GET ${OG_IMAGE_PATH} returned ${image.status}`);
|
|
12
|
+
}
|
|
13
|
+
const imageType = image.headers.get('content-type');
|
|
14
|
+
if (!imageType?.toLowerCase().startsWith('image/png')) {
|
|
15
|
+
throw new Error(`GET ${OG_IMAGE_PATH} returned ${imageType ?? 'no content type'}`);
|
|
16
|
+
}
|
|
17
|
+
if ((await image.arrayBuffer()).byteLength === 0) {
|
|
18
|
+
throw new Error(`GET ${OG_IMAGE_PATH} returned an empty image`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const sitemap = await fetch(publicUrl(webOrigin, SITEMAP_PATH));
|
|
22
|
+
if (sitemap.status !== 200) {
|
|
23
|
+
throw new Error(`GET ${SITEMAP_PATH} returned ${sitemap.status}`);
|
|
24
|
+
}
|
|
25
|
+
if (!(await sitemap.text()).includes('/ru/ui/themes')) {
|
|
26
|
+
throw new Error(`GET ${SITEMAP_PATH} omitted the localized theme-system URL`);
|
|
27
|
+
}
|
|
28
|
+
}
|