create-stitchkit 0.1.1 → 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 (82) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +11 -4
  3. package/dist/cli.js +93 -15
  4. package/examples/repository/_env.example.append +4 -0
  5. package/examples/repository/e2e/repository.spec.ts +69 -0
  6. package/{template → examples/repository}/packages/backend/src/domain/repository/github-cache.test.ts +8 -0
  7. package/{template → examples/repository}/packages/backend/src/domain/repository/github-cache.ts +2 -1
  8. package/examples/repository/packages/backend/src/surface.snapshot.json +58 -0
  9. package/examples/repository/packages/backend/src/surface.ts +16 -0
  10. package/examples/repository/packages/config/src/features.ts +7 -0
  11. package/examples/repository/packages/db/schema.prisma +31 -0
  12. package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +45 -0
  13. package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +151 -0
  14. package/{template → examples/repository}/packages/frontend/src/components/repository-summary.tsx +5 -1
  15. package/{template → examples/repository}/packages/frontend/src/lib/realtime/repository.ts +5 -6
  16. package/examples/repository/packages/frontend/src/providers/index.tsx +18 -0
  17. package/examples/repository/packages/shared/src/index.ts +5 -0
  18. package/examples/repository/packages/shared/src/realtime/repository.ts +10 -0
  19. package/{template → examples/repository}/packages/shared/src/schemas/repository.ts +4 -2
  20. package/examples/repository/scripts/runtime-smoke.ts +169 -0
  21. package/package.json +10 -1
  22. package/template/AGENTS.md +50 -0
  23. package/template/README.md +17 -8
  24. package/template/_env.example +1 -4
  25. package/template/app.config.json +9 -0
  26. package/template/bun.lock +6 -4
  27. package/template/docs/ADDING_A_FEATURE.md +102 -0
  28. package/template/e2e/starter.spec.ts +34 -22
  29. package/template/ecosystem.config.cjs +3 -2
  30. package/template/ecosystem.dev.config.cjs +7 -4
  31. package/template/package.json +6 -2
  32. package/template/packages/backend/src/cli.ts +2 -1
  33. package/template/packages/backend/src/index.ts +4 -3
  34. package/template/packages/backend/src/surface-manifest.test.ts +237 -0
  35. package/template/packages/backend/src/surface-manifest.ts +220 -0
  36. package/template/packages/backend/src/surface.snapshot.json +18 -0
  37. package/template/packages/backend/src/surface.ts +4 -9
  38. package/template/packages/backend/src/tools.ts +4 -1
  39. package/template/packages/backend/src/transport/errors.ts +1 -0
  40. package/template/packages/backend/src/transport/system-service.ts +8 -0
  41. package/template/packages/config/package.json +2 -1
  42. package/template/packages/config/src/features.ts +1 -0
  43. package/template/packages/config/src/identity.ts +18 -0
  44. package/template/packages/config/src/server.ts +10 -3
  45. package/template/packages/db/schema.prisma +0 -23
  46. package/template/packages/frontend/messages/en.json +0 -1
  47. package/template/packages/frontend/messages/ru.json +0 -1
  48. package/template/packages/frontend/package.json +1 -1
  49. package/template/packages/frontend/src/app/[locale]/page.tsx +8 -19
  50. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +3 -4
  51. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +3 -2
  52. package/template/packages/frontend/src/lib/query-client.test.ts +22 -0
  53. package/template/packages/frontend/src/lib/query-client.ts +10 -2
  54. package/template/packages/frontend/src/lib/seo/pages.ts +4 -5
  55. package/template/packages/frontend/src/providers/index.tsx +1 -4
  56. package/template/packages/frontend/src/theme/config.ts +2 -1
  57. package/template/packages/frontend/tsconfig.json +7 -1
  58. package/template/packages/shared/package.json +0 -1
  59. package/template/packages/shared/src/contracts/system.ts +19 -0
  60. package/template/packages/shared/src/index.ts +2 -3
  61. package/template/packages/shared/src/schemas/system.ts +4 -0
  62. package/template/playwright.config.ts +2 -1
  63. package/template/scripts/check-authored.ts +20 -6
  64. package/template/scripts/dev.ts +46 -7
  65. package/template/scripts/local-env.test.ts +33 -0
  66. package/template/scripts/local-env.ts +16 -5
  67. package/template/scripts/runtime-smoke.ts +22 -61
  68. package/template/scripts/surface-conformance.ts +166 -0
  69. package/template/scripts/surface-snapshot.ts +21 -0
  70. package/template/scripts/tooling-env.ts +16 -3
  71. package/template/tsconfig.json +3 -1
  72. package/template/_env +0 -11
  73. package/template/packages/shared/src/events/repository.ts +0 -9
  74. /package/{template → examples/repository}/packages/backend/src/domain/errors.ts +0 -0
  75. /package/{template → examples/repository}/packages/backend/src/transport/repository-service.ts +0 -0
  76. /package/{template → examples/repository}/packages/db/migrations/20260808000000_init/migration.sql +0 -0
  77. /package/{template → examples/repository}/packages/db/migrations/20260808170000_repository_visibility/migration.sql +0 -0
  78. /package/{template → examples/repository}/packages/frontend/src/lib/api/client.ts +0 -0
  79. /package/{template → examples/repository}/packages/frontend/src/lib/api/queries.ts +0 -0
  80. /package/{template → examples/repository}/packages/frontend/src/providers/realtime.tsx +0 -0
  81. /package/{template → examples/repository}/packages/shared/src/contracts/repository.ts +0 -0
  82. /package/{template → examples/repository}/packages/shared/src/schemas/repository.test.ts +0 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,89 @@ 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
