create-stitchkit 0.2.0 → 0.3.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 (54) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/cli.js +7 -16
  3. package/examples/repository/e2e/repository.spec.ts +50 -0
  4. package/examples/repository/packages/backend/src/domain/repository/github-cache.test.ts +8 -0
  5. package/examples/repository/packages/backend/src/surface.snapshot.json +58 -0
  6. package/examples/repository/packages/backend/src/surface.ts +7 -5
  7. package/examples/repository/packages/frontend/src/components/repository-summary.tsx +5 -1
  8. package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +5 -6
  9. package/examples/repository/packages/shared/src/index.ts +3 -1
  10. package/examples/repository/packages/shared/src/realtime/repository.ts +10 -0
  11. package/examples/repository/packages/shared/src/schemas/repository.ts +4 -2
  12. package/examples/repository/scripts/runtime-smoke.ts +96 -18
  13. package/package.json +1 -1
  14. package/template/README.md +6 -10
  15. package/template/_env.example +1 -1
  16. package/template/bun.lock +4 -4
  17. package/template/docs/ADDING_A_FEATURE.md +3 -2
  18. package/template/e2e/starter.spec.ts +17 -0
  19. package/template/ecosystem.dev.config.cjs +0 -12
  20. package/template/package.json +5 -3
  21. package/template/packages/backend/src/index.ts +1 -15
  22. package/template/packages/backend/src/surface-manifest.test.ts +183 -8
  23. package/template/packages/backend/src/surface-manifest.ts +108 -5
  24. package/template/packages/backend/src/surface.snapshot.json +18 -0
  25. package/template/packages/backend/src/surface.ts +2 -1
  26. package/template/packages/backend/src/tools.ts +4 -1
  27. package/template/packages/backend/src/transport/errors.ts +1 -0
  28. package/template/packages/backend/src/transport/system-service.ts +8 -0
  29. package/template/packages/config/src/server.ts +1 -4
  30. package/template/packages/frontend/package.json +0 -1
  31. package/template/packages/frontend/src/lib/query-client.test.ts +22 -0
  32. package/template/packages/frontend/src/lib/query-client.ts +10 -2
  33. package/template/packages/frontend/tsconfig.json +7 -1
  34. package/template/packages/shared/package.json +0 -1
  35. package/template/packages/shared/src/contracts/system.ts +19 -0
  36. package/template/packages/shared/src/index.ts +2 -1
  37. package/template/packages/shared/src/schemas/system.ts +4 -0
  38. package/template/playwright.config.ts +2 -1
  39. package/template/scripts/check-authored.ts +20 -6
  40. package/template/scripts/dev.ts +19 -10
  41. package/template/scripts/local-env.test.ts +33 -0
  42. package/template/scripts/local-env.ts +16 -5
  43. package/template/scripts/runtime-smoke.ts +12 -5
  44. package/template/scripts/surface-conformance.ts +76 -15
  45. package/template/scripts/surface-snapshot.ts +21 -0
  46. package/template/scripts/tooling-env.ts +16 -3
  47. package/template/tsconfig.json +3 -1
  48. package/examples/repository/_env.append +0 -3
  49. package/examples/repository/packages/shared/src/events/repository.ts +0 -9
  50. package/template/_env +0 -9
  51. package/template/docs/LAN_HTTPS.md +0 -28
  52. package/template/packages/backend/src/transport/lan-onboarding.ts +0 -29
  53. package/template/scripts/dev-lan.test.ts +0 -58
  54. package/template/scripts/dev-lan.ts +0 -176
