create-stitchkit 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +11 -4
  3. package/dist/cli.js +99 -12
  4. package/examples/repository/_env.append +3 -0
  5. package/examples/repository/_env.example.append +4 -0
  6. package/examples/repository/e2e/repository.spec.ts +19 -0
  7. package/{template → examples/repository}/packages/backend/src/domain/repository/github-cache.ts +2 -1
  8. package/examples/repository/packages/backend/src/surface.ts +14 -0
  9. package/examples/repository/packages/config/src/features.ts +7 -0
  10. package/examples/repository/packages/db/schema.prisma +31 -0
  11. package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +45 -0
  12. package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +151 -0
  13. package/examples/repository/packages/frontend/src/providers/index.tsx +18 -0
  14. package/examples/repository/packages/shared/src/index.ts +3 -0
  15. package/examples/repository/scripts/runtime-smoke.ts +91 -0
  16. package/package.json +10 -1
  17. package/template/AGENTS.md +50 -0
  18. package/template/README.md +18 -5
  19. package/template/_env +1 -3
  20. package/template/_env.example +1 -4
  21. package/template/app.config.json +9 -0
  22. package/template/bun.lock +4 -2
  23. package/template/docs/ADDING_A_FEATURE.md +101 -0
  24. package/template/docs/LAN_HTTPS.md +28 -0
  25. package/template/e2e/starter.spec.ts +17 -22
  26. package/template/ecosystem.config.cjs +3 -2
  27. package/template/ecosystem.dev.config.cjs +19 -4
  28. package/template/package.json +3 -1
  29. package/template/packages/backend/src/cli.ts +2 -1
  30. package/template/packages/backend/src/index.ts +19 -4
  31. package/template/packages/backend/src/surface-manifest.test.ts +62 -0
  32. package/template/packages/backend/src/surface-manifest.ts +117 -0
  33. package/template/packages/backend/src/surface.ts +3 -9
  34. package/template/packages/backend/src/transport/lan-onboarding.ts +29 -0
  35. package/template/packages/config/package.json +2 -1
  36. package/template/packages/config/src/features.ts +1 -0
  37. package/template/packages/config/src/identity.ts +18 -0
  38. package/template/packages/config/src/server.ts +13 -3
  39. package/template/packages/db/schema.prisma +0 -23
  40. package/template/packages/frontend/messages/en.json +0 -1
  41. package/template/packages/frontend/messages/ru.json +0 -1
  42. package/template/packages/frontend/package.json +1 -0
  43. package/template/packages/frontend/src/app/[locale]/page.tsx +8 -19
  44. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +3 -4
  45. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +3 -2
  46. package/template/packages/frontend/src/lib/seo/pages.ts +4 -5
  47. package/template/packages/frontend/src/providers/index.tsx +1 -4
  48. package/template/packages/frontend/src/theme/config.ts +2 -1
  49. package/template/packages/shared/src/index.ts +1 -3
  50. package/template/scripts/dev-lan.test.ts +58 -0
  51. package/template/scripts/dev-lan.ts +176 -0
  52. package/template/scripts/dev.ts +37 -7
  53. package/template/scripts/runtime-smoke.ts +13 -59
  54. package/template/scripts/surface-conformance.ts +105 -0
  55. /package/{template → examples/repository}/packages/backend/src/domain/errors.ts +0 -0
  56. /package/{template → examples/repository}/packages/backend/src/domain/repository/github-cache.test.ts +0 -0
  57. /package/{template → examples/repository}/packages/backend/src/transport/repository-service.ts +0 -0
  58. /package/{template → examples/repository}/packages/db/migrations/20260808000000_init/migration.sql +0 -0
  59. /package/{template → examples/repository}/packages/db/migrations/20260808170000_repository_visibility/migration.sql +0 -0
  60. /package/{template → examples/repository}/packages/frontend/src/components/repository-summary.tsx +0 -0
  61. /package/{template → examples/repository}/packages/frontend/src/lib/api/client.ts +0 -0
  62. /package/{template → examples/repository}/packages/frontend/src/lib/api/queries.ts +0 -0
  63. /package/{template → examples/repository}/packages/frontend/src/lib/realtime/repository.ts +0 -0
  64. /package/{template → examples/repository}/packages/frontend/src/providers/realtime.tsx +0 -0
  65. /package/{template → examples/repository}/packages/shared/src/contracts/repository.ts +0 -0
  66. /package/{template → examples/repository}/packages/shared/src/events/repository.ts +0 -0
  67. /package/{template → examples/repository}/packages/shared/src/schemas/repository.test.ts +0 -0
  68. /package/{template → examples/repository}/packages/shared/src/schemas/repository.ts +0 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,29 @@ is declared in the template root catalog.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] — 2026-08-10
