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
@@ -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: listToolNames(surface),
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.io.close();
23
+ await socket.close();
21
24
  }
@@ -11,6 +11,7 @@ const codeMap: Record<StitchErrorCode, string> = {
11
11
  CONFLICT: 'conflict',
12
12
  RATE_LIMITED: 'rate_limited',
13
13
  INTERNAL_SERVER_ERROR: 'internal_error',
14
+ REALTIME_CONTRACT_VIOLATION: 'internal_error',
14
15
  };
15
16
 
16
17
  export const onError = createErrorHook({
@@ -0,0 +1,8 @@
1
+ import { type SystemStatus, systemContract } from '@app/shared';
2
+ import { implement } from 'stitchkit/server';
3
+
4
+ export function createSystemService() {
5
+ return implement(systemContract, {
6
+ status: (): SystemStatus => ({ status: 'ok' }),
7
+ });
8
+ }
@@ -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.string().min(1).default('*'),
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,
@@ -12,7 +12,6 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "@app/config": "workspace:*",
15
- "@app/db": "workspace:*",
16
15
  "@app/shared": "workspace:*",
17
16
  "@radix-ui/react-alert-dialog": "^1.1.23",
18
17
  "@radix-ui/react-avatar": "^1.2.6",
@@ -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: { shouldDehydrateQuery: (query) => query.state.status === 'pending' },
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": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"],
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
  }
@@ -12,7 +12,6 @@
12
12
  "test": "bun test --pass-with-no-tests"
13
13
  },
14
14
  "dependencies": {
15
- "@app/db": "workspace:*",
16
15
  "stitchkit": "catalog:",
17
16
  "zod": "^4.4.3"
18
17
  },
@@ -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';
@@ -0,0 +1,4 @@
1
+ import { z } from 'zod';
2
+
3
+ export const SystemStatusSchema = z.object({ status: z.literal('ok') });
4
+ export type SystemStatus = z.infer<typeof SystemStatusSchema>;
@@ -1,6 +1,7 @@
1
1
  import { defineConfig, devices } from '@playwright/test';
2
- import { toolingEnv } from './scripts/tooling-env';
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.loc?.start.line ?? 1}: type assertion`);
27
+ failures.push(`${path}:${lineAt(source, node.start)}: type assertion`);
21
28
  },
22
29
  TSTypeAssertion(node) {
23
- failures.push(`${path}:${node.loc?.start.line ?? 1}: type assertion`);
30
+ failures.push(`${path}:${lineAt(source, node.start)}: type assertion`);
24
31
  },
25
32
  TSAnyKeyword(node) {
26
- failures.push(`${path}:${node.loc?.start.line ?? 1}: explicit any`);
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 = (await Promise.all(roots.map(visitDirectory))).flat();
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(
@@ -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 ? childEnvironment(environment) : undefined,
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
- await ensureLocalEnvironment(root);
21
- const environmentForRun = developmentEnvironment(environment);
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
- export function developmentEnvironment(
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 { copyFile } from 'node:fs/promises';
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
- export async function ensureLocalEnvironment(root: string): Promise<void> {
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 (await Bun.file(destination).exists()) return;
7
- await copyFile(resolve(root, '_env'), destination);
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
- await ensureLocalEnvironment(resolve(import.meta.dir, '..'));
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 { toolingEnv } from './tooling-env';
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).some((path) => path.startsWith('/api/'))) {
18
- throw new Error('Blank starter unexpectedly publishes application contracts');
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('Blank runtime HTTP, OpenAPI and MCP smoke passed');
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
- async function discoverMcpTools(
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
- manifest: readonly SurfaceManifestOperation[],
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
- try {
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.io.close();
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 const toolingEnv = ToolingEnvSchema.parse(process.env);
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
+ }