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
package/template/_env.example
CHANGED
package/template/bun.lock
CHANGED
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
"@modelcontextprotocol/client": "^2.0.0",
|
|
16
16
|
"@playwright/test": "^1.55.0",
|
|
17
17
|
"@types/bun": "^1.3.14",
|
|
18
|
+
"@types/node": "^26.2.0",
|
|
18
19
|
"oxc-parser": "^0.143.0",
|
|
19
20
|
"socket.io-client": "^4.8.3",
|
|
21
|
+
"stitchkit": "catalog:",
|
|
20
22
|
"typescript": "^7.0.2",
|
|
21
23
|
"zod": "^4.4.3",
|
|
22
24
|
},
|
|
@@ -74,7 +76,6 @@
|
|
|
74
76
|
"version": "0.1.0",
|
|
75
77
|
"dependencies": {
|
|
76
78
|
"@app/config": "workspace:*",
|
|
77
|
-
"@app/db": "workspace:*",
|
|
78
79
|
"@app/shared": "workspace:*",
|
|
79
80
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
|
80
81
|
"@radix-ui/react-avatar": "^1.2.6",
|
|
@@ -127,7 +128,6 @@
|
|
|
127
128
|
"name": "@app/shared",
|
|
128
129
|
"version": "0.1.0",
|
|
129
130
|
"dependencies": {
|
|
130
|
-
"@app/db": "workspace:*",
|
|
131
131
|
"stitchkit": "catalog:",
|
|
132
132
|
"zod": "^4.4.3",
|
|
133
133
|
},
|
|
@@ -138,7 +138,7 @@
|
|
|
138
138
|
},
|
|
139
139
|
},
|
|
140
140
|
"catalog": {
|
|
141
|
-
"stitchkit": "^0.
|
|
141
|
+
"stitchkit": "^0.49.2",
|
|
142
142
|
},
|
|
143
143
|
"packages": {
|
|
144
144
|
"@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LIAO6kAG8fpXQb9L0iwPk1FIbXftvqnyC56v5NEAzeWTeL8fUsy/Hx86VPBTWEDFdwbVprjWifJOAqS6AOj3mA=="],
|
|
@@ -1117,7 +1117,7 @@
|
|
|
1117
1117
|
|
|
1118
1118
|
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
|
1119
1119
|
|
|
1120
|
-
"stitchkit": ["stitchkit@0.
|
|
1120
|
+
"stitchkit": ["stitchkit@0.49.2", "", { "dependencies": { "ky": "^2.0.2" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/server": "^2.0.0", "@socket.io/bun-engine": "^0.1.1", "@socket.io/component-emitter": "^3.1.2", "@tanstack/react-query": ">=5", "@types/bun": "^1.3.14", "ai": "^7.0.0", "react": ">=18", "react-query-kit": "^3.3.3", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "srvx": "^0.12.5", "zod": "^4.4.3" }, "optionalPeers": ["@modelcontextprotocol/ext-apps", "@modelcontextprotocol/server", "@socket.io/bun-engine", "@socket.io/component-emitter", "@tanstack/react-query", "@types/bun", "ai", "react", "react-query-kit", "socket.io", "socket.io-client", "srvx"] }, "sha512-QP0I//fs/khC+bTqYungjpZskAseQAB/Lm8UOx3mZDx+rmYtdsBhlAn0I0hBYE/du9jag5rE032s9HrZgT2VXg=="],
|
|
1121
1121
|
|
|
1122
1122
|
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
|
1123
1123
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Adding a vertical feature
|
|
2
2
|
|
|
3
3
|
This guide uses a small `status` resource to show the canonical path. The blank
|
|
4
|
-
scaffold starts with
|
|
5
|
-
keep the repository slice and add the
|
|
4
|
+
scaffold starts with only its HTTP readiness endpoint and no application or tool
|
|
5
|
+
surface. In `--example repository` mode, keep the repository slice and add the
|
|
6
|
+
same files beside it.
|
|
6
7
|
|
|
7
8
|
## 1. Define the wire data
|
|
8
9
|
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { appIdentity } from '@app/config/identity';
|
|
2
|
+
import { systemContract } from '@app/shared';
|
|
2
3
|
import AxeBuilder from '@axe-core/playwright';
|
|
3
4
|
import { expect, test } from '@playwright/test';
|
|
5
|
+
import { createClient, createHttpClient } from 'stitchkit';
|
|
6
|
+
import { loadToolingEnv } from '../scripts/tooling-env';
|
|
7
|
+
|
|
8
|
+
const toolingEnv = loadToolingEnv();
|
|
4
9
|
|
|
5
10
|
test('renders the hydrated starter application and catalogue', async ({ page }) => {
|
|
6
11
|
await page.goto('/en');
|
|
@@ -12,10 +17,19 @@ test('renders the hydrated starter application and catalogue', async ({ page })
|
|
|
12
17
|
await expect(page.getByRole('heading', { level: 1 })).toContainText('UI components');
|
|
13
18
|
});
|
|
14
19
|
|
|
15
|
-
test('
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
test('calls the live backend through the typed contract client', async () => {
|
|
21
|
+
const client = createClient(
|
|
22
|
+
systemContract,
|
|
23
|
+
createHttpClient({
|
|
24
|
+
baseUrl: `${toolingEnv.NEXT_PUBLIC_API_URL}/api`,
|
|
25
|
+
credentials: 'omit',
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
await expect(client.status()).resolves.toEqual({ status: 'ok' });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('publishes complete page metadata', async ({ page }) => {
|
|
19
33
|
await page.goto('/en/ui/themes');
|
|
20
34
|
await expect(page).toHaveTitle(`Theme system · ${appIdentity.name}`);
|
|
21
35
|
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
|
|
@@ -28,15 +42,9 @@ test('publishes complete page metadata and a reachable Open Graph card', async (
|
|
|
28
42
|
);
|
|
29
43
|
|
|
30
44
|
const imageUrl = await page.locator('meta[property="og:image"]').getAttribute('content');
|
|
31
|
-
expect(imageUrl).
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
expect(imageResponse.status()).toBe(200);
|
|
35
|
-
expect(imageResponse.headers()['content-type']).toContain('image/png');
|
|
36
|
-
|
|
37
|
-
const sitemapResponse = await request.get('/sitemap.xml');
|
|
38
|
-
expect(sitemapResponse.status()).toBe(200);
|
|
39
|
-
expect(await sitemapResponse.text()).toContain('/ru/ui/themes');
|
|
45
|
+
expect(imageUrl).toBe(
|
|
46
|
+
new URL('/api/og/en/themes', toolingEnv.NEXT_PUBLIC_WEB_URL).toString(),
|
|
47
|
+
);
|
|
40
48
|
});
|
|
41
49
|
|
|
42
50
|
test('switches catalogue sections and component tabs', async ({ page }) => {
|
|
@@ -5,18 +5,6 @@ const identity = require('./app.config.json');
|
|
|
5
5
|
config({ path: path.join(__dirname, '.env'), quiet: true });
|
|
6
6
|
|
|
7
7
|
const frontendArgs = ['dev', '--port', process.env.WEB_PORT, '--hostname', '0.0.0.0'];
|
|
8
|
-
if (process.env.DEV_HTTPS_CERT && process.env.DEV_HTTPS_KEY) {
|
|
9
|
-
frontendArgs.push(
|
|
10
|
-
'--experimental-https',
|
|
11
|
-
'--experimental-https-key',
|
|
12
|
-
process.env.DEV_HTTPS_KEY,
|
|
13
|
-
'--experimental-https-cert',
|
|
14
|
-
process.env.DEV_HTTPS_CERT,
|
|
15
|
-
);
|
|
16
|
-
if (process.env.DEV_HTTPS_CA) {
|
|
17
|
-
frontendArgs.push('--experimental-https-ca', process.env.DEV_HTTPS_CA);
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
8
|
|
|
21
9
|
module.exports = {
|
|
22
10
|
apps: [
|
package/template/package.json
CHANGED
|
@@ -7,12 +7,11 @@
|
|
|
7
7
|
"packages/*"
|
|
8
8
|
],
|
|
9
9
|
"catalog": {
|
|
10
|
-
"stitchkit": "^0.
|
|
10
|
+
"stitchkit": "^0.49.2"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
13
|
"dev": "bun scripts/dev.ts",
|
|
14
|
-
"
|
|
15
|
-
"check": "bun run db:generate && bun run check:authored && bun run --filter '*' check",
|
|
14
|
+
"check": "bun run db:generate && bun run check:authored && bun x tsc -p tsconfig.json --noEmit && bun run --filter '*' check",
|
|
16
15
|
"check:authored": "bun scripts/check-authored.ts",
|
|
17
16
|
"test": "bun run --filter '*' test",
|
|
18
17
|
"build": "bun run db:generate && bun --filter @app/backend build && bun --filter @app/frontend build",
|
|
@@ -27,6 +26,7 @@
|
|
|
27
26
|
"cli": "bun packages/backend/src/cli.ts",
|
|
28
27
|
"tools": "bun packages/backend/src/tools.ts",
|
|
29
28
|
"runtime:smoke": "bun scripts/runtime-smoke.ts",
|
|
29
|
+
"surface:snapshot": "bun scripts/surface-snapshot.ts",
|
|
30
30
|
"e2e": "playwright test",
|
|
31
31
|
"lint": "biome check --error-on-warnings .",
|
|
32
32
|
"lint:fix": "biome check --write .",
|
|
@@ -44,8 +44,10 @@
|
|
|
44
44
|
"@modelcontextprotocol/client": "^2.0.0",
|
|
45
45
|
"@playwright/test": "^1.55.0",
|
|
46
46
|
"@types/bun": "^1.3.14",
|
|
47
|
+
"@types/node": "^26.2.0",
|
|
47
48
|
"oxc-parser": "^0.143.0",
|
|
48
49
|
"socket.io-client": "^4.8.3",
|
|
50
|
+
"stitchkit": "catalog:",
|
|
49
51
|
"typescript": "^7.0.2",
|
|
50
52
|
"zod": "^4.4.3"
|
|
51
53
|
},
|
|
@@ -6,7 +6,6 @@ import { createMcpHandler, createMcpHttpRoute } from 'stitchkit/tools';
|
|
|
6
6
|
import { prisma } from './lib/db';
|
|
7
7
|
import { createSurface } from './surface';
|
|
8
8
|
import { onError } from './transport/errors';
|
|
9
|
-
import { createLanOnboardingRoutes } from './transport/lan-onboarding';
|
|
10
9
|
|
|
11
10
|
async function main(): Promise<void> {
|
|
12
11
|
const { services, socket } = await createSurface();
|
|
@@ -27,12 +26,10 @@ async function main(): Promise<void> {
|
|
|
27
26
|
cors: { origin: env.CORS_ORIGIN },
|
|
28
27
|
hooks: { onError },
|
|
29
28
|
logging: { format: env.LOG_FORMAT },
|
|
30
|
-
|
|
29
|
+
socket,
|
|
31
30
|
rawRoutes: [
|
|
32
|
-
socket.route,
|
|
33
31
|
openApiRoute('/openapi.json', openApi),
|
|
34
32
|
createMcpHttpRoute({ path: '/mcp', handler: mcp }),
|
|
35
|
-
...createLanOnboardingRoutes(),
|
|
36
33
|
{
|
|
37
34
|
method: 'GET',
|
|
38
35
|
path: '/health',
|
|
@@ -40,30 +37,36 @@ async function main(): Promise<void> {
|
|
|
40
37
|
},
|
|
41
38
|
],
|
|
42
39
|
wrapFetch: (fetch) => wrapInRequestContext(fetch),
|
|
43
|
-
bun:
|
|
44
|
-
env.DEV_HTTPS_CERT && env.DEV_HTTPS_KEY
|
|
45
|
-
? {
|
|
46
|
-
tls: {
|
|
47
|
-
cert: Bun.file(env.DEV_HTTPS_CERT),
|
|
48
|
-
key: Bun.file(env.DEV_HTTPS_KEY),
|
|
49
|
-
...(env.DEV_HTTPS_CA && { ca: Bun.file(env.DEV_HTTPS_CA) }),
|
|
50
|
-
},
|
|
51
|
-
}
|
|
52
|
-
: undefined,
|
|
53
40
|
});
|
|
54
41
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
42
|
+
const shutdownController = new AbortController();
|
|
43
|
+
let shutdownPromise: Promise<void> | undefined;
|
|
44
|
+
|
|
45
|
+
function shutdown(): Promise<void> {
|
|
46
|
+
if (shutdownPromise) {
|
|
47
|
+
shutdownController.abort();
|
|
48
|
+
return shutdownPromise;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
shutdownPromise = (async () => {
|
|
52
|
+
await server.shutdown({
|
|
53
|
+
gracePeriodMs: 30_000,
|
|
54
|
+
signal: shutdownController.signal,
|
|
55
|
+
});
|
|
56
|
+
await mcp.close();
|
|
57
|
+
await prisma.$disconnect();
|
|
58
|
+
})();
|
|
59
|
+
return shutdownPromise;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
const onSignal = () =>
|
|
63
|
+
void shutdown().catch((error: unknown) => {
|
|
64
|
+
console.error('Shutdown failed', error);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
process.on('SIGTERM', onSignal);
|
|
68
|
+
process.on('SIGINT', onSignal);
|
|
69
|
+
console.log(`API listening on http://127.0.0.1:${env.API_PORT}`);
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
main().catch((error: unknown) => {
|
|
@@ -1,19 +1,35 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createContractFactory } from 'stitchkit/contract';
|
|
3
5
|
import { generateOpenApiDocument, implement } from 'stitchkit/server';
|
|
4
6
|
import { z } from 'zod';
|
|
5
|
-
import {
|
|
7
|
+
import { discoverCliCommands, discoverMcpTools } from '../../../scripts/surface-conformance';
|
|
8
|
+
import {
|
|
9
|
+
assertManifestMatchesSnapshot,
|
|
10
|
+
assertSurfaceConformance,
|
|
11
|
+
buildSurfaceManifest,
|
|
12
|
+
} from './surface-manifest';
|
|
13
|
+
|
|
14
|
+
const NoteParamsSchema = z.object({ id: z.string() });
|
|
15
|
+
const NoteSchema = z.object({ id: z.string() });
|
|
16
|
+
// Same address and presence flags as NoteSchema — a different TYPE, for the
|
|
17
|
+
// snapshot-digest test. Named here, never inline in a contract (the template's
|
|
18
|
+
// own schema-separate-from-contract policy).
|
|
19
|
+
const RetypedNoteSchema = z.object({ id: z.number() });
|
|
20
|
+
const { defineContract } = createContractFactory<'public'>();
|
|
6
21
|
|
|
7
22
|
const service = implement(
|
|
8
23
|
defineContract(
|
|
9
|
-
{ prefix: 'notes' },
|
|
24
|
+
{ prefix: 'notes', scope: 'public' },
|
|
10
25
|
{
|
|
11
26
|
read: {
|
|
12
27
|
method: 'GET',
|
|
13
28
|
path: '/:id',
|
|
14
29
|
desc: 'Read a note',
|
|
15
|
-
params:
|
|
16
|
-
output:
|
|
30
|
+
params: NoteParamsSchema,
|
|
31
|
+
output: NoteSchema,
|
|
32
|
+
expose: ['HTTP', 'MCP', 'AGENT', 'CLI'],
|
|
17
33
|
},
|
|
18
34
|
},
|
|
19
35
|
),
|
|
@@ -31,32 +47,191 @@ describe('surface conformance', () => {
|
|
|
31
47
|
{
|
|
32
48
|
service: 'notes',
|
|
33
49
|
action: 'read',
|
|
50
|
+
scope: 'public',
|
|
51
|
+
hasInput: false,
|
|
52
|
+
hasOutput: true,
|
|
53
|
+
inputShape: null,
|
|
54
|
+
outputShape: expect.stringMatching(/^[0-9a-f]{16}$/),
|
|
34
55
|
http: [{ method: 'GET', path: '/api/notes/{id}' }],
|
|
35
|
-
tools: { AGENT: 'read_note', MCP: 'read_note' },
|
|
56
|
+
tools: { AGENT: 'read_note', CLI: 'read_note', MCP: 'read_note' },
|
|
36
57
|
},
|
|
37
58
|
]);
|
|
38
59
|
expect(() =>
|
|
39
|
-
assertSurfaceConformance({
|
|
60
|
+
assertSurfaceConformance({
|
|
61
|
+
manifest,
|
|
62
|
+
openApi,
|
|
63
|
+
mcpToolNames: ['read_note'],
|
|
64
|
+
agentToolNames: ['read_note'],
|
|
65
|
+
cliToolNames: ['read_note'],
|
|
66
|
+
}),
|
|
40
67
|
).not.toThrow();
|
|
41
68
|
});
|
|
42
69
|
|
|
43
70
|
test('fails with transport-specific expected and actual diagnostics', () => {
|
|
44
71
|
const manifest = buildSurfaceManifest([service]);
|
|
45
72
|
expect(() =>
|
|
46
|
-
assertSurfaceConformance({
|
|
73
|
+
assertSurfaceConformance({
|
|
74
|
+
manifest,
|
|
75
|
+
openApi: { paths: {} },
|
|
76
|
+
mcpToolNames: [],
|
|
77
|
+
agentToolNames: [],
|
|
78
|
+
cliToolNames: [],
|
|
79
|
+
}),
|
|
47
80
|
).toThrow('HTTP/OpenAPI surface mismatch');
|
|
48
81
|
expect(() =>
|
|
49
82
|
assertSurfaceConformance({
|
|
50
83
|
manifest: manifest.map((operation) => ({ ...operation, http: [] })),
|
|
51
84
|
openApi: { paths: {} },
|
|
52
85
|
mcpToolNames: ['unexpected'],
|
|
86
|
+
agentToolNames: ['read_note'],
|
|
87
|
+
cliToolNames: ['read_note'],
|
|
53
88
|
}),
|
|
54
89
|
).toThrow('MCP discovery surface mismatch');
|
|
55
90
|
});
|
|
56
91
|
|
|
92
|
+
test('missing Stitchkit metadata is an ERROR unless the standard-document mode is declared', () => {
|
|
93
|
+
const manifest = buildSurfaceManifest([service]);
|
|
94
|
+
const standardDocument = {
|
|
95
|
+
paths: {
|
|
96
|
+
'/api/notes/{id}': {
|
|
97
|
+
get: { responses: { 200: { description: 'Success' } } },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
const names = {
|
|
102
|
+
mcpToolNames: ['read_note'],
|
|
103
|
+
agentToolNames: ['read_note'],
|
|
104
|
+
cliToolNames: ['read_note'],
|
|
105
|
+
};
|
|
106
|
+
// A silent skip was the hole: no x-stitchkit-* keys meant no comparison at
|
|
107
|
+
// all. The default mode now refuses; opting out is an explicit decision.
|
|
108
|
+
expect(() =>
|
|
109
|
+
assertSurfaceConformance({ manifest, openApi: standardDocument, ...names }),
|
|
110
|
+
).toThrow(/no x-stitchkit-\* contract metadata/);
|
|
111
|
+
expect(() =>
|
|
112
|
+
assertSurfaceConformance({
|
|
113
|
+
manifest,
|
|
114
|
+
openApi: standardDocument,
|
|
115
|
+
...names,
|
|
116
|
+
metadata: 'ignore',
|
|
117
|
+
}),
|
|
118
|
+
).not.toThrow();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('a missing AGENT tool and a missing CLI command each fail conformance', () => {
|
|
122
|
+
const manifest = buildSurfaceManifest([service]);
|
|
123
|
+
const openApi = generateOpenApiDocument({
|
|
124
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
125
|
+
groups: [{ pathPrefix: '/api', services: [service] }],
|
|
126
|
+
});
|
|
127
|
+
expect(() =>
|
|
128
|
+
assertSurfaceConformance({
|
|
129
|
+
manifest,
|
|
130
|
+
openApi,
|
|
131
|
+
mcpToolNames: ['read_note'],
|
|
132
|
+
agentToolNames: [],
|
|
133
|
+
cliToolNames: ['read_note'],
|
|
134
|
+
}),
|
|
135
|
+
).toThrow('AGENT mount surface mismatch');
|
|
136
|
+
expect(() =>
|
|
137
|
+
assertSurfaceConformance({
|
|
138
|
+
manifest,
|
|
139
|
+
openApi,
|
|
140
|
+
mcpToolNames: ['read_note'],
|
|
141
|
+
agentToolNames: ['read_note'],
|
|
142
|
+
cliToolNames: [],
|
|
143
|
+
}),
|
|
144
|
+
).toThrow('CLI manifest surface mismatch');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('the committed snapshot catches an expose edit that moves BOTH in-process sides together', () => {
|
|
148
|
+
// The probe that defeated the old check: removing AGENT/CLI from `expose`
|
|
149
|
+
// changes the manifest AND the in-process tool lists in lockstep. The
|
|
150
|
+
// snapshot is the anchor that does not move with the source.
|
|
151
|
+
const snapshot = buildSurfaceManifest([service]);
|
|
152
|
+
const narrowed = implement(
|
|
153
|
+
defineContract(
|
|
154
|
+
{ prefix: 'notes', scope: 'public' },
|
|
155
|
+
{
|
|
156
|
+
read: {
|
|
157
|
+
method: 'GET',
|
|
158
|
+
path: '/:id',
|
|
159
|
+
desc: 'Read a note',
|
|
160
|
+
params: NoteParamsSchema,
|
|
161
|
+
output: NoteSchema,
|
|
162
|
+
expose: ['HTTP', 'MCP'],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
),
|
|
166
|
+
{ read: ({ params }) => ({ id: params.id }) },
|
|
167
|
+
);
|
|
168
|
+
expect(() =>
|
|
169
|
+
assertManifestMatchesSnapshot(buildSurfaceManifest([narrowed]), snapshot),
|
|
170
|
+
).toThrow(/diverged from the committed snapshot/);
|
|
171
|
+
expect(() =>
|
|
172
|
+
assertManifestMatchesSnapshot(buildSurfaceManifest([service]), snapshot),
|
|
173
|
+
).not.toThrow();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('a schema TYPE change at the same method and path flips the shape digest', () => {
|
|
177
|
+
const snapshot = buildSurfaceManifest([service]);
|
|
178
|
+
const retyped = implement(
|
|
179
|
+
defineContract(
|
|
180
|
+
{ prefix: 'notes', scope: 'public' },
|
|
181
|
+
{
|
|
182
|
+
read: {
|
|
183
|
+
method: 'GET',
|
|
184
|
+
path: '/:id',
|
|
185
|
+
desc: 'Read a note',
|
|
186
|
+
params: NoteParamsSchema,
|
|
187
|
+
// Same address, same presence flags — different output TYPE.
|
|
188
|
+
output: RetypedNoteSchema,
|
|
189
|
+
expose: ['HTTP', 'MCP', 'AGENT', 'CLI'],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
),
|
|
193
|
+
{ read: ({ params }) => ({ id: Number(params.id) }) },
|
|
194
|
+
);
|
|
195
|
+
expect(() =>
|
|
196
|
+
assertManifestMatchesSnapshot(buildSurfaceManifest([retyped]), snapshot),
|
|
197
|
+
).toThrow(/diverged from the committed snapshot/);
|
|
198
|
+
});
|
|
199
|
+
|
|
57
200
|
test('fails first on duplicate contract identity', () => {
|
|
58
201
|
expect(() => buildSurfaceManifest([service, service])).toThrow(
|
|
59
202
|
'Duplicate operation identity notes.read',
|
|
60
203
|
);
|
|
61
204
|
});
|
|
205
|
+
|
|
206
|
+
test('a broken /mcp endpoint rejects discovery — even when zero tools are expected', async () => {
|
|
207
|
+
const broken = Bun.serve({
|
|
208
|
+
port: 0,
|
|
209
|
+
fetch: () => new Response('not the MCP endpoint', { status: 404 }),
|
|
210
|
+
});
|
|
211
|
+
try {
|
|
212
|
+
await expect(discoverMcpTools(`http://127.0.0.1:${broken.port}`, [])).rejects.toThrow();
|
|
213
|
+
} finally {
|
|
214
|
+
broken.stop(true);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('the CLI surface observed from the SPAWNED process matches the committed snapshot', async () => {
|
|
219
|
+
// External observation against the external anchor — neither side is the
|
|
220
|
+
// in-process `services` object, so they cannot move together.
|
|
221
|
+
const root = join(import.meta.dir, '../../..');
|
|
222
|
+
const commands = await discoverCliCommands(root);
|
|
223
|
+
const snapshot = SurfaceSnapshotCliSchema.parse(
|
|
224
|
+
JSON.parse(
|
|
225
|
+
await readFile(join(root, 'packages/backend/src/surface.snapshot.json'), 'utf8'),
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
const expected = snapshot
|
|
229
|
+
.flatMap((operation) => (operation.tools.CLI ? [operation.tools.CLI] : []))
|
|
230
|
+
.sort();
|
|
231
|
+
expect([...commands].sort()).toEqual(expected);
|
|
232
|
+
});
|
|
62
233
|
});
|
|
234
|
+
|
|
235
|
+
const SurfaceSnapshotCliSchema = z.array(
|
|
236
|
+
z.object({ tools: z.object({ CLI: z.string().optional() }) }),
|
|
237
|
+
);
|
|
@@ -1,14 +1,29 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import type { HttpMethod } from 'stitchkit/contract';
|
|
2
3
|
import type { OpenApiDocument, ServiceDef } from 'stitchkit/server';
|
|
3
4
|
import { listToolNames } from 'stitchkit/tools';
|
|
5
|
+
import { z } from 'zod';
|
|
4
6
|
|
|
5
7
|
export interface SurfaceManifestOperation {
|
|
6
8
|
service: string;
|
|
7
9
|
action: string;
|
|
10
|
+
scope: string;
|
|
11
|
+
hasInput: boolean;
|
|
12
|
+
hasOutput: boolean;
|
|
13
|
+
/** Digest of the input JSON Schema — a TYPE change flips it, not just presence. */
|
|
14
|
+
inputShape: string | null;
|
|
15
|
+
/** Digest of the output JSON Schema. */
|
|
16
|
+
outputShape: string | null;
|
|
8
17
|
http: Array<{ method: HttpMethod; path: string }>;
|
|
9
18
|
tools: Partial<Record<'MCP' | 'AGENT' | 'CLI', string>>;
|
|
10
19
|
}
|
|
11
20
|
|
|
21
|
+
function schemaShape(schema: z.ZodType | undefined, io: 'input' | 'output'): string | null {
|
|
22
|
+
if (!schema) return null;
|
|
23
|
+
const document = z.toJSONSchema(schema, { io, unrepresentable: 'any' });
|
|
24
|
+
return createHash('sha256').update(JSON.stringify(document)).digest('hex').slice(0, 16);
|
|
25
|
+
}
|
|
26
|
+
|
|
12
27
|
function joinPath(...parts: Array<string | undefined>): string {
|
|
13
28
|
const joined = parts
|
|
14
29
|
.filter((part): part is string => Boolean(part))
|
|
@@ -58,6 +73,11 @@ export function buildSurfaceManifest(
|
|
|
58
73
|
operations.set(key, {
|
|
59
74
|
service: service.name,
|
|
60
75
|
action: method.key,
|
|
76
|
+
scope: method.scope ?? service.scope,
|
|
77
|
+
hasInput: Boolean(method.inputSchema),
|
|
78
|
+
hasOutput: Boolean(method.outputSchema),
|
|
79
|
+
inputShape: schemaShape(method.inputSchema, 'input'),
|
|
80
|
+
outputShape: schemaShape(method.outputSchema, 'output'),
|
|
61
81
|
http:
|
|
62
82
|
!method.expose || method.expose.includes('HTTP')
|
|
63
83
|
? [
|
|
@@ -89,29 +109,112 @@ export function buildSurfaceManifest(
|
|
|
89
109
|
);
|
|
90
110
|
}
|
|
91
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Compare the LIVE manifest against the committed snapshot — the external
|
|
114
|
+
* anchor that a source-level change (an `expose` edit, a schema type change, a
|
|
115
|
+
* scope change) cannot move along with itself. Regenerate deliberately with
|
|
116
|
+
* `bun run surface:snapshot` and review the diff.
|
|
117
|
+
*/
|
|
118
|
+
export function assertManifestMatchesSnapshot(
|
|
119
|
+
manifest: readonly SurfaceManifestOperation[],
|
|
120
|
+
snapshot: readonly SurfaceManifestOperation[],
|
|
121
|
+
): void {
|
|
122
|
+
const actual = JSON.stringify(manifest, null, 2);
|
|
123
|
+
const expected = JSON.stringify(snapshot, null, 2);
|
|
124
|
+
if (actual !== expected) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Declared surface diverged from the committed snapshot — if the change is intended, regenerate it with "bun run surface:snapshot" and review the diff.\nsnapshot: ${expected}\nactual: ${actual}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
92
131
|
export function assertSurfaceConformance({
|
|
93
132
|
manifest,
|
|
94
133
|
openApi,
|
|
95
134
|
mcpToolNames,
|
|
135
|
+
agentToolNames,
|
|
136
|
+
cliToolNames,
|
|
137
|
+
metadata = 'require',
|
|
96
138
|
}: {
|
|
97
139
|
manifest: readonly SurfaceManifestOperation[];
|
|
98
140
|
openApi: Pick<OpenApiDocument, 'paths'>;
|
|
99
141
|
mcpToolNames: readonly string[];
|
|
142
|
+
agentToolNames: readonly string[];
|
|
143
|
+
cliToolNames: readonly string[];
|
|
144
|
+
/**
|
|
145
|
+
* `'require'` (default) — the OpenAPI document must carry `x-stitchkit-*`
|
|
146
|
+
* metadata and it is compared; `'ignore'` — an explicitly declared mode for
|
|
147
|
+
* standard documents. A silent skip is not an option.
|
|
148
|
+
*/
|
|
149
|
+
metadata?: 'require' | 'ignore';
|
|
100
150
|
}): void {
|
|
151
|
+
const openApiOperations = Object.entries(openApi.paths).flatMap(([path, item]) =>
|
|
152
|
+
Object.entries(item)
|
|
153
|
+
.filter(([method]) => ['get', 'post', 'put', 'patch', 'delete', 'head'].includes(method))
|
|
154
|
+
.map(([method, operation]) => ({ method: method.toUpperCase(), path, operation })),
|
|
155
|
+
);
|
|
101
156
|
assertSameSet(
|
|
102
157
|
'HTTP/OpenAPI',
|
|
103
158
|
manifest.flatMap((operation) =>
|
|
104
159
|
operation.http.map((entry) => `${entry.method}:${entry.path}`),
|
|
105
160
|
),
|
|
106
|
-
|
|
107
|
-
Object.keys(item)
|
|
108
|
-
.filter((method) => ['get', 'post', 'put', 'patch', 'delete', 'head'].includes(method))
|
|
109
|
-
.map((method) => `${method.toUpperCase()}:${path}`),
|
|
110
|
-
),
|
|
161
|
+
openApiOperations.map(({ method, path }) => `${method}:${path}`),
|
|
111
162
|
);
|
|
163
|
+
const carriesContractMetadata = openApiOperations.some(({ operation }) =>
|
|
164
|
+
OpenApiOperationMetadataPresenceSchema.parse(operation),
|
|
165
|
+
);
|
|
166
|
+
if (metadata === 'require' && openApiOperations.length > 0 && !carriesContractMetadata) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
'OpenAPI document carries no x-stitchkit-* contract metadata — pass metadata: "ignore" only for a deliberately standard document',
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (metadata === 'require' && carriesContractMetadata) {
|
|
172
|
+
assertSameSet(
|
|
173
|
+
'HTTP/OpenAPI contract metadata',
|
|
174
|
+
manifest.flatMap((operation) =>
|
|
175
|
+
operation.http.map(
|
|
176
|
+
(entry) =>
|
|
177
|
+
`${entry.method}:${entry.path}:${operation.scope}:${operation.hasInput}:${operation.hasOutput}`,
|
|
178
|
+
),
|
|
179
|
+
),
|
|
180
|
+
openApiOperations.map(({ method, path, operation }) => {
|
|
181
|
+
const metadata = OpenApiOperationMetadataSchema.parse(operation);
|
|
182
|
+
return `${method}:${path}:${metadata['x-stitchkit-scope']}:${metadata['x-stitchkit-has-input']}:${metadata['x-stitchkit-has-output']}`;
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
112
186
|
assertSameSet(
|
|
113
187
|
'MCP discovery',
|
|
114
188
|
manifest.flatMap((operation) => (operation.tools.MCP ? [operation.tools.MCP] : [])),
|
|
115
189
|
mcpToolNames,
|
|
116
190
|
);
|
|
191
|
+
assertSameSet(
|
|
192
|
+
'AGENT mount',
|
|
193
|
+
manifest.flatMap((operation) => (operation.tools.AGENT ? [operation.tools.AGENT] : [])),
|
|
194
|
+
agentToolNames,
|
|
195
|
+
);
|
|
196
|
+
assertSameSet(
|
|
197
|
+
'CLI manifest',
|
|
198
|
+
manifest.flatMap((operation) => (operation.tools.CLI ? [operation.tools.CLI] : [])),
|
|
199
|
+
cliToolNames,
|
|
200
|
+
);
|
|
117
201
|
}
|
|
202
|
+
|
|
203
|
+
const OpenApiOperationMetadataSchema = z.object({
|
|
204
|
+
'x-stitchkit-scope': z.string(),
|
|
205
|
+
'x-stitchkit-has-input': z.boolean(),
|
|
206
|
+
'x-stitchkit-has-output': z.boolean(),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const OpenApiOperationMetadataPresenceSchema = z
|
|
210
|
+
.object({
|
|
211
|
+
'x-stitchkit-scope': z.unknown().optional(),
|
|
212
|
+
'x-stitchkit-has-input': z.unknown().optional(),
|
|
213
|
+
'x-stitchkit-has-output': z.unknown().optional(),
|
|
214
|
+
})
|
|
215
|
+
.transform(
|
|
216
|
+
(metadata) =>
|
|
217
|
+
metadata['x-stitchkit-scope'] !== undefined ||
|
|
218
|
+
metadata['x-stitchkit-has-input'] !== undefined ||
|
|
219
|
+
metadata['x-stitchkit-has-output'] !== undefined,
|
|
220
|
+
);
|