create-stitchkit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +11 -4
  3. package/dist/cli.js +99 -12
  4. package/examples/repository/_env.append +3 -0
  5. package/examples/repository/_env.example.append +4 -0
  6. package/examples/repository/e2e/repository.spec.ts +19 -0
  7. package/{template → examples/repository}/packages/backend/src/domain/repository/github-cache.ts +2 -1
  8. package/examples/repository/packages/backend/src/surface.ts +14 -0
  9. package/examples/repository/packages/config/src/features.ts +7 -0
  10. package/examples/repository/packages/db/schema.prisma +31 -0
  11. package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +45 -0
  12. package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +151 -0
  13. package/examples/repository/packages/frontend/src/providers/index.tsx +18 -0
  14. package/examples/repository/packages/shared/src/index.ts +3 -0
  15. package/examples/repository/scripts/runtime-smoke.ts +91 -0
  16. package/package.json +10 -1
  17. package/template/AGENTS.md +50 -0
  18. package/template/README.md +18 -5
  19. package/template/_env +1 -3
  20. package/template/_env.example +1 -4
  21. package/template/app.config.json +9 -0
  22. package/template/bun.lock +13 -141
  23. package/template/docs/ADDING_A_FEATURE.md +101 -0
  24. package/template/docs/LAN_HTTPS.md +28 -0
  25. package/template/e2e/starter.spec.ts +17 -22
  26. package/template/ecosystem.config.cjs +3 -2
  27. package/template/ecosystem.dev.config.cjs +19 -4
  28. package/template/package.json +4 -2
  29. package/template/packages/backend/package.json +1 -1
  30. package/template/packages/backend/src/cli.ts +2 -1
  31. package/template/packages/backend/src/index.ts +22 -6
  32. package/template/packages/backend/src/surface-manifest.test.ts +62 -0
  33. package/template/packages/backend/src/surface-manifest.ts +117 -0
  34. package/template/packages/backend/src/surface.ts +3 -9
  35. package/template/packages/backend/src/transport/lan-onboarding.ts +29 -0
  36. package/template/packages/config/package.json +2 -1
  37. package/template/packages/config/src/features.ts +1 -0
  38. package/template/packages/config/src/identity.ts +18 -0
  39. package/template/packages/config/src/server.ts +13 -3
  40. package/template/packages/db/schema.prisma +0 -23
  41. package/template/packages/frontend/messages/en.json +0 -1
  42. package/template/packages/frontend/messages/ru.json +0 -1
  43. package/template/packages/frontend/package.json +1 -0
  44. package/template/packages/frontend/src/app/[locale]/page.tsx +8 -19
  45. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +3 -4
  46. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +3 -2
  47. package/template/packages/frontend/src/lib/seo/pages.ts +4 -5
  48. package/template/packages/frontend/src/providers/index.tsx +1 -4
  49. package/template/packages/frontend/src/theme/config.ts +2 -1
  50. package/template/packages/shared/src/index.ts +1 -3
  51. package/template/scripts/dev-lan.test.ts +58 -0
  52. package/template/scripts/dev-lan.ts +176 -0
  53. package/template/scripts/dev.ts +37 -7
  54. package/template/scripts/runtime-smoke.ts +13 -57
  55. package/template/scripts/surface-conformance.ts +105 -0
  56. /package/{template → examples/repository}/packages/backend/src/domain/errors.ts +0 -0
  57. /package/{template → examples/repository}/packages/backend/src/domain/repository/github-cache.test.ts +0 -0
  58. /package/{template → examples/repository}/packages/backend/src/transport/repository-service.ts +0 -0
  59. /package/{template → examples/repository}/packages/db/migrations/20260808000000_init/migration.sql +0 -0
  60. /package/{template → examples/repository}/packages/db/migrations/20260808170000_repository_visibility/migration.sql +0 -0
  61. /package/{template → examples/repository}/packages/frontend/src/components/repository-summary.tsx +0 -0
  62. /package/{template → examples/repository}/packages/frontend/src/lib/api/client.ts +0 -0
  63. /package/{template → examples/repository}/packages/frontend/src/lib/api/queries.ts +0 -0
  64. /package/{template → examples/repository}/packages/frontend/src/lib/realtime/repository.ts +0 -0
  65. /package/{template → examples/repository}/packages/frontend/src/providers/realtime.tsx +0 -0
  66. /package/{template → examples/repository}/packages/shared/src/contracts/repository.ts +0 -0
  67. /package/{template → examples/repository}/packages/shared/src/events/repository.ts +0 -0
  68. /package/{template → examples/repository}/packages/shared/src/schemas/repository.test.ts +0 -0
  69. /package/{template → examples/repository}/packages/shared/src/schemas/repository.ts +0 -0