package/CHANGELOG.md CHANGED
@@ -6,6 +6,66 @@ is declared in the template root catalog.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] — 2026-08-10
10
+
11
+ ### ⚠️ Breaking changes
12
+
13
+ - **Generated projects ship no `.env`.** The single environment source is
14
+ `.env.example`; `bun run env:ensure` (and every tooling entry point,
15
+ self-healing before validation) renders `.env` with the application-derived
16
+ database name. A clone and a post-generation rename now produce the same
17
+ database, and the second-developer path (`runtime:smoke`, `e2e`) works
18
+ unaided.
19
+ `// before: scaffold writes .env with a neutral database name` →
20
+ `// after: .env is rendered on first run from .env.example`
21
+
22
+ - **Starter-owned LAN HTTPS is removed.** Generated applications no longer ship
23
+ `dev:lan`, certificate generation, onboarding routes or `DEV_HTTPS_*` settings.
24
+ Applications that need a trusted device-testing origin should own certificate
25
+ creation and pass TLS files through Stitchkit's documented `bun.tls` boundary.
26
+
27
+ ### Changed
28
+
29
+ - **Surface conformance is anchored and total.** The declared surface is
30
+ compared against a committed `surface.snapshot.json` (with schema-shape
31
+ digests, regenerated deliberately via `bun run surface:snapshot`), the CLI is
32
+ observed by spawning the real process, missing `x-stitchkit-*` metadata is an
33
+ error unless a standard-document mode is declared explicitly, and the starter
34
+ lane sweeps generated trees for neutral-identity leaks against a committed
35
+ allowlist.
36
+ - **The template targets Stitchkit 0.46.** The error-code map covers
37
+ `REALTIME_CONTRACT_VIOLATION` and conformance validates the operation
38
+ metadata 0.46 emits.
39
+ - **Scaffold identity is rendered from one config.** The generated
40
+ `app.config.json` drives runtime identity and database naming; only the root
41
+ package manifest is structurally projected, with no global text search or
42
+ inert lockfile rewrite.
43
+ - **Development is explicit and portable.** `bun run dev` validates PM2 and
44
+ external PostgreSQL requirements before side effects. The removed LAN HTTPS
45
+ mode is documented as application-owned framework configuration instead of a
46
+ starter subsystem.
47
+
48
+ ### Fixed
49
+
50
+ - **Fresh clones bootstrap in the right order.** The starter materializes its
51
+ local environment before Prisma/type gates and reports a missing or placeholder
52
+ `DATABASE_URL` directly.
53
+ - **Every executable source is checked.** Root TypeScript coverage includes
54
+ scripts and browser E2E; the authored-source guard scans CJS and reports the
55
+ real offending line.
56
+ - **Runtime and browser gates prove the backend.** Surface conformance compares
57
+ HTTP/OpenAPI, MCP, Agent and CLI identities and schemas; browser E2E performs
58
+ a contract call and realtime cache update.
59
+ - **Repository example uses the canonical realtime contract.** Shared Zod event
60
+ definitions drive backend emission, browser subscriptions and cache bridging
61
+ without handwritten event maps.
62
+
63
+ ### Removed
64
+
65
+ - **Starter-owned LAN HTTPS.** `dev:lan`, certificate generation, onboarding
66
+ transport and `DEV_HTTPS_*` settings are gone; trusted local TLS remains an
67
+ application-owned adapter choice documented by Stitchkit.
68
+
9
69
  ## [0.2.0] — 2026-08-10
10
70
 
11
71
  ### ⚠️ Breaking changes
