create-stitchkit 0.4.4 → 0.5.1

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
@@ -12,6 +12,101 @@ step is overwritten by the next release.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.5.1] — 2026-09-01
16
+
17
+ Three findings from someone setting up a new application on the starter from
18
+ scratch, as a consumer who had never seen it. All of them live between "the
19
+ scaffold is green" and "my first feature renders".
20
+
21
+ ### Fixed
22
+
23
+ - **The generated `.env` no longer looks ready when it is not.** `local-env.ts`
24
+ rendered the database name and left `USER:PASSWORD` literal, in a file a
25
+ generator had just written — and a generated file reads as finished.
26
+ `assertUsableEnvironment` now names the file, the line and the variable, and
27
+ covers `ACCEPTANCE_DATABASE_URL` as well as `DATABASE_URL`. It runs before the
28
+ supervisor check, so an unusable environment is reported instead of a pm2
29
+ error, and on every run rather than only the one that created the file.
30
+ Rendering still succeeds: a generator that refuses to generate would break
31
+ `--no-install` scaffolding.
32
+
33
+ - **`CREATEDB` is named where `DATABASE_URL` is named.** `prisma migrate dev`
34
+ creates a shadow database, so a least-privilege role — the sensible default
35
+ on a shared server — fails `db:migrate` with `P3014`. Neither the README nor
36
+ `_env.example` mentioned it.
37
+
38
+ - **`check:authored` no longer refuses `as const`.** The gate exists to catch a
39
+ cast that can *launder* a type; a const-assertion only narrows, introduces no
40
+ name and cannot widen. Five of the first fifteen findings on a real adoption
41
+ were this false positive. Findings now also name the sanctioned alternative
42
+ instead of only the sin.
43
+
44
+ - **`ADDING_A_FEATURE.md` no longer points at files the scaffold lacks.** Steps
45
+ 4 and 5 referenced `lib/api/client.ts` and a "shared realtime source" that a
46
+ generated project does not contain, phrased as "the same pattern used by the
47
+ application's other contracts" — of which there were none. Both steps now
48
+ create what they need, with the transport file given in full.
49
+
50
+ ### Added
51
+
52
+ - **`check:guides`**, part of `check`: every repository path a guide names must
53
+ exist, unless the guide declares it with `(created in this step)`. The guide
54
+ had five such references and three of them were legitimate; only a gate tells
55
+ those apart reliably.
56
+
57
+ ## [0.5.0] — 2026-09-01
58
+
59
+ ### ⚠️ Breaking changes
60
+
61
+ **Who must act:** two audiences. Anyone holding a generated **Agent** project
62
+ changes one key in one object, and nothing reports it when it goes wrong.
63
+ **Everyone** re-reads the Zod item: an API that accepted a timestamp without
64
+ seconds stops accepting it, which is a change in what your endpoints admit, not
65
+ in what your code compiles to.
66
+
67
+ - **The Agent approval policy keys `edit_file`, not `apply_patch`.** Stitchkit
68
+ 0.71.0 replaced `apply_patch` with a one-call `edit_file`, and `toolApproval`
69
+ names tools by string. A key matching no tool is not an error — an unlisted
70
+ tool falls through to "no approval required", so the effect of leaving the old
71
+ name is not that editing breaks. It is that editing stops asking: the one tool
72
+ the policy existed to gate now runs unattended, and the only visible sign is an
73
+ approval prompt that never appears.
74
+ `// before: toolApproval: { apply_patch: 'user-approval' }` →
75
+ `// after: toolApproval: { edit_file: 'user-approval' }`
76
+
77
+ - **Zod 4.5 makes seconds mandatory in `z.iso.datetime()`.** The template moves
78
+ to `zod@4.5.4`, and the tightening travels with it: `2026-08-08T00:15Z`
79
+ validated before and is refused now. Nothing in the generated code changes —
80
+ what changes is the set of inputs your API accepts, so a client that omitted
81
+ seconds starts receiving `BAD_REQUEST`. If you need the old latitude, say so
82
+ in the schema rather than by holding the version back:
83
+ `// before: z.iso.datetime()` → `// after: z.iso.datetime() // seconds now required`
84
+ The same release regenerates the committed **surface snapshots**: 4.5 encodes
85
+ a nullable as `type: ['string','null']` where 4.4 wrote `anyOf`, so every
86
+ affected shape fingerprint moves. A project of your own carrying
87
+ `surface.snapshot.json` regenerates it with `bun run surface:snapshot` and
88
+ reviews the diff — the hashes move, the operations do not.
89
+
90
+ ### Changed
91
+
92
+ - **The generated project targets the published `stitchkit@0.71.0` line.** The
93
+ catalog target and the frozen lockfile move together, so a fresh scaffold
94
+ receives `edit_file`, `list_directory`, `glob`, the typed coding-tool refusals
95
+ a model can act on, the context usage a step reports, and the peer-free
96
+ `stitchkit/telegram` leaf.
97
+ - **The whole toolchain moves to its current releases** — Zod 4.5.4 (see the
98
+ breaking note above), Biome 2.5.11, `@types/node` 26.4.0, and `ai` 7.0.87 in
99
+ the Agent template. One Zod resolves across the repository and both templates,
100
+ which is now a gate rather than a coincidence: two minors of Zod are two
101
+ incompatible type systems, and the error that surfaces names neither Zod nor
102
+ the file that moved it.
103
+ - **The Agent approval policy is exhaustive again.** 0.71.0 added
104
+ `list_directory` and `glob`; both are read-only and both were therefore
105
+ running under the framework's default rather than under the project's own
106
+ policy. They are now named `'approved'` beside `read_file` and `search_files`.
107
+ Nothing about what happens changes — what changes is that the file says so,
108
+ which is the whole reason the map is written out rather than defaulted.
109
+
15
110
  ## [0.4.4] — 2026-08-30
16
111
 
17
112
  ### Changed
package/UPGRADING.md CHANGED
@@ -60,6 +60,71 @@ the first scaffolder release with a migration channel of its own.
60
60
 
