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.
- package/CHANGELOG.md +83 -0
- package/README.md +11 -4
- package/dist/cli.js +93 -15
- package/examples/repository/_env.example.append +4 -0
- package/examples/repository/e2e/repository.spec.ts +69 -0
- package/{template → examples/repository}/packages/backend/src/domain/repository/github-cache.test.ts +8 -0
- package/{template → examples/repository}/packages/backend/src/domain/repository/github-cache.ts +2 -1
- package/examples/repository/packages/backend/src/surface.snapshot.json +58 -0
- package/examples/repository/packages/backend/src/surface.ts +16 -0
- package/examples/repository/packages/config/src/features.ts +7 -0
- package/examples/repository/packages/db/schema.prisma +31 -0
- package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +45 -0
- package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +151 -0
- package/{template → examples/repository}/packages/frontend/src/components/repository-summary.tsx +5 -1
- package/{template → examples/repository}/packages/frontend/src/lib/realtime/repository.ts +5 -6
- package/examples/repository/packages/frontend/src/providers/index.tsx +18 -0
- package/examples/repository/packages/shared/src/index.ts +5 -0
- package/examples/repository/packages/shared/src/realtime/repository.ts +10 -0
- package/{template → examples/repository}/packages/shared/src/schemas/repository.ts +4 -2
- package/examples/repository/scripts/runtime-smoke.ts +169 -0
- package/package.json +10 -1
- package/template/AGENTS.md +50 -0
- package/template/README.md +17 -8
- package/template/_env.example +1 -4
- package/template/app.config.json +9 -0
- package/template/bun.lock +6 -4
- package/template/docs/ADDING_A_FEATURE.md +102 -0
- package/template/e2e/starter.spec.ts +34 -22
- package/template/ecosystem.config.cjs +3 -2
- package/template/ecosystem.dev.config.cjs +7 -4
- package/template/package.json +6 -2
- package/template/packages/backend/src/cli.ts +2 -1
- package/template/packages/backend/src/index.ts +4 -3
- package/template/packages/backend/src/surface-manifest.test.ts +237 -0
- package/template/packages/backend/src/surface-manifest.ts +220 -0
- package/template/packages/backend/src/surface.snapshot.json +18 -0
- package/template/packages/backend/src/surface.ts +4 -9
- package/template/packages/backend/src/tools.ts +4 -1
- package/template/packages/backend/src/transport/errors.ts +1 -0
- package/template/packages/backend/src/transport/system-service.ts +8 -0
- package/template/packages/config/package.json +2 -1
- package/template/packages/config/src/features.ts +1 -0
- package/template/packages/config/src/identity.ts +18 -0
- package/template/packages/config/src/server.ts +10 -3
- package/template/packages/db/schema.prisma +0 -23
- package/template/packages/frontend/messages/en.json +0 -1
- package/template/packages/frontend/messages/ru.json +0 -1
- package/template/packages/frontend/package.json +1 -1
- package/template/packages/frontend/src/app/[locale]/page.tsx +8 -19
- package/template/packages/frontend/src/app/[locale]/starter-page.tsx +3 -4
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +3 -2
- package/template/packages/frontend/src/lib/query-client.test.ts +22 -0
- package/template/packages/frontend/src/lib/query-client.ts +10 -2
- package/template/packages/frontend/src/lib/seo/pages.ts +4 -5
- package/template/packages/frontend/src/providers/index.tsx +1 -4
- package/template/packages/frontend/src/theme/config.ts +2 -1
- package/template/packages/frontend/tsconfig.json +7 -1
- package/template/packages/shared/package.json +0 -1
- package/template/packages/shared/src/contracts/system.ts +19 -0
- package/template/packages/shared/src/index.ts +2 -3
- package/template/packages/shared/src/schemas/system.ts +4 -0
- package/template/playwright.config.ts +2 -1
- package/template/scripts/check-authored.ts +20 -6
- package/template/scripts/dev.ts +46 -7
- package/template/scripts/local-env.test.ts +33 -0
- package/template/scripts/local-env.ts +16 -5
- package/template/scripts/runtime-smoke.ts +22 -61
- package/template/scripts/surface-conformance.ts +166 -0
- package/template/scripts/surface-snapshot.ts +21 -0
- package/template/scripts/tooling-env.ts +16 -3
- package/template/tsconfig.json +3 -1
- package/template/_env +0 -11
- package/template/packages/shared/src/events/repository.ts +0 -9
- /package/{template → examples/repository}/packages/backend/src/domain/errors.ts +0 -0
- /package/{template → examples/repository}/packages/backend/src/transport/repository-service.ts +0 -0
- /package/{template → examples/repository}/packages/db/migrations/20260808000000_init/migration.sql +0 -0
- /package/{template → examples/repository}/packages/db/migrations/20260808170000_repository_visibility/migration.sql +0 -0
- /package/{template → examples/repository}/packages/frontend/src/lib/api/client.ts +0 -0
- /package/{template → examples/repository}/packages/frontend/src/lib/api/queries.ts +0 -0
- /package/{template → examples/repository}/packages/frontend/src/providers/realtime.tsx +0 -0
- /package/{template → examples/repository}/packages/shared/src/contracts/repository.ts +0 -0
- /package/{template → examples/repository}/packages/shared/src/schemas/repository.test.ts +0 -0
|
@@ -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
|
+
}
|
package/{template → examples/repository}/packages/frontend/src/components/repository-summary.tsx
RENAMED
|
@@ -52,7 +52,11 @@ export function RepositorySummary() {
|
|
|
52
52
|
: '—';
|
|
53
53
|
|
|
54
54
|
return (
|
|
55
|
-
<div
|
|
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
|
|
2
|
-
import {
|
|
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 =
|
|
9
|
-
|
|
10
|
-
|
|
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,
|
|
@@ -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,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:
|
|
11
|
+
visibility: RepositoryVisibilitySchema,
|
|
10
12
|
stars: z.number().int().nonnegative(),
|
|
11
13
|
forks: z.number().int().nonnegative(),
|
|
12
14
|
openIssues: z.number().int().nonnegative(),
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { RepositorySnapshotSchema, repositoryRealtimeContract } from '@app/shared';
|
|
2
|
+
import { createRealtimeClient, defineRealtimeContract } from 'stitchkit';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { defineSurfaceProbe, runSurfaceConformance } from './surface-conformance';
|
|
5
|
+
import { loadToolingEnv } from './tooling-env';
|
|
6
|
+
|
|
7
|
+
const toolingEnv = loadToolingEnv();
|
|
8
|
+
const apiOrigin = toolingEnv.NEXT_PUBLIC_API_URL;
|
|
9
|
+
|
|
10
|
+
async function json(path: string, init?: RequestInit): Promise<unknown> {
|
|
11
|
+
const response = await fetch(`${apiOrigin}${path}`, init);
|
|
12
|
+
if (!response.ok)
|
|
13
|
+
throw new Error(`${init?.method ?? 'GET'} ${path} returned ${response.status}`);
|
|
14
|
+
return response.json();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
z.object({ status: z.literal('ok') }).parse(await json('/health'));
|
|
18
|
+
|
|
19
|
+
await runSurfaceConformance({
|
|
20
|
+
apiOrigin,
|
|
21
|
+
probes: [
|
|
22
|
+
defineSurfaceProbe({
|
|
23
|
+
name: 'repository refresh lifecycle',
|
|
24
|
+
input: z.object({ path: z.literal('/api/repository/refresh') }),
|
|
25
|
+
fixture: { path: '/api/repository/refresh' },
|
|
26
|
+
output: RepositorySnapshotSchema,
|
|
27
|
+
run: async ({ path }) => {
|
|
28
|
+
const rejected: string[] = [];
|
|
29
|
+
const socket = createRealtimeClient(repositoryRealtimeContract, {
|
|
30
|
+
url: apiOrigin,
|
|
31
|
+
transports: ['websocket'],
|
|
32
|
+
onRejected: ({ event, direction, phase }) => {
|
|
33
|
+
rejected.push(`${event}:${direction}:${phase}`);
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
try {
|
|
37
|
+
await new Promise<void>((resolve, reject) => {
|
|
38
|
+
const timeout = setTimeout(
|
|
39
|
+
() => reject(new Error('Socket.IO connection timed out')),
|
|
40
|
+
5_000,
|
|
41
|
+
);
|
|
42
|
+
const unsubscribe = socket.onConnectionChange((connected, reason) => {
|
|
43
|
+
if (!connected) {
|
|
44
|
+
if (reason) reject(new Error(`Socket.IO disconnected: ${reason}`));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
clearTimeout(timeout);
|
|
48
|
+
unsubscribe();
|
|
49
|
+
resolve();
|
|
50
|
+
});
|
|
51
|
+
socket.connect();
|
|
52
|
+
});
|
|
53
|
+
const refreshedEvent = new Promise<string>((resolve, reject) => {
|
|
54
|
+
const timeout = setTimeout(
|
|
55
|
+
() => reject(new Error('Socket.IO repository refresh event timed out')),
|
|
56
|
+
5_000,
|
|
57
|
+
);
|
|
58
|
+
const unsubscribe = socket.on('repository:refreshed', (snapshot) => {
|
|
59
|
+
clearTimeout(timeout);
|
|
60
|
+
unsubscribe();
|
|
61
|
+
resolve(snapshot.fullName);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
const refreshed = RepositorySnapshotSchema.parse(
|
|
65
|
+
await json(path, { method: 'POST' }),
|
|
66
|
+
);
|
|
67
|
+
if ((await refreshedEvent) !== refreshed.fullName) {
|
|
68
|
+
throw new Error('Socket.IO repository identity differs');
|
|
69
|
+
}
|
|
70
|
+
const cached = RepositorySnapshotSchema.parse(await json('/api/repository/'));
|
|
71
|
+
if (cached.fullName !== refreshed.fullName) {
|
|
72
|
+
throw new Error('Repository cache read differs');
|
|
73
|
+
}
|
|
74
|
+
if (rejected.length > 0) {
|
|
75
|
+
throw new Error(`Realtime contract rejected ${rejected.join(', ')}`);
|
|
76
|
+
}
|
|
77
|
+
return refreshed;
|
|
78
|
+
} finally {
|
|
79
|
+
socket.disconnect();
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
defineSurfaceProbe({
|
|
84
|
+
name: 'realtime rejection path fires on a contract mismatch',
|
|
85
|
+
input: z.object({ path: z.literal('/api/repository/refresh') }),
|
|
86
|
+
fixture: { path: '/api/repository/refresh' },
|
|
87
|
+
run: async ({ path }) => {
|
|
88
|
+
// NEGATIVE probe: a client whose local contract disagrees with the
|
|
89
|
+
// server must see the real payload REJECTED — this executes the
|
|
90
|
+
// rejection path instead of merely asserting its absence. Matched by
|
|
91
|
+
// event/direction/phase, never by message text.
|
|
92
|
+
const divergentContract = defineRealtimeContract({
|
|
93
|
+
serverToClient: {
|
|
94
|
+
'repository:refreshed': { args: z.tuple([z.object({ bogus: z.string() })]) },
|
|
95
|
+
},
|
|
96
|
+
clientToServer: {},
|
|
97
|
+
});
|
|
98
|
+
const rejections: Array<{ event: string; direction: string; phase: string }> = [];
|
|
99
|
+
const socket = createRealtimeClient(divergentContract, {
|
|
100
|
+
url: apiOrigin,
|
|
101
|
+
transports: ['websocket'],
|
|
102
|
+
onRejected: ({ event, direction, phase }) => {
|
|
103
|
+
rejections.push({ event, direction, phase });
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
try {
|
|
107
|
+
await new Promise<void>((resolve, reject) => {
|
|
108
|
+
const timeout = setTimeout(
|
|
109
|
+
() => reject(new Error('Socket.IO connection timed out')),
|
|
110
|
+
5_000,
|
|
111
|
+
);
|
|
112
|
+
const unsubscribe = socket.onConnectionChange((connected) => {
|
|
113
|
+
if (!connected) return;
|
|
114
|
+
clearTimeout(timeout);
|
|
115
|
+
unsubscribe();
|
|
116
|
+
resolve();
|
|
117
|
+
});
|
|
118
|
+
socket.connect();
|
|
119
|
+
});
|
|
120
|
+
// A handler must be attached for the inbound frame to be validated.
|
|
121
|
+
const unsubscribe = socket.on('repository:refreshed', () => {
|
|
122
|
+
throw new Error('a payload outside the local contract reached the handler');
|
|
123
|
+
});
|
|
124
|
+
await json(path, { method: 'POST' });
|
|
125
|
+
const deadline = Date.now() + 5_000;
|
|
126
|
+
while (rejections.length === 0 && Date.now() < deadline) {
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
128
|
+
}
|
|
129
|
+
unsubscribe();
|
|
130
|
+
const rejection = rejections[0];
|
|
131
|
+
if (!rejection) {
|
|
132
|
+
throw new Error('the contract mismatch was never rejected');
|
|
133
|
+
}
|
|
134
|
+
if (
|
|
135
|
+
rejection.event !== 'repository:refreshed' ||
|
|
136
|
+
rejection.direction !== 'client-inbound' ||
|
|
137
|
+
rejection.phase !== 'arguments'
|
|
138
|
+
) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`unexpected rejection identity: ${rejection.event}:${rejection.direction}:${rejection.phase}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
socket.disconnect();
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
{
|
|
152
|
+
const corsResponse = await fetch(`${apiOrigin}/api/repository/`, {
|
|
153
|
+
headers: { Origin: 'http://localhost:58302' },
|
|
154
|
+
});
|
|
155
|
+
if (
|
|
156
|
+
corsResponse.headers.get('access-control-allow-origin') !==
|
|
157
|
+
new URL(toolingEnv.NEXT_PUBLIC_WEB_URL).origin
|
|
158
|
+
) {
|
|
159
|
+
throw new Error('API CORS origin differs from the configured web origin');
|
|
160
|
+
}
|
|
161
|
+
const openApi = z
|
|
162
|
+
.object({ paths: z.record(z.string(), z.unknown()) })
|
|
163
|
+
.parse(await json('/openapi.json'));
|
|
164
|
+
if (!Object.keys(openApi.paths).some((path) => path.startsWith('/api/repository'))) {
|
|
165
|
+
throw new Error('OpenAPI repository path is missing');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
console.log('Runtime HTTP, OpenAPI, Socket.IO and MCP smoke passed');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-stitchkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Create a production-shaped Stitchkit application",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Max Listov <maxlistov@gmail.com>",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"dist",
|
|
18
18
|
"template/**/*",
|
|
19
|
+
"examples/**/*",
|
|
19
20
|
"!template/**/.env",
|
|
20
21
|
"!template/**/node_modules/**",
|
|
21
22
|
"!template/**/.next/**",
|
|
@@ -26,6 +27,11 @@
|
|
|
26
27
|
"!template/**/src/generated/**",
|
|
27
28
|
"!template/**/*.log",
|
|
28
29
|
"!template/**/*.tsbuildinfo",
|
|
30
|
+
"!examples/**/node_modules/**",
|
|
31
|
+
"!examples/**/.next/**",
|
|
32
|
+
"!examples/**/dist/**",
|
|
33
|
+
"!examples/**/*.log",
|
|
34
|
+
"!examples/**/*.tsbuildinfo",
|
|
29
35
|
"README.md",
|
|
30
36
|
"CHANGELOG.md",
|
|
31
37
|
"LICENSE"
|
|
@@ -39,6 +45,9 @@
|
|
|
39
45
|
"build": "rm -rf dist && bun build src/cli.ts --outdir dist --target bun --packages external && chmod +x dist/cli.js",
|
|
40
46
|
"prepublishOnly": "bun run check && bun run test && bun run build"
|
|
41
47
|
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"zod": "^4.4.3"
|
|
50
|
+
},
|
|
42
51
|
"devDependencies": {
|
|
43
52
|
"@types/bun": "^1.3.14",
|
|
44
53
|
"typescript": "^7.0.2"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Application agent guide
|
|
2
|
+
|
|
3
|
+
This repository is a generated Stitchkit application. It is not the Stitchkit
|
|
4
|
+
framework source repository.
|
|
5
|
+
|
|
6
|
+
## Architecture
|
|
7
|
+
|
|
8
|
+
- `packages/shared` owns named Zod schemas, inferred DTO types, HTTP contracts
|
|
9
|
+
and realtime event definitions. It has no database, server or browser imports.
|
|
10
|
+
- `packages/db` owns Prisma schema, migrations and the generated client.
|
|
11
|
+
- `packages/backend` implements contracts, composes the registered surface and
|
|
12
|
+
owns application policy. Routes remain thin transport boundaries.
|
|
13
|
+
- `packages/frontend` owns Next.js pages, typed clients, query/mutation hooks and
|
|
14
|
+
cache reactions.
|
|
15
|
+
- `packages/config/src/server.ts` is the only server environment boundary.
|
|
16
|
+
`app.config.json` is the only application identity boundary.
|
|
17
|
+
|
|
18
|
+
Dependencies point inward: frontend/backend → shared; backend → db/config.
|
|
19
|
+
Shared never imports an application runtime package.
|
|
20
|
+
|
|
21
|
+
## Required patterns
|
|
22
|
+
|
|
23
|
+
- Define each runtime DTO as a named Zod schema in `packages/shared/src/schemas`.
|
|
24
|
+
- Reference schemas from a separate contract module; never inline `z.object()`
|
|
25
|
+
inside `defineContract`.
|
|
26
|
+
- Implement one service method per contract operation and register the service
|
|
27
|
+
once in `packages/backend/src/surface.ts`.
|
|
28
|
+
- Use Stitchkit's typed browser client and react-query-kit hooks; do not rebuild
|
|
29
|
+
endpoint URLs, query keys or error envelopes by hand.
|
|
30
|
+
- Use Socket.IO through the shared realtime contract and Stitchkit wrappers.
|
|
31
|
+
Authentication, authorization and room membership remain application policy.
|
|
32
|
+
- Extend runtime smoke with an explicit typed probe for operations whose handler
|
|
33
|
+
behavior matters. Generic OpenAPI/MCP discovery checks are already derived.
|
|
34
|
+
|
|
35
|
+
Do not duplicate DTOs, copy Stitchkit internals, add raw routes for operations a
|
|
36
|
+
contract can express, or create compatibility aliases. Fix framework gaps in
|
|
37
|
+
Stitchkit rather than copying its implementation into this application.
|
|
38
|
+
|
|
39
|
+
## Workflow
|
|
40
|
+
|
|
41
|
+
Follow [`docs/ADDING_A_FEATURE.md`](docs/ADDING_A_FEATURE.md) for the complete
|
|
42
|
+
vertical path. Before handing work off, run:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bun run check
|
|
46
|
+
bun run test
|
|
47
|
+
bun run build
|
|
48
|
+
bun run runtime:smoke
|
|
49
|
+
```
|
|
50
|
+
|
package/template/README.md
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Application starter
|
|
2
2
|
|
|
3
3
|
Production-shaped application generated by `create-stitchkit`.
|
|
4
4
|
|
|
5
|
+
Application identity lives in [`app.config.json`](app.config.json). Change the
|
|
6
|
+
slug, display name, version and localized description there; package names,
|
|
7
|
+
process names, MCP/OpenAPI identity, UI copy and SEO derive from it.
|
|
8
|
+
|
|
5
9
|
## Start
|
|
6
10
|
|
|
7
11
|
Point `DATABASE_URL` in `.env` at an existing PostgreSQL database, then run:
|
|
@@ -10,8 +14,8 @@ Point `DATABASE_URL` in `.env` at an existing PostgreSQL database, then run:
|
|
|
10
14
|
bun run dev
|
|
11
15
|
```
|
|
12
16
|
|
|
13
|
-
The command validates the environment, generates the Prisma client, applies
|
|
14
|
-
|
|
17
|
+
The command validates the environment, generates the Prisma client, applies any
|
|
18
|
+
database migrations you add and launches:
|
|
15
19
|
|
|
16
20
|
- Web: <http://localhost:3210>
|
|
17
21
|
- UI catalogue: <http://localhost:3210/en/ui>
|
|
@@ -19,11 +23,16 @@ checked-in migrations and launches:
|
|
|
19
23
|
- OpenAPI: <http://localhost:3211/openapi.json>
|
|
20
24
|
- MCP: <http://localhost:3211/mcp>
|
|
21
25
|
|
|
22
|
-
The home page is a compact
|
|
23
|
-
|
|
26
|
+
The home page is a compact, domain-free application reference surface.
|
|
27
|
+
Add application features as vertical schema → contract → service → client slices.
|
|
28
|
+
The exact workflow is in [`docs/ADDING_A_FEATURE.md`](docs/ADDING_A_FEATURE.md),
|
|
29
|
+
and root [`AGENTS.md`](AGENTS.md) gives coding agents the application boundaries.
|
|
30
|
+
|
|
31
|
+
When generated with `--example repository`, one configured GitHub repository
|
|
32
|
+
exercises the PostgreSQL cache → contract → typed client →
|
|
24
33
|
mutation invalidation → realtime path without adding a demo product domain.
|
|
25
34
|
Configure it with `GITHUB_REPOSITORY`; `GITHUB_TOKEN` is optional.
|
|
26
|
-
Repository visibility demonstrates the canonical Prisma enum → shared Zod schema
|
|
35
|
+
Repository visibility then demonstrates the canonical Prisma enum → shared Zod schema
|
|
27
36
|
→ HTTP/UI path without duplicating its allowed values.
|
|
28
37
|
|
|
29
38
|
The `/en/ui` catalogue is isolated under
|
|
@@ -45,6 +54,8 @@ Unsupported browsers and users requesting reduced motion switch immediately.
|
|
|
45
54
|
bun run check
|
|
46
55
|
bun run test
|
|
47
56
|
bun run build
|
|
57
|
+
bun run runtime:smoke
|
|
58
|
+
bun run e2e
|
|
48
59
|
```
|
|
49
60
|
|
|
50
61
|
## Production
|
|
@@ -65,5 +76,3 @@ application owns its schema and migrations; the environment owns the database
|
|
|
65
76
|
process and supplies its connection through `DATABASE_URL`.
|
|
66
77
|
|
|
67
78
|
`bun run dev` and `bun run pm2:dev` use the same direct PM2 development path.
|
|
68
|
-
Rename the neutral `stitchkit-starter` package and process names when adopting
|
|
69
|
-
the template for a product.
|
package/template/_env.example
CHANGED
|
@@ -6,7 +6,4 @@ NEXT_PUBLIC_API_URL=http://127.0.0.1:3211
|
|
|
6
6
|
INTERNAL_API_URL=http://127.0.0.1:3211
|
|
7
7
|
NEXT_PUBLIC_WEB_URL=http://127.0.0.1:3210
|
|
8
8
|
LOG_FORMAT=pretty
|
|
9
|
-
|
|
10
|
-
GITHUB_CACHE_TTL_SECONDS=900
|
|
11
|
-
# Optional. Authenticated conditional requests have a higher GitHub rate limit.
|
|
12
|
-
# GITHUB_TOKEN=github_pat_...
|
|
9
|
+
CORS_ORIGIN=http://127.0.0.1:3210
|
package/template/bun.lock
CHANGED
|
@@ -8,14 +8,17 @@
|
|
|
8
8
|
"dotenv": "^17.4.2",
|
|
9
9
|
},
|
|
10
10
|
"devDependencies": {
|
|
11
|
+
"@app/config": "workspace:*",
|
|
11
12
|
"@app/shared": "workspace:*",
|
|
12
13
|
"@axe-core/playwright": "^4.11.0",
|
|
13
14
|
"@biomejs/biome": "^2.5.7",
|
|
14
15
|
"@modelcontextprotocol/client": "^2.0.0",
|
|
15
16
|
"@playwright/test": "^1.55.0",
|
|
16
17
|
"@types/bun": "^1.3.14",
|
|
18
|
+
"@types/node": "^26.2.0",
|
|
17
19
|
"oxc-parser": "^0.143.0",
|
|
18
20
|
"socket.io-client": "^4.8.3",
|
|
21
|
+
"stitchkit": "catalog:",
|
|
19
22
|
"typescript": "^7.0.2",
|
|
20
23
|
"zod": "^4.4.3",
|
|
21
24
|
},
|
|
@@ -72,7 +75,7 @@
|
|
|
72
75
|
"name": "@app/frontend",
|
|
73
76
|
"version": "0.1.0",
|
|
74
77
|
"dependencies": {
|
|
75
|
-
"@app/
|
|
78
|
+
"@app/config": "workspace:*",
|
|
76
79
|
"@app/shared": "workspace:*",
|
|
77
80
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
|
78
81
|
"@radix-ui/react-avatar": "^1.2.6",
|
|
@@ -125,7 +128,6 @@
|
|
|
125
128
|
"name": "@app/shared",
|
|
126
129
|
"version": "0.1.0",
|
|
127
130
|
"dependencies": {
|
|
128
|
-
"@app/db": "workspace:*",
|
|
129
131
|
"stitchkit": "catalog:",
|
|
130
132
|
"zod": "^4.4.3",
|
|
131
133
|
},
|
|
@@ -136,7 +138,7 @@
|
|
|
136
138
|
},
|
|
137
139
|
},
|
|
138
140
|
"catalog": {
|
|
139
|
-
"stitchkit": "^0.
|
|
141
|
+
"stitchkit": "^0.46.0",
|
|
140
142
|
},
|
|
141
143
|
"packages": {
|
|
142
144
|
"@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LIAO6kAG8fpXQb9L0iwPk1FIbXftvqnyC56v5NEAzeWTeL8fUsy/Hx86VPBTWEDFdwbVprjWifJOAqS6AOj3mA=="],
|
|
@@ -1115,7 +1117,7 @@
|
|
|
1115
1117
|
|
|
1116
1118
|
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
|
1117
1119
|
|
|
1118
|
-
"stitchkit": ["stitchkit@0.
|
|
1120
|
+
"stitchkit": ["stitchkit@0.46.0", "", { "dependencies": { "ky": "^2.0.2" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/server": "^2.0.0", "@socket.io/bun-engine": "^0.1.1", "@socket.io/component-emitter": "^3.1.2", "@tanstack/react-query": ">=5", "@types/bun": "^1.3.14", "ai": "^7.0.0", "react": ">=18", "react-query-kit": "^3.3.3", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "srvx": "^0.12.5", "zod": "^4.4.3" }, "optionalPeers": ["@modelcontextprotocol/ext-apps", "@modelcontextprotocol/server", "@socket.io/bun-engine", "@socket.io/component-emitter", "@tanstack/react-query", "@types/bun", "ai", "react", "react-query-kit", "socket.io", "socket.io-client", "srvx"] }, "sha512-vHFaXHp5Ws4sFSRptqPqwpc7CGEEbmHY+duvvGkNGwuyp51aY4De3bXVh1xiZSGiUN5IS0jhj1P59GG3VjP/Ag=="],
|
|
1119
1121
|
|
|
1120
1122
|
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
|
1121
1123
|
|