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
package/CHANGELOG.md CHANGED
@@ -6,6 +6,83 @@ is declared in the template root catalog.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.1] — 2026-08-15
10
+
11
+ ### Changed
12
+
13
+ - **The template targets Stitchkit 0.49.2 and owns one managed server lifecycle.**
14
+ Generated backends pass the full Socket.IO handle to `createServer()`, close
15
+ through `server.shutdown()`, and use a repeated process signal to force the
16
+ same shutdown chain instead of creating a competing close path.
17
+
18
+ ### Fixed
19
+
20
+ - **Repository refresh and realtime verification are deterministic.** A successful
21
+ refresh updates the initiating browser from its typed mutation result, remote
22
+ browsers still update through the Socket.IO cache bridge, and the release lane
23
+ exercises the complete backend path against a local GitHub-compatible upstream
24
+ instead of depending on public API availability.
25
+
26
+ ## [0.3.0] — 2026-08-10
27
+
28
+ ### ⚠️ Breaking changes
29
+
30
+ - **Generated projects ship no `.env`.** The single environment source is
31
+ `.env.example`; `bun run env:ensure` (and every tooling entry point,
32
+ self-healing before validation) renders `.env` with the application-derived
33
+ database name. A clone and a post-generation rename now produce the same
34
+ database, and the second-developer path (`runtime:smoke`, `e2e`) works
35
+ unaided.
36
+ `// before: scaffold writes .env with a neutral database name` →
37
+ `// after: .env is rendered on first run from .env.example`
38
+
39
+ - **Starter-owned LAN HTTPS is removed.** Generated applications no longer ship
40
+ `dev:lan`, certificate generation, onboarding routes or `DEV_HTTPS_*` settings.
41
+ Applications that need a trusted device-testing origin should own certificate
42
+ creation and pass TLS files through Stitchkit's documented `bun.tls` boundary.
43
+
44
+ ### Changed
45
+
46
+ - **Surface conformance is anchored and total.** The declared surface is
47
+ compared against a committed `surface.snapshot.json` (with schema-shape
48
+ digests, regenerated deliberately via `bun run surface:snapshot`), the CLI is
49
+ observed by spawning the real process, missing `x-stitchkit-*` metadata is an
50
+ error unless a standard-document mode is declared explicitly, and the starter
51
+ lane sweeps generated trees for neutral-identity leaks against a committed
52
+ allowlist.
53
+ - **The template targets Stitchkit 0.46.** The error-code map covers
54
+ `REALTIME_CONTRACT_VIOLATION` and conformance validates the operation
55
+ metadata 0.46 emits.
56
+ - **Scaffold identity is rendered from one config.** The generated
57
+ `app.config.json` drives runtime identity and database naming; only the root
58
+ package manifest is structurally projected, with no global text search or
59
+ inert lockfile rewrite.
60
+ - **Development is explicit and portable.** `bun run dev` validates PM2 and
61
+ external PostgreSQL requirements before side effects. The removed LAN HTTPS
62
+ mode is documented as application-owned framework configuration instead of a
63
+ starter subsystem.
64
+
65
+ ### Fixed
66
+
67
+ - **Fresh clones bootstrap in the right order.** The starter materializes its
68
+ local environment before Prisma/type gates and reports a missing or placeholder
69
+ `DATABASE_URL` directly.
70
+ - **Every executable source is checked.** Root TypeScript coverage includes
71
+ scripts and browser E2E; the authored-source guard scans CJS and reports the
72
+ real offending line.
73
+ - **Runtime and browser gates prove the backend.** Surface conformance compares
74
+ HTTP/OpenAPI, MCP, Agent and CLI identities and schemas; browser E2E performs
75
+ a contract call and realtime cache update.
76
+ - **Repository example uses the canonical realtime contract.** Shared Zod event
77
+ definitions drive backend emission, browser subscriptions and cache bridging
78
+ without handwritten event maps.
79
+
80
+ ### Removed
81
+
82
+ - **Starter-owned LAN HTTPS.** `dev:lan`, certificate generation, onboarding
83
+ transport and `DEV_HTTPS_*` settings are gone; trusted local TLS remains an
84
+ application-owned adapter choice documented by Stitchkit.
85
+
9
86
  ## [0.2.0] — 2026-08-10
10
87
 
