start-vibing-stacks 2.20.0 → 2.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-vibing-stacks",
3
- "version": "2.20.0",
3
+ "version": "2.21.0",
4
4
  "description": "AI-powered multi-stack dev workflow for Claude Code. Supports PHP, Node.js, Python and more.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,8 @@ bun run typecheck # TypeScript errors
28
28
  bun run lint # ESLint
29
29
  bun run test # Vitest
30
30
  node scripts/check-route-slugs.mjs # Next.js — only run if framework=nextjs
31
- bun run build # Build verification (must come AFTER route-slugs)
31
+ node scripts/check-build-scripts.mjs # No dev-only tools in deploy scripts
32
+ bun run build # Build verification (must come AFTER both checks)
32
33
  ```
33
34
 
34
35
  > **Next.js note.** `next build` does NOT validate dynamic-segment slug
@@ -37,6 +38,14 @@ bun run build # Build verification (must come AFTER
37
38
  > statically — see `nextjs-app-router` skill, section "Dynamic Route Slug
38
39
  > Consistency".
39
40
 
41
+ > **Vercel/Docker deploy note.** Build environments strip `devDependencies`
42
+ > (`NODE_ENV=production` → `npm install --omit=dev`). Any binary called
43
+ > from `scripts.build` / `prebuild` / `postinstall` that's only in
44
+ > `devDependencies` (e.g. `tsx`, `ts-node`, `vitest`) will crash the
45
+ > deploy with `command not found / exit 127`. `check-build-scripts.mjs`
46
+ > catches this statically — see `nextjs-app-router` skill, section
47
+ > "Build Script Hygiene".
48
+
40
49
  ## Gate Results
41
50
 
42
51
  | Result | Action |
@@ -0,0 +1,351 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-build-scripts.mjs
4
+ *
5
+ * Static validator for package.json#scripts that run at deploy time.
6
+ *
7
+ * Catches the "tsx: command not found / Error: Command 'npm run build'
8
+ * exited with 127" class of bug, which appears on Vercel, Docker
9
+ * (`npm ci --omit=dev`), and any CI that runs with `NODE_ENV=production`.
10
+ *
11
+ * Rule: any binary invoked from `build`, `prebuild`, `postbuild`,
12
+ * `start`, `postinstall`, `prepare`, or `prepublishOnly` MUST be one of:
13
+ * - a Node-builtin (`node`, plain shell)
14
+ * - prefixed with `npx` / `bunx` / `pnpm exec`
15
+ * - the package's own `bin` entry
16
+ * - present in `dependencies` (NOT just `devDependencies`)
17
+ * - a plain-Node script: `node scripts/foo.mjs`
18
+ *
19
+ * Usage:
20
+ * node scripts/check-build-scripts.mjs # auto-detect ./package.json
21
+ * node scripts/check-build-scripts.mjs ./pkg/dir # explicit dir
22
+ *
23
+ * Exit codes:
24
+ * 0 OK
25
+ * 1 dev-only tool referenced from a deploy-time script
26
+ * 2 no package.json found (skipped)
27
+ */
28
+
29
+ import { readFile } from 'node:fs/promises';
30
+ import { existsSync } from 'node:fs';
31
+ import { resolve, join, relative } from 'node:path';
32
+
33
+ // Scripts that run during install/build on Vercel, Docker, npm publish, etc.
34
+ // `postinstall` runs even with `--omit=dev`, so its commands MUST resolve from prod deps.
35
+ const DEPLOY_TIME_SCRIPTS = [
36
+ 'build',
37
+ 'prebuild',
38
+ 'postbuild',
39
+ 'start',
40
+ 'postinstall',
41
+ 'prepare',
42
+ 'prepublishOnly',
43
+ ];
44
+
45
+ // Tools that are conventionally dev-only. If the script invokes one of these
46
+ // directly (without `npx`/`bunx`) AND it's not in `dependencies`, that's the bug.
47
+ const DEV_ONLY_TOOLS = new Set([
48
+ 'tsx',
49
+ 'ts-node',
50
+ 'tsc',
51
+ 'typescript',
52
+ 'vitest',
53
+ 'jest',
54
+ 'mocha',
55
+ 'ava',
56
+ 'tap',
57
+ 'eslint',
58
+ 'prettier',
59
+ 'biome',
60
+ 'rome',
61
+ 'tsup',
62
+ 'esbuild',
63
+ 'rollup',
64
+ 'webpack',
65
+ 'rspack',
66
+ 'parcel',
67
+ 'tailwindcss',
68
+ 'postcss',
69
+ 'sass',
70
+ 'less',
71
+ 'concurrently',
72
+ 'nodemon',
73
+ 'pm2',
74
+ ]);
75
+
76
+ // Tokens that are NEVER a binary invocation (control flow, shell built-ins, etc.)
77
+ const NON_BINARY_TOKENS = new Set([
78
+ '&&',
79
+ '||',
80
+ ';',
81
+ '|',
82
+ '&',
83
+ '>',
84
+ '<',
85
+ '>>',
86
+ '<<',
87
+ '!',
88
+ '$',
89
+ '(',
90
+ ')',
91
+ '{',
92
+ '}',
93
+ 'if',
94
+ 'then',
95
+ 'else',
96
+ 'fi',
97
+ 'for',
98
+ 'do',
99
+ 'done',
100
+ 'while',
101
+ 'case',
102
+ 'esac',
103
+ 'cd',
104
+ 'echo',
105
+ 'export',
106
+ 'true',
107
+ 'false',
108
+ 'cat',
109
+ 'rm',
110
+ 'cp',
111
+ 'mv',
112
+ 'mkdir',
113
+ 'test',
114
+ 'sh',
115
+ 'bash',
116
+ 'zsh',
117
+ 'set',
118
+ ]);
119
+
120
+ // Wrapper commands that defer to a separate binary (their first arg is the actual tool,
121
+ // which we want to evaluate against the same rules).
122
+ const WRAPPERS = new Set(['npx', 'bunx', 'pnpm', 'yarn', 'cross-env', 'env']);
123
+
124
+ /**
125
+ * Strip shell quoting and split a command line into bare tokens.
126
+ * Naive but adequate for typical package.json scripts.
127
+ */
128
+ function tokenize(line) {
129
+ const out = [];
130
+ let cur = '';
131
+ let quote = null;
132
+ for (const ch of line) {
133
+ if (quote) {
134
+ if (ch === quote) quote = null;
135
+ else cur += ch;
136
+ continue;
137
+ }
138
+ if (ch === '"' || ch === "'") {
139
+ quote = ch;
140
+ continue;
141
+ }
142
+ if (/\s/.test(ch)) {
143
+ if (cur) {
144
+ out.push(cur);
145
+ cur = '';
146
+ }
147
+ continue;
148
+ }
149
+ cur += ch;
150
+ }
151
+ if (cur) out.push(cur);
152
+ return out;
153
+ }
154
+
155
+ /**
156
+ * Walk a script command and return the list of "first-binary" tokens that
157
+ * would actually be invoked. Handles `&&`, `||`, `;`, and a small set of
158
+ * wrappers (npx, bunx, cross-env).
159
+ *
160
+ * Returns Array<{ binary: string, prefixedByWrapper: boolean }>
161
+ */
162
+ function extractInvocations(command) {
163
+ const tokens = tokenize(command);
164
+ const invocations = [];
165
+
166
+ let expectBinary = true; // start of a sub-command
167
+ let wrapperPrefix = false;
168
+
169
+ for (let i = 0; i < tokens.length; i++) {
170
+ const t = tokens[i];
171
+
172
+ if (t === '&&' || t === '||' || t === ';' || t === '|') {
173
+ expectBinary = true;
174
+ wrapperPrefix = false;
175
+ continue;
176
+ }
177
+
178
+ // Skip env-var assignments like FOO=bar baz
179
+ if (expectBinary && /^[A-Z_][A-Z0-9_]*=/.test(t)) {
180
+ continue;
181
+ }
182
+
183
+ if (!expectBinary) continue;
184
+
185
+ // Wrappers: their "real" binary is the next token
186
+ if (WRAPPERS.has(t)) {
187
+ wrapperPrefix = true;
188
+ // for `pnpm exec` / `pnpm run` style, eat the sub-word
189
+ if (t === 'pnpm' && tokens[i + 1] === 'exec') {
190
+ i++;
191
+ continue;
192
+ }
193
+ if (t === 'yarn' && tokens[i + 1] === 'dlx') {
194
+ i++;
195
+ continue;
196
+ }
197
+ // for `cross-env FOO=bar baz` we need to skip env assignments too
198
+ if (t === 'cross-env' || t === 'env') {
199
+ let j = i + 1;
200
+ while (j < tokens.length && /^[A-Z_][A-Z0-9_]*=/.test(tokens[j])) j++;
201
+ if (j < tokens.length) {
202
+ invocations.push({ binary: tokens[j], prefixedByWrapper: false });
203
+ }
204
+ i = j;
205
+ expectBinary = false;
206
+ wrapperPrefix = false;
207
+ continue;
208
+ }
209
+ continue;
210
+ }
211
+
212
+ if (NON_BINARY_TOKENS.has(t)) {
213
+ // Don't reset expectBinary for stuff like `echo`; treat as inert
214
+ continue;
215
+ }
216
+
217
+ invocations.push({ binary: t, prefixedByWrapper: wrapperPrefix });
218
+ expectBinary = false;
219
+ wrapperPrefix = false;
220
+ }
221
+
222
+ return invocations;
223
+ }
224
+
225
+ /**
226
+ * Returns true if the binary is "safe" — Node-builtin invocation, or
227
+ * resolves from prod dependencies (or the package's own bin).
228
+ */
229
+ function isSafe(binary, prefixedByWrapper, ctx) {
230
+ if (prefixedByWrapper) return true;
231
+ if (binary === 'node') return true;
232
+ // Direct script: `./scripts/x.sh`, `node ./scripts/x.mjs` — wrapped above
233
+ if (binary.startsWith('./') || binary.startsWith('/')) return true;
234
+ // Module via `node --import`: `node scripts/foo.mjs` is already handled above
235
+
236
+ // Package's own bin
237
+ if (ctx.ownBins.has(binary)) return true;
238
+
239
+ // Prod dep (the package itself may install a bin)
240
+ if (ctx.prodDeps.has(binary)) return true;
241
+
242
+ // Subpackages of a prod dep (rare but valid: `next-bundle-analyzer` → `next` group)
243
+ // — handled by direct match above for typical cases.
244
+
245
+ // Some prod deps install differently-named bins (e.g. `typescript` → `tsc`).
246
+ // We can't statically know these without crawling node_modules, so the
247
+ // heuristic falls back on the DEV_ONLY_TOOLS deny-list for the well-known
248
+ // offenders.
249
+ return !DEV_ONLY_TOOLS.has(binary);
250
+ }
251
+
252
+ async function readJSON(path) {
253
+ const raw = await readFile(path, 'utf8');
254
+ return JSON.parse(raw);
255
+ }
256
+
257
+ async function detectRoot(args) {
258
+ if (args.length > 0) return resolve(args[0]);
259
+ return process.cwd();
260
+ }
261
+
262
+ async function main() {
263
+ const args = process.argv.slice(2);
264
+ const root = await detectRoot(args);
265
+ const pkgPath = join(root, 'package.json');
266
+
267
+ if (!existsSync(pkgPath)) {
268
+ console.log('[build-scripts] no package.json found — skipped.');
269
+ process.exit(2);
270
+ }
271
+
272
+ const pkg = await readJSON(pkgPath);
273
+ const scripts = pkg.scripts ?? {};
274
+ const prodDeps = new Set(Object.keys(pkg.dependencies ?? {}));
275
+ const devDeps = new Set(Object.keys(pkg.devDependencies ?? {}));
276
+ const ownBins = new Set(
277
+ typeof pkg.bin === 'string' ? [pkg.name] : Object.keys(pkg.bin ?? {})
278
+ );
279
+ const ctx = { prodDeps, devDeps, ownBins };
280
+
281
+ const findings = [];
282
+
283
+ for (const scriptName of DEPLOY_TIME_SCRIPTS) {
284
+ const cmd = scripts[scriptName];
285
+ if (!cmd) continue;
286
+
287
+ const invocations = extractInvocations(cmd);
288
+ for (const inv of invocations) {
289
+ if (isSafe(inv.binary, inv.prefixedByWrapper, ctx)) continue;
290
+ const inDev = devDeps.has(inv.binary);
291
+ findings.push({
292
+ script: scriptName,
293
+ command: cmd,
294
+ binary: inv.binary,
295
+ inDev,
296
+ });
297
+ }
298
+ }
299
+
300
+ if (findings.length > 0) {
301
+ console.error('');
302
+ console.error(' BUILD SCRIPT HYGIENE FAILURE (deploy will crash with exit 127)');
303
+ console.error(' ' + '─'.repeat(60));
304
+ console.error('');
305
+ console.error(
306
+ ' These deploy-time scripts call a binary that will not be'
307
+ );
308
+ console.error(
309
+ ' available on Vercel/Docker (NODE_ENV=production strips devDeps).'
310
+ );
311
+ console.error('');
312
+ for (const f of findings) {
313
+ console.error(` scripts.${f.script} → ${f.command}`);
314
+ console.error(` missing binary: "${f.binary}"`);
315
+ if (f.inDev) {
316
+ console.error(
317
+ ` → "${f.binary}" is in devDependencies; deploy install omits devDeps`
318
+ );
319
+ } else {
320
+ console.error(
321
+ ` → "${f.binary}" is not in dependencies and not a known wrapper`
322
+ );
323
+ }
324
+ console.error('');
325
+ }
326
+ console.error(' Fix (in order of preference):');
327
+ console.error(
328
+ ' 1. Convert any TS helper to .mjs and call via `node scripts/x.mjs`'
329
+ );
330
+ console.error(
331
+ ' 2. Move the tool to `dependencies` if it is genuinely needed at runtime'
332
+ );
333
+ console.error(
334
+ ' 3. Prefix with `npx`/`bunx` (slower, network-dependent)'
335
+ );
336
+ console.error(
337
+ ' 4. As LAST RESORT, set Vercel `installCommand: "npm install --include=dev"`'
338
+ );
339
+ console.error('');
340
+ process.exit(1);
341
+ }
342
+
343
+ const pkgRel = relative(process.cwd(), pkgPath) || 'package.json';
344
+ console.log(`[build-scripts] OK — no dev-only tools in deploy-time scripts (${pkgRel})`);
345
+ process.exit(0);
346
+ }
347
+
348
+ main().catch((err) => {
349
+ console.error('[build-scripts] script failed:', err);
350
+ process.exit(1);
351
+ });
@@ -1,12 +1,32 @@
1
1
  ---
2
2
  name: bun-runtime
3
- version: 1.0.0
3
+ version: 1.1.0
4
4
  ---
5
5
 
6
6
  # Bun Runtime — Fast JavaScript Runtime
7
7
 
8
8
  **ALWAYS invoke when using Bun for scripts, packages, bundling, or testing.**
9
9
 
10
+ ## Deploy-Time Asymmetry (READ FIRST)
11
+
12
+ > Bun's local install is generous (installs ALL deps by default). Vercel /
13
+ > CI builds are strict (`NODE_ENV=production` → devDeps stripped). A
14
+ > `package.json#scripts.build` that calls `tsx`, `ts-node`, `vitest`,
15
+ > `eslint`, or `tsc` directly will work locally and fail at deploy with
16
+ > `sh: line 1: <tool>: command not found / Error: Command "npm run build"
17
+ > exited with 127`.
18
+
19
+ **Rule.** Anything invoked from `scripts.build` / `prebuild` /
20
+ `postinstall` / `prepare` must be either:
21
+
22
+ - in `dependencies` (not `devDependencies`)
23
+ - prefixed with `bunx` / `npx` (slower, fragile)
24
+ - a plain Node script: `node scripts/foo.mjs`
25
+
26
+ For one-off utilities the `.mjs` route is best — see
27
+ `nextjs-app-router` skill, section "Build Script Hygiene". The stack
28
+ ships `scripts/check-build-scripts.mjs` to catch this statically.
29
+
10
30
  ## Package Management