61
61
  ---
62
62
 
63
+ ## Released migration: 0.5.0
64
+
65
+ ### the approval policy names a tool that no longer exists
66
+
67
+ One code edit, and one thing to do to a machine that was left mid-question.
68
+
69
+ 1. **Rename the key.** In `src/runtime.ts`, inside `loop.toolApproval`, replace
70
+ `apply_patch: 'user-approval'` with `edit_file: 'user-approval'`. While you
71
+ are in that object, add `list_directory: 'approved'` and `glob: 'approved'`
72
+ beside `read_file` and `search_files` — 0.71.0 added both, they are read-only,
73
+ and an unlisted tool is governed by the framework's default rather than by
74
+ this file.
75
+
76
+ Do this **before** you start the upgraded agent, not after. The failure mode
77
+ is silent in the direction that costs you: a key matching no tool does not
78
+ raise, it simply stops gating, so the first edit after the upgrade is applied
79
+ without asking and looks exactly like an edit you approved.
80
+
81
+ 2. **Operator step — a durable session left waiting on the old tool cannot be
82
+ answered.** If an agent was interrupted while a `apply_patch` approval was
83
+ pending, that approval names a call for a tool the framework no longer
84
+ defines: approving it cannot execute anything, and the run will not move.
85
+ Interrupt that run, or drop the local state directory — `.stitchkit/` holds
86
+ `agent.sqlite`, the approval secret and the TUI logs, all of it local and
87
+ regenerated on the next start. There is nothing durable in it that a server
88
+ owns.
89
+
90
+ A session with no pending approval needs none of this and resumes normally.
91
+
92
+ 3. **The framework's own move is separate.** Going from a `0.70.x` line to
93
+ `0.71.0` is a Stitchkit upgrade with its own breaking notes — the coding-tool
94
+ surface changed for anyone calling it directly, not only through this
95
+ template. Read
96
+ [`docs/guide/upgrading.md`](../../docs/guide/upgrading.md), section
97
+ `Released migration: 0.71.0`.
98
+
99
+ ### the timestamp your API accepts got narrower
100
+
101
+ Zod moved to 4.5, and `z.iso.datetime()` now requires seconds. This is not a
102
+ code change — it is a change in what your endpoints admit, and it arrives the
103
+ moment you install.
104
+
105
+ 1. **Decide before you deploy, not after.** If any client sends
106
+ `2026-08-08T00:15Z`, it now receives `BAD_REQUEST` where it used to receive
107
+ `200`. Search your schemas for `z.iso.datetime()` and check who fills those
108
+ fields. A machine-to-machine caller you control is a one-line fix on its
109
+ side; a caller you do not control is a decision, and the honest form of it is
110
+ an explicit schema that accepts what you mean to accept — not a pinned Zod
111
+ version, which only moves the same day to a later one.
112
+
113
+ 2. **Regenerate your surface snapshot in the same commit.** 4.5 encodes a
114
+ nullable as `type: ["string","null"]` where 4.4 wrote `anyOf`, so the shape
115
+ fingerprints in `packages/backend/src/surface.snapshot.json` move without any
116
+ operation changing. Run `bun run surface:snapshot` and read the diff: only
117
+ `inputShape` / `outputShape` values may differ. If an operation appeared,
118
+ vanished, or changed its HTTP or tool exposure, that is **your** change and
119
+ Zod did not cause it.
120
+
121
+ 3. **Move one Zod, not several.** If your project vendors or links packages that
122
+ depend on Zod themselves, they must resolve the same minor. Two minors are
123
+ two incompatible type systems, and what surfaces is
124
+ `TS2589: Type instantiation is excessively deep` in a file you did not touch.
125
+ No operator step: nothing about a running machine changes.
126
+
127
+
63
128
  ## Released migration: 0.4.1
64
129
 
65
130
  ### a release refuses a stale artifact, and cleanup is bounded
@@ -6,7 +6,7 @@
6
6
  "hasInput": false,
7
7
  "hasOutput": true,
8
8
  "inputShape": null,
