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
@@ -0,0 +1,28 @@
1
+ # Trusted LAN HTTPS development
2
+
3
+ Use this optional mode to test microphone, camera, PWA and WebRTC behavior on a
4
+ physical device connected to the same private network:
5
+
6
+ ```bash
7
+ # Install the maintained local-CA tool first:
8
+ brew install mkcert
9
+
10
+ bun run dev:lan
11
+ # Multiple private interfaces:
12
+ bun run dev:lan --host 192.168.1.20
13
+ ```
14
+
15
+ The launcher uses mkcert, stores leaf certificates under
16
+ `~/.local/share/<app-slug>/lan-https`, and keeps the CA in mkcert's own CAROOT.
17
+ No certificate or machine address is written into the project. When the chosen
18
+ address changes, only the leaf certificate is renewed.
19
+
20
+ The command prints the HTTPS web/API URLs and a development-only onboarding URL.
21
+ Open that URL on the phone to download the **public** root certificate. On iOS,
22
+ install the profile and enable full trust under Certificate Trust Settings. On
23
+ Android, install it as a CA certificate; native development builds must opt into
24
+ user-installed roots. The private CA key is never served.
25
+
26
+ `bun run dev` remains plain localhost HTTP. Production never reads the LAN
27
+ certificate variables or mounts the onboarding routes.
28
+
@@ -1,3 +1,4 @@
1
+ import { appIdentity } from '@app/config/identity';
1
2
  import AxeBuilder from '@axe-core/playwright';
2
3
  import { expect, test } from '@playwright/test';
3
4
 