+
69
+ ## [0.2.0] — 2026-08-10
70
+
71
+ ### ⚠️ Breaking changes
72
+
73
+ - **The default scaffold is now domain-free.** The repository example is explicit
74
+ so a new product does not need to dismantle demo code.
75
+ `bun create stitchkit my-app` now creates the blank base; use
76
+ `bun create stitchkit my-app --example repository` for the previous runnable example.
77
+
78
+ ### Added
79
+
80
+ - **One generated application identity.** A validated `app.config.json` now drives
81
+ package, PM2, MCP, OpenAPI, CLI, SEO and theme identity from one edit point.
82
+ - **Contract-derived surface conformance.** Generated runtime smoke compares the
83
+ registered HTTP and tool manifest with live OpenAPI and MCP discovery, while
84
+ explicit typed probes remain application-owned.
85
+ - **Optional trusted LAN HTTPS.** `bun run dev:lan` uses mkcert, exact-origin CORS
86
+ and a development-only onboarding route for secure-context testing on physical
87
+ devices without changing ordinary development or production behavior.
88
+ - **Agent-first extension guide.** Generated applications include an application-
89
+ scoped `AGENTS.md` and a complete schema-to-runtime feature workflow in
90
+ `docs/ADDING_A_FEATURE.md`.
91
+
9
92
  ## [0.1.1] — 2026-08-10
10
93
 
11
94
  ### Changed
package/README.md CHANGED
@@ -14,11 +14,18 @@ API, Prisma/PostgreSQL, typed shared contracts, Socket.IO, MCP, CLI tools and a
14
14
  complete production UI system.
15
15
 
16
16
  It uses one conventional `packages/*` namespace: `backend`, `frontend`,
17
- `config`, `db` and `shared`. The copied application keeps the neutral
18
- `stitchkit-starter` identity until its owner explicitly renames it.
17
+ `config`, `db` and `shared`. The destination name becomes the generated slug;
18
+ `--display-name` sets the human title. Both are recorded once in
19
+ `app.config.json` and drive package, process, transport, UI and SEO identity.
19
20
 
20
- Its runnable example is the Stitchkit Starter: one configurable GitHub repository
21
- is persisted through a server-side PostgreSQL cache, including a Prisma-backed
21
+ The default scaffold is domain-free. To add the runnable repository example:
22
+
23
+ ```bash
24
+ bun create stitchkit my-app --example repository
25
+ ```
26
+
27
+ The optional example uses one configurable GitHub repository persisted through a
28
+ server-side PostgreSQL cache, including a Prisma-backed
22
29
  visibility enum that flows through the shared schema and typed client. `/en/ui`