10
+
11
+ ### ⚠️ Breaking changes
12
+
13
+ - **The default scaffold is now domain-free.** The repository example is explicit
14
+ so a new product does not need to dismantle demo code.
15
+ `bun create stitchkit my-app` now creates the blank base; use
16
+ `bun create stitchkit my-app --example repository` for the previous runnable example.
17
+
18
+ ### Added
19
+
20
+ - **One generated application identity.** A validated `app.config.json` now drives
21
+ package, PM2, MCP, OpenAPI, CLI, SEO and theme identity from one edit point.
22
+ - **Contract-derived surface conformance.** Generated runtime smoke compares the
23
+ registered HTTP and tool manifest with live OpenAPI and MCP discovery, while
24
+ explicit typed probes remain application-owned.
25
+ - **Optional trusted LAN HTTPS.** `bun run dev:lan` uses mkcert, exact-origin CORS
26
+ and a development-only onboarding route for secure-context testing on physical
27
+ devices without changing ordinary development or production behavior.
28
+ - **Agent-first extension guide.** Generated applications include an application-
29
+ scoped `AGENTS.md` and a complete schema-to-runtime feature workflow in
30
+ `docs/ADDING_A_FEATURE.md`.
31
+
9
32
  ## [0.1.1] — 2026-08-10
10
33
 
11
34
  ### 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,48 @@ 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, join, parse, relative, resolve, sep } from "path";