@@ -1,10 +1,7 @@
1
- import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
1
+ import { appIdentity } from '@app/config/identity';
2
2
  import type { Metadata } from 'next';
3
3
  import { getTranslations } from 'next-intl/server';
4
4
  import { LocaleSchema } from '@/i18n/locales';
5
- import { createServerRepositoryApi } from '@/lib/api/client';
6
- import { useRepository } from '@/lib/api/queries';
7
- import { getQueryClient } from '@/lib/query-client';
8
5
  import { createPageMetadata } from '@/lib/seo/metadata';
9
6
  import { StarterPage } from './starter-page';
10
7
 
@@ -22,23 +19,15 @@ export async function generateMetadata({
22
19
  export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
23
20
  const { locale } = await params;
24
21
  const appLocale = LocaleSchema.parse(locale);
25
- const queryClient = getQueryClient();
26
- const api = createServerRepositoryApi();
27
22
  const t = await getTranslations('App');
28
- await queryClient.prefetchQuery({
29
- queryKey: useRepository.getKey(),
30
- queryFn: () => api.read(),
31
- });
32
23
 
33
24
  return (
34
- <HydrationBoundary state={dehydrate(queryClient)}>
35
- <StarterPage
36
- applicationName={t('title')}
37
- applicationDescription={t('description')}
38
- heroTitle={t('heroTitle')}
39
- catalogueLabel={t('ui')}
40
- locale={appLocale}
41
- />
42
- </HydrationBoundary>
25
+ <StarterPage
26
+ applicationName={appIdentity.name}
27
+ applicationDescription={appIdentity.description[appLocale]}
28
+ heroTitle={t('heroTitle')}
29
+ catalogueLabel={t('ui')}
30
+ locale={appLocale}
31
+ />
43
32
  );
44
33
  }
@@ -1,7 +1,6 @@
1
1
  import { IconArrowUpRight } from '@tabler/icons-react';
2
2
  import Image from 'next/image';
3
3
  import { BrandMark } from '@/components/brand-mark';
4
- import { RepositorySummary } from '@/components/repository-summary';
5
4
  import { LanguageSwitcher, ThemeToggle } from '@/components/system-controls';
6
5
  import { buttonVariants } from '@/components/ui';
7
6
  import type { AppLocale } from '@/i18n/locales';
@@ -140,9 +139,9 @@ export function StarterPage({
140
139
  </div>
141
140
  </div>
142
141
 
143
- <div className='mt-5 w-full'>
144
- <RepositorySummary />
145
- </div>
142
+ <p className='mt-5 text-sm text-muted-foreground'>
143
+ Add your first vertical feature from schema to transport and UI.
144
+ </p>
146
145
  </section>
147
146
  </div>
148
147
  </div>
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { appIdentity } from '@app/config/identity';
3
4
  import { IconArrowRight, IconBraces, IconDatabase, IconWorld } from '@tabler/icons-react';
4
5
  import type { ReactNode } from 'react';
5
6
  import { BrandMark } from '@/components/brand-mark';
@@ -36,7 +37,7 @@ export function LandingHeader({ actions }: { actions?: ReactNode }) {
36
37
  <header className='flex items-center justify-between border-b border-border py-4'>
37
38
  <div className='flex items-center gap-2 font-medium'>
38
39
  <BrandMark priority />
39
- Stitchkit Starter
40
+ {appIdentity.name}
40
41
  </div>
41
42
  <div className='flex items-center gap-2'>{actions}</div>
42
43
  </header>
@@ -152,7 +153,7 @@ export function PricingSection() {
152
153
  export function LandingFooter() {
153
154
  return (
154
155
  <footer className='flex flex-col gap-3 border-t border-border py-8 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between'>
155
- <span>Stitchkit Starter · Built with Stitchkit</span>
156
+ <span>{appIdentity.name} · Built with Stitchkit</span>
156
157
  <span>Contracts · UI · Realtime · Tools</span>
157
158
  </footer>
158
159
  );
@@ -1,7 +1,8 @@
1
+ import { appIdentity } from '@app/config/identity';
1
2
  import { z } from 'zod';
2
3
  import type { AppLocale } from '@/i18n/locales';
3
4
 
4
- export const SITE_NAME = 'Stitchkit Starter';
5
+ export const SITE_NAME = appIdentity.name;
5
6
  export const StoryIdSchema = z.enum(['components', 'themes', 'blocks']);
6
7
  export type StoryId = z.infer<typeof StoryIdSchema>;
7
8
 
@@ -42,14 +43,12 @@ const seoPages: Record<SeoPageId, SeoPageDefinition> = {
42
43
  copy: {
43
44
  en: {
44
45
  title: 'UI components',
45
- description:
46
- 'Explore accessible actions, forms, feedback, overlays, data displays and motion components included in the Stitchkit starter.',
46
+ description: `Explore accessible actions, forms, feedback, overlays, data displays and motion components included in ${SITE_NAME}.`,
47
47
  eyebrow: 'UI system',
48
48
  },
49
49
  ru: {
50
50
  title: 'UI-компоненты',
51
- description:
52
- 'Изучите доступные действия, формы, обратную связь, оверлеи, представление данных и анимации в Stitchkit Starter.',
51
+ description: `Изучите доступные действия, формы, обратную связь, оверлеи, представление данных и анимации в ${SITE_NAME}.`,
53
52
  eyebrow: 'UI-система',
54
53
  },
55
54
  },
@@ -4,14 +4,11 @@ import { TooltipProvider } from '@radix-ui/react-tooltip';
4
4
  import type { ReactNode } from 'react';
5
5
  import { Toaster } from '@/components/ui/toaster';
6
6
  import { ReactQueryProvider } from './react-query';
7
- import { RealtimeProvider } from './realtime';
8
7
 
9
8
  export function Providers({ children }: { children: ReactNode }) {
10
9
  return (
11
10
  <ReactQueryProvider>
12
- <RealtimeProvider>
13
- <TooltipProvider delayDuration={250}>{children}</TooltipProvider>
14
- </RealtimeProvider>
11
+ <TooltipProvider delayDuration={250}>{children}</TooltipProvider>
15
12
  <Toaster />
16
13
  </ReactQueryProvider>
17
14
  );
@@ -1,9 +1,10 @@
1
+ import { appIdentity } from '@app/config/identity';
1
2
  import type { ThemeProviderProps } from '@wrksz/themes/next';
2
3
 
3
4
  export type AppTheme = 'light' | 'dark';
4
5
 
5
6
  export const appThemes: readonly [AppTheme, AppTheme] = ['light', 'dark'];
6
- export const themeStorageKey = 'stitchkit-starter-theme';
7
+ export const themeStorageKey = `${appIdentity.slug}-theme`;
7
8
 
8
9
  export const themeProviderConfig = {
9
10
  themes: appThemes,
@@ -1,3 +1 @@
1
- export * from './contracts/repository';
2
- export * from './events/repository';
3
- export * from './schemas/repository';
1
+ export {};
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { developmentEnvironment } from './dev';
3
+ import { privateLanAddresses, selectLanAddress } from './dev-lan';
4
+
5
+ describe('LAN HTTPS address selection', () => {
6
+ test('normal development clears every LAN-only process variable', () => {
7
+ expect(developmentEnvironment()).toMatchObject({
8
+ DEV_HTTPS_CA: '',
9
+ DEV_HTTPS_CERT: '',
10
+ DEV_HTTPS_KEY: '',
11
+ NODE_EXTRA_CA_CERTS: '',
12
+ });
13
+ expect(developmentEnvironment({ DEV_HTTPS_CERT: '/cert.pem' }).DEV_HTTPS_CERT).toBe(
14
+ '/cert.pem',
15
+ );
16
+ });
17
+
18
+ test('keeps private IPv4 interfaces and ignores public/internal addresses', () => {
19
+ expect(
20
+ privateLanAddresses({
21
+ eth0: [
22
+ {
23
+ address: '192.168.1.20',
24
+ netmask: '',
25
+ family: 'IPv4',
26
+ mac: '',
27
+ internal: false,
28
+ cidr: null,
29
+ },
30
+ {
31
+ address: '8.8.8.8',
32
+ netmask: '',
33
+ family: 'IPv4',
34
+ mac: '',
35
+ internal: false,
36
+ cidr: null,
37
+ },
38
+ ],
39
+ lo: [
40
+ {
41
+ address: '127.0.0.1',
42
+ netmask: '',
43
+ family: 'IPv4',
44
+ mac: '',
45
+ internal: true,
46
+ cidr: null,
47
+ },
48
+ ],
49
+ }),
50
+ ).toEqual(['192.168.1.20']);
51
+ });
52
+
53
+ test('auto-selects one address and fails on ambiguity', () => {
54
+ expect(selectLanAddress(['10.0.0.4'])).toBe('10.0.0.4');
55
+ expect(() => selectLanAddress(['10.0.0.4', '192.168.1.20'])).toThrow('ambiguous');
56
+ expect(() => selectLanAddress(['10.0.0.4'], '10.0.0.5')).toThrow('unavailable');
57
+ });
58
+ });
@@ -0,0 +1,176 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir, networkInterfaces } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { z } from 'zod';
5
+ import { appIdentity } from '../packages/config/src/identity';
6
+ import { childEnvironment, env } from '../packages/config/src/server';
7
+ import { runDevelopment } from './dev';
8
+
9
+ export function privateLanAddresses(
10
+ interfaces: ReturnType<typeof networkInterfaces>,
11
+ ): string[] {
12
+ const addresses = new Set<string>();
13
+ for (const entries of Object.values(interfaces)) {
14
+ for (const entry of entries ?? []) {
15
+ if (entry.family !== 'IPv4' || entry.internal) continue;
16
+ const [first, second] = entry.address.split('.').map(Number);
17
+ if (
18
+ first === 10 ||
19
+ (first === 172 && second !== undefined && second >= 16 && second <= 31) ||
20
+ (first === 192 && second === 168)
21
+ ) {
22
+ addresses.add(entry.address);
23
+ }
24
+ }
25
+ }
26
+ return [...addresses].sort();
27
+ }
28
+
29
+ export function selectLanAddress(addresses: readonly string[], requested?: string): string {
30
+ if (requested) {
31
+ if (!addresses.includes(requested)) {
32
+ throw new Error(
33
+ `Requested LAN address ${requested} is unavailable. Detected: ${addresses.join(', ') || '(none)'}`,
34
+ );
35
+ }
36
+ return requested;
37
+ }
38
+ if (addresses.length !== 1) {
39
+ throw new Error(
40
+ `LAN address is ${addresses.length === 0 ? 'unavailable' : 'ambiguous'}. Use --host <address>. Detected: ${addresses.join(', ') || '(none)'}`,
41
+ );
42
+ }
43
+ return addresses[0];
44
+ }
45
+
46
+ async function run(command: string[], environment?: Record<string, string>): Promise<string> {
47
+ const child = Bun.spawn(command, {
48
+ env: environment ? childEnvironment(environment) : undefined,
49
+ stdout: 'pipe',
50
+ stderr: 'inherit',
51
+ });
52
+ const output = await new Response(child.stdout).text();
53
+ const exitCode = await child.exited;
54
+ if (exitCode !== 0) throw new Error(`${command.join(' ')} failed with exit code ${exitCode}`);
55
+ return output.trim();
56
+ }
57
+
58
+ function requestedHost(args: readonly string[]): string | undefined {
59
+ const index = args.indexOf('--host');
60
+ if (index === -1) return undefined;
61
+ const value = args[index + 1];
62
+ if (!value) throw new Error('--host requires an IPv4 address');
63
+ return value;
64
+ }
65
+
66
+ async function ensureCertificates(host: string) {
67
+ if (!Bun.which('mkcert')) {
68
+ throw new Error(
69
+ 'dev:lan requires mkcert. Install it with `brew install mkcert` (macOS/Linuxbrew) or the official prebuilt binary, then rerun.',
70
+ );
71
+ }
72
+ const caRoot = await run(['mkcert', '-CAROOT']);
73
+ const ca = join(caRoot, 'rootCA.pem');
74
+ if (!(await Bun.file(ca).exists())) await run(['mkcert', '-install']);
75
+
76
+ const directory = join(homedir(), '.local', 'share', appIdentity.slug, 'lan-https');
77
+ const cert = join(directory, 'leaf.pem');
78
+ const key = join(directory, 'leaf-key.pem');
79
+ const marker = join(directory, 'host');
80
+ await mkdir(directory, { recursive: true, mode: 0o700 });
81
+ const previous = await readFile(marker, 'utf8').catch(() => '');
82
+ if (
83
+ previous.trim() !== host ||
84
+ !(await Bun.file(cert).exists()) ||
85
+ !(await Bun.file(key).exists())
86
+ ) {
87
+ await run([
88
+ 'mkcert',
89
+ '-cert-file',
90
+ cert,
91
+ '-key-file',
92
+ key,
93
+ host,
94
+ 'localhost',
95
+ '127.0.0.1',
96
+ '::1',
97
+ ]);
98
+ await writeFile(marker, `${host}\n`, { mode: 0o600 });
99
+ }
100
+ return { ca, cert, key };
101
+ }
102
+
103
+ async function isPortOccupied(port: number): Promise<boolean> {
104
+ return new Promise((resolve) => {
105
+ let settled = false;
106
+ const finish = (occupied: boolean, socket?: { end(): void }) => {
107
+ if (settled) return;
108
+ settled = true;
109
+ resolve(occupied);
110
+ socket?.end();
111
+ };
112
+ void Bun.connect({
113
+ hostname: '127.0.0.1',
114
+ port,
115
+ socket: {
116
+ open: (socket) => finish(true, socket),
117
+ data: () => undefined,
118
+ close: () => finish(false),
119
+ error: () => finish(false),
120
+ },
121
+ }).catch(() => finish(false));
122
+ });
123
+ }
124
+
125
+ async function assertDevelopmentPortsAvailable(): Promise<void> {
126
+ const processList = await run(['pm2', 'jlist']);
127
+ const onlineNames = new Set(
128
+ z
129
+ .array(
130
+ z.object({
131
+ name: z.string(),
132
+ pm2_env: z.object({ status: z.string() }),
133
+ }),
134
+ )
135
+ .parse(JSON.parse(processList))
136
+ .filter((process) => process.pm2_env.status === 'online')
137
+ .map((process) => process.name),
138
+ );
139
+ const expected = new Map([
140
+ [env.WEB_PORT, `${appIdentity.slug}-frontend-dev`],
141
+ [env.API_PORT, `${appIdentity.slug}-backend-dev`],
142
+ ]);
143
+ for (const [port, owner] of expected) {
144
+ if ((await isPortOccupied(port)) && !onlineNames.has(owner)) {
145
+ throw new Error(`Port ${port} is occupied by a process outside ${owner}`);
146
+ }
147
+ }
148
+ }
149
+
150
+ if (import.meta.main) {
151
+ if (env.NODE_ENV !== 'development') {
152
+ throw new Error('dev:lan is development-only');
153
+ }
154
+ const host = selectLanAddress(
155
+ privateLanAddresses(networkInterfaces()),
156
+ requestedHost(Bun.argv),
157
+ );
158
+ await assertDevelopmentPortsAvailable();
159
+ const certificates = await ensureCertificates(host);
160
+ const apiOrigin = `https://${host}:${env.API_PORT}`;
161
+ const webOrigin = `https://${host}:${env.WEB_PORT}`;
162
+ await runDevelopment({
163
+ CORS_ORIGIN: webOrigin,
164
+ DEV_HTTPS_CA: certificates.ca,
165
+ DEV_HTTPS_CERT: certificates.cert,
166
+ DEV_HTTPS_KEY: certificates.key,
167
+ INTERNAL_API_URL: apiOrigin,
168
+ NEXT_PUBLIC_API_URL: apiOrigin,
169
+ NEXT_PUBLIC_WEB_URL: webOrigin,
170
+ NODE_EXTRA_CA_CERTS: certificates.ca,
171
+ });
172
+ console.log(`${appIdentity.name} trusted LAN development is running`);
173
+ console.log(`Web: ${webOrigin}/en`);
174
+ console.log(`API: ${apiOrigin}`);
175
+ console.log(`Device CA setup: ${apiOrigin}/__dev/lan`);
176
+ }
@@ -1,10 +1,13 @@
1
1
  import { resolve } from 'node:path';
2
+ import { appIdentity } from '../packages/config/src/identity';
3
+ import { childEnvironment, env } from '../packages/config/src/server';
2
4
  import { ensureLocalEnvironment } from './local-env';
3
5
 
4
6
  const root = resolve(import.meta.dir, '..');
5
- async function run(command: string[]): Promise<void> {
7
+ async function run(command: string[], environment?: Record<string, string>): Promise<void> {
6
8
  const child = Bun.spawn(command, {
7
9
  cwd: root,
10
+ env: environment ? childEnvironment(environment) : undefined,
8
11
  stdin: 'inherit',
9
12
  stdout: 'inherit',
10
13
  stderr: 'inherit',
@@ -13,11 +16,38 @@ async function run(command: string[]): Promise<void> {
13
16
  if (exitCode !== 0) throw new Error(`${command.join(' ')} failed with exit code ${exitCode}`);
14
17
  }
15
18
 
16
- await ensureLocalEnvironment(root);
19
+ export async function runDevelopment(environment?: Record<string, string>): Promise<void> {
20
+ await ensureLocalEnvironment(root);
21
+ const environmentForRun = developmentEnvironment(environment);
22
+ await run(['bun', 'run', 'db:setup'], environmentForRun);
23
+ await run(
24
+ ['pm2', 'startOrReload', 'ecosystem.dev.config.cjs', '--update-env'],
25
+ environmentForRun,
26
+ );
27
+ }
28
+
29
+ export function developmentEnvironment(
30
+ overrides: Record<string, string> = {},
31
+ ): Record<string, string> {
32
+ return {
33
+ API_PORT: String(env.API_PORT),
34
+ WEB_PORT: String(env.WEB_PORT),
35
+ CORS_ORIGIN: env.CORS_ORIGIN,
36
+ NEXT_PUBLIC_API_URL: env.NEXT_PUBLIC_API_URL,
37
+ NEXT_PUBLIC_WEB_URL: env.NEXT_PUBLIC_WEB_URL,
38
+ 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
+ ...overrides,
44
+ };
45
+ }
17
46
 
18
- await run(['bun', 'run', 'db:setup']);
19
- await run(['pm2', 'startOrReload', 'ecosystem.dev.config.cjs', '--update-env']);
47
+ if (import.meta.main) {
48
+ await runDevelopment();
20
49
 
21
- console.log('Stitchkit Starter development processes are running');
22
- console.log('Web: http://localhost:3210/en');
23
- console.log('API: http://localhost:3211');
50
+ console.log(`${appIdentity.name} development processes are running`);
51
+ console.log('Web: http://localhost:3210/en');
52
+ console.log('API: http://localhost:3211');
53
+ }
@@ -1,67 +1,23 @@
1
- import { RepositorySnapshotSchema } from '@app/shared';
2
- import { Client } from '@modelcontextprotocol/sdk/client/index.js';
3
- import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
4
- import { io } from 'socket.io-client';
5
1
  import { z } from 'zod';
2
+ import { runSurfaceConformance } from './surface-conformance';
6
3
  import { toolingEnv } from './tooling-env';
7
4
 
8
5
  const apiOrigin = toolingEnv.NEXT_PUBLIC_API_URL;
9
6
 
10
- async function json(path: string, init?: RequestInit): Promise<unknown> {
11
- const response = await fetch(`${apiOrigin}${path}`, init);
12
- if (!response.ok)
13
- throw new Error(`${init?.method ?? 'GET'} ${path} returned ${response.status}`);
7
+ async function json(path: string): Promise<unknown> {
8
+ const response = await fetch(`${apiOrigin}${path}`);
9
+ if (!response.ok) throw new Error(`GET ${path} returned ${response.status}`);
14
10
  return response.json();
15
11
  }
16
12
 
17
- const socket = io(apiOrigin, { transports: ['websocket'] });
18
- const refreshedEvent = new Promise<string>((resolve, reject) => {
19
- const timeout = setTimeout(
20
- () => reject(new Error('Socket.IO repository refresh event timed out')),
21
- 5_000,
22
- );
23
- socket.once('repository:refreshed', (snapshot) => {
24
- clearTimeout(timeout);
25
- resolve(snapshot.fullName);
26
- });
27
- });
28
-
29
- try {
30
- z.object({ status: z.literal('ok') }).parse(await json('/health'));
31
- const corsResponse = await fetch(`${apiOrigin}/api/repository/`, {
32
- headers: { Origin: 'http://localhost:58302' },
33
- });
34
- if (corsResponse.headers.get('access-control-allow-origin') !== '*') {
35
- throw new Error('Public API does not allow a forwarded browser origin');
36
- }
37
- const openApi = z
38
- .object({ paths: z.record(z.string(), z.unknown()) })
39
- .parse(await json('/openapi.json'));
40
- if (!Object.keys(openApi.paths).some((path) => path.startsWith('/api/repository'))) {
41
- throw new Error('OpenAPI repository path is missing');
42
- }
43
-
44
- const refreshed = RepositorySnapshotSchema.parse(
45
- await json('/api/repository/refresh', { method: 'POST' }),
46
- );
47
- if ((await refreshedEvent) !== refreshed.fullName) {
48
- throw new Error('Socket.IO repository identity differs');
49
- }
50
- const cached = RepositorySnapshotSchema.parse(await json('/api/repository/'));
51
- if (cached.fullName !== refreshed.fullName) throw new Error('Repository cache read differs');
52
-
53
- const client = new Client({ name: 'starter-lane', version: '1.0.0' });
54
- const transport = new StreamableHTTPClientTransport(new URL(`${apiOrigin}/mcp`));
55
- await client.connect(transport);
56
- const tools = await client.listTools();
57
- if (!tools.tools.some((tool) => tool.name === 'repository_read')) {
58
- throw new Error('MCP repository tool is absent');
59
- }
60
- const result = await client.callTool({ name: 'repository_read', arguments: {} });
61
- if (result.isError) throw new Error('MCP repository read failed');
62
- await client.close();
63
- } finally {
64
- socket.close();
13
+ z.object({ status: z.literal('ok') }).parse(await json('/health'));
14
+ const openApi = z
15
+ .object({ paths: z.record(z.string(), z.unknown()) })
16
+ .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');
65
19
  }
66
20
 
67
- console.log('Runtime HTTP, OpenAPI, Socket.IO and MCP smoke passed');
21
+ await runSurfaceConformance({ apiOrigin });
22
+
23
+ console.log('Blank runtime HTTP, OpenAPI and MCP smoke passed');
@@ -0,0 +1,105 @@
1
+ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
2
+ import { z } from 'zod';
3
+ import { createSurface } from '../packages/backend/src/surface';
4
+ import {
5
+ assertSurfaceConformance,
6
+ buildSurfaceManifest,
7
+ type SurfaceManifestOperation,
8
+ } from '../packages/backend/src/surface-manifest';
9
+
10
+ export interface SurfaceProbe {
11
+ name: string;
12
+ run: () => Promise<void>;
13
+ }
14
+
15
+ export function defineSurfaceProbe<TInput, TOutput>({
16
+ name,
17
+ input,
18
+ fixture,
19
+ output,
20
+ run,
21
+ }: {
22
+ name: string;
23
+ input: z.ZodType<TInput>;
24
+ fixture: unknown;
25
+ output?: z.ZodType<TOutput>;
26
+ run: (input: TInput) => Promise<TOutput>;
27
+ }): SurfaceProbe {
28
+ return {
29
+ name,
30
+ run: async () => {
31
+ const result = await run(input.parse(fixture));
32
+ output?.parse(result);
33
+ },
34
+ };
35
+ }
36
+
37
+ interface SurfaceConformanceOptions {
38
+ apiOrigin: string;
39
+ probes?: readonly SurfaceProbe[];
40
+ }
41
+
42
+ async function readJson(url: string): Promise<unknown> {
43
+ const response = await fetch(url);
44
+ if (!response.ok) throw new Error(`GET ${url} returned ${response.status}`);
45
+ return response.json();
46
+ }
47
+
48
+ async function discoverMcpTools(
49
+ apiOrigin: string,
50
+ manifest: readonly SurfaceManifestOperation[],
51
+ ): Promise<string[]> {
52
+ const expectedCount = manifest.filter((operation) => operation.tools.MCP).length;
53
+ const client = new Client(
54
+ { name: 'surface-conformance', version: '1.0.0' },
55
+ { versionNegotiation: { mode: { pin: '2026-07-28' } } },
56
+ );
57
+ const transport = new StreamableHTTPClientTransport(new URL(`${apiOrigin}/mcp`));
58
+ try {
59
+ 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
+ }
72
+ } finally {
73
+ await client.close();
74
+ }
75
+ }
76
+
77
+ export async function runSurfaceConformance({
78
+ apiOrigin,
79
+ probes = [],
80
+ }: SurfaceConformanceOptions): Promise<void> {
81
+ const { services, socket } = await createSurface();
82
+ try {
83
+ const manifest = buildSurfaceManifest(services);
84
+ const openApi = await readJson(`${apiOrigin}/openapi.json`);
85
+ const mcpToolNames = await discoverMcpTools(apiOrigin, manifest);
86
+ assertSurfaceConformance({
87
+ manifest,
88
+ openApi: OpenApiSnapshotSchema.parse(openApi),
89
+ mcpToolNames,
90
+ });
91
+ for (const probe of probes) {
92
+ try {
93
+ await probe.run();
94
+ } catch (cause) {
95
+ throw new Error(`Surface probe "${probe.name}" failed`, { cause });
96
+ }
97
+ }
98
+ } finally {
99
+ await socket.io.close();
100
+ }
101
+ }
102
+
103
+ const OpenApiSnapshotSchema = z.object({
104
+ paths: z.record(z.string(), z.record(z.string(), z.unknown())),
105
+ });