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.
- package/CHANGELOG.md +23 -0
- package/README.md +11 -4
- package/dist/cli.js +99 -12
- package/examples/repository/_env.append +3 -0
- package/examples/repository/_env.example.append +4 -0
- package/examples/repository/e2e/repository.spec.ts +19 -0
- package/{template → examples/repository}/packages/backend/src/domain/repository/github-cache.ts +2 -1
- package/examples/repository/packages/backend/src/surface.ts +14 -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/examples/repository/packages/frontend/src/providers/index.tsx +18 -0
- package/examples/repository/packages/shared/src/index.ts +3 -0
- package/examples/repository/scripts/runtime-smoke.ts +91 -0
- package/package.json +10 -1
- package/template/AGENTS.md +50 -0
- package/template/README.md +18 -5
- package/template/_env +1 -3
- package/template/_env.example +1 -4
- package/template/app.config.json +9 -0
- package/template/bun.lock +4 -2
- package/template/docs/ADDING_A_FEATURE.md +101 -0
- package/template/docs/LAN_HTTPS.md +28 -0
- package/template/e2e/starter.spec.ts +17 -22
- package/template/ecosystem.config.cjs +3 -2
- package/template/ecosystem.dev.config.cjs +19 -4
- package/template/package.json +3 -1
- package/template/packages/backend/src/cli.ts +2 -1
- package/template/packages/backend/src/index.ts +19 -4
- package/template/packages/backend/src/surface-manifest.test.ts +62 -0
- package/template/packages/backend/src/surface-manifest.ts +117 -0
- package/template/packages/backend/src/surface.ts +3 -9
- package/template/packages/backend/src/transport/lan-onboarding.ts +29 -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 +13 -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 -0
- 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/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/shared/src/index.ts +1 -3
- package/template/scripts/dev-lan.test.ts +58 -0
- package/template/scripts/dev-lan.ts +176 -0
- package/template/scripts/dev.ts +37 -7
- package/template/scripts/runtime-smoke.ts +13 -59
- package/template/scripts/surface-conformance.ts +105 -0
- /package/{template → examples/repository}/packages/backend/src/domain/errors.ts +0 -0
- /package/{template → examples/repository}/packages/backend/src/domain/repository/github-cache.test.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/components/repository-summary.tsx +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/lib/realtime/repository.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/events/repository.ts +0 -0
- /package/{template → examples/repository}/packages/shared/src/schemas/repository.test.ts +0 -0
- /package/{template → examples/repository}/packages/shared/src/schemas/repository.ts +0 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { HttpMethod } from 'stitchkit/contract';
|
|
2
|
+
import type { OpenApiDocument, ServiceDef } from 'stitchkit/server';
|
|
3
|
+
import { listToolNames } from 'stitchkit/tools';
|
|
4
|
+
|
|
5
|
+
export interface SurfaceManifestOperation {
|
|
6
|
+
service: string;
|
|
7
|
+
action: string;
|
|
8
|
+
http: Array<{ method: HttpMethod; path: string }>;
|
|
9
|
+
tools: Partial<Record<'MCP' | 'AGENT' | 'CLI', string>>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function joinPath(...parts: Array<string | undefined>): string {
|
|
13
|
+
const joined = parts
|
|
14
|
+
.filter((part): part is string => Boolean(part))
|
|
15
|
+
.map((part) => part.replace(/^\/+|\/+$/g, ''))
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.join('/');
|
|
18
|
+
return `/${joined}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function operationKey(service: string, action: string): string {
|
|
22
|
+
return `${service}\u0000${action}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sorted(values: Iterable<string>): string[] {
|
|
26
|
+
return [...values].sort((left, right) => left.localeCompare(right));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function assertSameSet(
|
|
30
|
+
label: string,
|
|
31
|
+
expectedValues: Iterable<string>,
|
|
32
|
+
actualValues: Iterable<string>,
|
|
33
|
+
) {
|
|
34
|
+
const expected = sorted(expectedValues);
|
|
35
|
+
const actual = sorted(actualValues);
|
|
36
|
+
if (
|
|
37
|
+
expected.length === actual.length &&
|
|
38
|
+
expected.every((value, index) => value === actual[index])
|
|
39
|
+
) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
throw new Error(
|
|
43
|
+
`${label} surface mismatch\nexpected: ${expected.join(', ') || '(empty)'}\nactual: ${actual.join(', ') || '(empty)'}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildSurfaceManifest(
|
|
48
|
+
services: readonly ServiceDef[],
|
|
49
|
+
pathPrefix = '/api',
|
|
50
|
+
): SurfaceManifestOperation[] {
|
|
51
|
+
const operations = new Map<string, SurfaceManifestOperation>();
|
|
52
|
+
for (const service of services) {
|
|
53
|
+
for (const method of Object.values(service.methods)) {
|
|
54
|
+
const key = operationKey(service.name, method.key);
|
|
55
|
+
if (operations.has(key)) {
|
|
56
|
+
throw new Error(`Duplicate operation identity ${service.name}.${method.key}`);
|
|
57
|
+
}
|
|
58
|
+
operations.set(key, {
|
|
59
|
+
service: service.name,
|
|
60
|
+
action: method.key,
|
|
61
|
+
http:
|
|
62
|
+
!method.expose || method.expose.includes('HTTP')
|
|
63
|
+
? [
|
|
64
|
+
{
|
|
65
|
+
method: method.method,
|
|
66
|
+
path: joinPath(pathPrefix, service.prefix, method.path).replace(
|
|
67
|
+
/:([^/]+)/g,
|
|
68
|
+
'{$1}',
|
|
69
|
+
),
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
: [],
|
|
73
|
+
tools: {},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const tool of listToolNames({ services })) {
|
|
78
|
+
const operation = operations.get(operationKey(tool.service, tool.method));
|
|
79
|
+
if (!operation) {
|
|
80
|
+
throw new Error(`Tool ${tool.name} has no contract identity`);
|
|
81
|
+
}
|
|
82
|
+
for (const transport of tool.transports) {
|
|
83
|
+
if (transport !== 'HTTP') operation.tools[transport] = tool.name;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [...operations.values()].sort(
|
|
87
|
+
(left, right) =>
|
|
88
|
+
left.service.localeCompare(right.service) || left.action.localeCompare(right.action),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function assertSurfaceConformance({
|
|
93
|
+
manifest,
|
|
94
|
+
openApi,
|
|
95
|
+
mcpToolNames,
|
|
96
|
+
}: {
|
|
97
|
+
manifest: readonly SurfaceManifestOperation[];
|
|
98
|
+
openApi: Pick<OpenApiDocument, 'paths'>;
|
|
99
|
+
mcpToolNames: readonly string[];
|
|
100
|
+
}): void {
|
|
101
|
+
assertSameSet(
|
|
102
|
+
'HTTP/OpenAPI',
|
|
103
|
+
manifest.flatMap((operation) =>
|
|
104
|
+
operation.http.map((entry) => `${entry.method}:${entry.path}`),
|
|
105
|
+
),
|
|
106
|
+
Object.entries(openApi.paths).flatMap(([path, item]) =>
|
|
107
|
+
Object.keys(item)
|
|
108
|
+
.filter((method) => ['get', 'post', 'put', 'patch', 'delete', 'head'].includes(method))
|
|
109
|
+
.map((method) => `${method.toUpperCase()}:${path}`),
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
assertSameSet(
|
|
113
|
+
'MCP discovery',
|
|
114
|
+
manifest.flatMap((operation) => (operation.tools.MCP ? [operation.tools.MCP] : [])),
|
|
115
|
+
mcpToolNames,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -1,13 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { env } from '@app/config';
|
|
2
2
|
import { createSocketIOServer } from 'stitchkit/server';
|
|
3
|
-
import { createRepositoryService } from './transport/repository-service';
|
|
4
3
|
|
|
5
4
|
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] };
|
|
5
|
+
const socket = await createSocketIOServer({ cors: { origin: env.CORS_ORIGIN } });
|
|
6
|
+
return { socket, services: [] };
|
|
13
7
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { env } from '@app/config';
|
|
2
|
+
import type { RawRoute } from 'stitchkit/server';
|
|
3
|
+
|
|
4
|
+
export function createLanOnboardingRoutes(): RawRoute[] {
|
|
5
|
+
if (env.NODE_ENV !== 'development' || !env.DEV_HTTPS_CA) return [];
|
|
6
|
+
const caPath = env.DEV_HTTPS_CA;
|
|
7
|
+
return [
|
|
8
|
+
{
|
|
9
|
+
method: 'GET',
|
|
10
|
+
path: '/__dev/lan-ca',
|
|
11
|
+
handler: () =>
|
|
12
|
+
new Response(Bun.file(caPath), {
|
|
13
|
+
headers: {
|
|
14
|
+
'Content-Disposition': 'attachment; filename="stitchkit-lan-root-ca.pem"',
|
|
15
|
+
'Content-Type': 'application/x-pem-file',
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
method: 'GET',
|
|
21
|
+
path: '/__dev/lan',
|
|
22
|
+
handler: () =>
|
|
23
|
+
new Response(
|
|
24
|
+
`<!doctype html><meta name="viewport" content="width=device-width"><title>LAN HTTPS setup</title><main><h1>Trust this development CA</h1><p><a href="/__dev/lan-ca">Download the public root certificate</a>.</p><h2>iOS</h2><p>Install the downloaded profile, then enable full trust in Settings → General → About → Certificate Trust Settings.</p><h2>Android</h2><p>Install it as a CA certificate. Native development builds must explicitly allow user-installed roots.</p><p>No private key is exposed by this server.</p></main>`,
|
|
25
|
+
{ headers: { 'Content-Type': 'text/html; charset=utf-8' } },
|
|
26
|
+
),
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
}
|
|
@@ -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,19 @@ 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
|
-
|
|
19
|
+
CORS_ORIGIN: z.string().min(1).default('*'),
|
|
20
|
+
DEV_HTTPS_CERT: z.string().min(1).optional(),
|
|
21
|
+
DEV_HTTPS_KEY: z.string().min(1).optional(),
|
|
22
|
+
DEV_HTTPS_CA: z.string().min(1).optional(),
|
|
23
|
+
...featureServerSchema,
|
|
21
24
|
},
|
|
22
25
|
runtimeEnv: process.env,
|
|
23
26
|
emptyStringAsUndefined: true,
|
|
24
27
|
});
|
|
28
|
+
|
|
29
|
+
/** Preserve the host process environment while applying explicit child-process overrides. */
|
|
30
|
+
export function childEnvironment(
|
|
31
|
+
overrides: Record<string, string>,
|
|
32
|
+
): Record<string, string | undefined> {
|
|
33
|
+
return { ...process.env, ...overrides };
|
|
34
|
+
}
|
|
@@ -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
|
);
|
|
@@ -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,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { developmentEnvironment } from './dev';
|
|
3
|
+
import { privateLanAddresses, selectLanAddress } from './dev-lan';
|
|
4
|
+
|
|
5
|
+
describe('LAN HTTPS address selection', () => {
|
|
6
|
+
test('normal development clears every LAN-only process variable', () => {
|
|
7
|
+
expect(developmentEnvironment()).toMatchObject({
|
|
8
|
+
DEV_HTTPS_CA: '',
|
|
9
|
+
DEV_HTTPS_CERT: '',
|
|
10
|
+
DEV_HTTPS_KEY: '',
|
|
11
|
+
NODE_EXTRA_CA_CERTS: '',
|
|
12
|
+
});
|
|
13
|
+
expect(developmentEnvironment({ DEV_HTTPS_CERT: '/cert.pem' }).DEV_HTTPS_CERT).toBe(
|
|
14
|
+
'/cert.pem',
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('keeps private IPv4 interfaces and ignores public/internal addresses', () => {
|
|
19
|
+
expect(
|
|
20
|
+
privateLanAddresses({
|
|
21
|
+
eth0: [
|
|
22
|
+
{
|
|
23
|
+
address: '192.168.1.20',
|
|
24
|
+
netmask: '',
|
|
25
|
+
family: 'IPv4',
|
|
26
|
+
mac: '',
|
|
27
|
+
internal: false,
|
|
28
|
+
cidr: null,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
address: '8.8.8.8',
|
|
32
|
+
netmask: '',
|
|
33
|
+
family: 'IPv4',
|
|
34
|
+
mac: '',
|
|
35
|
+
internal: false,
|
|
36
|
+
cidr: null,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
lo: [
|
|
40
|
+
{
|
|
41
|
+
address: '127.0.0.1',
|
|
42
|
+
netmask: '',
|
|
43
|
+
family: 'IPv4',
|
|
44
|
+
mac: '',
|
|
45
|
+
internal: true,
|
|
46
|
+
cidr: null,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
}),
|
|
50
|
+
).toEqual(['192.168.1.20']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('auto-selects one address and fails on ambiguity', () => {
|
|
54
|
+
expect(selectLanAddress(['10.0.0.4'])).toBe('10.0.0.4');
|
|
55
|
+
expect(() => selectLanAddress(['10.0.0.4', '192.168.1.20'])).toThrow('ambiguous');
|
|
56
|
+
expect(() => selectLanAddress(['10.0.0.4'], '10.0.0.5')).toThrow('unavailable');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir, networkInterfaces } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { appIdentity } from '../packages/config/src/identity';
|
|
6
|
+
import { childEnvironment, env } from '../packages/config/src/server';
|
|
7
|
+
import { runDevelopment } from './dev';
|
|
8
|
+
|
|
9
|
+
export function privateLanAddresses(
|
|
10
|
+
interfaces: ReturnType<typeof networkInterfaces>,
|
|
11
|
+
): string[] {
|
|
12
|
+
const addresses = new Set<string>();
|
|
13
|
+
for (const entries of Object.values(interfaces)) {
|
|
14
|
+
for (const entry of entries ?? []) {
|
|
15
|
+
if (entry.family !== 'IPv4' || entry.internal) continue;
|
|
16
|
+
const [first, second] = entry.address.split('.').map(Number);
|
|
17
|
+
if (
|
|
18
|
+
first === 10 ||
|
|
19
|
+
(first === 172 && second !== undefined && second >= 16 && second <= 31) ||
|
|
20
|
+
(first === 192 && second === 168)
|
|
21
|
+
) {
|
|
22
|
+
addresses.add(entry.address);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return [...addresses].sort();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function selectLanAddress(addresses: readonly string[], requested?: string): string {
|
|
30
|
+
if (requested) {
|
|
31
|
+
if (!addresses.includes(requested)) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Requested LAN address ${requested} is unavailable. Detected: ${addresses.join(', ') || '(none)'}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return requested;
|
|
37
|
+
}
|
|
38
|
+
if (addresses.length !== 1) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`LAN address is ${addresses.length === 0 ? 'unavailable' : 'ambiguous'}. Use --host <address>. Detected: ${addresses.join(', ') || '(none)'}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return addresses[0];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function run(command: string[], environment?: Record<string, string>): Promise<string> {
|
|
47
|
+
const child = Bun.spawn(command, {
|
|
48
|
+
env: environment ? childEnvironment(environment) : undefined,
|
|
49
|
+
stdout: 'pipe',
|
|
50
|
+
stderr: 'inherit',
|
|
51
|
+
});
|
|
52
|
+
const output = await new Response(child.stdout).text();
|
|
53
|
+
const exitCode = await child.exited;
|
|
54
|
+
if (exitCode !== 0) throw new Error(`${command.join(' ')} failed with exit code ${exitCode}`);
|
|
55
|
+
return output.trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function requestedHost(args: readonly string[]): string | undefined {
|
|
59
|
+
const index = args.indexOf('--host');
|
|
60
|
+
if (index === -1) return undefined;
|
|
61
|
+
const value = args[index + 1];
|
|
62
|
+
if (!value) throw new Error('--host requires an IPv4 address');
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function ensureCertificates(host: string) {
|
|
67
|
+
if (!Bun.which('mkcert')) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
'dev:lan requires mkcert. Install it with `brew install mkcert` (macOS/Linuxbrew) or the official prebuilt binary, then rerun.',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const caRoot = await run(['mkcert', '-CAROOT']);
|
|
73
|
+
const ca = join(caRoot, 'rootCA.pem');
|
|
74
|
+
if (!(await Bun.file(ca).exists())) await run(['mkcert', '-install']);
|
|
75
|
+
|
|
76
|
+
const directory = join(homedir(), '.local', 'share', appIdentity.slug, 'lan-https');
|
|
77
|
+
const cert = join(directory, 'leaf.pem');
|
|
78
|
+
const key = join(directory, 'leaf-key.pem');
|
|
79
|
+
const marker = join(directory, 'host');
|
|
80
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
81
|
+
const previous = await readFile(marker, 'utf8').catch(() => '');
|
|
82
|
+
if (
|
|
83
|
+
previous.trim() !== host ||
|
|
84
|
+
!(await Bun.file(cert).exists()) ||
|
|
85
|
+
!(await Bun.file(key).exists())
|
|
86
|
+
) {
|
|
87
|
+
await run([
|
|
88
|
+
'mkcert',
|
|
89
|
+
'-cert-file',
|
|
90
|
+
cert,
|
|
91
|
+
'-key-file',
|
|
92
|
+
key,
|
|
93
|
+
host,
|
|
94
|
+
'localhost',
|
|
95
|
+
'127.0.0.1',
|
|
96
|
+
'::1',
|
|
97
|
+
]);
|
|
98
|
+
await writeFile(marker, `${host}\n`, { mode: 0o600 });
|
|
99
|
+
}
|
|
100
|
+
return { ca, cert, key };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function isPortOccupied(port: number): Promise<boolean> {
|
|
104
|
+
return new Promise((resolve) => {
|
|
105
|
+
let settled = false;
|
|
106
|
+
const finish = (occupied: boolean, socket?: { end(): void }) => {
|
|
107
|
+
if (settled) return;
|
|
108
|
+
settled = true;
|
|
109
|
+
resolve(occupied);
|
|
110
|
+
socket?.end();
|
|
111
|
+
};
|
|
112
|
+
void Bun.connect({
|
|
113
|
+
hostname: '127.0.0.1',
|
|
114
|
+
port,
|
|
115
|
+
socket: {
|
|
116
|
+
open: (socket) => finish(true, socket),
|
|
117
|
+
data: () => undefined,
|
|
118
|
+
close: () => finish(false),
|
|
119
|
+
error: () => finish(false),
|
|
120
|
+
},
|
|
121
|
+
}).catch(() => finish(false));
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function assertDevelopmentPortsAvailable(): Promise<void> {
|
|
126
|
+
const processList = await run(['pm2', 'jlist']);
|
|
127
|
+
const onlineNames = new Set(
|
|
128
|
+
z
|
|
129
|
+
.array(
|
|
130
|
+
z.object({
|
|
131
|
+
name: z.string(),
|
|
132
|
+
pm2_env: z.object({ status: z.string() }),
|
|
133
|
+
}),
|
|
134
|
+
)
|
|
135
|
+
.parse(JSON.parse(processList))
|
|
136
|
+
.filter((process) => process.pm2_env.status === 'online')
|
|
137
|
+
.map((process) => process.name),
|
|
138
|
+
);
|
|
139
|
+
const expected = new Map([
|
|
140
|
+
[env.WEB_PORT, `${appIdentity.slug}-frontend-dev`],
|
|
141
|
+
[env.API_PORT, `${appIdentity.slug}-backend-dev`],
|
|
142
|
+
]);
|
|
143
|
+
for (const [port, owner] of expected) {
|
|
144
|
+
if ((await isPortOccupied(port)) && !onlineNames.has(owner)) {
|
|
145
|
+
throw new Error(`Port ${port} is occupied by a process outside ${owner}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (import.meta.main) {
|
|
151
|
+
if (env.NODE_ENV !== 'development') {
|
|
152
|
+
throw new Error('dev:lan is development-only');
|
|
153
|
+
}
|
|
154
|
+
const host = selectLanAddress(
|
|
155
|
+
privateLanAddresses(networkInterfaces()),
|
|
156
|
+
requestedHost(Bun.argv),
|
|
157
|
+
);
|
|
158
|
+
await assertDevelopmentPortsAvailable();
|
|
159
|
+
const certificates = await ensureCertificates(host);
|
|
160
|
+
const apiOrigin = `https://${host}:${env.API_PORT}`;
|
|
161
|
+
const webOrigin = `https://${host}:${env.WEB_PORT}`;
|
|
162
|
+
await runDevelopment({
|
|
163
|
+
CORS_ORIGIN: webOrigin,
|
|
164
|
+
DEV_HTTPS_CA: certificates.ca,
|
|
165
|
+
DEV_HTTPS_CERT: certificates.cert,
|
|
166
|
+
DEV_HTTPS_KEY: certificates.key,
|
|
167
|
+
INTERNAL_API_URL: apiOrigin,
|
|
168
|
+
NEXT_PUBLIC_API_URL: apiOrigin,
|
|
169
|
+
NEXT_PUBLIC_WEB_URL: webOrigin,
|
|
170
|
+
NODE_EXTRA_CA_CERTS: certificates.ca,
|
|
171
|
+
});
|
|
172
|
+
console.log(`${appIdentity.name} trusted LAN development is running`);
|
|
173
|
+
console.log(`Web: ${webOrigin}/en`);
|
|
174
|
+
console.log(`API: ${apiOrigin}`);
|
|
175
|
+
console.log(`Device CA setup: ${apiOrigin}/__dev/lan`);
|
|
176
|
+
}
|