@@ -6,7 +7,6 @@ test('renders the hydrated starter application and catalogue', async ({ page })
6
7
  await expect(page.getByRole('heading', { level: 1 })).toContainText(
7
8
  'Build the product, not the plumbing',
8
9
  );
9
- await expect(page.getByText('max-listov/stitchkit')).toBeVisible();
10
10
  await page.getByRole('link', { name: /UI system/ }).click();
11
11
  await expect(page).toHaveURL(/\/en\/ui\/components$/);
12
12
  await expect(page.getByRole('heading', { level: 1 })).toContainText('UI components');
@@ -17,7 +17,7 @@ test('publishes complete page metadata and a reachable Open Graph card', async (
17
17
  request,
18
18
  }) => {
19
19
  await page.goto('/en/ui/themes');
20
- await expect(page).toHaveTitle('Theme system · Stitchkit Starter');
20
+ await expect(page).toHaveTitle(`Theme system · ${appIdentity.name}`);
21
21
  await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
22
22
  'href',
23
23
  /\/en\/ui\/themes$/,
@@ -39,23 +39,6 @@ test('publishes complete page metadata and a reachable Open Graph card', async (
39
39
  expect(await sitemapResponse.text()).toContain('/ru/ui/themes');
40
40
  });
41
41
 
42
- test('spins the refresh icon in place while its action is pending', async ({ page }) => {
43
- await page.route('**/api/repository/refresh', async (route) => {
44
- await new Promise((resolve) => setTimeout(resolve, 750));
45
- await route.continue();
46
- });
47
- await page.goto('/en');
48
-
49
- const refresh = page.getByRole('button', { name: 'Refresh repository data' });
50
- await expect(refresh).toHaveCSS('height', '32px');
51
- await expect(refresh).toHaveCSS('width', '32px');
52
- await expect(refresh.locator('.tabler-icon-refresh')).toHaveCount(1);
53
- await refresh.click();
54
- await expect(refresh).toHaveAttribute('aria-busy', 'true');
55
- await expect(refresh.locator('svg')).toHaveCount(1);
56
- await expect(refresh.locator('.tabler-icon-refresh')).toHaveClass(/animate-spin/);
57
- });
58
-
59
42
  test('switches catalogue sections and component tabs', async ({ page }) => {
60
43
  await page.goto('/en/ui');
61
44
  await expect(page).toHaveURL(/\/en\/ui\/components$/);
@@ -176,7 +159,11 @@ test('keeps long localized navigation labels inside the mobile drawer', async ({
176
159
  expect(overflow.page).toBeLessThanOrEqual(1);
177
160
  });
178
161
 
179
- test('provides a server-first synchronized theme system', async ({ context, page }) => {
162
+ test('provides a server-first synchronized theme system', async ({
163
+ browserName,
164
+ context,
165
+ page,
166
+ }) => {
180
167
  const consoleProblems: string[] = [];
181
168
  page.on('console', (message) => {
182
169
  if (message.type() === 'error' || message.type() === 'warning') {
@@ -210,17 +197,25 @@ test('provides a server-first synchronized theme system', async ({ context, page
210
197
  const secondPage = await context.newPage();
211
198
  await secondPage.goto('/en/ui/themes');
212
199
  await expect(secondPage.locator('html')).toHaveClass(/dark/);
200
+ await expect(secondPage.getByTestId('theme-state-selected')).not.toContainText('hydrating');
201
+ await page.emulateMedia({ reducedMotion: 'reduce' });
213
202
  await page.getByRole('button', { name: 'Light', exact: true }).click();
203
+ if (browserName === 'webkit') {
204
+ // Playwright WebKit shares localStorage but does not dispatch cross-page storage events.
205
+ await secondPage.reload();
206
+ }
214
207
  await expect(secondPage.locator('html')).toHaveClass(/light/);
215
208
 
216
209
  await page.getByRole('button', { name: 'System', exact: true }).click();
217
210
  await expect(page.getByTestId('theme-state-selected')).toContainText('system');
218
211
  const themeCookie = (await context.cookies()).find(
219
- (cookie) => cookie.name === 'stitchkit-starter-theme',
212
+ (cookie) => cookie.name === `${appIdentity.slug}-theme`,
220
213
  );
221
214
  expect(themeCookie?.value).toBe('system');
222
215
  await page.reload();
223
- await expect(page.getByTestId('theme-state-server-cookie')).toContainText('system');
216
+ const serverCookieState = page.getByTestId('theme-state-server-cookie');
217
+ await expect(serverCookieState).toHaveCount(1);
218
+ await expect(serverCookieState).toContainText('system');
224
219
  expect(
225
220
  consoleProblems.filter((message) =>
226
221
  /hydration|useServerInsertedHTML|inline script/i.test(message),
@@ -1,12 +1,13 @@
1
1
  const path = require('node:path');
2
2
  const { config } = require('dotenv');
3
+ const identity = require('./app.config.json');
3
4
 
4
5
  config({ path: path.join(__dirname, '.env'), quiet: true, override: true });
5
6
 
6
7
  module.exports = {
7
8
  apps: [
8
9
  {
9
- name: 'stitchkit-starter-backend',
10
+ name: `${identity.slug}-backend`,
10
11
  cwd: path.join(__dirname, 'packages/backend'),
11
12
  script: 'dist/index.js',
12
13
  interpreter: 'bun',
@@ -15,7 +16,7 @@ module.exports = {
15
16
  env: { NODE_ENV: 'production' },
16
17
  },
17
18
  {
18
- name: 'stitchkit-starter-frontend',
19
+ name: `${identity.slug}-frontend`,
19
20
  cwd: path.join(__dirname, 'packages/frontend'),
20
21
  script: 'node_modules/.bin/next',
21
22
  args: ['start', '--port', process.env.WEB_PORT, '--hostname', '0.0.0.0'],
@@ -1,12 +1,27 @@
1
1
  const path = require('node:path');
2
2
  const { config } = require('dotenv');
3
+ const identity = require('./app.config.json');
3
4
 
4
- config({ path: path.join(__dirname, '.env'), quiet: true, override: true });
5
+ config({ path: path.join(__dirname, '.env'), quiet: true });
6
+
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
+ }
5
20
 
6
21
  module.exports = {
7
22
  apps: [
8
23
  {
9
- name: 'stitchkit-starter-backend-dev',
24
+ name: `${identity.slug}-backend-dev`,
10
25
  cwd: path.join(__dirname, 'packages/backend'),
11
26
  script: 'src/index.ts',
12
27
  interpreter: 'bun',
@@ -16,10 +31,10 @@ module.exports = {
16
31
  env: { NODE_ENV: 'development' },
17
32
  },
18
33
  {
19
- name: 'stitchkit-starter-frontend-dev',
34
+ name: `${identity.slug}-frontend-dev`,
20
35
  cwd: path.join(__dirname, 'packages/frontend'),
21
36
  script: 'node_modules/.bin/next',
22
- args: ['dev', '--port', process.env.WEB_PORT, '--hostname', '0.0.0.0'],
37
+ args: frontendArgs,
23
38
  interpreter: 'bun',
24
39
  autorestart: true,
25
40
  kill_timeout: 10000,
@@ -7,10 +7,11 @@
7
7
  "packages/*"
8
8
  ],
9
9
  "catalog": {
10
- "stitchkit": "^0.42.0"
10
+ "stitchkit": "^0.45.0"
11
11
  },
12
12
  "scripts": {
13
13
  "dev": "bun scripts/dev.ts",
14
+ "dev:lan": "bun scripts/dev-lan.ts",
14
15
  "check": "bun run db:generate && bun run check:authored && bun run --filter '*' check",
15
16
  "check:authored": "bun scripts/check-authored.ts",
16
17
  "test": "bun run --filter '*' test",
@@ -36,10 +37,11 @@
36
37
  "dotenv": "^17.4.2"
37
38
  },
38
39
  "devDependencies": {
40
+ "@app/config": "workspace:*",
39
41
  "@app/shared": "workspace:*",
40
42
  "@axe-core/playwright": "^4.11.0",
41
43
  "@biomejs/biome": "^2.5.7",
42
- "@modelcontextprotocol/sdk": "^1.30.0",
44
+ "@modelcontextprotocol/client": "^2.0.0",
43
45
  "@playwright/test": "^1.55.0",
44
46
  "@types/bun": "^1.3.14",
45
47
  "oxc-parser": "^0.143.0",
@@ -16,7 +16,7 @@
16
16
  "@app/config": "workspace:*",
17
17
  "@app/db": "workspace:*",
18
18
  "@app/shared": "workspace:*",
19
- "@modelcontextprotocol/sdk": "^1.30.0",
19
+ "@modelcontextprotocol/server": "^2.0.0",
20
20
  "@prisma/adapter-pg": "^7.9.1",
21
21
  "@socket.io/bun-engine": "^0.1.1",
22
22
  "ai": "^7.0.58",
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
+ import { appIdentity } from '@app/config/identity';
3
4
  import { createCli } from 'stitchkit/cli';
4
5
  import { createSurface } from './surface';
5
6
 
6
7
  const { services, socket } = await createSurface();
7
8
 
8
9
  try {
9
- await createCli({ name: 'stitchkit-starter', version: '0.1.0', services });
10
+ await createCli({ name: appIdentity.slug, version: appIdentity.version, services });
10
11
  } finally {
11
12
  await socket.io.close();
12
13
  }
@@ -1,20 +1,22 @@
1
1
  import { env } from '@app/config';
2
+ import { appIdentity } from '@app/config/identity';
2
3
  import { wrapInRequestContext } from 'stitchkit/observability';
3
4
  import { createServer, generateOpenApiDocument, openApiRoute } from 'stitchkit/server';
4
- import { createMcpHandler } from 'stitchkit/tools';
5
+ import { createMcpHandler, createMcpHttpRoute } from 'stitchkit/tools';
5
6
  import { prisma } from './lib/db';
6
7
  import { createSurface } from './surface';
7
8
  import { onError } from './transport/errors';
9
+ import { createLanOnboardingRoutes } from './transport/lan-onboarding';
8
10
 
9
11
  async function main(): Promise<void> {
10
12
  const { services, socket } = await createSurface();
11
13
  const mcp = createMcpHandler({
12
- serverInfo: { name: 'stitchkit-starter', version: '0.1.0' },
14
+ serverInfo: { name: appIdentity.slug, version: appIdentity.version },
13
15
  auth: () => ({ scope: 'public' }),
14
16
  services,
15
17
  });
16
18
  const openApi = generateOpenApiDocument({
17
- info: { title: 'Stitchkit Starter API', version: '0.1.0' },
19
+ info: { title: `${appIdentity.name} API`, version: appIdentity.version },
18
20
  groups: [{ pathPrefix: '/api', services }],
19
21
  });
20
22
 
@@ -22,14 +24,15 @@ async function main(): Promise<void> {
22
24
  groups: [{ pathPrefix: '/api', services }],
23
25
  port: env.API_PORT,
24
26
  hostname: '0.0.0.0',
25
- cors: { origin: '*' },
27
+ cors: { origin: env.CORS_ORIGIN },
26
28
  hooks: { onError },
27
29
  logging: { format: env.LOG_FORMAT },
28
30
  websocket: socket.websocket,
29
31
  rawRoutes: [
30
32
  socket.route,
31
33
  openApiRoute('/openapi.json', openApi),
32
- { method: 'ALL', path: '/mcp', handler: (req) => mcp(req) },
34
+ createMcpHttpRoute({ path: '/mcp', handler: mcp }),
35
+ ...createLanOnboardingRoutes(),
33
36
  {
34
37
  method: 'GET',
35
38
  path: '/health',
@@ -37,17 +40,30 @@ async function main(): Promise<void> {
37
40
  },
38
41
  ],
39
42
  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,
40
53
  });
41
54
 
42
55
  async function shutdown(): Promise<void> {
43
56
  server.stop();
57
+ await mcp.close();
44
58
  await socket.io.close();
45
59
  await prisma.$disconnect();
46
60
  }
47
61
 
48
62
  process.once('SIGTERM', shutdown);
49
63
  process.once('SIGINT', shutdown);
50
- console.log(`API listening on http://127.0.0.1:${env.API_PORT}`);
64
+ console.log(
65
+ `API listening on ${env.DEV_HTTPS_CERT ? 'https' : 'http'}://127.0.0.1:${env.API_PORT}`,
66
+ );
51
67
  }
52
68
 
53
69
  main().catch((error: unknown) => {
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { defineContract } from 'stitchkit/contract';
3
+ import { generateOpenApiDocument, implement } from 'stitchkit/server';
4
+ import { z } from 'zod';
5
+ import { assertSurfaceConformance, buildSurfaceManifest } from './surface-manifest';
6
+
7
+ const service = implement(
8
+ defineContract(
9
+ { prefix: 'notes' },
10
+ {
11
+ read: {
12
+ method: 'GET',
13
+ path: '/:id',
14
+ desc: 'Read a note',
15
+ params: z.object({ id: z.string() }),
16
+ output: z.object({ id: z.string() }),
17
+ },
18
+ },
19
+ ),
20
+ { read: ({ params }) => ({ id: params.id }) },
21
+ );
22
+
23
+ describe('surface conformance', () => {
24
+ test('derives matching HTTP and MCP discovery identities without calling handlers', () => {
25
+ const manifest = buildSurfaceManifest([service]);
26
+ const openApi = generateOpenApiDocument({
27
+ info: { title: 'Test', version: '1.0.0' },
28
+ groups: [{ pathPrefix: '/api', services: [service] }],
29
+ });
30
+ expect(manifest).toEqual([
31
+ {
32
+ service: 'notes',
33
+ action: 'read',
34
+ http: [{ method: 'GET', path: '/api/notes/{id}' }],
35
+ tools: { AGENT: 'read_note', MCP: 'read_note' },
36
+ },
37
+ ]);
38
+ expect(() =>
39
+ assertSurfaceConformance({ manifest, openApi, mcpToolNames: ['read_note'] }),
40
+ ).not.toThrow();
41
+ });
42
+
43
+ test('fails with transport-specific expected and actual diagnostics', () => {
44
+ const manifest = buildSurfaceManifest([service]);
45
+ expect(() =>
46
+ assertSurfaceConformance({ manifest, openApi: { paths: {} }, mcpToolNames: [] }),
47
+ ).toThrow('HTTP/OpenAPI surface mismatch');
48
+ expect(() =>
49
+ assertSurfaceConformance({
50
+ manifest: manifest.map((operation) => ({ ...operation, http: [] })),
51
+ openApi: { paths: {} },
52
+ mcpToolNames: ['unexpected'],
53
+ }),
54
+ ).toThrow('MCP discovery surface mismatch');
55
+ });
56
+
57
+ test('fails first on duplicate contract identity', () => {
58
+ expect(() => buildSurfaceManifest([service, service])).toThrow(
59
+ 'Duplicate operation identity notes.read',
60
+ );
61
+ });
62
+ });
@@ -0,0 +1,117 @@
1
+ import type { HttpMethod } from 'stitchkit/contract';
2
+ import type { OpenApiDocument, ServiceDef } from 'stitchkit/server';
3
+ import { listToolNames } from 'stitchkit/tools';
4
+
5
+ export interface SurfaceManifestOperation {
6
+ service: string;
7
+ action: string;
8
+ http: Array<{ method: HttpMethod; path: string }>;
9
+ tools: Partial<Record<'MCP' | 'AGENT' | 'CLI', string>>;
10
+ }
11
+
12
+ function joinPath(...parts: Array<string | undefined>): string {
13
+ const joined = parts
14
+ .filter((part): part is string => Boolean(part))
15
+ .map((part) => part.replace(/^\/+|\/+$/g, ''))
16
+ .filter(Boolean)
17
+ .join('/');
18
+ return `/${joined}`;
19
+ }
20
+
21
+ function operationKey(service: string, action: string): string {
22
+ return `${service}\u0000${action}`;
23
+ }
24
+
25
+ function sorted(values: Iterable<string>): string[] {
26
+ return [...values].sort((left, right) => left.localeCompare(right));
27
+ }
28
+
29
+ function assertSameSet(
30
+ label: string,
31
+ expectedValues: Iterable<string>,
32
+ actualValues: Iterable<string>,
33
+ ) {
34
+ const expected = sorted(expectedValues);
35
+ const actual = sorted(actualValues);
36
+ if (
37
+ expected.length === actual.length &&
38
+ expected.every((value, index) => value === actual[index])
39
+ ) {
40
+ return;
41
+ }
42
+ throw new Error(
43
+ `${label} surface mismatch\nexpected: ${expected.join(', ') || '(empty)'}\nactual: ${actual.join(', ') || '(empty)'}`,
44
+ );
45
+ }
46
+
47
+ export function buildSurfaceManifest(
48
+ services: readonly ServiceDef[],
49
+ pathPrefix = '/api',
50
+ ): SurfaceManifestOperation[] {
51
+ const operations = new Map<string, SurfaceManifestOperation>();
52
+ for (const service of services) {
53
+ for (const method of Object.values(service.methods)) {
54
+ const key = operationKey(service.name, method.key);
55
+ if (operations.has(key)) {
56
+ throw new Error(`Duplicate operation identity ${service.name}.${method.key}`);
57
+ }
58
+ operations.set(key, {
59
+ service: service.name,
60
+ action: method.key,
61
+ http:
62
+ !method.expose || method.expose.includes('HTTP')
63
+ ? [
64
+ {
65
+ method: method.method,
66
+ path: joinPath(pathPrefix, service.prefix, method.path).replace(
67
+ /:([^/]+)/g,
68
+ '{$1}',
69
+ ),
70
+ },
71
+ ]
72
+ : [],
73
+ tools: {},
74
+ });
75
+ }
76
+ }
77
+ for (const tool of listToolNames({ services })) {
78
+ const operation = operations.get(operationKey(tool.service, tool.method));
79
+ if (!operation) {
80
+ throw new Error(`Tool ${tool.name} has no contract identity`);
81
+ }
82
+ for (const transport of tool.transports) {
83
+ if (transport !== 'HTTP') operation.tools[transport] = tool.name;
84
+ }
85
+ }
86
+ return [...operations.values()].sort(
87
+ (left, right) =>
88
+ left.service.localeCompare(right.service) || left.action.localeCompare(right.action),
89
+ );
90
+ }
91
+
92
+ export function assertSurfaceConformance({
93
+ manifest,
94
+ openApi,
95
+ mcpToolNames,
96
+ }: {
97
+ manifest: readonly SurfaceManifestOperation[];
98
+ openApi: Pick<OpenApiDocument, 'paths'>;
99
+ mcpToolNames: readonly string[];
100
+ }): void {
101
+ assertSameSet(
102
+ 'HTTP/OpenAPI',
103
+ manifest.flatMap((operation) =>
104
+ operation.http.map((entry) => `${entry.method}:${entry.path}`),
105
+ ),
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
+ ),
111
+ );
112
+ assertSameSet(
113
+ 'MCP discovery',
114
+ manifest.flatMap((operation) => (operation.tools.MCP ? [operation.tools.MCP] : [])),
115
+ mcpToolNames,
116
+ );
117
+ }
@@ -1,13 +1,7 @@
1
- import type { ClientToServerEvents, ServerToClientEvents } from '@app/shared';
1
+ import { env } from '@app/config';
2
2
  import { createSocketIOServer } from 'stitchkit/server';
3
- import { createRepositoryService } from './transport/repository-service';
4
3
 
5
4
  export async function createSurface() {
6
- const socket = await createSocketIOServer<ServerToClientEvents, ClientToServerEvents>({
7
- cors: { origin: '*' },
8
- });
9
- const repositoryService = createRepositoryService((snapshot) =>
10
- socket.io.emit('repository:refreshed', snapshot),
11
- );
12
- return { socket, services: [repositoryService] };
5
+ const socket = await createSocketIOServer({ cors: { origin: env.CORS_ORIGIN } });
6
+ return { socket, services: [] };
13
7
  }
@@ -0,0 +1,29 @@
1
+ import { env } from '@app/config';
2
+ import type { RawRoute } from 'stitchkit/server';
3
+
4
+ export function createLanOnboardingRoutes(): RawRoute[] {
5
+ if (env.NODE_ENV !== 'development' || !env.DEV_HTTPS_CA) return [];
6
+ const caPath = env.DEV_HTTPS_CA;
7
+ return [
8
+ {
9
+ method: 'GET',
10
+ path: '/__dev/lan-ca',
11
+ handler: () =>
12
+ new Response(Bun.file(caPath), {
13
+ headers: {
14
+ 'Content-Disposition': 'attachment; filename="stitchkit-lan-root-ca.pem"',
15
+ 'Content-Type': 'application/x-pem-file',
16
+ },
17
+ }),
18
+ },
19
+ {
20
+ method: 'GET',
21
+ path: '/__dev/lan',
22
+ handler: () =>
23
+ new Response(
24
+ `<!doctype html><meta name="viewport" content="width=device-width"><title>LAN HTTPS setup</title><main><h1>Trust this development CA</h1><p><a href="/__dev/lan-ca">Download the public root certificate</a>.</p><h2>iOS</h2><p>Install the downloaded profile, then enable full trust in Settings → General → About → Certificate Trust Settings.</p><h2>Android</h2><p>Install it as a CA certificate. Native development builds must explicitly allow user-installed roots.</p><p>No private key is exposed by this server.</p></main>`,
25
+ { headers: { 'Content-Type': 'text/html; charset=utf-8' } },
26
+ ),
27
+ },
28
+ ];
29
+ }
@@ -4,7 +4,8 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
7
- ".": "./src/server.ts"
7
+ ".": "./src/server.ts",
8
+ "./identity": "./src/identity.ts"
8
9
  },
9
10
  "scripts": {
10
11
  "check": "bun x tsc --noEmit",
@@ -0,0 +1 @@
1
+ export const featureServerSchema = {};
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod';
2
+ import source from '../../../app.config.json' with { type: 'json' };
3
+
4
+ export const ApplicationIdentitySchema = z.object({
5
+ slug: z
6
+ .string()
7
+ .min(1)
8
+ .max(64)
9
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
10
+ name: z.string().trim().min(1).max(80),
11
+ version: z.string().regex(/^\d+\.\d+\.\d+$/),
12
+ description: z.object({
13
+ en: z.string().trim().min(1),
14
+ ru: z.string().trim().min(1),
15
+ }),
16
+ });
17
+
18
+ export const appIdentity = ApplicationIdentitySchema.parse(source);
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import { createEnv } from '@t3-oss/env-core';
3
3
  import { config } from 'dotenv';
4
4
  import { z } from 'zod';
5
+ import { featureServerSchema } from './features';
5
6
 
6
7
  config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
7
8
 
@@ -15,10 +16,19 @@ export const env = createEnv({
15
16
  INTERNAL_API_URL: z.url(),
16
17
  NEXT_PUBLIC_WEB_URL: z.url(),
17
18
  LOG_FORMAT: z.enum(['pretty', 'json']).default('pretty'),
18
- GITHUB_REPOSITORY: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/),
19
- GITHUB_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(900),
20
- GITHUB_TOKEN: z.string().min(1).optional(),
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(),
23
+ ...featureServerSchema,
21
24
  },
22
25
  runtimeEnv: process.env,
23
26
  emptyStringAsUndefined: true,
24
27
  });
28
+
29
+ /** Preserve the host process environment while applying explicit child-process overrides. */
30
+ export function childEnvironment(
31
+ overrides: Record<string, string>,
32
+ ): Record<string, string | undefined> {
33
+ return { ...process.env, ...overrides };
34
+ }
@@ -6,26 +6,3 @@ generator client {
6
6
  datasource db {
7
7
  provider = "postgresql"
8
8
  }
9
-
10
- enum RepositoryVisibility {
11
- PUBLIC
12
- PRIVATE
13
- INTERNAL
14
- }
15
-
16
- model RepositorySnapshot {
17
- fullName String @id
18
- description String?
19
- htmlUrl String
20
- language String?
21
- visibility RepositoryVisibility @default(PUBLIC)
22
- stars Int
23
- forks Int
24
- openIssues Int
25
- commitCount Int
26
- latestCommitSha String?
27
- latestCommitMessage String?
28
- latestCommittedAt DateTime?
29
- fetchedAt DateTime
30
- expiresAt DateTime
31
- }
@@ -1,6 +1,5 @@
1
1
  {
2
2
  "App": {
3
- "title": "Stitchkit Starter",
4
3
  "description": "A compact production base with one typed contract across HTTP, MCP, agents and the browser — ready for your product, not a demo domain.",
5
4
  "heroTitle": "Build the product, not the plumbing.",
6
5
  "ui": "UI system"
@@ -1,6 +1,5 @@
1
1
  {
2
2
  "App": {
3
- "title": "Stitchkit Starter",
4
3
  "description": "Компактная production-база с единым типизированным контрактом для HTTP, MCP, агентов и браузера — готовая к вашему продукту, а не к демо-домену.",
5
4
  "heroTitle": "Стройте продукт, а не инфраструктурную рутину.",
6
5
  "ui": "UI-система"
@@ -11,6 +11,7 @@
11
11
  "test": "bun test --pass-with-no-tests"
12
12
  },
13
13
  "dependencies": {
14
+ "@app/config": "workspace:*",
14
15
  "@app/db": "workspace:*",
15
16
  "@app/shared": "workspace:*",
16
17
  "@radix-ui/react-alert-dialog": "^1.1.23",