create-stitchkit 0.3.1 → 0.3.3

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 CHANGED
@@ -6,6 +6,46 @@ is declared in the template root catalog.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.3] — 2026-08-18
10
+
11
+ ### Changed
12
+
13
+ - **Generated applications bind loopback by default.** Both processes listen on
14
+ `BIND_HOST` (default `127.0.0.1`) instead of a hardcoded `0.0.0.0` in the
15
+ backend server and both PM2 configs. Exposing the app to the network is now a
16
+ single conscious opt-in (`BIND_HOST=0.0.0.0` in `.env`) rather than the state
17
+ a forgotten edit leaves behind. Reported from a production deployment of a
18
+ generated app.
19
+ - **The template targets Stitchkit `^0.52.0`** — new applications get
20
+ `implement.declare`, keyed registries and hook-derived scope maps out of the
21
+ box. Purely additive relative to 0.50.
22
+ - **`bun run dev` reports honest URLs and fails fast on occupied ports.** The
23
+ final `Web:`/`API:` lines are rendered from the validated environment instead
24
+ of hardcoded ports, and before starting fresh PM2 processes the script probes
25
+ `API_PORT`/`WEB_PORT` and names the offending variable when a foreign process
26
+ holds one. Reloads of the app's own processes are unaffected.
27
+ - **`start` without a build says what to do.** A missing `dist/index.js` now
28
+ fails with “run `bun run build` first” (also preflighted in `pm2:prod`)
29
+ instead of a bare module-resolution error.
30
+ - **README and AGENTS.md pin the Prisma entry point.** Database commands go
31
+ through the root `bun run db:*` scripts; the `prisma` CLI invoked directly has
32
+ no datasource URL by design.
33
+
34
+ ## [0.3.2] — 2026-08-17
35
+
36
+ ### Changed
37
+
38
+ - **The template targets Stitchkit 0.50.0 and binds process signals through the
39
+ framework.** Generated backends replace the hand-written shutdown coordinator
40
+ with `bindProcessSignals(server, …)`: MCP and Prisma close in `onComplete`, the
41
+ exit code is set there, and failures are reported by phase. The manual version
42
+ the template used to ship reported a failing `mcp.close()` as a failed
43
+ shutdown, did nothing on a third signal, and collapsed the grace period when a
44
+ supervisor delivered two signals at once.
45
+ - **The repository example declares its domain error message once.**
46
+ `GITHUB_UNAVAILABLE` carries its text in the `defineErrors` definition instead
47
+ of repeating it at the throw site.
48
+
9
49
  ## [0.3.1] — 2026-08-15
10
50
 
11
51
  ### Changed
@@ -4,6 +4,8 @@ import { z } from 'zod';
4
4
  export const { errors: domainErrors, codes: domainErrorCodes } = defineErrors({
5
5
  GITHUB_UNAVAILABLE: {
6
6
  status: 503,
7
+ // Declared once here instead of at every throw site.
8
+ message: 'GitHub repository data is temporarily unavailable',
7
9
  details: z.object({ cause: z.string() }),
8
10
  },
9
11
  });
