create-stitchkit 0.2.0 → 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 +60 -0
- package/dist/cli.js +7 -16
- package/examples/repository/e2e/repository.spec.ts +50 -0
- package/examples/repository/packages/backend/src/domain/repository/github-cache.test.ts +8 -0
- package/examples/repository/packages/backend/src/surface.snapshot.json +58 -0
- package/examples/repository/packages/backend/src/surface.ts +7 -5
- package/examples/repository/packages/frontend/src/components/repository-summary.tsx +5 -1
- package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +5 -6
- package/examples/repository/packages/shared/src/index.ts +3 -1
- package/examples/repository/packages/shared/src/realtime/repository.ts +10 -0
- package/examples/repository/packages/shared/src/schemas/repository.ts +4 -2
- package/examples/repository/scripts/runtime-smoke.ts +96 -18
- package/package.json +1 -1
- package/template/README.md +6 -10
- package/template/_env.example +1 -1
- package/template/bun.lock +4 -4
- package/template/docs/ADDING_A_FEATURE.md +3 -2
- package/template/e2e/starter.spec.ts +17 -0
- package/template/ecosystem.dev.config.cjs +0 -12
- package/template/package.json +5 -3
- package/template/packages/backend/src/index.ts +1 -15
- package/template/packages/backend/src/surface-manifest.test.ts +183 -8
- package/template/packages/backend/src/surface-manifest.ts +108 -5
- package/template/packages/backend/src/surface.snapshot.json +18 -0
- package/template/packages/backend/src/surface.ts +2 -1
- 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/src/server.ts +1 -4
- package/template/packages/frontend/package.json +0 -1
- 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/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 -1
- 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 +19 -10
- package/template/scripts/local-env.test.ts +33 -0
- package/template/scripts/local-env.ts +16 -5
- package/template/scripts/runtime-smoke.ts +12 -5
- package/template/scripts/surface-conformance.ts +76 -15
- package/template/scripts/surface-snapshot.ts +21 -0
- package/template/scripts/tooling-env.ts +16 -3
- package/template/tsconfig.json +3 -1
- package/examples/repository/_env.append +0 -3
- package/examples/repository/packages/shared/src/events/repository.ts +0 -9
- package/template/_env +0 -9
- package/template/docs/LAN_HTTPS.md +0 -28
- package/template/packages/backend/src/transport/lan-onboarding.ts +0 -29
- package/template/scripts/dev-lan.test.ts +0 -58
- package/template/scripts/dev-lan.ts +0 -176
|
@@ -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 +1,2 @@
|
|
|
1
|
-
export
|
|
1
|
+
export * from './contracts/system';
|
|
2
|
+
export * from './schemas/system';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineConfig, devices } from '@playwright/test';
|
|
2
|
-
import {
|
|
2
|
+
import { loadToolingEnv } from './scripts/tooling-env';
|
|
3
3
|
|
|
4
|
+
const toolingEnv = loadToolingEnv();
|
|
4
5
|
const baseURL = toolingEnv.PLAYWRIGHT_BASE_URL ?? toolingEnv.NEXT_PUBLIC_WEB_URL;
|
|
5
6
|
|
|
6
7
|
export default defineConfig({
|
|
@@ -2,7 +2,8 @@ import { readdir, readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { extname, join } from 'node:path';
|
|
3
3
|
import { parseSync, Visitor } from 'oxc-parser';
|
|
4
4
|
|
|
5
|
-
const roots = ['packages', 'scripts'];
|
|
5
|
+
const roots = ['packages', 'scripts', 'e2e'];
|
|
6
|
+
const rootFiles = ['playwright.config.ts', 'ecosystem.config.cjs', 'ecosystem.dev.config.cjs'];
|
|
6
7
|
const processEnvMarker = ['process', 'env'].join('.');
|
|
7
8
|
const replacedThemePackage = ['next', 'themes'].join('-');
|
|
8
9
|
const generatedDirectories = new Set(['.git', '.next', 'dist', 'node_modules']);
|
|
@@ -10,20 +11,26 @@ const processEnvBoundaries = new Set([
|
|
|
10
11
|
'packages/frontend/src/env.ts',
|
|
11
12
|
'packages/config/src/server.ts',
|
|
12
13
|
'scripts/tooling-env.ts',
|
|
14
|
+
'ecosystem.config.cjs',
|
|
15
|
+
'ecosystem.dev.config.cjs',
|
|
13
16
|
]);
|
|
14
17
|
|
|
18
|
+
function lineAt(source: string, offset: number): number {
|
|
19
|
+
return source.slice(0, offset).split('\n').length;
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
function inspect(path: string, source: string): string[] {
|
|
16
23
|
const result = parseSync(path, source);
|
|
17
24
|
const failures = result.errors.map((error) => `${path}: parse error: ${error.message}`);
|
|
18
25
|
const visitor = new Visitor({
|
|
19
26
|
TSAsExpression(node) {
|
|
20
|
-
failures.push(`${path}:${node.
|
|
27
|
+
failures.push(`${path}:${lineAt(source, node.start)}: type assertion`);
|
|
21
28
|
},
|
|
22
29
|
TSTypeAssertion(node) {
|
|
23
|
-
failures.push(`${path}:${node.
|
|
30
|
+
failures.push(`${path}:${lineAt(source, node.start)}: type assertion`);
|
|
24
31
|
},
|
|
25
32
|
TSAnyKeyword(node) {
|
|
26
|
-
failures.push(`${path}:${node.
|
|
33
|
+
failures.push(`${path}:${lineAt(source, node.start)}: explicit any`);
|
|
27
34
|
},
|
|
28
35
|
});
|
|
29
36
|
visitor.visit(result.program);
|
|
@@ -62,13 +69,20 @@ async function visitDirectory(directory: string): Promise<string[]> {
|
|
|
62
69
|
failures.push(...(await visitDirectory(path)));
|
|
63
70
|
continue;
|
|
64
71
|
}
|
|
65
|
-
if (!['.ts', '.tsx'].includes(extname(path))) continue;
|
|
72
|
+
if (!['.cjs', '.ts', '.tsx'].includes(extname(path))) continue;
|
|
66
73
|
failures.push(...inspect(path, await readFile(path, 'utf8')));
|
|
67
74
|
}
|
|
68
75
|
return failures;
|
|
69
76
|
}
|
|
70
77
|
|
|
71
|
-
const failures =
|
|
78
|
+
const failures = [
|
|
79
|
+
...(await Promise.all(roots.map(visitDirectory))).flat(),
|
|
80
|
+
...(
|
|
81
|
+
await Promise.all(
|
|
82
|
+
rootFiles.map(async (path) => inspect(path, await readFile(path, 'utf8'))),
|
|
83
|
+
)
|
|
84
|
+
).flat(),
|
|
85
|
+
];
|
|
72
86
|
const webPackage = await readFile('packages/frontend/package.json', 'utf8');
|
|
73
87
|
if (webPackage.includes(replacedThemePackage)) {
|
|
74
88
|
failures.push(
|
package/template/scripts/dev.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { appIdentity } from '../packages/config/src/identity';
|
|
3
|
-
import { childEnvironment, env } from '../packages/config/src/server';
|
|
4
3
|
import { ensureLocalEnvironment } from './local-env';
|
|
4
|
+
import { inheritToolingEnvironment } from './tooling-env';
|
|
5
5
|
|
|
6
6
|
const root = resolve(import.meta.dir, '..');
|
|
7
7
|
async function run(command: string[], environment?: Record<string, string>): Promise<void> {
|
|
8
8
|
const child = Bun.spawn(command, {
|
|
9
9
|
cwd: root,
|
|
10
|
-
env: environment ?
|
|
10
|
+
env: environment ? inheritToolingEnvironment(environment) : undefined,
|
|
11
11
|
stdin: 'inherit',
|
|
12
12
|
stdout: 'inherit',
|
|
13
13
|
stderr: 'inherit',
|
|
@@ -17,8 +17,15 @@ async function run(command: string[], environment?: Record<string, string>): Pro
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export async function runDevelopment(environment?: Record<string, string>): Promise<void> {
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
ensureLocalEnvironment(root);
|
|
21
|
+
assertToolAvailable('pm2', 'Install PM2 with `bun add --global pm2`, then rerun.');
|
|
22
|
+
await run(['pm2', 'ping']);
|
|
23
|
+
const environmentForRun = await developmentEnvironment(environment);
|
|
24
|
+
if (environmentForRun.DATABASE_URL?.includes('USER:PASSWORD')) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'DATABASE_URL still contains the starter placeholder. Create a PostgreSQL database, update DATABASE_URL in .env, then rerun `bun run dev`.',
|
|
27
|
+
);
|
|
28
|
+
}
|
|
22
29
|
await run(['bun', 'run', 'db:setup'], environmentForRun);
|
|
23
30
|
await run(
|
|
24
31
|
['pm2', 'startOrReload', 'ecosystem.dev.config.cjs', '--update-env'],
|
|
@@ -26,20 +33,22 @@ export async function runDevelopment(environment?: Record<string, string>): Prom
|
|
|
26
33
|
);
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
|
|
36
|
+
function assertToolAvailable(command: string, instruction: string): void {
|
|
37
|
+
if (!Bun.which(command)) throw new Error(`${command} is required. ${instruction}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function developmentEnvironment(
|
|
30
41
|
overrides: Record<string, string> = {},
|
|
31
|
-
): Record<string, string
|
|
42
|
+
): Promise<Record<string, string>> {
|
|
43
|
+
const { env } = await import('../packages/config/src/server');
|
|
32
44
|
return {
|
|
45
|
+
DATABASE_URL: env.DATABASE_URL,
|
|
33
46
|
API_PORT: String(env.API_PORT),
|
|
34
47
|
WEB_PORT: String(env.WEB_PORT),
|
|
35
48
|
CORS_ORIGIN: env.CORS_ORIGIN,
|
|
36
49
|
NEXT_PUBLIC_API_URL: env.NEXT_PUBLIC_API_URL,
|
|
37
50
|
NEXT_PUBLIC_WEB_URL: env.NEXT_PUBLIC_WEB_URL,
|
|
38
51
|
INTERNAL_API_URL: env.INTERNAL_API_URL,
|
|
39
|
-
DEV_HTTPS_CERT: '',
|
|
40
|
-
DEV_HTTPS_KEY: '',
|
|
41
|
-
DEV_HTTPS_CA: '',
|
|
42
|
-
NODE_EXTRA_CA_CERTS: '',
|
|
43
52
|
...overrides,
|
|
44
53
|
};
|
|
45
54
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { appIdentity } from '../packages/config/src/identity';
|
|
6
|
+
import { ensureLocalEnvironment } from './local-env';
|
|
7
|
+
|
|
8
|
+
describe('ensureLocalEnvironment', () => {
|
|
9
|
+
test('renders the application identity into a fresh .env and never touches an existing one', async () => {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), 'sk-env-'));
|
|
11
|
+
try {
|
|
12
|
+
await writeFile(
|
|
13
|
+
join(root, '.env.example'),
|
|
14
|
+
'DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:5432/stitchkit_starter\n',
|
|
15
|
+
);
|
|
16
|
+
ensureLocalEnvironment(root);
|
|
17
|
+
const created = await readFile(join(root, '.env'), 'utf8');
|
|
18
|
+
// In the neutral dev workspace the slug IS the neutral identity, so the
|
|
19
|
+
// substitution is proven end-to-end by the starter lane on a renamed
|
|
20
|
+
// scaffold; here we prove the file is created from the example with the
|
|
21
|
+
// identity-derived database name in place.
|
|
22
|
+
const databaseName = appIdentity.slug.replaceAll('-', '_');
|
|
23
|
+
expect(created).toContain(`5432/${databaseName}`);
|
|
24
|
+
|
|
25
|
+
// Idempotency — a developer's local credentials survive every dev run.
|
|
26
|
+
await writeFile(join(root, '.env'), 'DATABASE_URL=postgresql://real-creds@db/mine\n');
|
|
27
|
+
ensureLocalEnvironment(root);
|
|
28
|
+
expect(await readFile(join(root, '.env'), 'utf8')).toContain('real-creds');
|
|
29
|
+
} finally {
|
|
30
|
+
await rm(root, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
|
+
import { appIdentity } from '../packages/config/src/identity';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Create `.env` from `.env.example` on first run, rendering the application
|
|
7
|
+
* identity into the database name. `.env.example` is the ONLY environment
|
|
8
|
+
* source the repository ships — the scaffolder never writes `.env`, so a
|
|
9
|
+
* rename in `app.config.json` changes the database of the next created
|
|
10
|
+
* environment too. Synchronous on purpose: `playwright.config.ts` and other
|
|
11
|
+
* synchronous entry points must be able to self-heal before validating.
|
|
12
|
+
*/
|
|
13
|
+
export function ensureLocalEnvironment(root: string): void {
|
|
5
14
|
const destination = resolve(root, '.env');
|
|
6
|
-
if (
|
|
7
|
-
|
|
15
|
+
if (existsSync(destination)) return;
|
|
16
|
+
const example = readFileSync(resolve(root, '.env.example'), 'utf8');
|
|
17
|
+
const databaseName = appIdentity.slug.replaceAll('-', '_');
|
|
18
|
+
writeFileSync(destination, example.replaceAll('stitchkit_starter', databaseName));
|
|
8
19
|
}
|
|
9
20
|
|
|
10
21
|
if (import.meta.main) {
|
|
11
|
-
|
|
22
|
+
ensureLocalEnvironment(resolve(import.meta.dir, '..'));
|
|
12
23
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { systemContract } from '@app/shared';
|
|
2
|
+
import { createClient, createHttpClient } from 'stitchkit';
|
|
1
3
|
import { z } from 'zod';
|
|
2
4
|
import { runSurfaceConformance } from './surface-conformance';
|
|
3
|
-
import {
|
|
5
|
+
import { loadToolingEnv } from './tooling-env';
|
|
4
6
|
|
|
5
|
-
const apiOrigin =
|
|
7
|
+
const apiOrigin = loadToolingEnv().NEXT_PUBLIC_API_URL;
|
|
6
8
|
|
|
7
9
|
async function json(path: string): Promise<unknown> {
|
|
8
10
|
const response = await fetch(`${apiOrigin}${path}`);
|
|
@@ -11,13 +13,18 @@ async function json(path: string): Promise<unknown> {
|
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
z.object({ status: z.literal('ok') }).parse(await json('/health'));
|
|
16
|
+
const system = createClient(
|
|
17
|
+
systemContract,
|
|
18
|
+
createHttpClient({ baseUrl: `${apiOrigin}/api`, credentials: 'omit' }),
|
|
19
|
+
);
|
|
20
|
+
z.object({ status: z.literal('ok') }).parse(await system.status());
|
|
14
21
|
const openApi = z
|
|
15
22
|
.object({ paths: z.record(z.string(), z.unknown()) })
|
|
16
23
|
.parse(await json('/openapi.json'));
|
|
17
|
-
if (Object.keys(openApi.paths).
|
|
18
|
-
throw new Error('
|
|
24
|
+
if (!Object.keys(openApi.paths).includes('/api/system/status')) {
|
|
25
|
+
throw new Error('System status contract is missing from OpenAPI');
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
await runSurfaceConformance({ apiOrigin });
|
|
22
29
|
|
|
23
|
-
console.log('
|
|
30
|
+
console.log('Runtime HTTP, typed client, OpenAPI and MCP smoke passed');
|
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
1
3
|
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
|
4
|
+
import { mountAgent } from 'stitchkit/tools';
|
|
2
5
|
import { z } from 'zod';
|
|
3
6
|
import { createSurface } from '../packages/backend/src/surface';
|
|
4
7
|
import {
|
|
8
|
+
assertManifestMatchesSnapshot,
|
|
5
9
|
assertSurfaceConformance,
|
|
6
10
|
buildSurfaceManifest,
|
|
7
11
|
type SurfaceManifestOperation,
|
|
8
12
|
} from '../packages/backend/src/surface-manifest';
|
|
9
13
|
|
|
14
|
+
export const SURFACE_SNAPSHOT_PATH = 'packages/backend/src/surface.snapshot.json';
|
|
15
|
+
|
|
10
16
|
export interface SurfaceProbe {
|
|
11
17
|
name: string;
|
|
12
18
|
run: () => Promise<void>;
|
|
@@ -45,11 +51,42 @@ async function readJson(url: string): Promise<unknown> {
|
|
|
45
51
|
return response.json();
|
|
46
52
|
}
|
|
47
53
|
|
|
48
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Observe the CLI surface EXTERNALLY — spawn the real CLI process and parse
|
|
56
|
+
* its command table, instead of re-deriving the list from the same in-process
|
|
57
|
+
* `services` the manifest was built from (which could only ever agree).
|
|
58
|
+
*/
|
|
59
|
+
export async function discoverCliCommands(root = process.cwd()): Promise<string[]> {
|
|
60
|
+
const child = Bun.spawn({
|
|
61
|
+
cmd: ['bun', 'packages/backend/src/cli.ts', '--help'],
|
|
62
|
+
cwd: root,
|
|
63
|
+
stdout: 'pipe',
|
|
64
|
+
stderr: 'pipe',
|
|
65
|
+
});
|
|
66
|
+
const [output, errors, exitCode] = await Promise.all([
|
|
67
|
+
new Response(child.stdout).text(),
|
|
68
|
+
new Response(child.stderr).text(),
|
|
69
|
+
child.exited,
|
|
70
|
+
]);
|
|
71
|
+
if (exitCode !== 0) {
|
|
72
|
+
throw new Error(`CLI --help exited with ${exitCode}: ${errors || output}`);
|
|
73
|
+
}
|
|
74
|
+
const lines = output.split('\n');
|
|
75
|
+
const start = lines.findIndex((line) => line.trim() === 'Commands:');
|
|
76
|
+
if (start === -1) throw new Error('CLI --help output carries no "Commands:" section');
|
|
77
|
+
const commands: string[] = [];
|
|
78
|
+
for (const line of lines.slice(start + 1)) {
|
|
79
|
+
if (!line.startsWith(' ')) break;
|
|
80
|
+
const name = line.trim().split(/\s+/)[0];
|
|
81
|
+
if (name) commands.push(name);
|
|
82
|
+
}
|
|
83
|
+
return commands;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function discoverMcpTools(
|
|
49
87
|
apiOrigin: string,
|
|
50
|
-
|
|
88
|
+
_manifest: readonly SurfaceManifestOperation[],
|
|
51
89
|
): Promise<string[]> {
|
|
52
|
-
const expectedCount = manifest.filter((operation) => operation.tools.MCP).length;
|
|
53
90
|
const client = new Client(
|
|
54
91
|
{ name: 'surface-conformance', version: '1.0.0' },
|
|
55
92
|
{ versionNegotiation: { mode: { pin: '2026-07-28' } } },
|
|
@@ -57,18 +94,7 @@ async function discoverMcpTools(
|
|
|
57
94
|
const transport = new StreamableHTTPClientTransport(new URL(`${apiOrigin}/mcp`));
|
|
58
95
|
try {
|
|
59
96
|
await client.connect(transport);
|
|
60
|
-
|
|
61
|
-
return (await client.listTools()).tools.map((tool) => tool.name);
|
|
62
|
-
} catch (error) {
|
|
63
|
-
if (
|
|
64
|
-
expectedCount === 0 &&
|
|
65
|
-
error instanceof Error &&
|
|
66
|
-
error.message.includes('not supported by the negotiated protocol version')
|
|
67
|
-
) {
|
|
68
|
-
return [];
|
|
69
|
-
}
|
|
70
|
-
throw error;
|
|
71
|
-
}
|
|
97
|
+
return (await client.listTools()).tools.map((tool) => tool.name);
|
|
72
98
|
} finally {
|
|
73
99
|
await client.close();
|
|
74
100
|
}
|
|
@@ -81,12 +107,24 @@ export async function runSurfaceConformance({
|
|
|
81
107
|
const { services, socket } = await createSurface();
|
|
82
108
|
try {
|
|
83
109
|
const manifest = buildSurfaceManifest(services);
|
|
110
|
+
// The committed snapshot is the anchor no source edit can move along with
|
|
111
|
+
// itself: removing a transport from `expose` changes the live manifest but
|
|
112
|
+
// not the snapshot, so the lane fails until the snapshot is deliberately
|
|
113
|
+
// regenerated and reviewed.
|
|
114
|
+
const snapshot = SurfaceSnapshotSchema.parse(
|
|
115
|
+
JSON.parse(await readFile(join(process.cwd(), SURFACE_SNAPSHOT_PATH), 'utf8')),
|
|
116
|
+
);
|
|
117
|
+
assertManifestMatchesSnapshot(manifest, snapshot);
|
|
84
118
|
const openApi = await readJson(`${apiOrigin}/openapi.json`);
|
|
85
119
|
const mcpToolNames = await discoverMcpTools(apiOrigin, manifest);
|
|
86
120
|
assertSurfaceConformance({
|
|
87
121
|
manifest,
|
|
88
122
|
openApi: OpenApiSnapshotSchema.parse(openApi),
|
|
89
123
|
mcpToolNames,
|
|
124
|
+
// AGENT has no external process to observe — it is anchored by the
|
|
125
|
+
// snapshot above; CLI is observed by spawning the real CLI.
|
|
126
|
+
agentToolNames: Object.keys(mountAgent(services)),
|
|
127
|
+
cliToolNames: await discoverCliCommands(),
|
|
90
128
|
});
|
|
91
129
|
for (const probe of probes) {
|
|
92
130
|
try {
|
|
@@ -103,3 +141,26 @@ export async function runSurfaceConformance({
|
|
|
103
141
|
const OpenApiSnapshotSchema = z.object({
|
|
104
142
|
paths: z.record(z.string(), z.record(z.string(), z.unknown())),
|
|
105
143
|
});
|
|
144
|
+
|
|
145
|
+
const SurfaceSnapshotSchema: z.ZodType<SurfaceManifestOperation[]> = z.array(
|
|
146
|
+
z.object({
|
|
147
|
+
service: z.string(),
|
|
148
|
+
action: z.string(),
|
|
149
|
+
scope: z.string(),
|
|
150
|
+
hasInput: z.boolean(),
|
|
151
|
+
hasOutput: z.boolean(),
|
|
152
|
+
inputShape: z.string().nullable(),
|
|
153
|
+
outputShape: z.string().nullable(),
|
|
154
|
+
http: z.array(
|
|
155
|
+
z.object({
|
|
156
|
+
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']),
|
|
157
|
+
path: z.string(),
|
|
158
|
+
}),
|
|
159
|
+
),
|
|
160
|
+
tools: z.object({
|
|
161
|
+
MCP: z.string().optional(),
|
|
162
|
+
AGENT: z.string().optional(),
|
|
163
|
+
CLI: z.string().optional(),
|
|
164
|
+
}),
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Regenerate the committed surface snapshot — the anchor `surface-conformance`
|
|
4
|
+
* compares the live contract surface against. Run it deliberately after an
|
|
5
|
+
* intended surface change and review the diff like any other contract change.
|
|
6
|
+
*/
|
|
7
|
+
import { writeFile } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { createSurface } from '../packages/backend/src/surface';
|
|
10
|
+
import { buildSurfaceManifest } from '../packages/backend/src/surface-manifest';
|
|
11
|
+
import { SURFACE_SNAPSHOT_PATH } from './surface-conformance';
|
|
12
|
+
|
|
13
|
+
const { services, socket } = await createSurface();
|
|
14
|
+
try {
|
|
15
|
+
const manifest = buildSurfaceManifest(services);
|
|
16
|
+
const target = join(process.cwd(), SURFACE_SNAPSHOT_PATH);
|
|
17
|
+
await writeFile(target, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
18
|
+
console.log(`Wrote ${manifest.length} operation(s) to ${SURFACE_SNAPSHOT_PATH}`);
|
|
19
|
+
} finally {
|
|
20
|
+
await socket.io.close();
|
|
21
|
+
}
|
|
@@ -2,14 +2,27 @@ import path from 'node:path';
|
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { config as dotenvConfig } from 'dotenv';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
import { ensureLocalEnvironment } from './local-env';
|
|
5
6
|
|
|
6
7
|
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
-
dotenvConfig({ path: path.resolve(scriptDirectory, '..', '.env'), quiet: true });
|
|
8
|
-
|
|
9
8
|
const ToolingEnvSchema = z.object({
|
|
10
9
|
NEXT_PUBLIC_API_URL: z.url(),
|
|
11
10
|
NEXT_PUBLIC_WEB_URL: z.url(),
|
|
12
11
|
PLAYWRIGHT_BASE_URL: z.url().optional(),
|
|
13
12
|
});
|
|
14
13
|
|
|
15
|
-
export
|
|
14
|
+
export function loadToolingEnv(root = path.resolve(scriptDirectory, '..')) {
|
|
15
|
+
// Self-heal FIRST — a fresh clone has no `.env`, and validation must not
|
|
16
|
+
// fire before the environment can be created (the second-developer path:
|
|
17
|
+
// `runtime:smoke` and `e2e` both start here).
|
|
18
|
+
ensureLocalEnvironment(root);
|
|
19
|
+
dotenvConfig({ path: path.resolve(root, '.env'), quiet: true });
|
|
20
|
+
return ToolingEnvSchema.parse(process.env);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Process inheritance is isolated to this tooling environment boundary. */
|
|
24
|
+
export function inheritToolingEnvironment(
|
|
25
|
+
overrides: Record<string, string>,
|
|
26
|
+
): Record<string, string | undefined> {
|
|
27
|
+
return { ...process.env, ...overrides };
|
|
28
|
+
}
|
package/template/tsconfig.json
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
"moduleResolution": "bundler",
|
|
6
6
|
"strict": true,
|
|
7
7
|
"skipLibCheck": true,
|
|
8
|
+
"types": ["bun", "node"],
|
|
8
9
|
"allowImportingTsExtensions": true,
|
|
9
10
|
"noEmit": true,
|
|
10
11
|
"resolveJsonModule": true,
|
|
11
12
|
"verbatimModuleSyntax": true
|
|
12
|
-
}
|
|
13
|
+
},
|
|
14
|
+
"include": ["scripts/**/*.ts", "e2e/**/*.ts", "playwright.config.ts"]
|
|
13
15
|
}
|
package/template/_env
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
NODE_ENV=development
|
|
2
|
-
DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:5432/stitchkit_starter
|
|
3
|
-
API_PORT=3211
|
|
4
|
-
WEB_PORT=3210
|
|
5
|
-
NEXT_PUBLIC_API_URL=http://127.0.0.1:3211
|
|
6
|
-
INTERNAL_API_URL=http://127.0.0.1:3211
|
|
7
|
-
NEXT_PUBLIC_WEB_URL=http://127.0.0.1:3210
|
|
8
|
-
LOG_FORMAT=pretty
|
|
9
|
-
CORS_ORIGIN=*
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# Trusted LAN HTTPS development
|
|
2
|
-
|
|
3
|
-
Use this optional mode to test microphone, camera, PWA and WebRTC behavior on a
|
|
4
|
-
physical device connected to the same private network:
|
|
5
|
-
|
|
6
|
-
```bash
|
|
7
|
-
# Install the maintained local-CA tool first:
|
|
8
|
-
brew install mkcert
|
|
9
|
-
|
|
10
|
-
bun run dev:lan
|
|
11
|
-
# Multiple private interfaces:
|
|
12
|
-
bun run dev:lan --host 192.168.1.20
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
The launcher uses mkcert, stores leaf certificates under
|
|
16
|
-
`~/.local/share/<app-slug>/lan-https`, and keeps the CA in mkcert's own CAROOT.
|
|
17
|
-
No certificate or machine address is written into the project. When the chosen
|
|
18
|
-
address changes, only the leaf certificate is renewed.
|
|
19
|
-
|
|
20
|
-
The command prints the HTTPS web/API URLs and a development-only onboarding URL.
|
|
21
|
-
Open that URL on the phone to download the **public** root certificate. On iOS,
|
|
22
|
-
install the profile and enable full trust under Certificate Trust Settings. On
|
|
23
|
-
Android, install it as a CA certificate; native development builds must opt into
|
|
24
|
-
user-installed roots. The private CA key is never served.
|
|
25
|
-
|
|
26
|
-
`bun run dev` remains plain localhost HTTP. Production never reads the LAN
|
|
27
|
-
certificate variables or mounts the onboarding routes.
|
|
28
|
-
|
|
@@ -1,29 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
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
|
-
});
|