9
- "outputShape": "49c1f81a53ca82ad",
9
+ "outputShape": "05d1386c30d82f6a",
10
10
  "http": [
11
11
  {
12
12
  "method": "GET",
@@ -26,7 +26,7 @@
26
26
  "hasInput": false,
27
27
  "hasOutput": true,
28
28
  "inputShape": null,
29
- "outputShape": "49c1f81a53ca82ad",
29
+ "outputShape": "05d1386c30d82f6a",
30
30
  "http": [
31
31
  {
32
32
  "method": "POST",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-stitchkit",
3
- "version": "0.4.4",
3
+ "version": "0.5.1",
4
4
  "description": "Create a production-shaped Stitchkit application",
5
5
  "license": "MIT",
6
6
  "author": "Max Listov <maxlistov@gmail.com>",
@@ -83,7 +83,7 @@
83
83
  "@types/react": "^19.2.18",
84
84
  "ai": "^7.0.84",
85
85
  "react": "^19.2.8",
86
- "stitchkit": "0.70.1",
86
+ "stitchkit": "0.71.0",
87
87
  "typescript": "^7.0.2"
88
88
  },
89
89
  "engines": {
@@ -29,6 +29,17 @@ Point `DATABASE_URL` in `.env` at an existing PostgreSQL database, then run:
29
29
  bun run dev
30
30
  ```
31
31
 
32
+ `.env` is generated on first run with the database name filled in and the
33
+ credentials left as `USER:PASSWORD`. Replace them: `dev` refuses to start while
34
+ the placeholder is there, naming the file and the line, rather than letting the
35
+ driver fail on the first request.
36
+
37
+ **The role that runs `bun run db:migrate` needs `CREATEDB`.** `prisma migrate
38
+ dev` creates a throwaway shadow database to diff against, so a least-privilege
39
+ role — the sensible default for a shared server — fails with `P3014: could not
40
+ create the shadow database`. Grant `CREATEDB` to the development role, or point
41
+ Prisma at a shadow database you create yourself.
42
+
32
43
  The command validates the environment, generates the Prisma client, applies any
33
44
  database migrations you add and launches:
34
45
 
@@ -1,4 +1,7 @@
1
1
  NODE_ENV=development
2
+ # Replace USER:PASSWORD before the first `bun run dev` — it refuses to start
3
+ # while they are here. The role also needs CREATEDB if it will run
4
+ # `db:migrate`: `prisma migrate dev` creates a shadow database to diff against.
2
5
  DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:5432/stitchkit_starter
3
6
  # The throwaway database `bun run acceptance:local` creates and writes to. The
4
7
  # runtime gates WRITE, so they get one of their own: the harness refuses to
package/template/bun.lock CHANGED
@@ -11,16 +11,16 @@
11
11
  "@app/config": "workspace:*",
12
12
  "@app/shared": "workspace:*",
13
13
  "@axe-core/playwright": "^4.13.0",
14
- "@biomejs/biome": "^2.5.10",
14
+ "@biomejs/biome": "^2.5.11",
15
15
  "@modelcontextprotocol/client": "^2.0.0",
16
16
  "@playwright/test": "^1.62.1",
17
17
  "@types/bun": "^1.4.0",
18
- "@types/node": "^26.3.0",
18
+ "@types/node": "^26.4.0",
19
19
  "oxc-parser": "^0.147.0",
20
20
  "socket.io-client": "^4.8.3",
21
21
  "stitchkit": "catalog:",
22
22
  "typescript": "^7.0.2",
23
- "zod": "^4.4.3",
23
+ "zod": "^4.5.4",
24
24
  },
25
25
  },
26
26
  "packages/backend": {
@@ -139,7 +139,7 @@
139
139
  },
140
140
  },
141
141
  "catalog": {
142
- "stitchkit": "^0.70.1",
142
+ "stitchkit": "^0.71.0",
143
143
  },
144
144
  "packages": {
145
145
  "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.63", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.29", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-D7BogSRg61QfTdr7AEcYn9h0I/e4QHvFXwIV1RW+DZZGJu1wSiX2cH06szZSYyKi7Eat50V4s4J8vggZUEs7eg=="],
@@ -168,23 +168,23 @@
168
168
 
169
169
  "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
170
170
 
171
- "@biomejs/biome": ["@biomejs/biome@2.5.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.10", "@biomejs/cli-darwin-x64": "2.5.10", "@biomejs/cli-linux-arm64": "2.5.10", "@biomejs/cli-linux-arm64-musl": "2.5.10", "@biomejs/cli-linux-x64": "2.5.10", "@biomejs/cli-linux-x64-musl": "2.5.10", "@biomejs/cli-win32-arm64": "2.5.10", "@biomejs/cli-win32-x64": "2.5.10" }, "bin": { "biome": "bin/biome" } }, "sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ=="],
171
+ "@biomejs/biome": ["@biomejs/biome@2.5.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.11", "@biomejs/cli-darwin-x64": "2.5.11", "@biomejs/cli-linux-arm64": "2.5.11", "@biomejs/cli-linux-arm64-musl": "2.5.11", "@biomejs/cli-linux-x64": "2.5.11", "@biomejs/cli-linux-x64-musl": "2.5.11", "@biomejs/cli-win32-arm64": "2.5.11", "@biomejs/cli-win32-x64": "2.5.11" }, "bin": { "biome": "bin/biome" } }, "sha512-Tj0dnkLPdW0ASjHfj2D/ZkkvPU2wrFmnE1jWTD2xzV1ycapV1DutbYXk4NDnR3rYTi1ZCbNFD4G2gRMEY65WaA=="],
172
172
 
173
- "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q=="],
173
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6SGZxoKbXvUjMn1t6A98HqWISPnGNbYs0R/Rt2JarmXBSev+lva4QxUMWEBX9lX1Wo1XTJ78uk5xVDtG58SRZg=="],
174
174
 
175
- "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw=="],
175
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-nYkXY7tLBEgnGbYapDKAyKzgt44ZEyG+AKalvTXtCWKYgepI9dw327q+cVgedxm+Udi1ZzHKUyZrIusHi/KQbw=="],
176
176
 
177
- "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg=="],
177
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-3PVLSTD9RR73rvVPt5G3T1gc+ycggWEGfTD7RvzzbtcDPD27NxgxBbAFfpm7DXJKW6VLHWE1lLMGvFt2Qxjcow=="],
178
178
 
179
- "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw=="],
179
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-qhyZUMyCbWYFV2bAwRNVvfMVZ+hv7WYl6mossGrxC+uiQQXhvsuWWU8zz6jYX0mChZd9MgQZbm4vozTmG/5iGw=="],
180
180
 
181
- "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg=="],
181
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-JOytptlsgM33B2MMFUg8iBrb4IKpbD5JnJrSeYiaFEeAj4vuXx0iQSQZ4qK7sqyMtfjZxxPdNdMZZVL4y/mFyA=="],
182
182
 
183
- "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ=="],
183
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-oRRlrchG5EfrEL/EmtT1qUjSNHk3/5LGeZhQqADBBAJF1b1ET6964xEKe7aGlGARzDfza8H/seEsFJl7S6Ql9w=="],
184
184
 
185
- "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A=="],
185
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-e49E6K9hzH/ohJNx8Y26mY8HaV4I4ZViIeoqhKsmoXLKHhQnMeBAVqCgsGf2Wa3lXlS7RkporDXMHHWkzvZzFw=="],
186
186
 
187
- "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA=="],
187
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.11", "", { "os": "win32", "cpu": "x64" }, "sha512-QSQr/KjOgXA7OzXJUWS+oguKyAZ3Q0l/lnlDGbu397eKo83atuWUjBPJrsqbKNF6CARGw8XXJLGzpHC8Ryhd4Q=="],
188
188
 
189
189
  "@electric-sql/pglite": ["@electric-sql/pglite@0.4.3", "", {}, "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ=="],
190
190
 
@@ -620,7 +620,7 @@
620
620
 
621
621
  "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
622
622
 
623
- "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="],
623
+ "@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
624
624
 
625
625
  "@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="],
626
626
 
@@ -1118,7 +1118,7 @@
1118
1118
 
1119
1119
  "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
1120
1120
 
1121
- "stitchkit": ["stitchkit@0.70.1", "", { "dependencies": { "ky": "^2.0.2" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/server": "^2.0.0", "@openrouter/ai-sdk-provider": "^3.0.0", "@opentelemetry/api": "^1.9.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", "grammy": "^1.45.1", "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", "@openrouter/ai-sdk-provider", "@opentelemetry/api", "@socket.io/bun-engine", "@socket.io/component-emitter", "@tanstack/react-query", "@types/bun", "ai", "grammy", "react", "react-query-kit", "socket.io", "socket.io-client", "srvx"] }, "sha512-UySE/DO1p7XZDmbISX3+U9RCYpepqsElovnL4IgUu0C9BpsFXGbYpoF0nL38vpY8SLV2frLiZXyy05gmmyrhrg=="],
1121
+ "stitchkit": ["stitchkit@0.71.0", "", { "dependencies": { "ky": "^2.0.2" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/server": "^2.0.0", "@openrouter/ai-sdk-provider": "^3.0.0", "@opentelemetry/api": "^1.9.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", "grammy": "^1.45.1", "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", "@openrouter/ai-sdk-provider", "@opentelemetry/api", "@socket.io/bun-engine", "@socket.io/component-emitter", "@tanstack/react-query", "@types/bun", "ai", "grammy", "react", "react-query-kit", "socket.io", "socket.io-client", "srvx"] }, "sha512-HXqTD1Sv534rWt+KKJV1Gp535NTRzbGFxNMuRAvo9TzuU0kZxBDF18gu+WexF9O8Z4jkhlfvTjXeRy/oQrnHwA=="],
1122
1122
 
1123
1123
  "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=="],
1124
1124
 
@@ -1186,10 +1186,13 @@
1186
1186
 
1187
1187
  "zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="],
1188
1188
 
1189
- "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
1189
+ "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
1190
1190
 
1191
1191
  "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
1192
1192
 
1193
+
1194
+
1195
+
1193
1196
  "@prisma/adapter-pg/@types/pg": ["@types/pg@8.21.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q=="],
1194
1197
 
1195
1198
  "@prisma/adapter-pg/pg": ["pg@8.22.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.15.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA=="],
@@ -1220,6 +1223,8 @@
1220
1223
 
1221
1224
  "@types/cors/@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
1222
1225
 
1226
+ "@types/pg/@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="],
1227
+
1223
1228
  "@types/ws/@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
1224
1229
 
1225
1230
  "@visx/vendor/@types/d3-array": ["@types/d3-array@3.0.3", "", {}, "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ=="],
@@ -1236,6 +1241,8 @@
1236
1241
 
1237
1242
  "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
1238
1243
 
1244
+ "bun-types/@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="],
1245
+
1239
1246
  "engine.io/@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
1240
1247
 
1241
1248
  "pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
@@ -7,7 +7,7 @@ same files beside it.
7
7
 
8
8
  ## 1. Define the wire data
9
9
 
10
- Create `packages/shared/src/schemas/status.ts`:
10
+ Create `packages/shared/src/schemas/status.ts` (created in this step):
11
11
 
12
12
  ```ts
13
13
  import { z } from 'zod'
@@ -21,7 +21,7 @@ Export it from the shared package. Do not introduce a second handwritten DTO.
21
21
 
22
22
  ## 2. Define the HTTP/tool contract separately
23
23
 
24
- Create `packages/shared/src/contracts/status.ts` and import the named schemas:
24
+ Create `packages/shared/src/contracts/status.ts` (created in this step) and import the named schemas:
25
25
 
26
26
  ```ts
27
27
  import { defineContract } from 'stitchkit'
@@ -44,7 +44,7 @@ The contract owns transport identity. Do not add a raw route or duplicate path.
44
44
 
45
45
  ## 3. Implement and register the service
46
46
 
47
- Create `packages/backend/src/transport/status-service.ts` with `implement()`.
47
+ Create `packages/backend/src/transport/status-service.ts` (created in this step) with `implement()`.
48
48
  Keep persistence and business rules in a domain/service module; the contract
49
49
  handler calls that module once. Add the returned service to the `services` array
50
50
  in `packages/backend/src/surface.ts`. That one registration drives HTTP,
@@ -52,20 +52,44 @@ OpenAPI, MCP, agent tools and CLI discovery.
52
52
 
53
53
  ## 4. Add typed browser access
54
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.
55
+ Export `statusContract` from `packages/shared/src/index.ts`.
56
+
57
+ The scaffold ships no browser transport layer, and that is the first thing this
58
+ step builds. It also makes one decision for you, because the starter already
59
+ made it: **no address is compiled into the artifact.** `packages/frontend/src/env.ts`
60
+ declares no `client` block on purpose — a `NEXT_PUBLIC_` variable is substituted
61
+ at build time, which freezes a value of the place into the bundle. So the client
62
+ is a factory over an origin the server reads per request, never a module-level
63
+ constant.
64
+
65
+ Create `packages/frontend/src/lib/api/client.ts` (created in this step):
66
+
67
+ ```ts
68
+ import { createClient, createHttpClient } from 'stitchkit'
69
+ import { statusContract } from '@app/shared'
70
+
71
+ /** One origin per request, supplied by the server — never read from the bundle. */
72
+ export function createStatusApi(origin: string) {
73
+ return createClient(statusContract, createHttpClient({ prefixUrl: origin }))
74
+ }
75
+ ```
76
+
77
+ Create `packages/frontend/src/lib/api/status.ts` (created in this step) for the
78
+ query key and the react-query-kit hooks, and keep the key canonical — one key
79
+ per resource, updated or invalidated on mutation success.
80
+
81
+ A server component reads `env.PUBLIC_API_ORIGIN` and hands it down as a prop;
82
+ the client component calls `createStatusApi(origin)`. That is the whole reason
83
+ `PUBLIC_API_ORIGIN` is a server variable rather than a public one.
61
84
 
62
85
  Render the hook from a feature component. Pages compose features; they do not
63
86
  call `fetch`, construct `/api/status` or decode error bodies themselves.
64
87
 
65
88
  ## 5. Add realtime only when another client must observe the change
66
89
 
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
90
+ Create `packages/shared/src/realtime.ts` (created in this step) and declare the
91
+ event there with a named Zod schema for its tuple — the scaffold ships no
92
+ realtime module, so this step introduces it rather than assuming it. The server emits after the domain change succeeds; the frontend cache
69
93
  bridge reacts by updating or invalidating the status query. Keep handshake auth,
70
94
  authorization and room membership in the application. Socket.IO delivery,
71
95
  reconnection, retained subscriptions and validation belong to Stitchkit.
@@ -7,12 +7,13 @@
7
7
  "packages/*"
8
8
  ],
9
9
  "catalog": {
10
- "stitchkit": "^0.70.1"
10
+ "stitchkit": "^0.71.0"
11
11
  },
12
12
  "scripts": {
13
13
  "dev": "bun scripts/dev.ts",
14
- "check": "bun run db:generate && bun run check:authored && bun x tsc -p tsconfig.json --noEmit && bun run --filter '*' check",
14
+ "check": "bun run db:generate && bun run check:authored && bun run check:guides && bun x tsc -p tsconfig.json --noEmit && bun run --filter '*' check",
15
15
  "check:authored": "bun scripts/check-authored.ts",
16
+ "check:guides": "bun scripts/guide-paths.ts",
16
17
  "test": "bun test scripts && bun run --filter '*' test",
17
18
  "build": "bun scripts/build-inputs.ts && bun run db:generate && bun --filter @app/backend build && bun --filter @app/frontend build && bun scripts/build-stamp.ts",
18
19
  "start:api": "bun --filter @app/backend start",
@@ -42,16 +43,16 @@
42
43
  "@app/config": "workspace:*",
43
44
  "@app/shared": "workspace:*",
44
45
  "@axe-core/playwright": "^4.13.0",
45
- "@biomejs/biome": "^2.5.10",
46
+ "@biomejs/biome": "^2.5.11",
46
47
  "@modelcontextprotocol/client": "^2.0.0",
47
48
  "@playwright/test": "^1.62.1",
48
49
  "@types/bun": "^1.4.0",
49
- "@types/node": "^26.3.0",
50
+ "@types/node": "^26.4.0",
50
51
  "oxc-parser": "^0.147.0",
51
52
  "socket.io-client": "^4.8.3",
52
53
  "stitchkit": "catalog:",
53
54
  "typescript": "^7.0.2",
54
- "zod": "^4.4.3"
55
+ "zod": "^4.5.4"
55
56
  },
56
57
  "engines": {
57
58
  "bun": ">=1.2.0",
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { inspect } from './check-authored';
3
+
4
+ describe('check:authored — assertions', () => {
5
+ test('lets a const assertion through: it narrows and cannot launder a type', () => {
6
+ const source = [
7
+ "export const benchKeys = { jobs: ['bench', 'jobs'] as const };",
8
+ "export const modes = ['fast', 'full'] as const;",
9
+ '',
10
+ ].join('\n');
11
+ expect(inspect('packages/shared/src/bench.ts', source)).toEqual([]);
12
+ });
13
+
14
+ test('still refuses a real assertion, and the finding names what to do instead', () => {
15
+ const findings = inspect(
16
+ 'packages/shared/src/parse.ts',
17
+ 'const parsed = JSON.parse(raw) as Payload;\n',
18
+ );
19
+ expect(findings).toHaveLength(1);
20
+ const [finding] = findings;
21
+ // Line and file, so it is navigable…
22
+ expect(finding).toContain('packages/shared/src/parse.ts:1');
23
+ // …and the remedy, so the reader does not have to search for the sanctioned way.
24
+ expect(finding).toContain('satisfies');
25
+ expect(finding).toContain('schema at the boundary');
26
+ });
27
+
28
+ test('the angle-bracket form is refused too, and explicit any separately', () => {
29
+ expect(inspect('scripts/x.ts', 'const a = <Foo>bar;\n')).toHaveLength(1);
30
+ const anyFindings = inspect('scripts/y.ts', 'function f(a: any) { return a; }\n');
31
+ expect(anyFindings).toHaveLength(1);
32
+ expect(anyFindings[0]).toContain('explicit any');
33
+ });
34
+
35
+ test('a const assertion beside a real one reports only the real one', () => {
36
+ const findings = inspect(
37
+ 'scripts/mixed.ts',
38
+ "const keys = ['a'] as const;\nconst value = raw as Payload;\n",
39
+ );
40
+ expect(findings).toHaveLength(1);
41
+ expect(findings[0]).toContain('scripts/mixed.ts:2');
42
+ });
43
+ });
@@ -20,15 +20,42 @@ function lineAt(source: string, offset: number): number {
20
20
  return source.slice(0, offset).split('\n').length;
21
21
  }
22
22
 
23
- function inspect(path: string, source: string): string[] {
23
+ /**
24
+ * `as const` is not the assertion this gate exists for.
25
+ *
26
+ * The gate refuses a cast because a cast can LAUNDER a type — claim something the value has not
27
+ * been shown to be. A const-assertion cannot: it only narrows, it introduces no name, and it
28
+ * cannot widen. Refusing it made five of the first fifteen findings on a real adoption false, in
29
+ * a gate whose whole value is that its findings are worth acting on.
30
+ *
31
+ * `const` is a reserved word, so no type can be named `const` — the check has no false positive
32
+ * of its own.
33
+ */
34
+ function isConstAssertion(node: { typeAnnotation?: unknown }): boolean {
35
+ const annotation = node.typeAnnotation;
36
+ if (!isRecord(annotation) || annotation.type !== 'TSTypeReference') return false;
37
+ const typeName = annotation.typeName;
38
+ return isRecord(typeName) && typeName.type === 'Identifier' && typeName.name === 'const';
39
+ }
40
+
41
+ function isRecord(value: unknown): value is Record<string, unknown> {
42
+ return typeof value === 'object' && value !== null;
43
+ }
44
+
45
+ /** What to do instead — a finding that only names the sin costs the reader a search. */
46
+ const ASSERTION_REMEDY =
47
+ 'type assertion — use `satisfies` to check a literal against a type, or parse the value with its schema at the boundary';
48
+
49
+ export function inspect(path: string, source: string): string[] {
24
50
  const result = parseSync(path, source);
25
51
  const failures = result.errors.map((error) => `${path}: parse error: ${error.message}`);
26
52
  const visitor = new Visitor({
27
53
  TSAsExpression(node) {
28
- failures.push(`${path}:${lineAt(source, node.start)}: type assertion`);
54
+ if (isConstAssertion(node)) return;
55
+ failures.push(`${path}:${lineAt(source, node.start)}: ${ASSERTION_REMEDY}`);
29
56
  },
30
57
  TSTypeAssertion(node) {
31
- failures.push(`${path}:${lineAt(source, node.start)}: type assertion`);
58
+ failures.push(`${path}:${lineAt(source, node.start)}: ${ASSERTION_REMEDY}`);
32
59
  },
33
60
  TSAnyKeyword(node) {
34
61
  failures.push(`${path}:${lineAt(source, node.start)}: explicit any`);
@@ -91,21 +118,25 @@ async function visitDirectory(directory: string): Promise<string[]> {
91
118
  return failures;
92
119
  }
93
120
 
94
- const failures = [
95
- ...(await Promise.all(roots.map(visitDirectory))).flat(),
96
- ...(
97
- await Promise.all(
98
- rootFiles.map(async (path) => inspect(path, await readFile(path, 'utf8'))),
99
- )
100
- ).flat(),
101
- ];
102
- const webPackage = await readFile('packages/frontend/package.json', 'utf8');
103
- if (webPackage.includes(replacedThemePackage)) {
104
- failures.push(
105
- 'packages/frontend/package.json: use @wrksz/themes as the single theme runtime',
106
- );
107
- }
108
- if (failures.length > 0) {
109
- for (const failure of failures) console.error(failure);
110
- process.exit(1);
121
+ // Guarded so the module can be imported by its own test without running the gate over the
122
+ // repository — importing a script that scans the tree and exits is not a testable unit.
123
+ if (import.meta.main) {
124
+ const failures = [
125
+ ...(await Promise.all(roots.map(visitDirectory))).flat(),
126
+ ...(
127
+ await Promise.all(
128
+ rootFiles.map(async (path) => inspect(path, await readFile(path, 'utf8'))),
129
+ )
130
+ ).flat(),
131
+ ];
132
+ const webPackage = await readFile('packages/frontend/package.json', 'utf8');
133
+ if (webPackage.includes(replacedThemePackage)) {
134
+ failures.push(
135
+ 'packages/frontend/package.json: use @wrksz/themes as the single theme runtime',
136
+ );
137
+ }
138
+ if (failures.length > 0) {
139
+ for (const failure of failures) console.error(failure);
140
+ process.exit(1);
141
+ }
111
142
  }
@@ -1,7 +1,7 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { z } from 'zod';
3
3
  import { appDeclaration } from '../packages/config/src/declaration';
4
- import { ensureLocalEnvironment } from './local-env';
4
+ import { assertUsableEnvironment, ensureLocalEnvironment } from './local-env';
5
5
  import { awaitRolesAnswering, declaredRoleReadiness } from './readiness';
6
6
  import { runDeclaredReleaseSteps } from './release-steps';
7
7
  import { inheritToolingEnvironment } from './tooling-env';
@@ -21,9 +21,14 @@ async function run(command: string[], environment?: Record<string, string>): Pro
21
21
 
22
22
  export async function runDevelopment(environment?: Record<string, string>): Promise<void> {
23
23
  ensureLocalEnvironment(root);
24
+ // Before pm2, before anything: an unusable environment is the reader's problem to fix, and
25
+ // making them read a supervisor error first only delays the sentence that matters.
26
+ assertUsableEnvironment(root);
24
27
  assertToolAvailable('pm2', 'Install PM2 with `bun add --global pm2`, then rerun.');
25
28
  await run(['pm2', 'ping']);
26
29
  const environmentForRun = await developmentEnvironment(environment);
30
+ // Kept for an environment supplied from the shell rather than from `.env`, which the file
31
+ // check above cannot see.
27
32
  if (environmentForRun.DATABASE_URL?.includes('USER:PASSWORD')) {
28
33
  throw new Error(
29
34
  'DATABASE_URL still contains the starter placeholder. Create a PostgreSQL database, update DATABASE_URL in .env, then rerun `bun run dev`.',
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { inspectGuide, inspectGuides } from './guide-paths';
3
+
4
+ const present = (path: string) => path === 'packages/shared/src/index.ts';
5
+
6
+ describe('guide paths', () => {
7
+ test('reports a path the guide names but the scaffold lacks', () => {
8
+ const findings = inspectGuide(
9
+ 'docs/G.md',
10
+ 'In `packages/frontend/src/lib/api/client.ts`, create the api.\n',
11
+ present,
12
+ );
13
+ expect(findings).toEqual([
14
+ { guide: 'docs/G.md', line: 1, path: 'packages/frontend/src/lib/api/client.ts' },
15
+ ]);
16
+ });
17
+
18
+ test('a path the guide creates is declared, not broken', () => {
19
+ const source = 'Create `packages/shared/src/realtime.ts` (created in this step) and…\n';
20
+ expect(inspectGuide('docs/G.md', source, present)).toEqual([]);
21
+ });
22
+
23
+ test('an existing path is quiet, and prose without a path is quiet', () => {
24
+ expect(
25
+ inspectGuide('docs/G.md', 'Export it from `packages/shared/src/index.ts`.\n', present),
26
+ ).toEqual([]);
27
+ expect(inspectGuide('docs/G.md', 'Pages compose features.\n', present)).toEqual([]);
28
+ });
29
+
30
+ test('the real guide resolves every path it does not create', async () => {
31
+ // The denominator matters: a guide the scanner cannot read would also return [].
32
+ const findings = await inspectGuides(`${import.meta.dir}/..`);
33
+ expect(findings).toEqual([]);
34
+ const anyPath = inspectGuide('docs/G.md', 'See `packages/absent/x.ts`.\n', () => false);
35
+ expect(anyPath).toHaveLength(1);
36
+ });
37
+ });
@@ -0,0 +1,66 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+
5
+ /**
6
+ * Every repository path a guide names has to exist.
7
+ *
8
+ * `ADDING_A_FEATURE.md` is the one document a new consumer executes literally, and two of its
9
+ * steps pointed at files the scaffold does not contain — `lib/api/client.ts` and a "shared
10
+ * realtime source". The reader does not learn that by reading; they learn it by opening the path
11
+ * and finding nothing, after having already trusted the sentence around it.
12
+ *
13
+ * A path the guide tells you to CREATE is not a broken reference, so those are declared by the
14
+ * guide itself: a line that introduces one carries `(created in this step)`. Everything else
15
+ * must resolve.
16
+ */
17
+ const GUIDES = ['docs/ADDING_A_FEATURE.md'];
18
+ const PATH_PATTERN = /`((?:packages|scripts|docs|e2e)\/[A-Za-z0-9_./-]+)`/g;
19
+
20
+ export interface GuidePathFinding {
21
+ readonly guide: string;
22
+ readonly line: number;
23
+ readonly path: string;
24
+ }
25
+
26
+ export function inspectGuide(
27
+ guide: string,
28
+ source: string,
29
+ exists: (path: string) => boolean,
30
+ ): GuidePathFinding[] {
31
+ const findings: GuidePathFinding[] = [];
32
+ source.split('\n').forEach((text, index) => {
33
+ if (text.includes('(created in this step)')) return;
34
+ for (const match of text.matchAll(PATH_PATTERN)) {
35
+ const path = match[1];
36
+ if (!path || exists(path)) continue;
37
+ findings.push({ guide, line: index + 1, path });
38
+ }
39
+ });
40
+ return findings;
41
+ }
42
+
43
+ export async function inspectGuides(root: string): Promise<GuidePathFinding[]> {
44
+ const findings: GuidePathFinding[] = [];
45
+ for (const guide of GUIDES) {
46
+ const absolute = resolve(root, guide);
47
+ if (!existsSync(absolute)) {
48
+ findings.push({ guide, line: 0, path: guide });
49
+ continue;
50
+ }
51
+ findings.push(
52
+ ...inspectGuide(guide, await readFile(absolute, 'utf8'), (path) =>
53
+ existsSync(join(root, path)),
54
+ ),
55
+ );
56
+ }
57
+ return findings;
58
+ }
59
+
60
+ if (import.meta.main) {
61
+ const findings = await inspectGuides(resolve(import.meta.dir, '..'));
62
+ for (const { guide, line, path } of findings) {
63
+ console.error(`${guide}:${line}: names ${path}, which does not exist`);
64
+ }
65
+ if (findings.length > 0) process.exit(1);
66
+ }
@@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { appDeclaration } from '../packages/config/src/declaration';
6
- import { ensureLocalEnvironment } from './local-env';
6
+ import { assertUsableEnvironment, ensureLocalEnvironment } from './local-env';
7
7
 
8
8
  describe('ensureLocalEnvironment', () => {
9
9
  test('renders the application identity into a fresh .env and never touches an existing one', async () => {
@@ -13,7 +13,10 @@ describe('ensureLocalEnvironment', () => {
13
13
  join(root, '.env.example'),
14
14
  'DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:5432/stitchkit_starter\n',
15
15
  );
16
+ // Rendering succeeds — a generator that refuses to generate would break `--no-install`
17
+ // scaffolding — and the separate usability check is what refuses.
16
18
  ensureLocalEnvironment(root);
19
+ expect(() => assertUsableEnvironment(root)).toThrow(/USER:PASSWORD/);
17
20
  const created = await readFile(join(root, '.env'), 'utf8');
18
21
  // In the neutral dev workspace the slug IS the neutral identity, so the
19
22
  // substitution is proven end-to-end by the starter lane on a renamed
@@ -39,10 +42,56 @@ describe('ensureLocalEnvironment', () => {
39
42
  'DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:5432/stitchkit_starter\n',
40
43
  );
41
44
  ensureLocalEnvironment(root);
45
+ expect(() => assertUsableEnvironment(root)).toThrow(/USER:PASSWORD/);
42
46
  const databaseName = appDeclaration.identity.slug.replaceAll('-', '_');
43
47
  expect(await readFile(join(root, '.env'), 'utf8')).toContain(`5432/${databaseName}`);
44
48
  } finally {
45
49
  await rm(root, { recursive: true, force: true });
46
50
  }
47
51
  });
52
+
53
+ test('an unedited .env is refused on every later run, not only the one that wrote it', async () => {
54
+ const root = await mkdtemp(join(tmpdir(), 'sk-env-again-'));
55
+ try {
56
+ // No example at all: the file is already there, exactly as a second `bun run dev` finds it.
57
+ await writeFile(
58
+ join(root, '.env'),
59
+ 'DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:5432/app\n',
60
+ );
61
+ let message = '';
62
+ try {
63
+ assertUsableEnvironment(root);
64
+ } catch (error) {
65
+ message = error instanceof Error ? error.message : String(error);
66
+ }
67
+ // The message has to name the file, the line and the privilege the next step needs —
68
+ // a refusal that only says "invalid" moves the search back to the reader.
69
+ expect(message).toContain('.env:1');
70
+ expect(message).toContain('DATABASE_URL');
71
+ expect(message).toContain('CREATEDB');
72
+ } finally {
73
+ await rm(root, { recursive: true, force: true });
74
+ }
75
+ });
76
+
77
+ test('a commented example line is not mistaken for an unresolved credential', async () => {
78
+ const root = await mkdtemp(join(tmpdir(), 'sk-env-comment-'));
79
+ try {
80
+ // The placeholder is assembled rather than written: spelled out it is a credential inside
81
+ // a URL, which the publication-privacy gate refuses on sight — rightly, since "it is only
82
+ // a fixture" is the argument every leak makes. Same idiom as `check-authored.ts`.
83
+ const placeholder = ['USER', 'PASSWORD'].join(':');
84
+ await writeFile(
85
+ join(root, '.env'),
86
+ [
87
+ `# DATABASE_URL=postgresql://${placeholder}@127.0.0.1:5432/example`,
88
+ 'DATABASE_URL=postgresql://127.0.0.1:5432/app',
89
+ '',
90
+ ].join('\n'),
91
+ );
92
+ expect(() => assertUsableEnvironment(root)).not.toThrow();
93
+ } finally {
94
+ await rm(root, { recursive: true, force: true });
95
+ }
96
+ });
48
97
  });
