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,102 @@
|
|
|
1
|
+
# Adding a vertical feature
|
|
2
|
+
|
|
3
|
+
This guide uses a small `status` resource to show the canonical path. The blank
|
|
4
|
+
scaffold starts with only its HTTP readiness endpoint and no application or tool
|
|
5
|
+
surface. In `--example repository` mode, keep the repository slice and add the
|
|
6
|
+
same files beside it.
|
|
7
|
+
|
|
8
|
+
## 1. Define the wire data
|
|
9
|
+
|
|
10
|
+
Create `packages/shared/src/schemas/status.ts`:
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { z } from 'zod'
|
|
14
|
+
|
|
15
|
+
export const StatusSchema = z.object({ message: z.string().min(1) })
|
|
16
|
+
export const UpdateStatusInputSchema = StatusSchema
|
|
17
|
+
export type Status = z.infer<typeof StatusSchema>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Export it from the shared package. Do not introduce a second handwritten DTO.
|
|
21
|
+
|
|
22
|
+
## 2. Define the HTTP/tool contract separately
|
|
23
|
+
|
|
24
|
+
Create `packages/shared/src/contracts/status.ts` and import the named schemas:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { defineContract } from 'stitchkit'
|
|
28
|
+
import { StatusSchema, UpdateStatusInputSchema } from '../schemas/status'
|
|
29
|
+
|
|
30
|
+
export const statusContract = defineContract(
|
|
31
|
+
{ prefix: 'status', scope: 'public' },
|
|
32
|
+
{
|
|
33
|
+
read: { method: 'GET', path: '/', desc: 'Read status', output: StatusSchema },
|
|
34
|
+
update: {
|
|
35
|
+
method: 'PUT', path: '/', desc: 'Update status',
|
|
36
|
+
input: UpdateStatusInputSchema, output: StatusSchema,
|
|
37
|
+
expose: ['HTTP', 'MCP', 'AGENT', 'CLI'],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The contract owns transport identity. Do not add a raw route or duplicate path.
|
|
44
|
+
|
|
45
|
+
## 3. Implement and register the service
|
|
46
|
+
|
|
47
|
+
Create `packages/backend/src/transport/status-service.ts` with `implement()`.
|
|
48
|
+
Keep persistence and business rules in a domain/service module; the contract
|
|
49
|
+
handler calls that module once. Add the returned service to the `services` array
|
|
50
|
+
in `packages/backend/src/surface.ts`. That one registration drives HTTP,
|
|
51
|
+
OpenAPI, MCP, agent tools and CLI discovery.
|
|
52
|
+
|
|
53
|
+
## 4. Add typed browser access
|
|
54
|
+
|
|
55
|
+
Export `statusContract` from `packages/shared/src/index.ts`. In
|
|
56
|
+
`packages/frontend/src/lib/api/client.ts`, create `statusApi` with the same
|
|
57
|
+
`createClient(statusContract, http)` pattern used by the application's other
|
|
58
|
+
contracts. Create the query key and react-query-kit query/mutation hooks in
|
|
59
|
+
`packages/frontend/src/lib/api/status.ts`. On mutation success, update or
|
|
60
|
+
invalidate that canonical key.
|
|
61
|
+
|
|
62
|
+
Render the hook from a feature component. Pages compose features; they do not
|
|
63
|
+
call `fetch`, construct `/api/status` or decode error bodies themselves.
|
|
64
|
+
|
|
65
|
+
## 5. Add realtime only when another client must observe the change
|
|
66
|
+
|
|
67
|
+
Declare the event in the shared realtime source and use a named Zod schema for
|
|
68
|
+
its tuple. The server emits after the domain change succeeds; the frontend cache
|
|
69
|
+
bridge reacts by updating or invalidating the status query. Keep handshake auth,
|
|
70
|
+
authorization and room membership in the application. Socket.IO delivery,
|
|
71
|
+
reconnection, retained subscriptions and validation belong to Stitchkit.
|
|
72
|
+
|
|
73
|
+
The starter advances its single `catalog.stitchkit` target only after the
|
|
74
|
+
required framework release exists. Do not copy a framework adapter into the
|
|
75
|
+
application or maintain a parallel event-map API.
|
|
76
|
+
|
|
77
|
+
## 6. Prove the surface and behavior
|
|
78
|
+
|
|
79
|
+
Generic smoke already derives expected HTTP operations and MCP tool names from
|
|
80
|
+
the registered services and compares them with live OpenAPI/MCP discovery. Add
|
|
81
|
+
an explicit probe only for handler behavior:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
defineSurfaceProbe({
|
|
85
|
+
name: 'status update lifecycle',
|
|
86
|
+
input: UpdateStatusInputSchema,
|
|
87
|
+
fixture: { message: 'Ready' },
|
|
88
|
+
output: StatusSchema,
|
|
89
|
+
run: (input) => statusClient.update(input),
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Add domain tests beside domain code, contract/schema tests in shared, and UI E2E
|
|
94
|
+
only for user-visible behavior. Finish with the commands in root `AGENTS.md`.
|
|
95
|
+
|
|
96
|
+
## Ownership boundary
|
|
97
|
+
|
|
98
|
+
Application-owned: domain policy, persistence, auth decisions, rooms, cache
|
|
99
|
+
semantics and presentation. Framework-owned: contract routing, validation,
|
|
100
|
+
normalized errors, transport lifecycle, tool discovery and Socket.IO wrappers.
|
|
101
|
+
When the framework-owned layer is missing a generic capability, fix Stitchkit
|
|
102
|
+
and upgrade this application's one catalog target after that release exists.
|
|
@@ -1,23 +1,40 @@
|
|
|
1
|
+
import { appIdentity } from '@app/config/identity';
|
|
2
|
+
import { systemContract } from '@app/shared';
|
|
1
3
|
import AxeBuilder from '@axe-core/playwright';
|
|
2
4
|
import { expect, test } from '@playwright/test';
|
|
5
|
+
import { createClient, createHttpClient } from 'stitchkit';
|
|
6
|
+
import { loadToolingEnv } from '../scripts/tooling-env';
|
|
7
|
+
|
|
8
|
+
const toolingEnv = loadToolingEnv();
|
|
3
9
|
|
|
4
10
|
test('renders the hydrated starter application and catalogue', async ({ page }) => {
|
|
5
11
|
await page.goto('/en');
|
|
6
12
|
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
|
7
13
|
'Build the product, not the plumbing',
|
|
8
14
|
);
|
|
9
|
-
await expect(page.getByText('max-listov/stitchkit')).toBeVisible();
|
|
10
15
|
await page.getByRole('link', { name: /UI system/ }).click();
|
|
11
16
|
await expect(page).toHaveURL(/\/en\/ui\/components$/);
|
|
12
17
|
await expect(page.getByRole('heading', { level: 1 })).toContainText('UI components');
|
|
13
18
|
});
|
|
14
19
|
|
|
20
|
+
test('calls the live backend through the typed contract client', async () => {
|
|
21
|
+
const client = createClient(
|
|
22
|
+
systemContract,
|
|
23
|
+
createHttpClient({
|
|
24
|
+
baseUrl: `${toolingEnv.NEXT_PUBLIC_API_URL}/api`,
|
|
25
|
+
credentials: 'omit',
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
await expect(client.status()).resolves.toEqual({ status: 'ok' });
|
|
30
|
+
});
|
|
31
|
+
|
|
15
32
|
test('publishes complete page metadata and a reachable Open Graph card', async ({
|
|
16
33
|
page,
|
|
17
34
|
request,
|
|
18
35
|
}) => {
|
|
19
36
|
await page.goto('/en/ui/themes');
|
|
20
|
-
await expect(page).toHaveTitle(
|
|
37
|
+
await expect(page).toHaveTitle(`Theme system · ${appIdentity.name}`);
|
|
21
38
|
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
|
|
22
39
|
'href',
|
|
23
40
|
/\/en\/ui\/themes$/,
|
|
@@ -39,23 +56,6 @@ test('publishes complete page metadata and a reachable Open Graph card', async (
|
|
|
39
56
|
expect(await sitemapResponse.text()).toContain('/ru/ui/themes');
|
|
40
57
|
});
|
|
41
58
|
|
|
42
|
-
test('spins the refresh icon in place while its action is pending', async ({ page }) => {
|
|
43
|
-
await page.route('**/api/repository/refresh', async (route) => {
|
|
44
|
-
await new Promise((resolve) => setTimeout(resolve, 750));
|
|
45
|
-
await route.continue();
|
|
46
|
-
});
|
|
47
|
-
await page.goto('/en');
|
|
48
|
-
|
|
49
|
-
const refresh = page.getByRole('button', { name: 'Refresh repository data' });
|
|
50
|
-
await expect(refresh).toHaveCSS('height', '32px');
|
|
51
|
-
await expect(refresh).toHaveCSS('width', '32px');
|
|
52
|
-
await expect(refresh.locator('.tabler-icon-refresh')).toHaveCount(1);
|
|
53
|
-
await refresh.click();
|
|
54
|
-
await expect(refresh).toHaveAttribute('aria-busy', 'true');
|
|
55
|
-
await expect(refresh.locator('svg')).toHaveCount(1);
|
|
56
|
-
await expect(refresh.locator('.tabler-icon-refresh')).toHaveClass(/animate-spin/);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
59
|
test('switches catalogue sections and component tabs', async ({ page }) => {
|
|
60
60
|
await page.goto('/en/ui');
|
|
61
61
|
await expect(page).toHaveURL(/\/en\/ui\/components$/);
|
|
@@ -176,7 +176,11 @@ test('keeps long localized navigation labels inside the mobile drawer', async ({
|
|
|
176
176
|
expect(overflow.page).toBeLessThanOrEqual(1);
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
-
test('provides a server-first synchronized theme system', async ({
|
|
179
|
+
test('provides a server-first synchronized theme system', async ({
|
|
180
|
+
browserName,
|
|
181
|
+
context,
|
|
182
|
+
page,
|
|
183
|
+
}) => {
|
|
180
184
|
const consoleProblems: string[] = [];
|
|
181
185
|
page.on('console', (message) => {
|
|
182
186
|
if (message.type() === 'error' || message.type() === 'warning') {
|
|
@@ -210,17 +214,25 @@ test('provides a server-first synchronized theme system', async ({ context, page
|
|
|
210
214
|
const secondPage = await context.newPage();
|
|
211
215
|
await secondPage.goto('/en/ui/themes');
|
|
212
216
|
await expect(secondPage.locator('html')).toHaveClass(/dark/);
|
|
217
|
+
await expect(secondPage.getByTestId('theme-state-selected')).not.toContainText('hydrating');
|
|
218
|
+
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
213
219
|
await page.getByRole('button', { name: 'Light', exact: true }).click();
|
|
220
|
+
if (browserName === 'webkit') {
|
|
221
|
+
// Playwright WebKit shares localStorage but does not dispatch cross-page storage events.
|
|
222
|
+
await secondPage.reload();
|
|
223
|
+
}
|
|
214
224
|
await expect(secondPage.locator('html')).toHaveClass(/light/);
|
|
215
225
|
|
|
216
226
|
await page.getByRole('button', { name: 'System', exact: true }).click();
|
|
217
227
|
await expect(page.getByTestId('theme-state-selected')).toContainText('system');
|
|
218
228
|
const themeCookie = (await context.cookies()).find(
|
|
219
|
-
(cookie) => cookie.name ===
|
|
229
|
+
(cookie) => cookie.name === `${appIdentity.slug}-theme`,
|
|
220
230
|
);
|
|
221
231
|
expect(themeCookie?.value).toBe('system');
|
|
222
232
|
await page.reload();
|
|
223
|
-
|
|
233
|
+
const serverCookieState = page.getByTestId('theme-state-server-cookie');
|
|
234
|
+
await expect(serverCookieState).toHaveCount(1);
|
|
235
|
+
await expect(serverCookieState).toContainText('system');
|
|
224
236
|
expect(
|
|
225
237
|
consoleProblems.filter((message) =>
|
|
226
238
|
/hydration|useServerInsertedHTML|inline script/i.test(message),
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
const path = require('node:path');
|
|
2
2
|
const { config } = require('dotenv');
|
|
3
|
+
const identity = require('./app.config.json');
|
|
3
4
|
|
|
4
5
|
config({ path: path.join(__dirname, '.env'), quiet: true, override: true });
|
|
5
6
|
|
|
6
7
|
module.exports = {
|
|
7
8
|
apps: [
|
|
8
9
|
{
|
|
9
|
-
name:
|
|
10
|
+
name: `${identity.slug}-backend`,
|
|
10
11
|
cwd: path.join(__dirname, 'packages/backend'),
|
|
11
12
|
script: 'dist/index.js',
|
|
12
13
|
interpreter: 'bun',
|
|
@@ -15,7 +16,7 @@ module.exports = {
|
|
|
15
16
|
env: { NODE_ENV: 'production' },
|
|
16
17
|
},
|
|
17
18
|
{
|
|
18
|
-
name:
|
|
19
|
+
name: `${identity.slug}-frontend`,
|
|
19
20
|
cwd: path.join(__dirname, 'packages/frontend'),
|
|
20
21
|
script: 'node_modules/.bin/next',
|
|
21
22
|
args: ['start', '--port', process.env.WEB_PORT, '--hostname', '0.0.0.0'],
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
const path = require('node:path');
|
|
2
2
|
const { config } = require('dotenv');
|
|
3
|
+
const identity = require('./app.config.json');
|
|
3
4
|
|
|
4
|
-
config({ path: path.join(__dirname, '.env'), quiet: true
|
|
5
|
+
config({ path: path.join(__dirname, '.env'), quiet: true });
|
|
6
|
+
|
|
7
|
+
const frontendArgs = ['dev', '--port', process.env.WEB_PORT, '--hostname', '0.0.0.0'];
|
|
5
8
|
|
|
6
9
|
module.exports = {
|
|
7
10
|
apps: [
|
|
8
11
|
{
|
|
9
|
-
name:
|
|
12
|
+
name: `${identity.slug}-backend-dev`,
|
|
10
13
|
cwd: path.join(__dirname, 'packages/backend'),
|
|
11
14
|
script: 'src/index.ts',
|
|
12
15
|
interpreter: 'bun',
|
|
@@ -16,10 +19,10 @@ module.exports = {
|
|
|
16
19
|
env: { NODE_ENV: 'development' },
|
|
17
20
|
},
|
|
18
21
|
{
|
|
19
|
-
name:
|
|
22
|
+
name: `${identity.slug}-frontend-dev`,
|
|
20
23
|
cwd: path.join(__dirname, 'packages/frontend'),
|
|
21
24
|
script: 'node_modules/.bin/next',
|
|
22
|
-
args:
|
|
25
|
+
args: frontendArgs,
|
|
23
26
|
interpreter: 'bun',
|
|
24
27
|
autorestart: true,
|
|
25
28
|
kill_timeout: 10000,
|
package/template/package.json
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
"packages/*"
|
|
8
8
|
],
|
|
9
9
|
"catalog": {
|
|
10
|
-
"stitchkit": "^0.
|
|
10
|
+
"stitchkit": "^0.46.0"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
13
|
"dev": "bun scripts/dev.ts",
|
|
14
|
-
"check": "bun run db:generate && bun run check:authored && bun run --filter '*' check",
|
|
14
|
+
"check": "bun run db:generate && bun run check:authored && bun x tsc -p tsconfig.json --noEmit && bun run --filter '*' check",
|
|
15
15
|
"check:authored": "bun scripts/check-authored.ts",
|
|
16
16
|
"test": "bun run --filter '*' test",
|
|
17
17
|
"build": "bun run db:generate && bun --filter @app/backend build && bun --filter @app/frontend build",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"cli": "bun packages/backend/src/cli.ts",
|
|
27
27
|
"tools": "bun packages/backend/src/tools.ts",
|
|
28
28
|
"runtime:smoke": "bun scripts/runtime-smoke.ts",
|
|
29
|
+
"surface:snapshot": "bun scripts/surface-snapshot.ts",
|
|
29
30
|
"e2e": "playwright test",
|
|
30
31
|
"lint": "biome check --error-on-warnings .",
|
|
31
32
|
"lint:fix": "biome check --write .",
|
|
@@ -36,14 +37,17 @@
|
|
|
36
37
|
"dotenv": "^17.4.2"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
40
|
+
"@app/config": "workspace:*",
|
|
39
41
|
"@app/shared": "workspace:*",
|
|
40
42
|
"@axe-core/playwright": "^4.11.0",
|
|
41
43
|
"@biomejs/biome": "^2.5.7",
|
|
42
44
|
"@modelcontextprotocol/client": "^2.0.0",
|
|
43
45
|
"@playwright/test": "^1.55.0",
|
|
44
46
|
"@types/bun": "^1.3.14",
|
|
47
|
+
"@types/node": "^26.2.0",
|
|
45
48
|
"oxc-parser": "^0.143.0",
|
|
46
49
|
"socket.io-client": "^4.8.3",
|
|
50
|
+
"stitchkit": "catalog:",
|
|
47
51
|
"typescript": "^7.0.2",
|
|
48
52
|
"zod": "^4.4.3"
|
|
49
53
|
},
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
+
import { appIdentity } from '@app/config/identity';
|
|
3
4
|
import { createCli } from 'stitchkit/cli';
|
|
4
5
|
import { createSurface } from './surface';
|
|
5
6
|
|
|
6
7
|
const { services, socket } = await createSurface();
|
|
7
8
|
|
|
8
9
|
try {
|
|
9
|
-
await createCli({ name:
|
|
10
|
+
await createCli({ name: appIdentity.slug, version: appIdentity.version, services });
|
|
10
11
|
} finally {
|
|
11
12
|
await socket.io.close();
|
|
12
13
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { env } from '@app/config';
|
|
2
|
+
import { appIdentity } from '@app/config/identity';
|
|
2
3
|
import { wrapInRequestContext } from 'stitchkit/observability';
|
|
3
4
|
import { createServer, generateOpenApiDocument, openApiRoute } from 'stitchkit/server';
|
|
4
5
|
import { createMcpHandler, createMcpHttpRoute } from 'stitchkit/tools';
|
|
@@ -9,12 +10,12 @@ import { onError } from './transport/errors';
|
|
|
9
10
|
async function main(): Promise<void> {
|
|
10
11
|
const { services, socket } = await createSurface();
|
|
11
12
|
const mcp = createMcpHandler({
|
|
12
|
-
serverInfo: { name:
|
|
13
|
+
serverInfo: { name: appIdentity.slug, version: appIdentity.version },
|
|
13
14
|
auth: () => ({ scope: 'public' }),
|
|
14
15
|
services,
|
|
15
16
|
});
|
|
16
17
|
const openApi = generateOpenApiDocument({
|
|
17
|
-
info: { title:
|
|
18
|
+
info: { title: `${appIdentity.name} API`, version: appIdentity.version },
|
|
18
19
|
groups: [{ pathPrefix: '/api', services }],
|
|
19
20
|
});
|
|
20
21
|
|
|
@@ -22,7 +23,7 @@ async function main(): Promise<void> {
|
|
|
22
23
|
groups: [{ pathPrefix: '/api', services }],
|
|
23
24
|
port: env.API_PORT,
|
|
24
25
|
hostname: '0.0.0.0',
|
|
25
|
-
cors: { origin:
|
|
26
|
+
cors: { origin: env.CORS_ORIGIN },
|
|
26
27
|
hooks: { onError },
|
|
27
28
|
logging: { format: env.LOG_FORMAT },
|
|
28
29
|
websocket: socket.websocket,
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createContractFactory } from 'stitchkit/contract';
|
|
5
|
+
import { generateOpenApiDocument, implement } from 'stitchkit/server';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { discoverCliCommands, discoverMcpTools } from '../../../scripts/surface-conformance';
|
|
8
|
+
import {
|
|
9
|
+
assertManifestMatchesSnapshot,
|
|
10
|
+
assertSurfaceConformance,
|
|
11
|
+
buildSurfaceManifest,
|
|
12
|
+
} from './surface-manifest';
|
|
13
|
+
|
|
14
|
+
const NoteParamsSchema = z.object({ id: z.string() });
|
|
15
|
+
const NoteSchema = z.object({ id: z.string() });
|
|
16
|
+
// Same address and presence flags as NoteSchema — a different TYPE, for the
|
|
17
|
+
// snapshot-digest test. Named here, never inline in a contract (the template's
|
|
18
|
+
// own schema-separate-from-contract policy).
|
|
19
|
+
const RetypedNoteSchema = z.object({ id: z.number() });
|
|
20
|
+
const { defineContract } = createContractFactory<'public'>();
|
|
21
|
+
|
|
22
|
+
const service = implement(
|
|
23
|
+
defineContract(
|
|
24
|
+
{ prefix: 'notes', scope: 'public' },
|
|
25
|
+
{
|
|
26
|
+
read: {
|
|
27
|
+
method: 'GET',
|
|
28
|
+
path: '/:id',
|
|
29
|
+
desc: 'Read a note',
|
|
30
|
+
params: NoteParamsSchema,
|
|
31
|
+
output: NoteSchema,
|
|
32
|
+
expose: ['HTTP', 'MCP', 'AGENT', 'CLI'],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
),
|
|
36
|
+
{ read: ({ params }) => ({ id: params.id }) },
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
describe('surface conformance', () => {
|
|
40
|
+
test('derives matching HTTP and MCP discovery identities without calling handlers', () => {
|
|
41
|
+
const manifest = buildSurfaceManifest([service]);
|
|
42
|
+
const openApi = generateOpenApiDocument({
|
|
43
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
44
|
+
groups: [{ pathPrefix: '/api', services: [service] }],
|
|
45
|
+
});
|
|
46
|
+
expect(manifest).toEqual([
|
|
47
|
+
{
|
|
48
|
+
service: 'notes',
|
|
49
|
+
action: 'read',
|
|
50
|
+
scope: 'public',
|
|
51
|
+
hasInput: false,
|
|
52
|
+
hasOutput: true,
|
|
53
|
+
inputShape: null,
|
|
54
|
+
outputShape: expect.stringMatching(/^[0-9a-f]{16}$/),
|
|
55
|
+
http: [{ method: 'GET', path: '/api/notes/{id}' }],
|
|
56
|
+
tools: { AGENT: 'read_note', CLI: 'read_note', MCP: 'read_note' },
|
|
57
|
+
},
|
|
58
|
+
]);
|
|
59
|
+
expect(() =>
|
|
60
|
+
assertSurfaceConformance({
|
|
61
|
+
manifest,
|
|
62
|
+
openApi,
|
|
63
|
+
mcpToolNames: ['read_note'],
|
|
64
|
+
agentToolNames: ['read_note'],
|
|
65
|
+
cliToolNames: ['read_note'],
|
|
66
|
+
}),
|
|
67
|
+
).not.toThrow();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('fails with transport-specific expected and actual diagnostics', () => {
|
|
71
|
+
const manifest = buildSurfaceManifest([service]);
|
|
72
|
+
expect(() =>
|
|
73
|
+
assertSurfaceConformance({
|
|
74
|
+
manifest,
|
|
75
|
+
openApi: { paths: {} },
|
|
76
|
+
mcpToolNames: [],
|
|
77
|
+
agentToolNames: [],
|
|
78
|
+
cliToolNames: [],
|
|
79
|
+
}),
|
|
80
|
+
).toThrow('HTTP/OpenAPI surface mismatch');
|
|
81
|
+
expect(() =>
|
|
82
|
+
assertSurfaceConformance({
|
|
83
|
+
manifest: manifest.map((operation) => ({ ...operation, http: [] })),
|
|
84
|
+
openApi: { paths: {} },
|
|
85
|
+
mcpToolNames: ['unexpected'],
|
|
86
|
+
agentToolNames: ['read_note'],
|
|
87
|
+
cliToolNames: ['read_note'],
|
|
88
|
+
}),
|
|
89
|
+
).toThrow('MCP discovery surface mismatch');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('missing Stitchkit metadata is an ERROR unless the standard-document mode is declared', () => {
|
|
93
|
+
const manifest = buildSurfaceManifest([service]);
|
|
94
|
+
const standardDocument = {
|
|
95
|
+
paths: {
|
|
96
|
+
'/api/notes/{id}': {
|
|
97
|
+
get: { responses: { 200: { description: 'Success' } } },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
const names = {
|
|
102
|
+
mcpToolNames: ['read_note'],
|
|
103
|
+
agentToolNames: ['read_note'],
|
|
104
|
+
cliToolNames: ['read_note'],
|
|
105
|
+
};
|
|
106
|
+
// A silent skip was the hole: no x-stitchkit-* keys meant no comparison at
|
|
107
|
+
// all. The default mode now refuses; opting out is an explicit decision.
|
|
108
|
+
expect(() =>
|
|
109
|
+
assertSurfaceConformance({ manifest, openApi: standardDocument, ...names }),
|
|
110
|
+
).toThrow(/no x-stitchkit-\* contract metadata/);
|
|
111
|
+
expect(() =>
|
|
112
|
+
assertSurfaceConformance({
|
|
113
|
+
manifest,
|
|
114
|
+
openApi: standardDocument,
|
|
115
|
+
...names,
|
|
116
|
+
metadata: 'ignore',
|
|
117
|
+
}),
|
|
118
|
+
).not.toThrow();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('a missing AGENT tool and a missing CLI command each fail conformance', () => {
|
|
122
|
+
const manifest = buildSurfaceManifest([service]);
|
|
123
|
+
const openApi = generateOpenApiDocument({
|
|
124
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
125
|
+
groups: [{ pathPrefix: '/api', services: [service] }],
|
|
126
|
+
});
|
|
127
|
+
expect(() =>
|
|
128
|
+
assertSurfaceConformance({
|
|
129
|
+
manifest,
|
|
130
|
+
openApi,
|
|
131
|
+
mcpToolNames: ['read_note'],
|
|
132
|
+
agentToolNames: [],
|
|
133
|
+
cliToolNames: ['read_note'],
|
|
134
|
+
}),
|
|
135
|
+
).toThrow('AGENT mount surface mismatch');
|
|
136
|
+
expect(() =>
|
|
137
|
+
assertSurfaceConformance({
|
|
138
|
+
manifest,
|
|
139
|
+
openApi,
|
|
140
|
+
mcpToolNames: ['read_note'],
|
|
141
|
+
agentToolNames: ['read_note'],
|
|
142
|
+
cliToolNames: [],
|
|
143
|
+
}),
|
|
144
|
+
).toThrow('CLI manifest surface mismatch');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('the committed snapshot catches an expose edit that moves BOTH in-process sides together', () => {
|
|
148
|
+
// The probe that defeated the old check: removing AGENT/CLI from `expose`
|
|
149
|
+
// changes the manifest AND the in-process tool lists in lockstep. The
|
|
150
|
+
// snapshot is the anchor that does not move with the source.
|
|
151
|
+
const snapshot = buildSurfaceManifest([service]);
|
|
152
|
+
const narrowed = implement(
|
|
153
|
+
defineContract(
|
|
154
|
+
{ prefix: 'notes', scope: 'public' },
|
|
155
|
+
{
|
|
156
|
+
read: {
|
|
157
|
+
method: 'GET',
|
|
158
|
+
path: '/:id',
|
|
159
|
+
desc: 'Read a note',
|
|
160
|
+
params: NoteParamsSchema,
|
|
161
|
+
output: NoteSchema,
|
|
162
|
+
expose: ['HTTP', 'MCP'],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
),
|
|
166
|
+
{ read: ({ params }) => ({ id: params.id }) },
|
|
167
|
+
);
|
|
168
|
+
expect(() =>
|
|
169
|
+
assertManifestMatchesSnapshot(buildSurfaceManifest([narrowed]), snapshot),
|
|
170
|
+
).toThrow(/diverged from the committed snapshot/);
|
|
171
|
+
expect(() =>
|
|
172
|
+
assertManifestMatchesSnapshot(buildSurfaceManifest([service]), snapshot),
|
|
173
|
+
).not.toThrow();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('a schema TYPE change at the same method and path flips the shape digest', () => {
|
|
177
|
+
const snapshot = buildSurfaceManifest([service]);
|
|
178
|
+
const retyped = implement(
|
|
179
|
+
defineContract(
|
|
180
|
+
{ prefix: 'notes', scope: 'public' },
|
|
181
|
+
{
|
|
182
|
+
read: {
|
|
183
|
+
method: 'GET',
|
|
184
|
+
path: '/:id',
|
|
185
|
+
desc: 'Read a note',
|
|
186
|
+
params: NoteParamsSchema,
|
|
187
|
+
// Same address, same presence flags — different output TYPE.
|
|
188
|
+
output: RetypedNoteSchema,
|
|
189
|
+
expose: ['HTTP', 'MCP', 'AGENT', 'CLI'],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
),
|
|
193
|
+
{ read: ({ params }) => ({ id: Number(params.id) }) },
|
|
194
|
+
);
|
|
195
|
+
expect(() =>
|
|
196
|
+
assertManifestMatchesSnapshot(buildSurfaceManifest([retyped]), snapshot),
|
|
197
|
+
).toThrow(/diverged from the committed snapshot/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('fails first on duplicate contract identity', () => {
|
|
201
|
+
expect(() => buildSurfaceManifest([service, service])).toThrow(
|
|
202
|
+
'Duplicate operation identity notes.read',
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('a broken /mcp endpoint rejects discovery — even when zero tools are expected', async () => {
|
|
207
|
+
const broken = Bun.serve({
|
|
208
|
+
port: 0,
|
|
209
|
+
fetch: () => new Response('not the MCP endpoint', { status: 404 }),
|
|
210
|
+
});
|
|
211
|
+
try {
|
|
212
|
+
await expect(discoverMcpTools(`http://127.0.0.1:${broken.port}`, [])).rejects.toThrow();
|
|
213
|
+
} finally {
|
|
214
|
+
broken.stop(true);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('the CLI surface observed from the SPAWNED process matches the committed snapshot', async () => {
|
|
219
|
+
// External observation against the external anchor — neither side is the
|
|
220
|
+
// in-process `services` object, so they cannot move together.
|
|
221
|
+
const root = join(import.meta.dir, '../../..');
|
|
222
|
+
const commands = await discoverCliCommands(root);
|
|
223
|
+
const snapshot = SurfaceSnapshotCliSchema.parse(
|
|
224
|
+
JSON.parse(
|
|
225
|
+
await readFile(join(root, 'packages/backend/src/surface.snapshot.json'), 'utf8'),
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
const expected = snapshot
|
|
229
|
+
.flatMap((operation) => (operation.tools.CLI ? [operation.tools.CLI] : []))
|
|
230
|
+
.sort();
|
|
231
|
+
expect([...commands].sort()).toEqual(expected);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const SurfaceSnapshotCliSchema = z.array(
|
|
236
|
+
z.object({ tools: z.object({ CLI: z.string().optional() }) }),
|
|
237
|
+
);
|