23
30
  presents every reusable primitive and composition. Every public page ships with
24
31
  localized metadata, canonical and language alternatives, sitemap coverage and a
package/dist/cli.js CHANGED
@@ -2,17 +2,19 @@
2
2
  // @bun
3
3
 
4
4
  // src/cli.ts
5
- import { basename as basename2, resolve as resolve2 } from "path";
5
+ import { basename as basename3, resolve as resolve2 } from "path";
6
6
  var {spawn } = globalThis.Bun;
7
7
 
8
8
  // src/options.ts
9
9
  var HELP = `Create a production-shaped Stitchkit application.
10
10
 
11
11
  Usage:
12
- bun create stitchkit <directory> [--no-install]
12
+ bun create stitchkit <directory> [--display-name "Product Name"] [--example repository] [--no-install]
13
13
 
14
14
  Options:
15
15
  --no-install Generate files without installing dependencies
16
+ --example Add an isolated runnable example (supported: repository)
17
+ --display-name Set the initial public application name
16
18
  --help Show this help
17
19
  `;
18
20
  function helpText() {
@@ -21,11 +23,26 @@ function helpText() {
21
23
  function parseOptions(args) {
22
24
  if (args.includes("--help") || args.includes("-h"))
23
25
  return "help";
24
- const unknown = args.filter((arg) => arg.startsWith("-") && arg !== "--no-install");
26
+ const unknown = args.filter((arg) => arg.startsWith("-") && arg !== "--no-install" && arg !== "--example" && arg !== "--display-name");
25
27
  if (unknown.length > 0) {
26
28
  throw new Error(`Unknown option: ${unknown[0]}`);
27
29
  }
28
- const positionals = args.filter((arg) => !arg.startsWith("-"));
30
+ const exampleFlagIndex = args.indexOf("--example");
31
+ const example = exampleFlagIndex === -1 ? undefined : args[exampleFlagIndex + 1];
32
+ if (exampleFlagIndex !== -1 && example === undefined) {
33
+ throw new Error("--example requires a value");
34
+ }
35
+ if (example !== undefined && example !== "repository") {
36
+ throw new Error(`Unknown example: ${example}`);
37
+ }
38
+ const displayNameFlagIndex = args.indexOf("--display-name");
39
+ const displayName = displayNameFlagIndex === -1 ? undefined : args[displayNameFlagIndex + 1];
40
+ if (displayNameFlagIndex !== -1 && displayName === undefined) {
41
+ throw new Error("--display-name requires a value");
42
+ }
43
+ const consumedExampleIndex = exampleFlagIndex === -1 ? -1 : exampleFlagIndex + 1;
44
+ const consumedDisplayNameIndex = displayNameFlagIndex === -1 ? -1 : displayNameFlagIndex + 1;
45
+ const positionals = args.filter((arg, index) => !arg.startsWith("-") && index !== consumedExampleIndex && index !== consumedDisplayNameIndex);
29
46
  if (positionals.length !== 1) {
30
47
  throw new Error("Exactly one destination directory is required");
31
48
  }
@@ -34,14 +51,49 @@ function parseOptions(args) {
34
51
  throw new Error("Destination directory is required");
35
52
  return {
36
53
  destination,
37
- install: !args.includes("--no-install")
54
+ install: !args.includes("--no-install"),
55
+ ...example && { example },
56
+ ...displayName && { displayName }
38
57
  };
39
58
  }
40
59
 
41
60
  // src/scaffold.ts
42
61
  import { lstat, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
43
62
  import { homedir } from "os";
44
- import { basename, 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";
65
+
66
+ // src/identity.ts
67
+ import { basename } from "path";
68
+ import { z } from "zod";
69
+ var ApplicationSlugSchema = z.string().min(1).max(64).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers and single hyphens (for example: talk-control)");
70
+ var ApplicationIdentitySchema = z.object({
71
+ slug: ApplicationSlugSchema,
72
+ name: z.string().trim().min(1).max(80),
73
+ version: z.string().regex(/^\d+\.\d+\.\d+$/, "Use a semantic version such as 0.1.0"),
74
+ description: z.object({
75
+ en: z.string().trim().min(1),
76
+ ru: z.string().trim().min(1)
77
+ })
78
+ });
79
+ function displayNameFromSlug(slug) {
80
+ return slug.split("-").map((part) => `${part[0]?.toUpperCase()}${part.slice(1)}`).join(" ");
81
+ }
82
+ function createApplicationIdentity(destination, displayName) {
83
+ const slug = ApplicationSlugSchema.parse(basename(destination));
84
+ const name = displayName?.trim() || displayNameFromSlug(slug);
85
+ return ApplicationIdentitySchema.parse({
86
+ slug,
87
+ name,
88
+ version: "0.1.0",
89
+ description: {
90
+ en: `${name} is a production application built with Stitchkit.`,
91
+ ru: `${name} \u2014 production-\u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u0435 \u043D\u0430 Stitchkit.`
92
+ }
93
+ });
94
+ }
95
+
96
+ // src/scaffold.ts
45
97
  var TEXT_EXTENSIONS = new Set([
46
98
  ".cjs",
47
99
  ".css",
@@ -61,10 +113,11 @@ var TEXT_EXTENSIONS = new Set([
61
113
  ".yml"
62
114
  ]);
63
115
  var TEMPLATE_RENAMES = new Map([
64
- ["_env", ".env"],
65
116
  ["_env.example", ".env.example"],
117
+ ["_env.example.append", ".env.example"],
66
118
  ["_gitignore", ".gitignore"]
67
119
  ]);
120
+ var RootManifestSchema = z2.looseObject({ name: z2.string().min(1) });
68
121
  var IGNORED_DIRECTORIES = new Set([
69
122
  ".next",
70
123
  "coverage",
@@ -82,7 +135,7 @@ function isTemplateSourcePathIncluded(sourcePath) {
82
135
  return false;
83
136
  if (normalized === "packages/db/src/generated" || normalized.startsWith("packages/db/src/generated/"))
84
137
  return false;
85
- const name = basename(normalized);
138
+ const name = basename2(normalized);
86
139
  return name !== ".env" && name !== "next-env.d.ts" && !name.endsWith(".log") && !name.endsWith(".tsbuildinfo");
87
140
  }
88
141
  function shouldIncludeTemplatePath(templateDirectory, sourcePath) {
@@ -136,13 +189,14 @@ async function collectMaterialisedFiles(templateDirectory, directory, files) {
136
189
  const targetName = TEMPLATE_RENAMES.get(entry.name);
137
190
  const sourceRelativePath = relative(templateDirectory, sourcePath);
138
191
  const outputRelativePath = targetName ? join(dirname(sourceRelativePath), targetName) : sourceRelativePath;
139
- const extension = targetName?.includes(".") ? targetName.slice(targetName.indexOf(".")) : "";
140
- const fileExtension = extension || sourcePath.slice(sourcePath.lastIndexOf("."));
192
+ const materialisedName = outputRelativePath.endsWith(".append") ? outputRelativePath.slice(0, -".append".length) : outputRelativePath;
193
+ const fileExtension = extname(materialisedName);
141
194
  const content = TEXT_EXTENSIONS.has(fileExtension) || targetName ? await readFile(sourcePath, "utf8") : await readFile(sourcePath);
142
195
  files.push({
143
196
  sourcePath: portablePath(sourceRelativePath),
144
197
  outputPath: portablePath(outputRelativePath),
145
- content
198
+ content,
199
+ append: entry.name.endsWith(".append")
146
200
  });
147
201
  }
148
202
  }
@@ -155,15 +209,34 @@ async function writeMaterialisedFiles(destination, files) {
155
209
  for (const file of files) {
156
210
  const targetPath = join(destination, file.outputPath);
157
211
  await mkdir(dirname(targetPath), { recursive: true });
158
- await writeFile(targetPath, file.content);
212
+ if (file.append) {
213
+ if (typeof file.content !== "string") {
214
+ throw new Error(`Append template entries must contain text: ${file.sourcePath}`);
215
+ }
216
+ const existing = await readFile(targetPath, "utf8");
217
+ await writeFile(targetPath, `${existing.trimEnd()}
218
+ ${file.content.trimStart()}`);
219
+ } else {
220
+ await writeFile(targetPath, file.content);
221
+ }
159
222
  }
160
223
  }
161
- async function scaffoldProject(templateDirectory, destination) {
224
+ async function scaffoldProject(templateDirectory, destination, options = {}) {
162
225
  const resolvedDestination = resolve(destination);
226
+ const identity = createApplicationIdentity(resolvedDestination, options.displayName);
163
227
  const destinationExisted = await assertDestinationAvailable(resolvedDestination);
164
228
  await mkdir(resolvedDestination, { recursive: true });
165
229
  try {
166
230
  await writeMaterialisedFiles(resolvedDestination, await materialiseTemplateFiles(templateDirectory));
231
+ if (options.overlayDirectory) {
232
+ await writeMaterialisedFiles(resolvedDestination, await materialiseTemplateFiles(options.overlayDirectory));
233
+ }
234
+ await writeFile(join(resolvedDestination, "app.config.json"), `${JSON.stringify(identity, undefined, 2)}
235
+ `);
236
+ const manifestPath = join(resolvedDestination, "package.json");
237
+ const manifest = RootManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8")));
238
+ await writeFile(manifestPath, `${JSON.stringify({ ...manifest, name: identity.slug }, undefined, 2)}
239
+ `);
167
240
  } catch (error) {
168
241
  if (!destinationExisted) {
169
242
  await rm(resolvedDestination, { recursive: true, force: true });
@@ -185,7 +258,11 @@ async function run(args) {
185
258
  }
186
259
  const destination = resolve2(options.destination);
187
260
  const templateDirectory = resolve2(import.meta.dir, "../template");
188
- await scaffoldProject(templateDirectory, destination);
261
+ const overlayDirectory = options.example ? resolve2(import.meta.dir, `../examples/${options.example}`) : undefined;
262
+ await scaffoldProject(templateDirectory, destination, {
263
+ ...overlayDirectory && { overlayDirectory },
264
+ ...options.displayName && { displayName: options.displayName }
265
+ });
189
266
  if (options.install) {
190
267
  const install = spawn(["bun", "install"], {
191
268
  cwd: destination,
@@ -197,8 +274,9 @@ async function run(args) {
197
274
  if (exitCode !== 0)
198
275
  throw new Error(`bun install failed with exit code ${exitCode}`);
199
276
  }
277
+ const mode = options.example ? ` with the ${options.example} example` : "";
200
278
  process.stdout.write(`
201
- Created Stitchkit Starter in ${basename2(destination)}
279
+ Created ${options.displayName ?? basename3(destination)}${mode}
202
280
 
203
281
  `);
204
282
  process.stdout.write(` cd ${options.destination}
@@ -0,0 +1,4 @@
1
+ GITHUB_REPOSITORY=max-listov/stitchkit
2
+ GITHUB_CACHE_TTL_SECONDS=900
3
+ # Optional. Authenticated conditional requests have a higher GitHub rate limit.
4
+ # GITHUB_TOKEN=github_pat_...
@@ -0,0 +1,69 @@
1
+ import { expect, test } from '@playwright/test';
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
+
53
+ test('renders and refreshes the repository example', async ({ page }) => {
54
+ await page.route('**/api/repository/refresh', async (route) => {
55
+ await new Promise((resolve) => setTimeout(resolve, 750));
56
+ await route.continue();
57
+ });
58
+ await page.goto('/en');
59
+ await expect(page.getByText('max-listov/stitchkit')).toBeVisible();
60
+
61
+ const refresh = page.getByRole('button', { name: 'Refresh repository data' });
62
+ await expect(refresh).toHaveCSS('height', '32px');
63
+ await expect(refresh).toHaveCSS('width', '32px');
64
+ await expect(refresh.locator('.tabler-icon-refresh')).toHaveCount(1);
65
+ await refresh.click();
66
+ await expect(refresh).toHaveAttribute('aria-busy', 'true');
67
+ await expect(refresh.locator('svg')).toHaveCount(1);
68
+ await expect(refresh.locator('.tabler-icon-refresh')).toHaveClass(/animate-spin/);
69
+ });
@@ -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) => {
@@ -1,4 +1,5 @@
1
1
  import { env } from '@app/config';
2
+ import { appIdentity } from '@app/config/identity';
2
3
  import { RepositoryVisibility } from '@app/db';
3
4
  import type { RepositorySnapshot } from '@app/shared';
4
5
  import { z } from 'zod';
@@ -80,7 +81,7 @@ const snapshotStore: RepositorySnapshotStore = {
80
81
  function githubHeaders(): Headers {
81
82
  const headers = new Headers({
82
83
  Accept: 'application/vnd.github+json',
83
- 'User-Agent': 'stitchkit-starter',
84
+ 'User-Agent': appIdentity.slug,
84
85
  'X-GitHub-Api-Version': '2026-03-10',
85
86
  });
86
87
  if (env.GITHUB_TOKEN) headers.set('Authorization', `Bearer ${env.GITHUB_TOKEN}`);
@@ -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
+ ]
@@ -0,0 +1,16 @@
1
+ import { env } from '@app/config';
2
+ import { repositoryRealtimeContract } from '@app/shared';
3
+ import { bindRealtimeServer, createSocketIOServer } from 'stitchkit/server';
4
+ import { createRepositoryService } from './transport/repository-service';
5
+ import { createSystemService } from './transport/system-service';
6
+
7
+ export async function createSurface() {
8
+ const socket = await createSocketIOServer({
9
+ cors: { origin: env.CORS_ORIGIN },
10
+ });
11
+ const realtime = bindRealtimeServer(repositoryRealtimeContract, socket);
12
+ const repositoryService = createRepositoryService((snapshot) =>
13
+ realtime.emit('repository:refreshed', snapshot),
14
+ );
15
+ return { socket, services: [createSystemService(), repositoryService] };
16
+ }
@@ -0,0 +1,7 @@
1
+ import { z } from 'zod';
2
+
3
+ export const featureServerSchema = {
4
+ GITHUB_REPOSITORY: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/),
5
+ GITHUB_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(900),
6
+ GITHUB_TOKEN: z.string().min(1).optional(),
7
+ };
@@ -0,0 +1,31 @@
1
+ generator client {
2
+ provider = "prisma-client"
3
+ output = "./src/generated"
4
+ }
5
+
6
+ datasource db {
7
+ provider = "postgresql"
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
+ }
@@ -0,0 +1,45 @@
1
+ import { appIdentity } from '@app/config/identity';
2
+ import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
3
+ import type { Metadata } from 'next';
4
+ import { getTranslations } from 'next-intl/server';
5
+ import { LocaleSchema } from '@/i18n/locales';
6
+ import { createServerRepositoryApi } from '@/lib/api/client';
7
+ import { useRepository } from '@/lib/api/queries';
8
+ import { getQueryClient } from '@/lib/query-client';
9
+ import { createPageMetadata } from '@/lib/seo/metadata';
10
+ import { StarterPage } from './starter-page';
11
+
12
+ export const dynamic = 'force-dynamic';
13
+
14
+ export async function generateMetadata({
15
+ params,
16
+ }: {
17
+ params: Promise<{ locale: string }>;
18
+ }): Promise<Metadata> {
19
+ const { locale } = await params;
20
+ return createPageMetadata('home', LocaleSchema.parse(locale));
21
+ }
22
+
23
+ export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
24
+ const { locale } = await params;
25
+ const appLocale = LocaleSchema.parse(locale);
26
+ const queryClient = getQueryClient();
27
+ const api = createServerRepositoryApi();
28
+ const t = await getTranslations('App');
29
+ await queryClient.prefetchQuery({
30
+ queryKey: useRepository.getKey(),
31
+ queryFn: () => api.read(),
32
+ });
33
+
34
+ return (
35
+ <HydrationBoundary state={dehydrate(queryClient)}>
36
+ <StarterPage
37
+ applicationName={appIdentity.name}
38
+ applicationDescription={appIdentity.description[appLocale]}
39
+ heroTitle={t('heroTitle')}
40
+ catalogueLabel={t('ui')}
41
+ locale={appLocale}
42
+ />
43
+ </HydrationBoundary>
44
+ );
45
+ }