@@ -19,18 +19,62 @@ import { appIdentity } from '../packages/config/src/app-identity.generated';
19
19
  * environment too. Synchronous on purpose: `playwright.config.ts` and other
20
20
  * synchronous entry points must be able to self-heal before validating.
21
21
  */
22
- export function ensureLocalEnvironment(root: string): void {
22
+ /**
23
+ * The credentials this file renders but cannot know.
24
+ *
25
+ * `.env.example` ships a connection string with literal `USER:PASSWORD`, and this script
26
+ * substitutes only the database name. A file that is generated, edited by the generator and then
27
+ * left unusable is the worst of the three: the reader assumes a generated file is ready. So the
28
+ * placeholder is named here rather than discovered as a driver stack on the first request.
29
+ */
30
+ function unresolvedCredentialLines(destination: string): string[] {
31
+ return readFileSync(destination, 'utf8')
32
+ .split('\n')
33
+ .map((line, index) => ({ line: line.trim(), number: index + 1 }))
34
+ .filter(({ line }) => !line.startsWith('#') && line.includes('USER:PASSWORD'))
35
+ .map(({ line, number }) => ` ${destination}:${number} ${line.split('=')[0] ?? line}`);
36
+ }
37
+
38
+ /**
39
+ * Refuse to start on an environment that still carries the example credentials.
40
+ *
41
+ * Separate from rendering on purpose: a generator that refuses to generate is wrong — `--no-install`
42
+ * scaffolding renders `.env` before anyone could have filled it in — while a start that proceeds
43
+ * into a driver stack is the defect. So `env:ensure` writes and reports; `dev` writes and refuses.
44
+ */
45
+ export function assertUsableEnvironment(root: string): void {
23
46
  const destination = resolve(root, '.env');
24
- if (existsSync(destination)) return;
25
- const publicExample = resolve(root, '.env.example');
26
- const example = readFileSync(
27
- existsSync(publicExample) ? publicExample : resolve(root, '_env.example'),
28
- 'utf8',
47
+ const unresolved = unresolvedCredentialLines(destination);
48
+ if (unresolved.length === 0) return;
49
+ throw new Error(
50
+ `This environment still carries the example credentials.\n${unresolved.join('\n')}\n` +
51
+ 'Replace USER:PASSWORD with a role that can reach your PostgreSQL server. ' +
52
+ 'A role that runs `db:migrate` also needs CREATEDB, because `prisma migrate dev` ' +
53
+ 'creates a shadow database.',
29
54
  );
30
- const databaseName = appIdentity.slug.replaceAll('-', '_');
31
- writeFileSync(destination, example.replaceAll('stitchkit_starter', databaseName));
55
+ }
56
+
57
+ export function ensureLocalEnvironment(root: string): void {
58
+ const destination = resolve(root, '.env');
59
+ if (!existsSync(destination)) {
60
+ const publicExample = resolve(root, '.env.example');
61
+ const example = readFileSync(
62
+ existsSync(publicExample) ? publicExample : resolve(root, '_env.example'),
63
+ 'utf8',
64
+ );
65
+ const databaseName = appIdentity.slug.replaceAll('-', '_');
66
+ writeFileSync(destination, example.replaceAll('stitchkit_starter', databaseName));
67
+ }
32
68
  }
33
69
 
34
70
  if (import.meta.main) {
35
- ensureLocalEnvironment(resolve(import.meta.dir, '..'));
71
+ const root = resolve(import.meta.dir, '..');
72
+ ensureLocalEnvironment(root);
73
+ // Reported, not fatal: this command's job is to produce the file, and it has.
74
+ const unresolved = unresolvedCredentialLines(resolve(root, '.env'));
75
+ if (unresolved.length > 0) {
76
+ process.stderr.write(
77
+ `.env still carries the example credentials — \`bun run dev\` will refuse until they are replaced:\n${unresolved.join('\n')}\n`,
78
+ );
79
+ }
36
80
  }
@@ -16,11 +16,11 @@
16
16
  "build": "bun build src/index.ts --outdir dist --target bun --packages external"
17
17
  },
18
18
  "dependencies": {
19
- "stitchkit-tui": "file:../../../tui",
20
19
  "@openrouter/ai-sdk-provider": "^3.0.0",
21
- "ai": "^7.0.84",
20
+ "ai": "^7.0.87",
22
21
  "stitchkit": "file:../../../core",
23
- "zod": "4.4.3"
22
+ "stitchkit-tui": "file:../../../tui",
23
+ "zod": "4.5.4"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
@@ -118,9 +118,11 @@ export async function createStarterHarness(
118
118
  toolApproval: {
119
119
  read_file: 'approved',
120
120
  search_files: 'approved',
121
+ list_directory: 'approved',
122
+ glob: 'approved',
121
123
  read_resource: 'approved',
122
124
  write_file: 'user-approval',
123
- apply_patch: 'user-approval',
125
+ edit_file: 'user-approval',
124
126
  run_command: 'user-approval',
125
127
  },
126
128
  toolApprovalSecret: await persistentApprovalSecret(stateDirectory),