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,220 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { HttpMethod } from 'stitchkit/contract';
|
|
3
|
+
import type { OpenApiDocument, ServiceDef } from 'stitchkit/server';
|
|
4
|
+
import { listToolNames } from 'stitchkit/tools';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
|
|
7
|
+
export interface SurfaceManifestOperation {
|
|
8
|
+
service: string;
|
|
9
|
+
action: string;
|
|
10
|
+
scope: string;
|
|
11
|
+
hasInput: boolean;
|
|
12
|
+
hasOutput: boolean;
|
|
13
|
+
/** Digest of the input JSON Schema — a TYPE change flips it, not just presence. */
|
|
14
|
+
inputShape: string | null;
|
|
15
|
+
/** Digest of the output JSON Schema. */
|
|
16
|
+
outputShape: string | null;
|
|
17
|
+
http: Array<{ method: HttpMethod; path: string }>;
|
|
18
|
+
tools: Partial<Record<'MCP' | 'AGENT' | 'CLI', string>>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function schemaShape(schema: z.ZodType | undefined, io: 'input' | 'output'): string | null {
|
|
22
|
+
if (!schema) return null;
|
|
23
|
+
const document = z.toJSONSchema(schema, { io, unrepresentable: 'any' });
|
|
24
|
+
return createHash('sha256').update(JSON.stringify(document)).digest('hex').slice(0, 16);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function joinPath(...parts: Array<string | undefined>): string {
|
|
28
|
+
const joined = parts
|
|
29
|
+
.filter((part): part is string => Boolean(part))
|
|
30
|
+
.map((part) => part.replace(/^\/+|\/+$/g, ''))
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join('/');
|
|
33
|
+
return `/${joined}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function operationKey(service: string, action: string): string {
|
|
37
|
+
return `${service}\u0000${action}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sorted(values: Iterable<string>): string[] {
|
|
41
|
+
return [...values].sort((left, right) => left.localeCompare(right));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function assertSameSet(
|
|
45
|
+
label: string,
|
|
46
|
+
expectedValues: Iterable<string>,
|
|
47
|
+
actualValues: Iterable<string>,
|
|
48
|
+
) {
|
|
49
|
+
const expected = sorted(expectedValues);
|
|
50
|
+
const actual = sorted(actualValues);
|
|
51
|
+
if (
|
|
52
|
+
expected.length === actual.length &&
|
|
53
|
+
expected.every((value, index) => value === actual[index])
|
|
54
|
+
) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
throw new Error(
|
|
58
|
+
`${label} surface mismatch\nexpected: ${expected.join(', ') || '(empty)'}\nactual: ${actual.join(', ') || '(empty)'}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function buildSurfaceManifest(
|
|
63
|
+
services: readonly ServiceDef[],
|
|
64
|
+
pathPrefix = '/api',
|
|
65
|
+
): SurfaceManifestOperation[] {
|
|
66
|
+
const operations = new Map<string, SurfaceManifestOperation>();
|
|
67
|
+
for (const service of services) {
|
|
68
|
+
for (const method of Object.values(service.methods)) {
|
|
69
|
+
const key = operationKey(service.name, method.key);
|
|
70
|
+
if (operations.has(key)) {
|
|
71
|
+
throw new Error(`Duplicate operation identity ${service.name}.${method.key}`);
|
|
72
|
+
}
|
|
73
|
+
operations.set(key, {
|
|
74
|
+
service: service.name,
|
|
75
|
+
action: method.key,
|
|
76
|
+
scope: method.scope ?? service.scope,
|
|
77
|
+
hasInput: Boolean(method.inputSchema),
|
|
78
|
+
hasOutput: Boolean(method.outputSchema),
|
|
79
|
+
inputShape: schemaShape(method.inputSchema, 'input'),
|
|
80
|
+
outputShape: schemaShape(method.outputSchema, 'output'),
|
|
81
|
+
http:
|
|
82
|
+
!method.expose || method.expose.includes('HTTP')
|
|
83
|
+
? [
|
|
84
|
+
{
|
|
85
|
+
method: method.method,
|
|
86
|
+
path: joinPath(pathPrefix, service.prefix, method.path).replace(
|
|
87
|
+
/:([^/]+)/g,
|
|
88
|
+
'{$1}',
|
|
89
|
+
),
|
|
90
|
+
},
|
|
91
|
+
]
|
|
92
|
+
: [],
|
|
93
|
+
tools: {},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const tool of listToolNames({ services })) {
|
|
98
|
+
const operation = operations.get(operationKey(tool.service, tool.method));
|
|
99
|
+
if (!operation) {
|
|
100
|
+
throw new Error(`Tool ${tool.name} has no contract identity`);
|
|
101
|
+
}
|
|
102
|
+
for (const transport of tool.transports) {
|
|
103
|
+
if (transport !== 'HTTP') operation.tools[transport] = tool.name;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return [...operations.values()].sort(
|
|
107
|
+
(left, right) =>
|
|
108
|
+
left.service.localeCompare(right.service) || left.action.localeCompare(right.action),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Compare the LIVE manifest against the committed snapshot — the external
|
|
114
|
+
* anchor that a source-level change (an `expose` edit, a schema type change, a
|
|
115
|
+
* scope change) cannot move along with itself. Regenerate deliberately with
|
|
116
|
+
* `bun run surface:snapshot` and review the diff.
|
|
117
|
+
*/
|
|
118
|
+
export function assertManifestMatchesSnapshot(
|
|
119
|
+
manifest: readonly SurfaceManifestOperation[],
|
|
120
|
+
snapshot: readonly SurfaceManifestOperation[],
|
|
121
|
+
): void {
|
|
122
|
+
const actual = JSON.stringify(manifest, null, 2);
|
|
123
|
+
const expected = JSON.stringify(snapshot, null, 2);
|
|
124
|
+
if (actual !== expected) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Declared surface diverged from the committed snapshot — if the change is intended, regenerate it with "bun run surface:snapshot" and review the diff.\nsnapshot: ${expected}\nactual: ${actual}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function assertSurfaceConformance({
|
|
132
|
+
manifest,
|
|
133
|
+
openApi,
|
|
134
|
+
mcpToolNames,
|
|
135
|
+
agentToolNames,
|
|
136
|
+
cliToolNames,
|
|
137
|
+
metadata = 'require',
|
|
138
|
+
}: {
|
|
139
|
+
manifest: readonly SurfaceManifestOperation[];
|
|
140
|
+
openApi: Pick<OpenApiDocument, 'paths'>;
|
|
141
|
+
mcpToolNames: readonly string[];
|
|
142
|
+
agentToolNames: readonly string[];
|
|
143
|
+
cliToolNames: readonly string[];
|
|
144
|
+
/**
|
|
145
|
+
* `'require'` (default) — the OpenAPI document must carry `x-stitchkit-*`
|
|
146
|
+
* metadata and it is compared; `'ignore'` — an explicitly declared mode for
|
|
147
|
+
* standard documents. A silent skip is not an option.
|
|
148
|
+
*/
|
|
149
|
+
metadata?: 'require' | 'ignore';
|
|
150
|
+
}): void {
|
|
151
|
+
const openApiOperations = Object.entries(openApi.paths).flatMap(([path, item]) =>
|
|
152
|
+
Object.entries(item)
|
|
153
|
+
.filter(([method]) => ['get', 'post', 'put', 'patch', 'delete', 'head'].includes(method))
|
|
154
|
+
.map(([method, operation]) => ({ method: method.toUpperCase(), path, operation })),
|
|
155
|
+
);
|
|
156
|
+
assertSameSet(
|
|
157
|
+
'HTTP/OpenAPI',
|
|
158
|
+
manifest.flatMap((operation) =>
|
|
159
|
+
operation.http.map((entry) => `${entry.method}:${entry.path}`),
|
|
160
|
+
),
|
|
161
|
+
openApiOperations.map(({ method, path }) => `${method}:${path}`),
|
|
162
|
+
);
|
|
163
|
+
const carriesContractMetadata = openApiOperations.some(({ operation }) =>
|
|
164
|
+
OpenApiOperationMetadataPresenceSchema.parse(operation),
|
|
165
|
+
);
|
|
166
|
+
if (metadata === 'require' && openApiOperations.length > 0 && !carriesContractMetadata) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
'OpenAPI document carries no x-stitchkit-* contract metadata — pass metadata: "ignore" only for a deliberately standard document',
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (metadata === 'require' && carriesContractMetadata) {
|
|
172
|
+
assertSameSet(
|
|
173
|
+
'HTTP/OpenAPI contract metadata',
|
|
174
|
+
manifest.flatMap((operation) =>
|
|
175
|
+
operation.http.map(
|
|
176
|
+
(entry) =>
|
|
177
|
+
`${entry.method}:${entry.path}:${operation.scope}:${operation.hasInput}:${operation.hasOutput}`,
|
|
178
|
+
),
|
|
179
|
+
),
|
|
180
|
+
openApiOperations.map(({ method, path, operation }) => {
|
|
181
|
+
const metadata = OpenApiOperationMetadataSchema.parse(operation);
|
|
182
|
+
return `${method}:${path}:${metadata['x-stitchkit-scope']}:${metadata['x-stitchkit-has-input']}:${metadata['x-stitchkit-has-output']}`;
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
assertSameSet(
|
|
187
|
+
'MCP discovery',
|
|
188
|
+
manifest.flatMap((operation) => (operation.tools.MCP ? [operation.tools.MCP] : [])),
|
|
189
|
+
mcpToolNames,
|
|
190
|
+
);
|
|
191
|
+
assertSameSet(
|
|
192
|
+
'AGENT mount',
|
|
193
|
+
manifest.flatMap((operation) => (operation.tools.AGENT ? [operation.tools.AGENT] : [])),
|
|
194
|
+
agentToolNames,
|
|
195
|
+
);
|
|
196
|
+
assertSameSet(
|
|
197
|
+
'CLI manifest',
|
|
198
|
+
manifest.flatMap((operation) => (operation.tools.CLI ? [operation.tools.CLI] : [])),
|
|
199
|
+
cliToolNames,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const OpenApiOperationMetadataSchema = z.object({
|
|
204
|
+
'x-stitchkit-scope': z.string(),
|
|
205
|
+
'x-stitchkit-has-input': z.boolean(),
|
|
206
|
+
'x-stitchkit-has-output': z.boolean(),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const OpenApiOperationMetadataPresenceSchema = z
|
|
210
|
+
.object({
|
|
211
|
+
'x-stitchkit-scope': z.unknown().optional(),
|
|
212
|
+
'x-stitchkit-has-input': z.unknown().optional(),
|
|
213
|
+
'x-stitchkit-has-output': z.unknown().optional(),
|
|
214
|
+
})
|
|
215
|
+
.transform(
|
|
216
|
+
(metadata) =>
|
|
217
|
+
metadata['x-stitchkit-scope'] !== undefined ||
|
|
218
|
+
metadata['x-stitchkit-has-input'] !== undefined ||
|
|
219
|
+
metadata['x-stitchkit-has-output'] !== undefined,
|
|
220
|
+
);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"service": "system",
|
|
4
|
+
"action": "status",
|
|
5
|
+
"scope": "public",
|
|
6
|
+
"hasInput": false,
|
|
7
|
+
"hasOutput": true,
|
|
8
|
+
"inputShape": null,
|
|
9
|
+
"outputShape": "58b078ade3ee6167",
|
|
10
|
+
"http": [
|
|
11
|
+
{
|
|
12
|
+
"method": "GET",
|
|
13
|
+
"path": "/api/system/status"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"tools": {}
|
|
17
|
+
}
|
|
18
|
+
]
|
|
@@ -1,13 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { env } from '@app/config';
|
|
2
2
|
import { createSocketIOServer } from 'stitchkit/server';
|
|
3
|
-
import {
|
|
3
|
+
import { createSystemService } from './transport/system-service';
|
|
4
4
|
|
|
5
5
|
export async function createSurface() {
|
|
6
|
-
const socket = await createSocketIOServer
|
|
7
|
-
|
|
8
|
-
});
|
|
9
|
-
const repositoryService = createRepositoryService((snapshot) =>
|
|
10
|
-
socket.io.emit('repository:refreshed', snapshot),
|
|
11
|
-
);
|
|
12
|
-
return { socket, services: [repositoryService] };
|
|
6
|
+
const socket = await createSocketIOServer({ cors: { origin: env.CORS_ORIGIN } });
|
|
7
|
+
return { socket, services: [createSystemService()] };
|
|
13
8
|
}
|
|
@@ -5,11 +5,14 @@ const { services, socket } = await createSurface();
|
|
|
5
5
|
|
|
6
6
|
try {
|
|
7
7
|
const surface = { services };
|
|
8
|
+
const toolNames = listToolNames(surface)
|
|
9
|
+
.filter((tool) => tool.transports.some((transport) => transport !== 'HTTP'))
|
|
10
|
+
.map((tool) => tool.name);
|
|
8
11
|
console.log(
|
|
9
12
|
JSON.stringify(
|
|
10
13
|
{
|
|
11
14
|
manifest: buildToolManifest({ services, transport: 'AGENT' }),
|
|
12
|
-
names:
|
|
15
|
+
names: toolNames,
|
|
13
16
|
transports: summarizeTransports(surface),
|
|
14
17
|
},
|
|
15
18
|
null,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const featureServerSchema = {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import source from '../../../app.config.json' with { type: 'json' };
|
|
3
|
+
|
|
4
|
+
export const ApplicationIdentitySchema = z.object({
|
|
5
|
+
slug: z
|
|
6
|
+
.string()
|
|
7
|
+
.min(1)
|
|
8
|
+
.max(64)
|
|
9
|
+
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
10
|
+
name: z.string().trim().min(1).max(80),
|
|
11
|
+
version: z.string().regex(/^\d+\.\d+\.\d+$/),
|
|
12
|
+
description: z.object({
|
|
13
|
+
en: z.string().trim().min(1),
|
|
14
|
+
ru: z.string().trim().min(1),
|
|
15
|
+
}),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const appIdentity = ApplicationIdentitySchema.parse(source);
|
|
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { createEnv } from '@t3-oss/env-core';
|
|
3
3
|
import { config } from 'dotenv';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
import { featureServerSchema } from './features';
|
|
5
6
|
|
|
6
7
|
config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
|
|
7
8
|
|
|
@@ -15,10 +16,16 @@ export const env = createEnv({
|
|
|
15
16
|
INTERNAL_API_URL: z.url(),
|
|
16
17
|
NEXT_PUBLIC_WEB_URL: z.url(),
|
|
17
18
|
LOG_FORMAT: z.enum(['pretty', 'json']).default('pretty'),
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
GITHUB_TOKEN: z.string().min(1).optional(),
|
|
19
|
+
CORS_ORIGIN: z.url(),
|
|
20
|
+
...featureServerSchema,
|
|
21
21
|
},
|
|
22
22
|
runtimeEnv: process.env,
|
|
23
23
|
emptyStringAsUndefined: true,
|
|
24
24
|
});
|
|
25
|
+
|
|
26
|
+
/** Preserve the host process environment while applying explicit child-process overrides. */
|
|
27
|
+
export function childEnvironment(
|
|
28
|
+
overrides: Record<string, string>,
|
|
29
|
+
): Record<string, string | undefined> {
|
|
30
|
+
return { ...process.env, ...overrides };
|
|
31
|
+
}
|
|
@@ -6,26 +6,3 @@ generator client {
|
|
|
6
6
|
datasource db {
|
|
7
7
|
provider = "postgresql"
|
|
8
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
|
-
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"App": {
|
|
3
|
-
"title": "Stitchkit Starter",
|
|
4
3
|
"description": "A compact production base with one typed contract across HTTP, MCP, agents and the browser — ready for your product, not a demo domain.",
|
|
5
4
|
"heroTitle": "Build the product, not the plumbing.",
|
|
6
5
|
"ui": "UI system"
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"App": {
|
|
3
|
-
"title": "Stitchkit Starter",
|
|
4
3
|
"description": "Компактная production-база с единым типизированным контрактом для HTTP, MCP, агентов и браузера — готовая к вашему продукту, а не к демо-домену.",
|
|
5
4
|
"heroTitle": "Стройте продукт, а не инфраструктурную рутину.",
|
|
6
5
|
"ui": "UI-система"
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { appIdentity } from '@app/config/identity';
|
|
2
2
|
import type { Metadata } from 'next';
|
|
3
3
|
import { getTranslations } from 'next-intl/server';
|
|
4
4
|
import { LocaleSchema } from '@/i18n/locales';
|
|
5
|
-
import { createServerRepositoryApi } from '@/lib/api/client';
|
|
6
|
-
import { useRepository } from '@/lib/api/queries';
|
|
7
|
-
import { getQueryClient } from '@/lib/query-client';
|
|
8
5
|
import { createPageMetadata } from '@/lib/seo/metadata';
|
|
9
6
|
import { StarterPage } from './starter-page';
|
|
10
7
|
|
|
@@ -22,23 +19,15 @@ export async function generateMetadata({
|
|
|
22
19
|
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
|
|
23
20
|
const { locale } = await params;
|
|
24
21
|
const appLocale = LocaleSchema.parse(locale);
|
|
25
|
-
const queryClient = getQueryClient();
|
|
26
|
-
const api = createServerRepositoryApi();
|
|
27
22
|
const t = await getTranslations('App');
|
|
28
|
-
await queryClient.prefetchQuery({
|
|
29
|
-
queryKey: useRepository.getKey(),
|
|
30
|
-
queryFn: () => api.read(),
|
|
31
|
-
});
|
|
32
23
|
|
|
33
24
|
return (
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
/>
|
|
42
|
-
</HydrationBoundary>
|
|
25
|
+
<StarterPage
|
|
26
|
+
applicationName={appIdentity.name}
|
|
27
|
+
applicationDescription={appIdentity.description[appLocale]}
|
|
28
|
+
heroTitle={t('heroTitle')}
|
|
29
|
+
catalogueLabel={t('ui')}
|
|
30
|
+
locale={appLocale}
|
|
31
|
+
/>
|
|
43
32
|
);
|
|
44
33
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { IconArrowUpRight } from '@tabler/icons-react';
|
|
2
2
|
import Image from 'next/image';
|
|
3
3
|
import { BrandMark } from '@/components/brand-mark';
|
|
4
|
-
import { RepositorySummary } from '@/components/repository-summary';
|
|
5
4
|
import { LanguageSwitcher, ThemeToggle } from '@/components/system-controls';
|
|
6
5
|
import { buttonVariants } from '@/components/ui';
|
|
7
6
|
import type { AppLocale } from '@/i18n/locales';
|
|
@@ -140,9 +139,9 @@ export function StarterPage({
|
|
|
140
139
|
</div>
|
|
141
140
|
</div>
|
|
142
141
|
|
|
143
|
-
<
|
|
144
|
-
|
|
145
|
-
</
|
|
142
|
+
<p className='mt-5 text-sm text-muted-foreground'>
|
|
143
|
+
Add your first vertical feature from schema to transport and UI.
|
|
144
|
+
</p>
|
|
146
145
|
</section>
|
|
147
146
|
</div>
|
|
148
147
|
</div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { appIdentity } from '@app/config/identity';
|
|
3
4
|
import { IconArrowRight, IconBraces, IconDatabase, IconWorld } from '@tabler/icons-react';
|
|
4
5
|
import type { ReactNode } from 'react';
|
|
5
6
|
import { BrandMark } from '@/components/brand-mark';
|
|
@@ -36,7 +37,7 @@ export function LandingHeader({ actions }: { actions?: ReactNode }) {
|
|
|
36
37
|
<header className='flex items-center justify-between border-b border-border py-4'>
|
|
37
38
|
<div className='flex items-center gap-2 font-medium'>
|
|
38
39
|
<BrandMark priority />
|
|
39
|
-
|
|
40
|
+
{appIdentity.name}
|
|
40
41
|
</div>
|
|
41
42
|
<div className='flex items-center gap-2'>{actions}</div>
|
|
42
43
|
</header>
|
|
@@ -152,7 +153,7 @@ export function PricingSection() {
|
|
|
152
153
|
export function LandingFooter() {
|
|
153
154
|
return (
|
|
154
155
|
<footer className='flex flex-col gap-3 border-t border-border py-8 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between'>
|
|
155
|
-
<span>
|
|
156
|
+
<span>{appIdentity.name} · Built with Stitchkit</span>
|
|
156
157
|
<span>Contracts · UI · Realtime · Tools</span>
|
|
157
158
|
</footer>
|
|
158
159
|
);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { getQueryClient } from './query-client';
|
|
3
|
+
|
|
4
|
+
describe('query dehydration policy', () => {
|
|
5
|
+
test('includes successful prefetched data and pending streamed queries', () => {
|
|
6
|
+
const queryClient = getQueryClient();
|
|
7
|
+
queryClient.setQueryData(['prefetched-project'], { id: 'project-1' });
|
|
8
|
+
void queryClient.prefetchQuery({
|
|
9
|
+
queryKey: ['pending-project'],
|
|
10
|
+
queryFn: () => new Promise(() => undefined),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const shouldDehydrate = queryClient.getDefaultOptions().dehydrate?.shouldDehydrateQuery;
|
|
14
|
+
if (!shouldDehydrate) throw new Error('dehydration policy is required');
|
|
15
|
+
const successful = queryClient.getQueryCache().find({ queryKey: ['prefetched-project'] });
|
|
16
|
+
const pending = queryClient.getQueryCache().find({ queryKey: ['pending-project'] });
|
|
17
|
+
if (!successful || !pending) throw new Error('test queries were not created');
|
|
18
|
+
|
|
19
|
+
expect(shouldDehydrate(successful)).toBe(true);
|
|
20
|
+
expect(shouldDehydrate(pending)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -1,11 +1,19 @@
|
|
|
1
|
-
import { isServer, QueryClient } from '@tanstack/react-query';
|
|
1
|
+
import { defaultShouldDehydrateQuery, isServer, QueryClient } from '@tanstack/react-query';
|
|
2
2
|
import { cache } from 'react';
|
|
3
3
|
|
|
4
4
|
function createQueryClient(): QueryClient {
|
|
5
5
|
return new QueryClient({
|
|
6
6
|
defaultOptions: {
|
|
7
7
|
queries: { staleTime: 30_000, retry: 1 },
|
|
8
|
-
dehydrate: {
|
|
8
|
+
dehydrate: {
|
|
9
|
+
// `pending` queries dehydrate too: a server component may kick off a
|
|
10
|
+
// prefetch without awaiting it, and streaming SSR hands the in-flight
|
|
11
|
+
// promise to the client, which resumes it instead of refetching. The
|
|
12
|
+
// default predicate would drop exactly those queries and reintroduce
|
|
13
|
+
// the client-side loading flash.
|
|
14
|
+
shouldDehydrateQuery: (query) =>
|
|
15
|
+
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
|
|
16
|
+
},
|
|
9
17
|
},
|
|
10
18
|
});
|
|
11
19
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { appIdentity } from '@app/config/identity';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import type { AppLocale } from '@/i18n/locales';
|
|
3
4
|
|
|
4
|
-
export const SITE_NAME =
|
|
5
|
+
export const SITE_NAME = appIdentity.name;
|
|
5
6
|
export const StoryIdSchema = z.enum(['components', 'themes', 'blocks']);
|
|
6
7
|
export type StoryId = z.infer<typeof StoryIdSchema>;
|
|
7
8
|
|
|
@@ -42,14 +43,12 @@ const seoPages: Record<SeoPageId, SeoPageDefinition> = {
|
|
|
42
43
|
copy: {
|
|
43
44
|
en: {
|
|
44
45
|
title: 'UI components',
|
|
45
|
-
description:
|
|
46
|
-
'Explore accessible actions, forms, feedback, overlays, data displays and motion components included in the Stitchkit starter.',
|
|
46
|
+
description: `Explore accessible actions, forms, feedback, overlays, data displays and motion components included in ${SITE_NAME}.`,
|
|
47
47
|
eyebrow: 'UI system',
|
|
48
48
|
},
|
|
49
49
|
ru: {
|
|
50
50
|
title: 'UI-компоненты',
|
|
51
|
-
description:
|
|
52
|
-
'Изучите доступные действия, формы, обратную связь, оверлеи, представление данных и анимации в Stitchkit Starter.',
|
|
51
|
+
description: `Изучите доступные действия, формы, обратную связь, оверлеи, представление данных и анимации в ${SITE_NAME}.`,
|
|
53
52
|
eyebrow: 'UI-система',
|
|
54
53
|
},
|
|
55
54
|
},
|
|
@@ -4,14 +4,11 @@ import { TooltipProvider } from '@radix-ui/react-tooltip';
|
|
|
4
4
|
import type { ReactNode } from 'react';
|
|
5
5
|
import { Toaster } from '@/components/ui/toaster';
|
|
6
6
|
import { ReactQueryProvider } from './react-query';
|
|
7
|
-
import { RealtimeProvider } from './realtime';
|
|
8
7
|
|
|
9
8
|
export function Providers({ children }: { children: ReactNode }) {
|
|
10
9
|
return (
|
|
11
10
|
<ReactQueryProvider>
|
|
12
|
-
<
|
|
13
|
-
<TooltipProvider delayDuration={250}>{children}</TooltipProvider>
|
|
14
|
-
</RealtimeProvider>
|
|
11
|
+
<TooltipProvider delayDuration={250}>{children}</TooltipProvider>
|
|
15
12
|
<Toaster />
|
|
16
13
|
</ReactQueryProvider>
|
|
17
14
|
);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { appIdentity } from '@app/config/identity';
|
|
1
2
|
import type { ThemeProviderProps } from '@wrksz/themes/next';
|
|
2
3
|
|
|
3
4
|
export type AppTheme = 'light' | 'dark';
|
|
4
5
|
|
|
5
6
|
export const appThemes: readonly [AppTheme, AppTheme] = ['light', 'dark'];
|
|
6
|
-
export const themeStorageKey =
|
|
7
|
+
export const themeStorageKey = `${appIdentity.slug}-theme`;
|
|
7
8
|
|
|
8
9
|
export const themeProviderConfig = {
|
|
9
10
|
themes: appThemes,
|
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
"plugins": [{ "name": "next" }],
|
|
9
9
|
"paths": { "@/*": ["./src/*"] }
|
|
10
10
|
},
|
|
11
|
-
"include": [
|
|
11
|
+
"include": [
|
|
12
|
+
"next-env.d.ts",
|
|
13
|
+
"next.config.ts",
|
|
14
|
+
"src/**/*.ts",
|
|
15
|
+
"src/**/*.tsx",
|
|
16
|
+
".next/types/**/*.ts"
|
|
17
|
+
],
|
|
12
18
|
"exclude": ["node_modules"]
|
|
13
19
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createContractFactory } from 'stitchkit';
|
|
2
|
+
import { SystemStatusSchema } from '../schemas/system';
|
|
3
|
+
|
|
4
|
+
const { defineContract } = createContractFactory<'public'>({
|
|
5
|
+
toolExposure: 'explicit',
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const systemContract = defineContract(
|
|
9
|
+
{ prefix: 'system', scope: 'public' },
|
|
10
|
+
{
|
|
11
|
+
status: {
|
|
12
|
+
method: 'GET',
|
|
13
|
+
path: '/status',
|
|
14
|
+
desc: 'Read application readiness',
|
|
15
|
+
output: SystemStatusSchema,
|
|
16
|
+
expose: ['HTTP'],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
);
|
|
@@ -1,3 +1,2 @@
|
|
|
1
|
-
export * from './contracts/
|
|
2
|
-
export * from './
|
|
3
|
-
export * from './schemas/repository';
|
|
1
|
+
export * from './contracts/system';
|
|
2
|
+
export * from './schemas/system';
|