package/dist/cli.js CHANGED
@@ -60,7 +60,8 @@ function parseOptions(args) {
60
60
  // src/scaffold.ts
61
61
  import { lstat, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
62
62
  import { homedir } from "os";
63
- import { basename as basename2, dirname, join, parse, relative, resolve, sep } from "path";
63
+ import { basename as basename2, dirname, extname, join, parse, relative, resolve, sep } from "path";
64
+ import { z as z2 } from "zod";
64
65
 
65
66
  // src/identity.ts
66
67
  import { basename } from "path";
@@ -112,12 +113,11 @@ var TEXT_EXTENSIONS = new Set([
112
113
  ".yml"
113
114
  ]);
114
115
  var TEMPLATE_RENAMES = new Map([
115
- ["_env", ".env"],
116
- ["_env.append", ".env"],
117
116
  ["_env.example", ".env.example"],
118
117
  ["_env.example.append", ".env.example"],
119
118
  ["_gitignore", ".gitignore"]
120
119
  ]);
120
+ var RootManifestSchema = z2.looseObject({ name: z2.string().min(1) });
121
121
  var IGNORED_DIRECTORIES = new Set([
122
122
  ".next",
123
123
  "coverage",
@@ -189,8 +189,8 @@ async function collectMaterialisedFiles(templateDirectory, directory, files) {
189
189
  const targetName = TEMPLATE_RENAMES.get(entry.name);
190
190
  const sourceRelativePath = relative(templateDirectory, sourcePath);
191
191
  const outputRelativePath = targetName ? join(dirname(sourceRelativePath), targetName) : sourceRelativePath;
192
- const extension = targetName?.includes(".") ? targetName.slice(targetName.indexOf(".")) : "";
193
- const fileExtension = extension || sourcePath.slice(sourcePath.lastIndexOf("."));
192
+ const materialisedName = outputRelativePath.endsWith(".append") ? outputRelativePath.slice(0, -".append".length) : outputRelativePath;
193
+ const fileExtension = extname(materialisedName);
194
194
  const content = TEXT_EXTENSIONS.has(fileExtension) || targetName ? await readFile(sourcePath, "utf8") : await readFile(sourcePath);
195
195
  files.push({
196
196
  sourcePath: portablePath(sourceRelativePath),
@@ -234,18 +234,9 @@ async function scaffoldProject(templateDirectory, destination, options = {}) {
234
234
  await writeFile(join(resolvedDestination, "app.config.json"), `${JSON.stringify(identity, undefined, 2)}
235
235
  `);
236
236
  const manifestPath = join(resolvedDestination, "package.json");
237
- const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
238
- manifest.name = identity.slug;
239
- await writeFile(manifestPath, `${JSON.stringify(manifest, undefined, 2)}
237
+ const manifest = RootManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8")));
238
+ await writeFile(manifestPath, `${JSON.stringify({ ...manifest, name: identity.slug }, undefined, 2)}
240
239
  `);
241
- const lockPath = join(resolvedDestination, "bun.lock");
242
- const lock = await readFile(lockPath, "utf8");
243
- await writeFile(lockPath, lock.replace(/"name"\s*:\s*"stitchkit-starter"/, `"name": "${identity.slug}"`));
244
- for (const environmentPath of [".env", ".env.example"]) {
245
- const fullPath = join(resolvedDestination, environmentPath);
246
- const environment = await readFile(fullPath, "utf8");
247
- await writeFile(fullPath, environment.replaceAll("stitchkit_starter", identity.slug.replaceAll("-", "_")));
248
- }
249
240
  } catch (error) {
250
241
  if (!destinationExisted) {
251
242
  await rm(resolvedDestination, { recursive: true, force: true });
@@ -1,5 +1,55 @@
1
1
  import { expect, test } from '@playwright/test';
2
2
 
3
+ test('prefetched data hydrates without a loading flash or a client refetch', async ({
4
+ page,
5
+ request,
6
+ }) => {
7
+ // The SSR document itself carries the prefetched repository data — the
8
+ // dehydration envelope is not empty.
9
+ const document = await request.get('/en');
10
+ expect(await document.text()).toContain('max-listov/stitchkit');
11
+
12
+ // Block the browser-side read entirely: the page must still render the data
13
+ // from the hydration envelope — never refetching, never flashing a loader.
14
+ let clientReads = 0;
15
+ await page.route('**/api/repository', async (route) => {
16
+ if (route.request().method() === 'GET') {
17
+ clientReads += 1;
18
+ await route.abort();
19
+ return;
20
+ }
21
+ await route.continue();
22
+ });
23
+ await page.goto('/en');
24
+ await expect(page.getByText('max-listov/stitchkit')).toBeVisible();
25
+ expect(clientReads).toBe(0);
26
+ });
27
+
28
+ test('a server realtime event updates the TanStack cache without a client refetch', async ({
29
+ page,
30
+ }) => {
31
+ await page.goto('/en');
32
+ const summary = page.getByTestId('repository-summary');
33
+ await expect(summary).toBeVisible();
34
+ const before = await summary.getAttribute('data-fetched-at');
35
+ expect(before).not.toBeNull();
36
+
37
+ // Sever the refetch path completely: the refresh mutation invalidates the
38
+ // query, but its refetch is aborted here — so the ONLY way the summary can
39
+ // carry a new snapshot is the Socket.IO event through the cache bridge.
40
+ await page.route('**/api/repository', async (route) => {
41
+ if (route.request().method() === 'GET') {
42
+ await route.abort();
43
+ return;
44
+ }
45
+ await route.continue();
46
+ });
47
+ await page.getByRole('button', { name: 'Refresh repository data' }).click();
48
+ await expect(summary).not.toHaveAttribute('data-fetched-at', before ?? '', {
49
+ timeout: 10_000,
50
+ });
51
+ });
52
+
3
53
  test('renders and refreshes the repository example', async ({ page }) => {
4
54
  await page.route('**/api/repository/refresh', async (route) => {
5
55
  await new Promise((resolve) => setTimeout(resolve, 750));
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
+ import { RepositoryVisibility } from '@app/db';
3
+ import { RepositoryVisibilitySchema } from '@app/shared';
2
4
  import {
3
5
  GitHubRepositoryCache,
4
6
  type RepositorySnapshotStore,
@@ -39,6 +41,12 @@ function commitsResponse(): Response {
39
41
  }
40
42
 
41
43
  describe('GitHubRepositoryCache', () => {
44
+ test('keeps the shared wire enum aligned with the database enum', () => {
45
+ expect([...RepositoryVisibilitySchema.options].sort()).toEqual(
46
+ Object.values(RepositoryVisibility).sort(),
47
+ );
48
+ });
49
+
42
50
  test('deduplicates refreshes and persists an exact repository snapshot', async () => {
43
51
  const requests: URL[] = [];
44
52
  const fetcher = async (url: URL) => {
@@ -0,0 +1,58 @@
1
+ [
2
+ {
3
+ "service": "repository",
4
+ "action": "read",
5
+ "scope": "public",
6
+ "hasInput": false,
7
+ "hasOutput": true,
8
+ "inputShape": null,
9
+ "outputShape": "49c1f81a53ca82ad",
10
+ "http": [
11
+ {
12
+ "method": "GET",
13
+ "path": "/api/repository"
14
+ }
15
+ ],
16
+ "tools": {
17
+ "MCP": "repository_read",
18
+ "AGENT": "repository_read",
19
+ "CLI": "repository_read"
20
+ }
21
+ },
22
+ {
23
+ "service": "repository",
24
+ "action": "refresh",
25
+ "scope": "public",
26
+ "hasInput": false,
27
+ "hasOutput": true,
28
+ "inputShape": null,
29
+ "outputShape": "49c1f81a53ca82ad",
30
+ "http": [
31
+ {
32
+ "method": "POST",
33
+ "path": "/api/repository/refresh"
34
+ }
35
+ ],
36
+ "tools": {
37
+ "MCP": "repository_refresh",
38
+ "AGENT": "repository_refresh",
39
+ "CLI": "repository_refresh"
40
+ }
41
+ },
42
+ {
43
+ "service": "system",
44
+ "action": "status",
45
+ "scope": "public",
46
+ "hasInput": false,
47
+ "hasOutput": true,
48
+ "inputShape": null,
49
+ "outputShape": "58b078ade3ee6167",
50
+ "http": [
51
+ {
52
+ "method": "GET",
53
+ "path": "/api/system/status"
54
+ }
55
+ ],
56
+ "tools": {}
57
+ }
58
+ ]
@@ -1,14 +1,16 @@
1
1
  import { env } from '@app/config';
2
- import type { ClientToServerEvents, ServerToClientEvents } from '@app/shared';
3
- import { createSocketIOServer } from 'stitchkit/server';
2
+ import { repositoryRealtimeContract } from '@app/shared';
3
+ import { bindRealtimeServer, createSocketIOServer } from 'stitchkit/server';
4
4
  import { createRepositoryService } from './transport/repository-service';
5
+ import { createSystemService } from './transport/system-service';
5
6
 
6
7
  export async function createSurface() {
7
- const socket = await createSocketIOServer<ServerToClientEvents, ClientToServerEvents>({
8
+ const socket = await createSocketIOServer({
8
9
  cors: { origin: env.CORS_ORIGIN },
9
10
  });
11
+ const realtime = bindRealtimeServer(repositoryRealtimeContract, socket);
10
12
  const repositoryService = createRepositoryService((snapshot) =>
11
- socket.io.emit('repository:refreshed', snapshot),
13
+ realtime.emit('repository:refreshed', snapshot),
12
14
  );
13
- return { socket, services: [repositoryService] };
15
+ return { socket, services: [createSystemService(), repositoryService] };
14
16
  }
@@ -52,7 +52,11 @@ export function RepositorySummary() {
52
52
  : '—';
53
53
 
54
54
  return (
55
- <div className='mx-auto flex w-full max-w-4xl flex-col gap-4 rounded-xl border border-border bg-card/80 px-4 py-4 text-left backdrop-blur sm:flex-row sm:items-center sm:px-5'>
55
+ <div
56
+ className='mx-auto flex w-full max-w-4xl flex-col gap-4 rounded-xl border border-border bg-card/80 px-4 py-4 text-left backdrop-blur sm:flex-row sm:items-center sm:px-5'
57
+ data-fetched-at={snapshot.cache.fetchedAt}
58
+ data-testid='repository-summary'
59
+ >
56
60
  <a
57
61
  className='group flex min-w-0 flex-1 items-center gap-3'
58
62
  href={snapshot.htmlUrl}
@@ -1,14 +1,13 @@
1
- import type { ClientToServerEvents, ServerToClientEvents } from '@app/shared';
2
- import { createSocketIOClient } from 'stitchkit';
1
+ import { repositoryRealtimeContract } from '@app/shared';
2
+ import { createRealtimeClient } from 'stitchkit';
3
3
  import { createCacheBridge } from 'stitchkit/react';
4
4
  import { env } from '@/env';
5
5
  import { useRepository } from '@/lib/api/queries';
6
6
  import { getQueryClient } from '@/lib/query-client';
7
7
 
8
- export const repositorySocket = createSocketIOClient<
9
- ServerToClientEvents,
10
- ClientToServerEvents
11
- >({ url: env.NEXT_PUBLIC_API_URL });
8
+ export const repositorySocket = createRealtimeClient(repositoryRealtimeContract, {
9
+ url: env.NEXT_PUBLIC_API_URL,
10
+ });
12
11
 
13
12
  export const repositoryBridge = createCacheBridge({
14
13
  socket: repositorySocket,
@@ -1,3 +1,5 @@
1
1
  export * from './contracts/repository';
2
- export * from './events/repository';
2
+ export * from './contracts/system';
3
+ export * from './realtime/repository';
3
4
  export * from './schemas/repository';
5
+ export * from './schemas/system';
@@ -0,0 +1,10 @@
1
+ import { defineRealtimeContract } from 'stitchkit';
2
+ import { z } from 'zod';
3
+ import { RepositorySnapshotSchema } from '../schemas/repository';
4
+
5
+ export const repositoryRealtimeContract = defineRealtimeContract({
6
+ serverToClient: {
7
+ 'repository:refreshed': { args: z.tuple([RepositorySnapshotSchema]) },
8
+ },
9
+ clientToServer: {},
10
+ });
@@ -1,12 +1,14 @@
1
- import { RepositoryVisibility } from '@app/db/enums';
2
1
  import { z } from 'zod';
3
2
 
3
+ export const RepositoryVisibilitySchema = z.enum(['PUBLIC', 'PRIVATE', 'INTERNAL']);
4
+ export type RepositoryVisibility = z.infer<typeof RepositoryVisibilitySchema>;
5
+
4
6
  export const RepositorySnapshotSchema = z.object({
5
7
  fullName: z.string().min(1),
6
8
  description: z.string().nullable(),
7
9
  htmlUrl: z.url(),
8
10
  language: z.string().nullable(),
9
- visibility: z.enum(RepositoryVisibility),
11
+ visibility: RepositoryVisibilitySchema,
10
12
  stars: z.number().int().nonnegative(),
11
13
  forks: z.number().int().nonnegative(),
12
14
  openIssues: z.number().int().nonnegative(),
@@ -1,9 +1,10 @@
1
- import { RepositorySnapshotSchema } from '@app/shared';
2
- import { io } from 'socket.io-client';
1
+ import { RepositorySnapshotSchema, repositoryRealtimeContract } from '@app/shared';
2
+ import { createRealtimeClient, defineRealtimeContract } from 'stitchkit';
3
3
  import { z } from 'zod';
4
4
  import { defineSurfaceProbe, runSurfaceConformance } from './surface-conformance';
5
- import { toolingEnv } from './tooling-env';
5
+ import { loadToolingEnv } from './tooling-env';
6
6
 
7
+ const toolingEnv = loadToolingEnv();
7
8
  const apiOrigin = toolingEnv.NEXT_PUBLIC_API_URL;
8
9
 
9
10
  async function json(path: string, init?: RequestInit): Promise<unknown> {
@@ -24,33 +25,39 @@ await runSurfaceConformance({
24
25
  fixture: { path: '/api/repository/refresh' },
25
26
  output: RepositorySnapshotSchema,
26
27
  run: async ({ path }) => {
27
- const socket = io(apiOrigin, { transports: ['websocket'] });
28
+ const rejected: string[] = [];
29
+ const socket = createRealtimeClient(repositoryRealtimeContract, {
30
+ url: apiOrigin,
31
+ transports: ['websocket'],
32
+ onRejected: ({ event, direction, phase }) => {
33
+ rejected.push(`${event}:${direction}:${phase}`);
34
+ },
35
+ });
28
36
  try {
29
37
  await new Promise<void>((resolve, reject) => {
30
38
  const timeout = setTimeout(
31
39
  () => reject(new Error('Socket.IO connection timed out')),
32
40
  5_000,
33
41
  );
34
- const onConnect = () => {
42
+ const unsubscribe = socket.onConnectionChange((connected, reason) => {
43
+ if (!connected) {
44
+ if (reason) reject(new Error(`Socket.IO disconnected: ${reason}`));
45
+ return;
46
+ }
35
47
  clearTimeout(timeout);
36
- socket.off('connect_error', onConnectError);
48
+ unsubscribe();
37
49
  resolve();
38
- };
39
- const onConnectError = (error: Error) => {
40
- clearTimeout(timeout);
41
- socket.off('connect', onConnect);
42
- reject(error);
43
- };
44
- socket.once('connect', onConnect);
45
- socket.once('connect_error', onConnectError);
50
+ });
51
+ socket.connect();
46
52
  });
47
53
  const refreshedEvent = new Promise<string>((resolve, reject) => {
48
54
  const timeout = setTimeout(
49
55
  () => reject(new Error('Socket.IO repository refresh event timed out')),
50
56
  5_000,
51
57
  );
52
- socket.once('repository:refreshed', (snapshot) => {
58
+ const unsubscribe = socket.on('repository:refreshed', (snapshot) => {
53
59
  clearTimeout(timeout);
60
+ unsubscribe();
54
61
  resolve(snapshot.fullName);
55
62
  });
56
63
  });
@@ -64,9 +71,77 @@ await runSurfaceConformance({
64
71
  if (cached.fullName !== refreshed.fullName) {
65
72
  throw new Error('Repository cache read differs');
66
73
  }
74
+ if (rejected.length > 0) {
75
+ throw new Error(`Realtime contract rejected ${rejected.join(', ')}`);
76
+ }
67
77
  return refreshed;
68
78
  } finally {
69
- socket.close();
79
+ socket.disconnect();
80
+ }
81
+ },
82
+ }),
83
+ defineSurfaceProbe({
84
+ name: 'realtime rejection path fires on a contract mismatch',
85
+ input: z.object({ path: z.literal('/api/repository/refresh') }),
86
+ fixture: { path: '/api/repository/refresh' },
87
+ run: async ({ path }) => {
88
+ // NEGATIVE probe: a client whose local contract disagrees with the
89
+ // server must see the real payload REJECTED — this executes the
90
+ // rejection path instead of merely asserting its absence. Matched by
91
+ // event/direction/phase, never by message text.
92
+ const divergentContract = defineRealtimeContract({
93
+ serverToClient: {
94
+ 'repository:refreshed': { args: z.tuple([z.object({ bogus: z.string() })]) },
95
+ },
96
+ clientToServer: {},
97
+ });
98
+ const rejections: Array<{ event: string; direction: string; phase: string }> = [];
99
+ const socket = createRealtimeClient(divergentContract, {
100
+ url: apiOrigin,
101
+ transports: ['websocket'],
102
+ onRejected: ({ event, direction, phase }) => {
103
+ rejections.push({ event, direction, phase });
104
+ },
105
+ });
106
+ try {
107
+ await new Promise<void>((resolve, reject) => {
108
+ const timeout = setTimeout(
109
+ () => reject(new Error('Socket.IO connection timed out')),
110
+ 5_000,
111
+ );
112
+ const unsubscribe = socket.onConnectionChange((connected) => {
113
+ if (!connected) return;
114
+ clearTimeout(timeout);
115
+ unsubscribe();
116
+ resolve();
117
+ });
118
+ socket.connect();
119
+ });
120
+ // A handler must be attached for the inbound frame to be validated.
121
+ const unsubscribe = socket.on('repository:refreshed', () => {
122
+ throw new Error('a payload outside the local contract reached the handler');
123
+ });
124
+ await json(path, { method: 'POST' });
125
+ const deadline = Date.now() + 5_000;
126
+ while (rejections.length === 0 && Date.now() < deadline) {
127
+ await new Promise((resolve) => setTimeout(resolve, 50));
128
+ }
129
+ unsubscribe();
130
+ const rejection = rejections[0];
131
+ if (!rejection) {
132
+ throw new Error('the contract mismatch was never rejected');
133
+ }
134
+ if (
135
+ rejection.event !== 'repository:refreshed' ||
136
+ rejection.direction !== 'client-inbound' ||
137
+ rejection.phase !== 'arguments'
138
+ ) {
139
+ throw new Error(
140
+ `unexpected rejection identity: ${rejection.event}:${rejection.direction}:${rejection.phase}`,
141
+ );
142
+ }
143
+ } finally {
144
+ socket.disconnect();
70
145
  }
71
146
  },
72
147
  }),
@@ -77,8 +152,11 @@ await runSurfaceConformance({
77
152
  const corsResponse = await fetch(`${apiOrigin}/api/repository/`, {
78
153
  headers: { Origin: 'http://localhost:58302' },
79
154
  });
80
- if (corsResponse.headers.get('access-control-allow-origin') !== '*') {
81
- throw new Error('Public API does not allow a forwarded browser origin');
155
+ if (
156
+ corsResponse.headers.get('access-control-allow-origin') !==
157
+ new URL(toolingEnv.NEXT_PUBLIC_WEB_URL).origin
158
+ ) {
159
+ throw new Error('API CORS origin differs from the configured web origin');
82
160
  }
83
161
  const openApi = z
84
162
  .object({ paths: z.record(z.string(), z.unknown()) })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-stitchkit",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Create a production-shaped Stitchkit application",
5
5
  "license": "MIT",
6
6
  "author": "Max Listov <maxlistov@gmail.com>",
@@ -1,4 +1,4 @@
1
- # Stitchkit Starter
1
+ # Application starter
2
2
 
3
3
  Production-shaped application generated by `create-stitchkit`.
4
4
 
@@ -14,8 +14,8 @@ Point `DATABASE_URL` in `.env` at an existing PostgreSQL database, then run:
14
14
  bun run dev
15
15
  ```
16
16
 
17
- The command validates the environment, generates the Prisma client, applies the
18
- checked-in migrations and launches:
17
+ The command validates the environment, generates the Prisma client, applies any
18
+ database migrations you add and launches:
19
19
 
20
20
  - Web: <http://localhost:3210>
21
21
  - UI catalogue: <http://localhost:3210/en/ui>
@@ -23,7 +23,7 @@ checked-in migrations and launches:
23
23
  - OpenAPI: <http://localhost:3211/openapi.json>
24
24
  - MCP: <http://localhost:3211/mcp>
25
25
 
26
- The home page is a compact, domain-free Stitchkit Starter reference surface.
26
+ The home page is a compact, domain-free application reference surface.
27
27
  Add application features as vertical schema → contract → service → client slices.
28
28
  The exact workflow is in [`docs/ADDING_A_FEATURE.md`](docs/ADDING_A_FEATURE.md),
29
29
  and root [`AGENTS.md`](AGENTS.md) gives coding agents the application boundaries.
@@ -54,14 +54,10 @@ Unsupported browsers and users requesting reduced motion switch immediately.
54
54
  bun run check
55
55
  bun run test
56
56
  bun run build
57
+ bun run runtime:smoke
58
+ bun run e2e
57
59
  ```
58
60
 
59
- ## Physical-device HTTPS
60
-
61
- `bun run dev:lan` is an explicit trusted-LAN mode powered by mkcert. It leaves
62
- normal development and production unchanged. Setup and device trust steps are
63
- in [`docs/LAN_HTTPS.md`](docs/LAN_HTTPS.md).
64
-
65
61
  ## Production
66
62
 
67
63
  Provide a production `.env`, then:
@@ -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.46.0",
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.46.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-vHFaXHp5Ws4sFSRptqPqwpc7CGEEbmHY+duvvGkNGwuyp51aY4De3bXVh1xiZSGiUN5IS0jhj1P59GG3VjP/Ag=="],
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