11
31
 
12
32
  ```bash
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: nextjs-app-router
3
- version: 1.1.0
3
+ version: 1.2.0
4
4
  ---
5
5
 
6
6
  # Next.js App Router — Modern Patterns
@@ -323,6 +323,147 @@ See `error-handling` Pattern 5 for circuit breaker tuning and Pattern 4 for retr
323
323
  - [ ] Tested: duplicate delivery → 200 (no duplicate side-effect)
324
324
  - [ ] Tested: invalid signature → 401, never reaches the parser
325
325
 
326
+ ---
327
+
328
+ ## Build Script Hygiene (Vercel / CI — silent deploy killer)
329
+
330
+ > **Vercel sets `NODE_ENV=production` during deploy**, which causes `npm
331
+ > install` to **omit `devDependencies`**. Any binary your `build` /
332
+ > `prebuild` / `postinstall` script invokes must be resolvable from
333
+ > `dependencies` (or `node_modules/.bin` shipped via a prod dep) —
334
+ > otherwise you get `sh: line 1: <tool>: command not found` and
335
+ > `Error: Command "npm run build" exited with 127` at deploy time, even
336
+ > though `bun run build` worked locally.
337
+
338
+ This is **not** a Next.js bug. Same trap exists on Vercel Functions,
339
+ Netlify, Cloudflare Pages, Railway, Render, Fly.io, AWS Amplify, Docker
340
+ multi-stage builds, and any CI runner that respects `NODE_ENV` or runs
341
+ `npm ci --omit=dev`.
342
+
343
+ ### Common Offenders (devDep tools invoked from `scripts`)
344
+
345
+ | Tool | Typical wrong usage | Why it breaks |
346
+ |---|---|---|
347
+ | `tsx` | `"build": "tsx scripts/seed.ts && next build"` | `tsx` is dev-only; not installed on Vercel |
348
+ | `ts-node` | `"prebuild": "ts-node ./gen.ts"` | Same — `ts-node` rarely in `dependencies` |
349
+ | `tsc` | `"prebuild": "tsc -p tsconfig.gen.json"` | `typescript` is conventionally a devDep |
350
+ | `vitest` / `jest` | `"prebuild": "vitest run"` | Test runners are dev-only |
351
+ | `eslint` / `prettier` / `biome` | `"build": "eslint . && next build"` | Linters are dev-only |
352
+ | `tailwindcss` (CLI) | `"build": "tailwindcss -i ... && next build"` | Next.js handles Tailwind via its compiler; the standalone CLI is dev-only |
353
+ | `prisma` | `"postinstall": "prisma generate"` | **OK** only if `prisma` is in `dependencies` (it should be) — generation needs the CLI on every install |
354
+
355
+ ### The Rule
356
+
357
+ Anything referenced in `scripts.build`, `scripts.prebuild`,
358
+ `scripts.postbuild`, `scripts.start`, `scripts.postinstall`,
359
+ `scripts.prepare`, `scripts.prepublishOnly` must be one of:
360
+
361
+ 1. A Node-builtin (`node`, plain shell)
362
+ 2. Prefixed with `npx` / `bunx` / `pnpm exec` (downloads on demand — slow, fragile)
363
+ 3. The package's own `bin` entry
364
+ 4. Present in `dependencies` (not just `devDependencies`)
365
+ 5. A plain-Node script: `node scripts/foo.mjs` (zero deps; works everywhere)
366
+
367
+ ### Fix Vectors (in order of preference)
368
+
369
+ #### 1. Convert TS scripts to zero-dep `.mjs` (BEST for tiny utilities)
370
+
371
+ ```jsonc
372
+ // BEFORE — fails on Vercel
373
+ { "scripts": { "prebuild": "tsx scripts/check-routes.ts" } }
374
+
375
+ // AFTER — works everywhere, no devDep needed
376
+ { "scripts": { "prebuild": "node scripts/check-routes.mjs" } }
377
+ ```
378
+
379
+ Use modern Node features (`node:fs/promises`, top-level `await`,
380
+ `import.meta`). Bun, Node 20+, and every CI runner support `.mjs`
381
+ natively.
382
+
383
+ #### 2. Move the tool to `dependencies` (when you genuinely need the runtime tool)
384
+
385
+ ```bash
386
+ # Prisma client generation — needs the CLI on every install
387
+ bun remove -D prisma
388
+ bun add prisma
389
+ ```
390
+
391
+ Costs: larger node_modules in prod. Acceptable for runtime-needed CLIs
392
+ (`prisma`, sometimes `tsx` if you have many TS scripts).
393
+
394
+ #### 3. Configure Vercel to install devDeps (LAST RESORT — global override)
395
+
396
+ ```jsonc
397
+ // vercel.json
398
+ { "installCommand": "npm install --include=dev" }
399
+ ```
400
+
401
+ This **doubles** the install size for every deploy. Only use when you
402
+ have a real TypeScript build pipeline that can't be migrated to `.mjs`.
403
+
404
+ #### 4. Compile TS scripts ahead of time (advanced)
405
+
406
+ Bundle TS utilities to `.js` with `tsup`/`esbuild` during dev, commit
407
+ the output, run the `.js` in build. Useful for big script suites; for
408
+ one-off utilities the `.mjs` approach is simpler.
409
+
410
+ ### Why "It Works on My Machine"
411
+
412
+ | Environment | Behavior |
413
+ |---|---|
414
+ | Local `bun install` | Installs ALL deps by default (including devDeps) |
415
+ | Local `bun run build` | `node_modules/.bin/tsx` exists → succeeds |
416
+ | Local `npm install` (no flags) | Installs ALL deps (devDeps included unless `NODE_ENV=production`) |
417
+ | Vercel build step | Runs with `NODE_ENV=production` → devDeps **stripped** → `tsx` missing |
418
+ | Docker `FROM node:20` + `npm ci --omit=dev` | Same as Vercel |
419
+ | GitHub Actions default | Installs ALL deps unless workflow explicitly sets `NODE_ENV=production` |
420
+
421
+ The asymmetry is the trap. Local dev and CI accidentally agree; deploy
422
+ disagrees. Catch it statically.
423
+
424
+ ### Static Check (CI gate — mandatory)
425
+
426
+ The stack ships `scripts/check-build-scripts.mjs` (zero deps). It parses
427
+ `package.json`, walks every deploy-time script (`build`, `prebuild`,
428
+ `postbuild`, `start`, `postinstall`, `prepare`, `prepublishOnly`),
429
+ tokenises the command, and flags any token that is:
430
+
431
+ - A known dev-only tool name (`tsx`, `ts-node`, `vitest`, `eslint`, ...)
432
+ - AND not prefixed with `npx`/`bunx`/`pnpm exec`/`node`
433
+ - AND not present in `dependencies`
434
+
435
+ Wire it as `prebuild` AND in CI:
436
+
437
+ ```jsonc
438
+ {
439
+ "scripts": {
440
+ "routes:check": "node scripts/check-route-slugs.mjs",
441
+ "build:check": "node scripts/check-build-scripts.mjs",
442
+ "prebuild": "bun run routes:check && bun run build:check",
443
+ "build": "next build"
444
+ }
445
+ }
446
+ ```
447
+
448
+ ### Stack-shipped Scripts MUST Be `.mjs`
449
+
450
+ When this stack scaffolds a helper script (`check-route-slugs.mjs`,
451
+ `check-build-scripts.mjs`, anything in `scripts/`), it is **always**
452
+ plain `.mjs` runnable via `node`. **Never** use `.ts` requiring `tsx`
453
+ or `ts-node`. The whole point of these scripts is to run during
454
+ build — they have to work on Vercel.
455
+
456
+ ### Pre-Commit Checklist (Build Scripts)
457
+
458
+ - [ ] `package.json#scripts.build` has no `tsx` / `ts-node` / dev-only CLI
459
+ - [ ] `package.json#scripts.prebuild` (if any) is also clean
460
+ - [ ] `package.json#scripts.postinstall` (runs on Vercel during install) is also clean
461
+ - [ ] Any helper script in `scripts/` is `.mjs`, runnable via plain `node`
462
+ - [ ] If a runtime tool is genuinely needed (e.g. `prisma generate`), it lives in `dependencies`, not `devDependencies`
463
+ - [ ] Tested with `NODE_ENV=production npm ci && npm run build` locally before deploy
464
+
465
+ ---
466
+
326
467
  ## Metadata
