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.
Files changed (59) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/cli.js +7 -16
  3. package/examples/repository/_env.example.append +2 -0
  4. package/examples/repository/e2e/repository.spec.ts +76 -2
  5. package/examples/repository/packages/backend/src/domain/repository/github-cache.test.ts +8 -0
  6. package/examples/repository/packages/backend/src/domain/repository/github-cache.ts +2 -2
  7. package/examples/repository/packages/backend/src/surface.snapshot.json +58 -0
  8. package/examples/repository/packages/backend/src/surface.ts +7 -5
  9. package/examples/repository/packages/config/src/features.ts +1 -0
  10. package/examples/repository/packages/frontend/src/components/repository-summary.tsx +7 -3
  11. package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +5 -6
  12. package/examples/repository/packages/shared/src/index.ts +3 -1
  13. package/examples/repository/packages/shared/src/realtime/repository.ts +10 -0
  14. package/examples/repository/packages/shared/src/schemas/repository.ts +4 -2
  15. package/examples/repository/scripts/runtime-smoke.ts +100 -19
  16. package/package.json +1 -1
  17. package/template/README.md +6 -10
  18. package/template/_env.example +1 -1
  19. package/template/bun.lock +4 -4
  20. package/template/docs/ADDING_A_FEATURE.md +3 -2
  21. package/template/e2e/starter.spec.ts +21 -13
  22. package/template/ecosystem.dev.config.cjs +0 -12
  23. package/template/package.json +5 -3
  24. package/template/packages/backend/src/cli.ts +1 -1
  25. package/template/packages/backend/src/index.ts +27 -24
  26. package/template/packages/backend/src/surface-manifest.test.ts +183 -8
  27. package/template/packages/backend/src/surface-manifest.ts +108 -5
  28. package/template/packages/backend/src/surface.snapshot.json +18 -0
  29. package/template/packages/backend/src/surface.ts +2 -1
  30. package/template/packages/backend/src/tools.ts +5 -2
  31. package/template/packages/backend/src/transport/errors.ts +1 -0
  32. package/template/packages/backend/src/transport/system-service.ts +8 -0
  33. package/template/packages/config/src/server.ts +1 -4
  34. package/template/packages/frontend/package.json +0 -1
  35. package/template/packages/frontend/src/lib/query-client.test.ts +22 -0
  36. package/template/packages/frontend/src/lib/query-client.ts +10 -2
  37. package/template/packages/frontend/tsconfig.json +7 -1
  38. package/template/packages/shared/package.json +0 -1
  39. package/template/packages/shared/src/contracts/system.ts +19 -0
  40. package/template/packages/shared/src/index.ts +2 -1
  41. package/template/packages/shared/src/schemas/system.ts +4 -0
  42. package/template/playwright.config.ts +2 -1
  43. package/template/scripts/check-authored.ts +20 -6
  44. package/template/scripts/dev.ts +19 -10
  45. package/template/scripts/local-env.test.ts +33 -0
  46. package/template/scripts/local-env.ts +16 -5
  47. package/template/scripts/runtime-smoke.ts +14 -4
  48. package/template/scripts/surface-conformance.ts +77 -16
  49. package/template/scripts/surface-snapshot.ts +21 -0
  50. package/template/scripts/tooling-env.ts +16 -3
  51. package/template/scripts/web-surface-smoke.ts +28 -0
  52. package/template/tsconfig.json +3 -1
  53. package/examples/repository/_env.append +0 -3
  54. package/examples/repository/packages/shared/src/events/repository.ts +0 -9
  55. package/template/_env +0 -9
  56. package/template/docs/LAN_HTTPS.md +0 -28
  57. package/template/packages/backend/src/transport/lan-onboarding.ts +0 -29
  58. package/template/scripts/dev-lan.test.ts +0 -58
  59. package/template/scripts/dev-lan.ts +0 -176
@@ -6,4 +6,4 @@ NEXT_PUBLIC_API_URL=http://127.0.0.1:3211
6
6
  INTERNAL_API_URL=http://127.0.0.1:3211
7
7
  NEXT_PUBLIC_WEB_URL=http://127.0.0.1:3210
8
8
  LOG_FORMAT=pretty
9
- CORS_ORIGIN=*
9
+ CORS_ORIGIN=http://127.0.0.1:3210
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.45.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.45.0", "", { "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-IYeet50iZK3J5/bPJApLfASxX1vb5KwliHvHLOwQsLx1QqIELmgeVBl3p5IbFd4sXKW0OIBPDZdtfN+xUsjT0A=="],
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 no application surface. In `--example repository` mode,
5
- keep the repository slice and add the same files beside it.
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('publishes complete page metadata and a reachable Open Graph card', async ({
16
- page,
17
- request,
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).not.toBeNull();
32
- if (!imageUrl) throw new Error('Open Graph image URL is missing');
33
- const imageResponse = await request.get(imageUrl);
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: [
@@ -7,12 +7,11 @@
7
7
  "packages/*"
8
8
  ],
9
9
  "catalog": {
10
- "stitchkit": "^0.45.0"
10
+ "stitchkit": "^0.49.2"
11
11
  },
12
12
  "scripts": {
13
13
  "dev": "bun scripts/dev.ts",
14
- "dev:lan": "bun scripts/dev-lan.ts",
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
  },
@@ -9,5 +9,5 @@ const { services, socket } = await createSurface();
9
9
  try {
10
10
  await createCli({ name: appIdentity.slug, version: appIdentity.version, services });
11
11
  } finally {
12
- await socket.io.close();
12
+ await socket.close();
13
13
  }
@@ -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
- websocket: socket.websocket,
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
- async function shutdown(): Promise<void> {
56
- server.stop();
57
- await mcp.close();
58
- await socket.io.close();
59
- await prisma.$disconnect();
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
- process.once('SIGTERM', shutdown);
63
- process.once('SIGINT', shutdown);
64
- console.log(
65
- `API listening on ${env.DEV_HTTPS_CERT ? 'https' : 'http'}://127.0.0.1:${env.API_PORT}`,
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 { defineContract } from 'stitchkit/contract';
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 { assertSurfaceConformance, buildSurfaceManifest } from './surface-manifest';
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: z.object({ id: z.string() }),
16
- output: z.object({ id: z.string() }),
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({ manifest, openApi, mcpToolNames: ['read_note'] }),
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({ manifest, openApi: { paths: {} }, mcpToolNames: [] }),
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
- Object.entries(openApi.paths).flatMap(([path, item]) =>
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
+ );