@@ -197,7 +197,6 @@ export class GitHubRepositoryCache {
197
197
  } catch (error) {
198
198
  if (previous) return serialize(previous, 'stale');
199
199
  throw domainErrors.GITHUB_UNAVAILABLE({
200
- message: 'GitHub repository data is temporarily unavailable',
201
200
  details: { cause: error instanceof Error ? error.message : 'Unknown upstream error' },
202
201
  hint: 'Retry after the upstream service recovers',
203
202
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-stitchkit",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Create a production-shaped Stitchkit application",
5
5
  "license": "MIT",
6
6
  "author": "Max Listov <maxlistov@gmail.com>",
@@ -29,6 +29,9 @@ Shared never imports an application runtime package.
29
29
  endpoint URLs, query keys or error envelopes by hand.
30
30
  - Use Socket.IO through the shared realtime contract and Stitchkit wrappers.
31
31
  Authentication, authorization and room membership remain application policy.
32
+ - Drive Prisma only through the root `bun run db:*` scripts; the `prisma` CLI
33
+ invoked directly has no datasource URL. Keep `BIND_HOST` at its loopback
34
+ default unless network exposure is an explicit requirement.
32
35
  - Extend runtime smoke with an explicit typed probe for operations whose handler
33
36
  behavior matters. Generic OpenAPI/MCP discovery checks are already derived.
34
37
 
@@ -75,4 +75,12 @@ PostgreSQL is external infrastructure in development and production. The
75
75
  application owns its schema and migrations; the environment owns the database
76
76
  process and supplies its connection through `DATABASE_URL`.
77
77
 
78
+ Both processes bind `BIND_HOST` (default `127.0.0.1`, loopback only). Set
79
+ `BIND_HOST=0.0.0.0` in `.env` to expose the app on every network interface —
80
+ that is a conscious opt-in, typically behind a reverse proxy or firewall.
81
+
82
+ Always drive Prisma through the root `bun run db:*` scripts — invoking the
83
+ `prisma` CLI directly fails because the datasource URL is wired through the
84
+ `@app/db` package environment, not a static config.
85
+
78
86
  `bun run dev` and `bun run pm2:dev` use the same direct PM2 development path.
@@ -1,5 +1,7 @@
1
1
  NODE_ENV=development
2
2
  DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:5432/stitchkit_starter
3
+ # 0.0.0.0 exposes the app to every network interface — opt in consciously.
4
+ BIND_HOST=127.0.0.1
3
5
  API_PORT=3211
4
6
  WEB_PORT=3210
5
7
  NEXT_PUBLIC_API_URL=http://127.0.0.1:3211
package/template/bun.lock CHANGED
@@ -138,7 +138,7 @@
138
138
  },
139
139
  },
140
140
  "catalog": {
141
- "stitchkit": "^0.49.2",
141
+ "stitchkit": "^0.52.0",
142
142
  },
143
143
  "packages": {
144
144
  "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LIAO6kAG8fpXQb9L0iwPk1FIbXftvqnyC56v5NEAzeWTeL8fUsy/Hx86VPBTWEDFdwbVprjWifJOAqS6AOj3mA=="],
@@ -1117,7 +1117,7 @@
1117
1117
 
1118
1118
  "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
1119
1119
 
1120
- "stitchkit": ["stitchkit@0.49.2", "", { "dependencies": { "ky": "^2.0.2" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/server": "^2.0.0", "@socket.io/bun-engine": "^0.1.1", "@socket.io/component-emitter": "^3.1.2", "@tanstack/react-query": ">=5", "@types/bun": "^1.3.14", "ai": "^7.0.0", "react": ">=18", "react-query-kit": "^3.3.3", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "srvx": "^0.12.5", "zod": "^4.4.3" }, "optionalPeers": ["@modelcontextprotocol/ext-apps", "@modelcontextprotocol/server", "@socket.io/bun-engine", "@socket.io/component-emitter", "@tanstack/react-query", "@types/bun", "ai", "react", "react-query-kit", "socket.io", "socket.io-client", "srvx"] }, "sha512-QP0I//fs/khC+bTqYungjpZskAseQAB/Lm8UOx3mZDx+rmYtdsBhlAn0I0hBYE/du9jag5rE032s9HrZgT2VXg=="],
1120
+ "stitchkit": ["stitchkit@0.52.0", "", { "dependencies": { "ky": "^2.0.2" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/server": "^2.0.0", "@socket.io/bun-engine": "^0.1.1", "@socket.io/component-emitter": "^3.1.2", "@tanstack/react-query": ">=5", "@types/bun": "^1.3.14", "ai": "^7.0.0", "react": ">=18", "react-query-kit": "^3.3.3", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "srvx": "^0.12.5", "zod": "^4.4.3" }, "optionalPeers": ["@modelcontextprotocol/ext-apps", "@modelcontextprotocol/server", "@socket.io/bun-engine", "@socket.io/component-emitter", "@tanstack/react-query", "@types/bun", "ai", "react", "react-query-kit", "socket.io", "socket.io-client", "srvx"] }, "sha512-sqtbZUbndsInagUr8/QoD1Hkc8nQMy1TiHu0Txo0XcRMSu4b73aGDdbyk8g4lnhEk0jOqxPXoGQZS/m7XMnnbQ=="],
1121
1121
 
1122
1122
  "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
1123
1123
 
@@ -19,7 +19,13 @@ module.exports = {
19
19
  name: `${identity.slug}-frontend`,
20
20
  cwd: path.join(__dirname, 'packages/frontend'),
21
21
  script: 'node_modules/.bin/next',
22
- args: ['start', '--port', process.env.WEB_PORT, '--hostname', '0.0.0.0'],
22
+ args: [
23
+ 'start',
24
+ '--port',
25
+ process.env.WEB_PORT,
26
+ '--hostname',
27
+ process.env.BIND_HOST ?? '127.0.0.1',
28
+ ],
23
29
  interpreter: 'bun',
24
30
  autorestart: true,
25
31
  kill_timeout: 15000,
@@ -4,7 +4,13 @@ const identity = require('./app.config.json');
4
4
 
5
5
  config({ path: path.join(__dirname, '.env'), quiet: true });
6
6
 
7
- const frontendArgs = ['dev', '--port', process.env.WEB_PORT, '--hostname', '0.0.0.0'];
7
+ const frontendArgs = [
8
+ 'dev',
9
+ '--port',
10
+ process.env.WEB_PORT,
11
+ '--hostname',
12
+ process.env.BIND_HOST ?? '127.0.0.1',
13
+ ];
8
14
 
9
15
  module.exports = {
10
16
  apps: [
@@ -7,7 +7,7 @@
7
7
  "packages/*"
8
8
  ],
9
9
  "catalog": {
10
- "stitchkit": "^0.49.2"
10
+ "stitchkit": "^0.52.0"
11
11
  },
12
12
  "scripts": {
13
13
  "dev": "bun scripts/dev.ts",
@@ -31,7 +31,7 @@
31
31
  "lint": "biome check --error-on-warnings .",
32
32
  "lint:fix": "biome check --write .",
33
33
  "pm2:dev": "bun scripts/dev.ts",
34
- "pm2:prod": "bun run db:deploy && pm2 startOrReload ecosystem.config.cjs --update-env"
34
+ "pm2:prod": "bun run db:deploy && bun packages/backend/scripts/ensure-built.ts && pm2 startOrReload ecosystem.config.cjs --update-env"
35
35
  },
36
36
  "dependencies": {
37
37
  "dotenv": "^17.4.2"
@@ -6,7 +6,7 @@
6
6
  "scripts": {
7
7
  "dev": "bun --watch src/index.ts",
8
8
  "build": "rm -rf dist && bun build src/index.ts src/cli.ts src/tools.ts --outdir dist --target bun --packages external",
9
- "start": "bun dist/index.js",
9
+ "start": "bun scripts/ensure-built.ts && bun dist/index.js",
10
10
  "check": "bun x tsc --noEmit",
11
11
  "test": "bun test --pass-with-no-tests",
12
12
  "cli": "bun src/cli.ts",
@@ -0,0 +1,7 @@
1
+ // Preflight for `start` / `pm2:prod`: a missing build otherwise surfaces as a
2
+ // bare `Module not found "dist/index.js"` with no hint at the obvious fix.
3
+ const entry = new URL('../dist/index.js', import.meta.url);
4
+ if (!(await Bun.file(entry).exists())) {
5
+ console.error('dist/index.js not found — run `bun run build` first.');
6
+ process.exit(1);
7
+ }
@@ -1,7 +1,12 @@
1
1
  import { env } from '@app/config';
2
2
  import { appIdentity } from '@app/config/identity';
3
3
  import { wrapInRequestContext } from 'stitchkit/observability';
4
- import { createServer, generateOpenApiDocument, openApiRoute } from 'stitchkit/server';
4
+ import {
5
+ bindProcessSignals,
6
+ createServer,
7
+ generateOpenApiDocument,
8
+ openApiRoute,
9
+ } from 'stitchkit/server';
5
10
  import { createMcpHandler, createMcpHttpRoute } from 'stitchkit/tools';
6
11
  import { prisma } from './lib/db';
7
12
  import { createSurface } from './surface';
@@ -22,7 +27,7 @@ async function main(): Promise<void> {
22
27
  const server = createServer({
23
28
  groups: [{ pathPrefix: '/api', services }],
24
29
  port: env.API_PORT,
25
- hostname: '0.0.0.0',
30
+ hostname: env.BIND_HOST,
26
31
  cors: { origin: env.CORS_ORIGIN },
27
32
  hooks: { onError },
28
33
  logging: { format: env.LOG_FORMAT },
@@ -39,34 +44,23 @@ async function main(): Promise<void> {
39
44
  wrapFetch: (fetch) => wrapInRequestContext(fetch),
40
45
  });
41
46
 
42
- const shutdownController = new AbortController();
43
- let shutdownPromise: Promise<void> | undefined;
44
-
45
- function shutdown(): Promise<void> {
46
- if (shutdownPromise) {
47
- shutdownController.abort();
48
- return shutdownPromise;
49
- }
50
-
51
- shutdownPromise = (async () => {
52
- await server.shutdown({
53
- gracePeriodMs: 30_000,
54
- signal: shutdownController.signal,
55
- });
47
+ // The server owns HTTP and Socket.IO; MCP, Prisma and the exit code are the
48
+ // application's and close after the drain. A second signal forces this same
49
+ // shutdown, a third hands the signal back to its default disposition.
50
+ bindProcessSignals(server, {
51
+ shutdown: { gracePeriodMs: 30_000 },
52
+ onComplete: async (result) => {
56
53
  await mcp.close();
57
54
  await prisma.$disconnect();
58
- })();
59
- return shutdownPromise;
60
- }
61
-
62
- const onSignal = () =>
63
- void shutdown().catch((error: unknown) => {
64
- console.error('Shutdown failed', error);
65
- });
55
+ process.exitCode = result.outcome === 'clean' ? 0 : 1;
56
+ },
57
+ onError: (phase, error) => {
58
+ console.error(`Shutdown failed during ${phase}`, error);
59
+ process.exitCode = 1;
60
+ },
61
+ });
66
62
 
67
- process.on('SIGTERM', onSignal);
68
- process.on('SIGINT', onSignal);
69
- console.log(`API listening on http://127.0.0.1:${env.API_PORT}`);
63
+ console.log(`API listening on http://${env.BIND_HOST}:${env.API_PORT}`);
70
64
  }
71
65
 
72
66
  main().catch((error: unknown) => {
@@ -4,5 +4,5 @@
4
4
  "types": ["bun"],
5
5
  "paths": { "@/*": ["./src/*"] }
6
6
  },
7
- "include": ["src"]
7
+ "include": ["src", "scripts"]
8
8
  }
@@ -10,6 +10,9 @@ export const env = createEnv({
10
10
  server: {
11
11
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
12
12
  DATABASE_URL: z.url(),
13
+ // Loopback by default — exposing the app to the network is an explicit
14
+ // opt-in (`BIND_HOST=0.0.0.0`), never something a forgotten edit causes.
15
+ BIND_HOST: z.string().min(1).default('127.0.0.1'),
13
16
  API_PORT: z.coerce.number().int().positive(),
14
17
  WEB_PORT: z.coerce.number().int().positive(),
15
18
  NEXT_PUBLIC_API_URL: z.url(),
@@ -1,4 +1,5 @@
1
1
  import { resolve } from 'node:path';
2
+ import { z } from 'zod';
2
3
  import { appIdentity } from '../packages/config/src/identity';
3
4
  import { ensureLocalEnvironment } from './local-env';
4
5
  import { inheritToolingEnvironment } from './tooling-env';
@@ -26,6 +27,7 @@ export async function runDevelopment(environment?: Record<string, string>): Prom
26
27
  'DATABASE_URL still contains the starter placeholder. Create a PostgreSQL database, update DATABASE_URL in .env, then rerun `bun run dev`.',
27
28
  );
28
29
  }
30
+ await assertPortsAvailable(environmentForRun);
29
31
  await run(['bun', 'run', 'db:setup'], environmentForRun);
30
32
  await run(
31
33
  ['pm2', 'startOrReload', 'ecosystem.dev.config.cjs', '--update-env'],
@@ -33,6 +35,50 @@ export async function runDevelopment(environment?: Record<string, string>): Prom
33
35
  );
34
36
  }
35
37
 
38
+ /**
39
+ * Fail fast with the offending variable when a port is held by a FOREIGN
40
+ * process. A rerun of `bun run dev` reloads this app's own PM2 processes while
41
+ * they still hold the ports, so the probe is skipped once they are registered.
42
+ */
43
+ async function assertPortsAvailable(environment: Record<string, string>): Promise<void> {
44
+ const registered = await registeredPm2Names();
45
+ const managed = [`${appIdentity.slug}-backend-dev`, `${appIdentity.slug}-frontend-dev`];
46
+ if (managed.some((name) => registered.has(name))) return;
47
+ assertPortFree(Number(environment.API_PORT), 'API_PORT');
48
+ assertPortFree(Number(environment.WEB_PORT), 'WEB_PORT');
49
+ }
50
+
51
+ async function registeredPm2Names(): Promise<Set<string>> {
52
+ const child = Bun.spawn(['pm2', 'jlist'], { cwd: root, stdout: 'pipe', stderr: 'ignore' });
53
+ const output = await new Response(child.stdout).text();
54
+ await child.exited;
55
+ const parsed = z.array(z.object({ name: z.string() })).safeParse(safeJsonParse(output));
56
+ return new Set(parsed.success ? parsed.data.map((entry) => entry.name) : []);
57
+ }
58
+
59
+ function safeJsonParse(text: string): unknown {
60
+ try {
61
+ return JSON.parse(text);
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ function assertPortFree(port: number, variable: string): void {
68
+ try {
69
+ const listener = Bun.listen({
70
+ hostname: '127.0.0.1',
71
+ port,
72
+ socket: { data: () => undefined },
73
+ });
74
+ listener.stop(true);
75
+ } catch {
76
+ throw new Error(
77
+ `Port ${port} (${variable}) is already in use by another process. Pick a free port in .env and update the URLs that embed it.`,
78
+ );
79
+ }
80
+ }
81
+
36
82
  function assertToolAvailable(command: string, instruction: string): void {
37
83
  if (!Bun.which(command)) throw new Error(`${command} is required. ${instruction}`);
38
84
  }
@@ -43,6 +89,7 @@ export async function developmentEnvironment(
43
89
  const { env } = await import('../packages/config/src/server');
44
90
  return {
45
91
  DATABASE_URL: env.DATABASE_URL,
92
+ BIND_HOST: env.BIND_HOST,
46
93
  API_PORT: String(env.API_PORT),
47
94
  WEB_PORT: String(env.WEB_PORT),
48
95
  CORS_ORIGIN: env.CORS_ORIGIN,
@@ -56,7 +103,8 @@ export async function developmentEnvironment(
56
103
  if (import.meta.main) {
57
104
  await runDevelopment();
58
105
 
106
+ const environment = await developmentEnvironment();
59
107
  console.log(`${appIdentity.name} development processes are running`);
60
- console.log('Web: http://localhost:3210/en');
61
- console.log('API: http://localhost:3211');
108
+ console.log(`Web: ${environment.NEXT_PUBLIC_WEB_URL}/en`);
109
+ console.log(`API: ${environment.NEXT_PUBLIC_API_URL}`);
62
110
  }