64
+
65
+ // src/identity.ts
66
+ import { basename } from "path";
67
+ import { z } from "zod";
68
+ 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)");
69
+ var ApplicationIdentitySchema = z.object({
70
+ slug: ApplicationSlugSchema,
71
+ name: z.string().trim().min(1).max(80),
72
+ version: z.string().regex(/^\d+\.\d+\.\d+$/, "Use a semantic version such as 0.1.0"),
73
+ description: z.object({
74
+ en: z.string().trim().min(1),
75
+ ru: z.string().trim().min(1)
76
+ })
77
+ });
78
+ function displayNameFromSlug(slug) {
79
+ return slug.split("-").map((part) => `${part[0]?.toUpperCase()}${part.slice(1)}`).join(" ");
80
+ }
81
+ function createApplicationIdentity(destination, displayName) {
82
+ const slug = ApplicationSlugSchema.parse(basename(destination));
83
+ const name = displayName?.trim() || displayNameFromSlug(slug);
84
+ return ApplicationIdentitySchema.parse({
85
+ slug,
86
+ name,
87
+ version: "0.1.0",
88
+ description: {
89
+ en: `${name} is a production application built with Stitchkit.`,
90
+ ru: `${name} \u2014 production-\u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u0435 \u043D\u0430 Stitchkit.`
91
+ }
92
+ });
93
+ }
94
+
95
+ // src/scaffold.ts
45
96
  var TEXT_EXTENSIONS = new Set([
46
97
  ".cjs",
47
98
  ".css",
@@ -62,7 +113,9 @@ var TEXT_EXTENSIONS = new Set([
62
113
  ]);
63
114
  var TEMPLATE_RENAMES = new Map([
64
115
  ["_env", ".env"],
116
+ ["_env.append", ".env"],
65
117
  ["_env.example", ".env.example"],
118
+ ["_env.example.append", ".env.example"],
66
119
  ["_gitignore", ".gitignore"]
67
120
  ]);
68
121
  var IGNORED_DIRECTORIES = new Set([
@@ -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) {
@@ -142,7 +195,8 @@ async function collectMaterialisedFiles(templateDirectory, directory, files) {
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,43 @@ 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 = JSON.parse(await readFile(manifestPath, "utf8"));
238
+ manifest.name = identity.slug;
239
+ await writeFile(manifestPath, `${JSON.stringify(manifest, undefined, 2)}
240
+ `);
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
+ }
167
249
  } catch (error) {
168
250
  if (!destinationExisted) {
169
251
  await rm(resolvedDestination, { recursive: true, force: true });
@@ -185,7 +267,11 @@ async function run(args) {
185
267
  }
186
268
  const destination = resolve2(options.destination);
187
269
  const templateDirectory = resolve2(import.meta.dir, "../template");
188
- await scaffoldProject(templateDirectory, destination);
270
+ const overlayDirectory = options.example ? resolve2(import.meta.dir, `../examples/${options.example}`) : undefined;
271
+ await scaffoldProject(templateDirectory, destination, {
272
+ ...overlayDirectory && { overlayDirectory },
273
+ ...options.displayName && { displayName: options.displayName }
274
+ });
189
275
  if (options.install) {
190
276
  const install = spawn(["bun", "install"], {
191
277
  cwd: destination,
@@ -197,8 +283,9 @@ async function run(args) {
197
283
  if (exitCode !== 0)
198
284
  throw new Error(`bun install failed with exit code ${exitCode}`);
199
285
  }
286
+ const mode = options.example ? ` with the ${options.example} example` : "";
200
287
  process.stdout.write(`
201
- Created Stitchkit Starter in ${basename2(destination)}
288
+ Created ${options.displayName ?? basename3(destination)}${mode}
202
289
 
203
290
  `);
204
291
  process.stdout.write(` cd ${options.destination}
@@ -0,0 +1,3 @@
1
+ GITHUB_REPOSITORY=max-listov/stitchkit
2
+ GITHUB_CACHE_TTL_SECONDS=900
3
+ # GITHUB_TOKEN=github_pat_...
@@ -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,19 @@
1
+ import { expect, test } from '@playwright/test';
2
+
3
+ test('renders and refreshes the repository example', async ({ page }) => {
4
+ await page.route('**/api/repository/refresh', async (route) => {
5
+ await new Promise((resolve) => setTimeout(resolve, 750));
6
+ await route.continue();
7
+ });
8
+ await page.goto('/en');
9
+ await expect(page.getByText('max-listov/stitchkit')).toBeVisible();
10
+
11
+ const refresh = page.getByRole('button', { name: 'Refresh repository data' });
12
+ await expect(refresh).toHaveCSS('height', '32px');
13
+ await expect(refresh).toHaveCSS('width', '32px');
14
+ await expect(refresh.locator('.tabler-icon-refresh')).toHaveCount(1);
15
+ await refresh.click();
16
+ await expect(refresh).toHaveAttribute('aria-busy', 'true');
17
+ await expect(refresh.locator('svg')).toHaveCount(1);
18
+ await expect(refresh.locator('.tabler-icon-refresh')).toHaveClass(/animate-spin/);
19
+ });
@@ -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,14 @@
1
+ import { env } from '@app/config';
2
+ import type { ClientToServerEvents, ServerToClientEvents } from '@app/shared';
3
+ import { createSocketIOServer } from 'stitchkit/server';
4
+ import { createRepositoryService } from './transport/repository-service';
5
+
6
+ export async function createSurface() {
7
+ const socket = await createSocketIOServer<ServerToClientEvents, ClientToServerEvents>({
8
+ cors: { origin: env.CORS_ORIGIN },
9
+ });
10
+ const repositoryService = createRepositoryService((snapshot) =>
11
+ socket.io.emit('repository:refreshed', snapshot),
12
+ );
13
+ return { socket, services: [repositoryService] };
14
+ }
@@ -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
+ }
@@ -0,0 +1,151 @@
1
+ import { IconArrowUpRight } from '@tabler/icons-react';
2
+ import Image from 'next/image';
3
+ import { BrandMark } from '@/components/brand-mark';
4
+ import { RepositorySummary } from '@/components/repository-summary';
5
+ import { LanguageSwitcher, ThemeToggle } from '@/components/system-controls';
6
+ import { buttonVariants } from '@/components/ui';
7
+ import type { AppLocale } from '@/i18n/locales';
8
+ import { Link } from '@/i18n/navigation';
9
+ import { absoluteSiteUrl } from '@/lib/seo/metadata';
10
+ import { getSeoPage, SITE_NAME } from '@/lib/seo/pages';
11
+ import { cn } from '@/lib/utils/cn';
12
+
13
+ interface StarterPageProps {
14
+ applicationName: string;
15
+ applicationDescription: string;
16
+ heroTitle: string;
17
+ catalogueLabel: string;
18
+ locale: AppLocale;
19
+ }
20
+
21
+ const architecture = [
22
+ {
23
+ name: 'Frontend',
24
+ detail: 'Web',
25
+ technologies: [
26
+ { name: 'Next.js', icon: '/icons/next.svg' },
27
+ { name: 'React', icon: '/icons/react.svg' },
28
+ { name: 'Tailwind CSS', icon: '/icons/tailwind.svg' },
29
+ { name: 'React Query', icon: '/icons/react-query.svg' },
30
+ ],
31
+ },
32
+ {
33
+ name: 'Backend',
34
+ detail: 'API',
35
+ technologies: [
36
+ { name: 'Stitchkit', icon: '/mascot-stitch.png' },
37
+ { name: 'Bun', icon: '/icons/bun.svg' },
38
+ { name: 'Zod', icon: '/icons/zod.svg' },
39
+ ],
40
+ },
41
+ {
42
+ name: 'Data',
43
+ detail: 'Storage',
44
+ technologies: [
45
+ { name: 'PostgreSQL', icon: '/icons/postgresql.svg' },
46
+ { name: 'Prisma', icon: '/icons/prisma.svg', invertInDark: true },
47
+ ],
48
+ },
49
+ {
50
+ name: 'Tooling',
51
+ detail: 'Quality',
52
+ technologies: [
53
+ { name: 'TypeScript', icon: '/icons/typescript.svg' },
54
+ { name: 'TSConfig', icon: '/icons/tsconfig.svg' },
55
+ { name: 'Biome', icon: '/icons/biome.svg' },
56
+ ],
57
+ },
58
+ ];
59
+
60
+ export function StarterPage({
61
+ applicationName,
62
+ applicationDescription,
63
+ heroTitle,
64
+ catalogueLabel,
65
+ locale,
66
+ }: StarterPageProps) {
67
+ const homeSeo = getSeoPage('home', locale);
68
+ const structuredData = {
69
+ '@context': 'https://schema.org',
70
+ '@type': 'SoftwareApplication',
71
+ name: SITE_NAME,
72
+ applicationCategory: 'DeveloperApplication',
73
+ operatingSystem: 'Web',
74
+ url: absoluteSiteUrl(`/${locale}`),
75
+ description: homeSeo.description,
76
+ };
77
+
78
+ return (
79
+ <main className='h-svh overflow-y-auto bg-background lg:overflow-hidden'>
80
+ <script type='application/ld+json'>{JSON.stringify(structuredData)}</script>
81
+ <div className='mx-auto flex min-h-full w-full max-w-7xl flex-col px-5 lg:h-full lg:px-8'>
82
+ <header className='flex h-16 shrink-0 items-center justify-between'>
83
+ <div className='flex min-w-0 items-center gap-2 font-medium'>
84
+ <BrandMark priority />
85
+ <span className='truncate'>{applicationName}</span>
86
+ </div>
87
+ <div className='flex items-center gap-2'>
88
+ <LanguageSwitcher />
89
+ <ThemeToggle />
90
+ <Link
91
+ aria-label={catalogueLabel}
92
+ className={cn(buttonVariants({ variant: 'outline', size: 'sm' }))}
93
+ href='/ui'
94
+ >
95
+ <span className='hidden sm:inline'>{catalogueLabel}</span>
96
+ <IconArrowUpRight />
97
+ </Link>
98
+ </div>
99
+ </header>
100
+
101
+ <div className='flex flex-1 flex-col items-center justify-center py-8 text-center lg:min-h-0 lg:py-5'>
102
+ <section className='flex w-full min-w-0 flex-col items-center'>
103
+ <h1 className='max-w-4xl font-display text-5xl font-medium tracking-tight sm:text-6xl lg:text-7xl lg:leading-[0.96]'>
104
+ {heroTitle}
105
+ </h1>
106
+ <p className='mt-5 max-w-3xl text-base leading-7 text-muted-foreground lg:text-lg'>
107
+ {applicationDescription}
108
+ </p>
109
+
110
+ <div className='mt-8 w-full max-w-4xl'>
111
+ <div className='grid grid-cols-2 overflow-hidden rounded-xl border border-border bg-card text-left sm:grid-cols-4'>
112
+ {architecture.map((item) => (
113
+ <div
114
+ className='min-w-0 border-border px-3 py-3 odd:border-r first:border-b second:border-b sm:border-r sm:border-b-0 sm:last:border-r-0 sm:px-4'
115
+ key={item.name}
116
+ >
117
+ <div className='flex min-w-0 items-baseline gap-2'>
118
+ <p className='truncate text-sm font-medium'>{item.name}</p>
119
+ <p className='text-xs text-muted-foreground'>{item.detail}</p>
120
+ </div>
121
+ <div className='mt-2 flex min-h-6 items-center gap-2'>
122
+ {item.technologies.map((technology) => (
123
+ <Image
124
+ alt={technology.name}
125
+ className={
126
+ technology.invertInDark
127
+ ? 'technology-icon-invert-dark size-5 shrink-0 object-contain'
128
+ : 'size-5 shrink-0 object-contain'
129
+ }
130
+ height={20}
131
+ key={technology.icon}
132
+ src={technology.icon}
133
+ title={technology.name}
134
+ width={20}
135
+ />
136
+ ))}
137
+ </div>
138
+ </div>
139
+ ))}
140
+ </div>
141
+ </div>
142
+
143
+ <div className='mt-5 w-full'>
144
+ <RepositorySummary />
145
+ </div>
146
+ </section>
147
+ </div>
148
+ </div>
149
+ </main>
150
+ );
151
+ }
@@ -0,0 +1,18 @@
1
+ 'use client';
2
+
3
+ import { TooltipProvider } from '@radix-ui/react-tooltip';
4
+ import type { ReactNode } from 'react';
5
+ import { Toaster } from '@/components/ui/toaster';
6
+ import { ReactQueryProvider } from './react-query';
7
+ import { RealtimeProvider } from './realtime';
8
+
9
+ export function Providers({ children }: { children: ReactNode }) {
10
+ return (
11
+ <ReactQueryProvider>
12
+ <RealtimeProvider>
13
+ <TooltipProvider delayDuration={250}>{children}</TooltipProvider>
14
+ </RealtimeProvider>
15
+ <Toaster />
16
+ </ReactQueryProvider>
17
+ );
18
+ }
@@ -0,0 +1,3 @@
1
+ export * from './contracts/repository';
2
+ export * from './events/repository';
3
+ export * from './schemas/repository';
@@ -0,0 +1,91 @@
1
+ import { RepositorySnapshotSchema } from '@app/shared';
2
+ import { io } from 'socket.io-client';
3
+ import { z } from 'zod';
4
+ import { defineSurfaceProbe, runSurfaceConformance } from './surface-conformance';
5
+ import { toolingEnv } from './tooling-env';
6
+
7
+ const apiOrigin = toolingEnv.NEXT_PUBLIC_API_URL;
8
+
9
+ async function json(path: string, init?: RequestInit): Promise<unknown> {
10
+ const response = await fetch(`${apiOrigin}${path}`, init);
11
+ if (!response.ok)
12
+ throw new Error(`${init?.method ?? 'GET'} ${path} returned ${response.status}`);
13
+ return response.json();
14
+ }
15
+
16
+ z.object({ status: z.literal('ok') }).parse(await json('/health'));
17
+
18
+ await runSurfaceConformance({
19
+ apiOrigin,
20
+ probes: [
21
+ defineSurfaceProbe({
22
+ name: 'repository refresh lifecycle',
23
+ input: z.object({ path: z.literal('/api/repository/refresh') }),
24
+ fixture: { path: '/api/repository/refresh' },
25
+ output: RepositorySnapshotSchema,
26
+ run: async ({ path }) => {
27
+ const socket = io(apiOrigin, { transports: ['websocket'] });
28
+ try {
29
+ await new Promise<void>((resolve, reject) => {
30
+ const timeout = setTimeout(
31
+ () => reject(new Error('Socket.IO connection timed out')),
32
+ 5_000,
33
+ );
34
+ const onConnect = () => {
35
+ clearTimeout(timeout);
36
+ socket.off('connect_error', onConnectError);
37
+ 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);
46
+ });
47
+ const refreshedEvent = new Promise<string>((resolve, reject) => {
48
+ const timeout = setTimeout(
49
+ () => reject(new Error('Socket.IO repository refresh event timed out')),
50
+ 5_000,
51
+ );
52
+ socket.once('repository:refreshed', (snapshot) => {
53
+ clearTimeout(timeout);
54
+ resolve(snapshot.fullName);
55
+ });
56
+ });
57
+ const refreshed = RepositorySnapshotSchema.parse(
58
+ await json(path, { method: 'POST' }),
59
+ );
60
+ if ((await refreshedEvent) !== refreshed.fullName) {
61
+ throw new Error('Socket.IO repository identity differs');
62
+ }
63
+ const cached = RepositorySnapshotSchema.parse(await json('/api/repository/'));
64
+ if (cached.fullName !== refreshed.fullName) {
65
+ throw new Error('Repository cache read differs');
66
+ }
67
+ return refreshed;
68
+ } finally {
69
+ socket.close();
70
+ }
71
+ },
72
+ }),
73
+ ],
74
+ });
75
+
76
+ {
77
+ const corsResponse = await fetch(`${apiOrigin}/api/repository/`, {
78
+ headers: { Origin: 'http://localhost:58302' },
79
+ });
80
+ if (corsResponse.headers.get('access-control-allow-origin') !== '*') {
81
+ throw new Error('Public API does not allow a forwarded browser origin');
82
+ }
83
+ const openApi = z
84
+ .object({ paths: z.record(z.string(), z.unknown()) })
85
+ .parse(await json('/openapi.json'));
86
+ if (!Object.keys(openApi.paths).some((path) => path.startsWith('/api/repository'))) {
87
+ throw new Error('OpenAPI repository path is missing');
88
+ }
89
+ }
90
+
91
+ console.log('Runtime HTTP, OpenAPI, Socket.IO and MCP smoke passed');