327
468
 
328
469
  ```tsx
@@ -448,3 +589,5 @@ export async function createCheckout(priceId: string) {
448
589
  10. **Webhook business logic inline in the Route Handler** — ack 2xx fast, process async (see "Webhook Handler — Critical Path")
449
590
  11. **Skipping signature verification or parsing JSON before verifying** — always verify the raw body first
450
591
  12. **Returning 5xx from a webhook on a downstream failure** — triggers provider retry storms; ack 2xx and retry from your side
592
+ 13. **Calling `tsx` / `ts-node` / `vitest` / `eslint` directly from `scripts.build` or `scripts.prebuild`** — Vercel strips devDeps; build fails with exit 127. Convert to `.mjs` or move to `dependencies` (see "Build Script Hygiene")
593
+ 14. **Shipping helper scripts as `.ts`** — they cannot run on Vercel without `tsx` in `dependencies`. Use `.mjs` and plain `node`
@@ -21,7 +21,8 @@
21
21
  { "name": "Lint", "command": "bun run lint", "required": true, "order": 2 },
22
22
  { "name": "Tests", "command": "bun run test", "required": true, "order": 3 },
23
23
  { "name": "RouteSlugs", "command": "node scripts/check-route-slugs.mjs", "required": true, "order": 4, "appliesTo": ["nextjs"], "description": "Static Next.js dynamic-route slug consistency check. `next build` does not catch this class of bug." },
24
- { "name": "Build", "command": "bun run build", "required": true, "order": 5 }
24
+ { "name": "BuildScripts", "command": "node scripts/check-build-scripts.mjs", "required": true, "order": 5, "description": "Static check that scripts.build/prebuild/postinstall do not call dev-only binaries (tsx, ts-node, vitest, eslint, ...) — Vercel/Docker strip devDeps and the build crashes with exit 127." },
25
+ { "name": "Build", "command": "bun run build", "required": true, "order": 6 }
25
26
  ],
26
27
  "frameworks": [
27
28
  {
@@ -46,6 +46,17 @@ jobs:
46
46
  if [ "${code:-0}" = "1" ]; then exit 1; fi
47
47
  fi
48
48
 
49
+ - name: Build-script hygiene (no devDeps in deploy scripts)
50
+ # Vercel/Docker strip devDependencies during install. Any binary
51
+ # invoked from build/prebuild/postinstall MUST be in dependencies
52
+ # or be a plain `node script.mjs` call. Catches the
53
+ # "tsx: command not found / exited 127" class of bug.
54
+ run: |
55
+ if [ -f scripts/check-build-scripts.mjs ]; then
56
+ node scripts/check-build-scripts.mjs || code=$?
57
+ if [ "${code:-0}" = "1" ]; then exit 1; fi
58
+ fi
59
+
49
60
  - name: Build
50
61
  run: bun run build
51
62