11
88
  ### ⚠️ 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,4 +1,6 @@
1
1
  GITHUB_REPOSITORY=max-listov/stitchkit
2
2
  GITHUB_CACHE_TTL_SECONDS=900
3
+ # Optional. Defaults to the public GitHub API and supports GitHub Enterprise.
4
+ # GITHUB_API_URL=https://api.github.com
3
5
  # Optional. Authenticated conditional requests have a higher GitHub rate limit.
4
6
  # GITHUB_TOKEN=github_pat_...
@@ -1,4 +1,78 @@
1
- import { expect, test } from '@playwright/test';
1
+ import { expect, type Page, test } from '@playwright/test';
2
+
3
+ async function gotoWithRealtime(page: Page): Promise<void> {
4
+ const connected = page
5
+ .waitForEvent('websocket', {
6
+ predicate: (socket) => socket.url().includes('/socket.io/'),
7
+ })
8
+ .then((socket) =>
9
+ socket.waitForEvent('framereceived', {
10
+ predicate: ({ payload }) => typeof payload === 'string' && payload.startsWith('40'),
11
+ }),
12
+ );
13
+ await page.goto('/en');
14
+ await connected;
15
+ }
16
+
17
+ async function expectRepositorySummary(page: Page): Promise<void> {
18
+ const summary = page.getByTestId('repository-summary');
19
+ await expect(summary).toHaveCount(1);
20
+ await expect(summary).toBeVisible();
21
+ await expect(summary.getByText('max-listov/stitchkit', { exact: true })).toBeVisible();
22
+ }
23
+
24
+ test('prefetched data hydrates without a loading flash or a client refetch', async ({
25
+ page,
26
+ request,
27
+ }) => {
28
+ // The SSR document itself carries the prefetched repository data — the
29
+ // dehydration envelope is not empty.
30
+ const document = await request.get('/en');
31
+ expect(await document.text()).toContain('max-listov/stitchkit');
32
+
33
+ // Block the browser-side read entirely: the page must still render the data
34
+ // from the hydration envelope — never refetching, never flashing a loader.
35
+ let clientReads = 0;
36
+ await page.route('**/api/repository', async (route) => {
37
+ if (route.request().method() === 'GET') {
38
+ clientReads += 1;
39
+ await route.abort();
40
+ return;
41
+ }
42
+ await route.continue();
43
+ });
44
+ await page.goto('/en');
45
+ await expectRepositorySummary(page);
46
+ expect(clientReads).toBe(0);
47
+ });
48
+
49
+ test('a server realtime event updates the TanStack cache without a client refetch', async ({
50
+ context,
51
+ page,
52
+ }) => {
53
+ await gotoWithRealtime(page);
54
+ const summary = page.getByTestId('repository-summary');
55
+ await expect(summary).toBeVisible();
56
+ const before = await summary.getAttribute('data-fetched-at');
57
+ expect(before).not.toBeNull();
58
+
59
+ // Sever the observer's HTTP read path completely, then refresh from a second
60
+ // tab. The observer never invokes the mutation and cannot consume its HTTP
61
+ // result, so only the Socket.IO event can update its TanStack cache.
62
+ await page.route('**/api/repository', async (route) => {
63
+ if (route.request().method() === 'GET') {
64
+ await route.abort();
65
+ return;
66
+ }
67
+ await route.continue();
68
+ });
69
+ const trigger = await context.newPage();
70
+ await trigger.goto('/en');
71
+ await trigger.getByRole('button', { name: 'Refresh repository data' }).click();
72
+ await expect(summary).not.toHaveAttribute('data-fetched-at', before ?? '', {
73
+ timeout: 10_000,
74
+ });
75
+ });
2
76
 
3
77
  test('renders and refreshes the repository example', async ({ page }) => {
4
78
  await page.route('**/api/repository/refresh', async (route) => {
@@ -6,7 +80,7 @@ test('renders and refreshes the repository example', async ({ page }) => {
6
80
  await route.continue();
7
81
  });
8
82
  await page.goto('/en');
9
- await expect(page.getByText('max-listov/stitchkit')).toBeVisible();
83
+ await expectRepositorySummary(page);
10
84
 
11
85
  const refresh = page.getByRole('button', { name: 'Refresh repository data' });
12
86
  await expect(refresh).toHaveCSS('height', '32px');
@@ -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) => {
@@ -155,12 +155,12 @@ export class GitHubRepositoryCache {
155
155
 
156
156
  try {
157
157
  const [repositoryResponse, commitsResponse] = await Promise.all([
158
- this.fetcher(new URL(`/repos/${repositoryName}`, 'https://api.github.com'), {
158
+ this.fetcher(new URL(`/repos/${repositoryName}`, env.GITHUB_API_URL), {
159
159
  headers: githubHeaders(),
160
160
  signal: AbortSignal.timeout(8_000),
161
161
  }),
162
162
  this.fetcher(
163
- new URL(`/repos/${repositoryName}/commits?per_page=1`, 'https://api.github.com'),
163
+ new URL(`/repos/${repositoryName}/commits?per_page=1`, env.GITHUB_API_URL),
164
164
  { headers: githubHeaders(), signal: AbortSignal.timeout(8_000) },
165
165
  ),
166
166
  ]);
@@ -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
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
 
3
3
  export const featureServerSchema = {
4
+ GITHUB_API_URL: z.url().default('https://api.github.com'),
4
5
  GITHUB_REPOSITORY: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/),
5
6
  GITHUB_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(900),
6
7
  GITHUB_TOKEN: z.string().min(1).optional(),
@@ -30,8 +30,8 @@ export function RepositorySummary() {
30
30
  const queryClient = useQueryClient();
31
31
  const repository = useRepository();
32
32
  const refresh = useRefreshRepository({
33
- onSuccess: async () => {
34
- await queryClient.invalidateQueries({ queryKey: useRepository.getKey() });
33
+ onSuccess: (snapshot) => {
34
+ queryClient.setQueryData(useRepository.getKey(), snapshot);
35
35
  },
36
36
  });
37
37
 
@@ -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,11 @@
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
+ import { assertPublicWebSurface } from './web-surface-smoke';
6
7
 
8
+ const toolingEnv = loadToolingEnv();
7
9
  const apiOrigin = toolingEnv.NEXT_PUBLIC_API_URL;
8
10
 
9
11
  async function json(path: string, init?: RequestInit): Promise<unknown> {
@@ -24,33 +26,39 @@ await runSurfaceConformance({
24
26
  fixture: { path: '/api/repository/refresh' },
25
27
  output: RepositorySnapshotSchema,
26
28
  run: async ({ path }) => {
27
- const socket = io(apiOrigin, { transports: ['websocket'] });
29
+ const rejected: string[] = [];
30
+ const socket = createRealtimeClient(repositoryRealtimeContract, {
31
+ url: apiOrigin,
32
+ transports: ['websocket'],
33
+ onRejected: ({ event, direction, phase }) => {
34
+ rejected.push(`${event}:${direction}:${phase}`);
35
+ },
36
+ });
28
37
  try {
29
38
  await new Promise<void>((resolve, reject) => {
30
39
  const timeout = setTimeout(
31
40
  () => reject(new Error('Socket.IO connection timed out')),
32
41
  5_000,
33
42
  );
34
- const onConnect = () => {
43
+ const unsubscribe = socket.onConnectionChange((connected, reason) => {
44
+ if (!connected) {
45
+ if (reason) reject(new Error(`Socket.IO disconnected: ${reason}`));
46
+ return;
47
+ }
35
48
  clearTimeout(timeout);
36
- socket.off('connect_error', onConnectError);
49
+ unsubscribe();
37
50
  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);
51
+ });
52
+ socket.connect();
46
53
  });
47
54
  const refreshedEvent = new Promise<string>((resolve, reject) => {
48
55
  const timeout = setTimeout(
49
56
  () => reject(new Error('Socket.IO repository refresh event timed out')),
50
57
  5_000,
51
58
  );
52
- socket.once('repository:refreshed', (snapshot) => {
59
+ const unsubscribe = socket.on('repository:refreshed', (snapshot) => {
53
60
  clearTimeout(timeout);
61
+ unsubscribe();
54
62
  resolve(snapshot.fullName);
55
63
  });
56
64
  });
@@ -64,9 +72,77 @@ await runSurfaceConformance({
64
72
  if (cached.fullName !== refreshed.fullName) {
65
73
  throw new Error('Repository cache read differs');
66
74
  }
75
+ if (rejected.length > 0) {
76
+ throw new Error(`Realtime contract rejected ${rejected.join(', ')}`);
77
+ }
67
78
  return refreshed;
68
79
  } finally {
69
- socket.close();
80
+ socket.disconnect();
81
+ }
82
+ },
83
+ }),
84
+ defineSurfaceProbe({
85
+ name: 'realtime rejection path fires on a contract mismatch',
86
+ input: z.object({ path: z.literal('/api/repository/refresh') }),
87
+ fixture: { path: '/api/repository/refresh' },
88
+ run: async ({ path }) => {
89
+ // NEGATIVE probe: a client whose local contract disagrees with the
90
+ // server must see the real payload REJECTED — this executes the
91
+ // rejection path instead of merely asserting its absence. Matched by
92
+ // event/direction/phase, never by message text.
93
+ const divergentContract = defineRealtimeContract({
94
+ serverToClient: {
95
+ 'repository:refreshed': { args: z.tuple([z.object({ bogus: z.string() })]) },
96
+ },
97
+ clientToServer: {},
98
+ });
99
+ const rejections: Array<{ event: string; direction: string; phase: string }> = [];
100
+ const socket = createRealtimeClient(divergentContract, {
101
+ url: apiOrigin,
102
+ transports: ['websocket'],
103
+ onRejected: ({ event, direction, phase }) => {
104
+ rejections.push({ event, direction, phase });
105
+ },
106
+ });
107
+ try {
108
+ await new Promise<void>((resolve, reject) => {
109
+ const timeout = setTimeout(
110
+ () => reject(new Error('Socket.IO connection timed out')),
111
+ 5_000,
112
+ );
113
+ const unsubscribe = socket.onConnectionChange((connected) => {
114
+ if (!connected) return;
115
+ clearTimeout(timeout);
116
+ unsubscribe();
117
+ resolve();
118
+ });
119
+ socket.connect();
120
+ });
121
+ // A handler must be attached for the inbound frame to be validated.
122
+ const unsubscribe = socket.on('repository:refreshed', () => {
123
+ throw new Error('a payload outside the local contract reached the handler');
124
+ });
125
+ await json(path, { method: 'POST' });
126
+ const deadline = Date.now() + 5_000;
127
+ while (rejections.length === 0 && Date.now() < deadline) {
128
+ await new Promise((resolve) => setTimeout(resolve, 50));
129
+ }
130
+ unsubscribe();
131
+ const rejection = rejections[0];
132
+ if (!rejection) {
133
+ throw new Error('the contract mismatch was never rejected');
134
+ }
135
+ if (
136
+ rejection.event !== 'repository:refreshed' ||
137
+ rejection.direction !== 'client-inbound' ||
138
+ rejection.phase !== 'arguments'
139
+ ) {
140
+ throw new Error(
141
+ `unexpected rejection identity: ${rejection.event}:${rejection.direction}:${rejection.phase}`,
142
+ );
143
+ }
144
+ } finally {
145
+ socket.disconnect();
70
146
  }
71
147
  },
72
148
  }),
@@ -77,8 +153,11 @@ await runSurfaceConformance({
77
153
  const corsResponse = await fetch(`${apiOrigin}/api/repository/`, {
78
154
  headers: { Origin: 'http://localhost:58302' },
79
155
  });
80
- if (corsResponse.headers.get('access-control-allow-origin') !== '*') {
81
- throw new Error('Public API does not allow a forwarded browser origin');
156
+ if (
157
+ corsResponse.headers.get('access-control-allow-origin') !==
158
+ new URL(toolingEnv.NEXT_PUBLIC_WEB_URL).origin
159
+ ) {
160
+ throw new Error('API CORS origin differs from the configured web origin');
82
161
  }
83
162
  const openApi = z
84
163
  .object({ paths: z.record(z.string(), z.unknown()) })
@@ -88,4 +167,6 @@ await runSurfaceConformance({
88
167
  }
89
168
  }
90
169
 
91
- console.log('Runtime HTTP, OpenAPI, Socket.IO and MCP smoke passed');
170
+ await assertPublicWebSurface(toolingEnv.NEXT_PUBLIC_WEB_URL);
171
+
172
+ console.log('Runtime HTTP, OpenAPI, Socket.IO, MCP and public web smoke passed');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-stitchkit",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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: