This repository was archived by the owner on Oct 8, 2026. It is now read-only.
Repository navigation
Expand file tree
/
Copy pathcodegen-scaffold
More file actions
executable file
·669 lines (615 loc) · 29.4 KB
/
Copy pathcodegen-scaffold
File metadata and controls
executable file
·669 lines (615 loc) · 29.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
#!/usr/bin/env bash
# codegen-scaffold — deterministic scaffold entrypoint for user app provisioning.
#
# Usage:
# codegen-scaffold create --stack=<phoenix|static> --cwd=<dir> --slug=<slug> [options]
# codegen-scaffold integrate --stack=<phoenix|static> --cwd=<dir> [--slug=<slug>]
#
# Subcommands:
# create Run full scaffold (phx.new / static scaffold) then integrate
# integrate Drop agent-file + rules symlinks, wire codegen/recipes symlink,
# inject format: Makefile target, append README codegen-integration note,
# append machine-local symlink entries to .gitignore
#
# Options (create):
# --stack=<phoenix|static> required
# --cwd=<dir> required: parent directory; app created at <cwd>/<slug>
# --slug=<slug> required: URL-safe app slug
# --app-name=<name> human-readable app name (used by static scaffold; default: slug)
# --restart-rpc-cmd=<cmd> shell command to restart the server via platform RPC (optional)
# --recipe-source=<path> consumer-hosted recipe dir; planted as codegen/recipes-extra symlink (optional)
# --elixir-version=<v> phoenix only; default: 1.19.5
# --node-version=<v> phoenix only; default: 24.14.0
# --otp-version=<v> phoenix only; default: 28.4.1
# -- <phx.new flags> phoenix only: raw passthrough appended AFTER codegen's own
# phx.new defaults (last-wins; also toggles --no-x for booleans).
# A fixed denylist of flags codegen does not support is rejected
# at parse time — see PHX_NEW_DENYLIST below. `--database <db>`
# is inspected (not just forwarded) to pick the right `.env` var.
#
# Options (integrate):
# --stack=<phoenix|static> required
# --cwd=<dir> required: existing app root to wire
# --slug=<slug> optional: defaults to basename of --cwd
#
# Exit codes: 0 = success, 1 = runtime error, 2 = usage/config error.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
# ── Version defaults (empty = resolve from mise at runtime) ───────────────────
ELIXIR_VERSION=""
NODE_VERSION=""
OTP_VERSION=""
# ── Arg state ─────────────────────────────────────────────────────────────────
SUBCOMMAND=""
STACK=""
CWD=""
SLUG=""
APP_NAME=""
NO_ECTO=""
WITH_APPSIGNAL=""
GITHUB_URL=""
RESTART_RPC_CMD=""
RECIPE_SOURCE=""
PHX_PASSTHROUGH_DATABASE=""
PHX_PASSTHROUGH_FLAGS=()
# ── phx.new flags codegen does not support via `--` passthrough ──────────────
# Denylist, not allowlist: phx.new's own flag set evolves; codegen names only
# what actively breaks a mutation/template, and lets everything else through.
# Each entry: "<flag>|<reason>". Checked at parse time, before any mix work.
PHX_NEW_DENYLIST=(
"--app|derived from --slug; a mismatch breaks endpoint.sh's anchor and the health-controller template's <Module>.Repo reference"
"--module|derived from --slug; a mismatch breaks endpoint.sh's anchor and the health-controller template's <Module>.Repo reference"
"--umbrella|every mutation hardcodes lib/<app> + lib/<app>_web; umbrella layout is incompatible"
"--no-agents-md|codegen owns AGENTS.md (symlinked at integrate); do not let phx.new manage it"
"--agents-md|codegen owns AGENTS.md (symlinked at integrate); do not let phx.new manage it"
"--no-html|formatter_exs.sh and router.sh assume phx.new's default HTML/LiveView scaffolding is present"
"--no-live|formatter_exs.sh and router.sh assume phx.new's default HTML/LiveView scaffolding is present"
"--no-assets|data/mix_exs/aliases.txt wires tailwind/esbuild into the setup: alias that scaffold.sh's mix setup step runs"
"--no-esbuild|data/mix_exs/aliases.txt wires esbuild into the setup: alias that scaffold.sh's mix setup step runs"
"--no-tailwind|data/mix_exs/aliases.txt wires tailwind into the setup: alias that scaffold.sh's mix setup step runs"
)
usage() {
printf 'Usage:\n' >&2
printf ' codegen-scaffold create --stack=<phoenix|static> --cwd=<dir> --slug=<slug> [--app-name=<name>] [--restart-rpc-cmd=<cmd>] [--recipe-source=<path>] [--elixir-version=<v>] [--node-version=<v>] [--otp-version=<v>] [-- <phx.new flags>]\n' >&2
printf ' codegen-scaffold integrate --stack=<phoenix|static> --cwd=<dir> [--slug=<slug>]\n' >&2
exit 2
}
# Consume subcommand from $1
if [[ $# -gt 0 ]]; then
case "$1" in
create | integrate)
SUBCOMMAND="$1"
shift
;;
--*)
# No subcommand — old-style invocation without a verb
printf 'codegen-scaffold: subcommand required (create|integrate)\n' >&2
usage
;;
*)
printf 'codegen-scaffold: unknown subcommand %q (expected create|integrate)\n' "$1" >&2
usage
;;
esac
else
printf 'codegen-scaffold: subcommand required (create|integrate)\n' >&2
usage
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--stack=*)
STACK="${1#--stack=}"
shift
;;
--stack)
STACK="$2"
shift 2
;;
--cwd=*)
CWD="${1#--cwd=}"
shift
;;
--cwd)
CWD="$2"
shift 2
;;
--slug=*)
SLUG="${1#--slug=}"
shift
;;
--slug)
SLUG="$2"
shift 2
;;
--app-name=*)
APP_NAME="${1#--app-name=}"
shift
;;
--app-name)
APP_NAME="$2"
shift 2
;;
--elixir-version=*)
ELIXIR_VERSION="${1#--elixir-version=}"
shift
;;
--elixir-version)
ELIXIR_VERSION="$2"
shift 2
;;
--node-version=*)
NODE_VERSION="${1#--node-version=}"
shift
;;
--node-version)
NODE_VERSION="$2"
shift 2
;;
--otp-version=*)
OTP_VERSION="${1#--otp-version=}"
shift
;;
--otp-version)
OTP_VERSION="$2"
shift 2
;;
--no-ecto)
NO_ECTO="1"
shift
;;
--with-appsignal)
WITH_APPSIGNAL="1"
shift
;;
--github-url=*)
GITHUB_URL="${1#--github-url=}"
shift
;;
--github-url)
GITHUB_URL="$2"
shift 2
;;
--restart-rpc-cmd=*)
RESTART_RPC_CMD="${1#--restart-rpc-cmd=}"
shift
;;
--restart-rpc-cmd)
RESTART_RPC_CMD="$2"
shift 2
;;
--recipe-source=*)
RECIPE_SOURCE="${1#--recipe-source=}"
shift
;;
--recipe-source)
RECIPE_SOURCE="$2"
shift 2
;;
--)
shift
# Everything after `--` is a raw phx.new passthrough tail. Check each
# flag against the denylist at parse time (before any mix work), and
# capture --database's value separately (it drives .env rendering,
# not just a passthrough forward).
while [[ $# -gt 0 ]]; do
for _deny_entry in "${PHX_NEW_DENYLIST[@]}"; do
_deny_flag="${_deny_entry%%|*}"
_deny_reason="${_deny_entry#*|}"
if [[ "$1" == "$_deny_flag" || "$1" == "$_deny_flag="* ]]; then
printf 'codegen-scaffold: %s is not supported by the codegen phoenix scaffold (%s)\n' "$_deny_flag" "$_deny_reason" >&2
exit 2
fi
done
case "$1" in
--database=*)
PHX_PASSTHROUGH_DATABASE="${1#--database=}"
PHX_PASSTHROUGH_FLAGS+=("$1")
shift
;;
--database)
PHX_PASSTHROUGH_DATABASE="${2:-}"
PHX_PASSTHROUGH_FLAGS+=("$1" "${2:-}")
shift 2
;;
*)
PHX_PASSTHROUGH_FLAGS+=("$1")
shift
;;
esac
done
;;
--*)
printf 'codegen-scaffold: unknown flag %q\n' "$1" >&2
usage
;;
*)
printf 'codegen-scaffold: unexpected positional arg %q\n' "$1" >&2
usage
;;
esac
done
# ── Fill empty version vars from box-live mise current ────────────────────────
# Allows flag overrides to win; falls back to resolved versions on this machine.
if [[ -z "$ELIXIR_VERSION" ]]; then
ELIXIR_VERSION="$(mise current elixir 2>/dev/null || true)"
# Strip any trailing -otp-N suffix: mise returns e.g. "1.19.5-otp-28" but
# .tool-versions.eex appends "-otp-<otp_major_version>" itself. Keep only
# the bare semver so we don't produce "1.19.5-otp-28-otp-28".
ELIXIR_VERSION="${ELIXIR_VERSION%-otp-*}"
fi
if [[ -z "$OTP_VERSION" ]]; then
OTP_VERSION="$(mise current erlang 2>/dev/null || true)"
fi
# Floor Node at 24.14.0 — do NOT inherit `mise current node` here. A drifted
# box default (e.g. Node 20) flushes the build transcript too slowly and
# deadlocks the first-subagent-spawn step-log gate; pin a known-good floor instead.
# Flag override (--node-version) still wins (this block only fires when unset).
if [[ -z "$NODE_VERSION" ]]; then
NODE_VERSION="24.14.0"
fi
# ── Validate common required args ─────────────────────────────────────────────
if [[ -z "$STACK" ]]; then
printf 'codegen-scaffold: --stack is required\n' >&2
usage
fi
if [[ -z "$CWD" ]]; then
printf 'codegen-scaffold: --cwd is required\n' >&2
usage
fi
if [[ "$STACK" != "phoenix" && "$STACK" != "static" ]]; then
printf 'codegen-scaffold: --stack must be "phoenix" or "static", got %q\n' "$STACK" >&2
usage
fi
# --no-ecto + a passthrough --database is a conflicting combination: phx.new
# generates the ecto-repo migrator code (skip_migrations?/0 in application.ex)
# whenever --database is present, REGARDLESS of --no-ecto — an interaction
# phx.new itself doesn't validate, and it fails codegen's own `make ci` gate
# (unused-parens Credo violation) on the generated app. Fail loud at parse
# time rather than let the operator hit a confusing scaffold-time credo error.
if [[ -n "$NO_ECTO" && -n "$PHX_PASSTHROUGH_DATABASE" ]]; then
printf 'codegen-scaffold: --no-ecto and -- --database %s conflict: phx.new generates ecto-repo migrator code whenever --database is set, regardless of --no-ecto. Drop one of the two.\n' "$PHX_PASSTHROUGH_DATABASE" >&2
exit 2
fi
# ── Subcommand-specific validation ────────────────────────────────────────────
if [[ "$SUBCOMMAND" == "create" ]]; then
if [[ -z "$SLUG" ]]; then
printf 'codegen-scaffold: create requires --slug\n' >&2
usage
fi
elif [[ "$SUBCOMMAND" == "integrate" ]]; then
# --slug defaults to basename of --cwd for integrate
if [[ -z "$SLUG" ]]; then
SLUG="$(basename "$CWD")"
fi
fi
# Default app-name to slug when not provided
if [[ -z "$APP_NAME" ]]; then
APP_NAME="$SLUG"
fi
# ── run_integrate_stage: symlinks + Makefile + README + .gitignore ────────────
# Called by both create (after scaffold) and integrate (directly).
# Argument: $1 = target dir (the app root to wire)
run_integrate_stage() {
local target_dir="$1"
# link_base_dir = the FINAL location the links will live at (create-path builds in a
# temp dir then mv's; relpath MUST be computed against the final dir, not the temp dir).
local link_base_dir="${2:-$target_dir}"
# Portable relative-path helper (macOS lacks GNU realpath --relative-to).
# python3 is a hard codegen dep (generator pipeline). $1=target, $2=start dir.
_relpath() { python3 -c 'import os,sys; t=os.path.realpath(sys.argv[1]); s=os.path.realpath(sys.argv[2]); print(os.path.relpath(t, s))' "$1" "$2"; }
# Determine per-stack agent files
local agents_source claude_source
case "$STACK" in
phoenix)
agents_source="$SCRIPT_DIR/shared/apps/AGENTS-phoenix.md"
claude_source="$SCRIPT_DIR/shared/apps/CLAUDE-phoenix.md"
;;
static)
agents_source="$SCRIPT_DIR/shared/apps/AGENTS-static.md"
claude_source="$SCRIPT_DIR/shared/apps/CLAUDE-static.md"
;;
esac
local rules_source="$SCRIPT_DIR/shared/rules"
local usage_rules_source="$SCRIPT_DIR/shared/usage_rules"
local recipes_source="$SCRIPT_DIR/shared/recipes"
# Verify every symlink target exists BEFORE linking — a dangling link here
# ships an app that resolves AGENTS.md/CLAUDE.md/codegen/rules/etc to
# nothing, and the failure would otherwise surface much later (or not at
# all, since `ln -sf` never checks its target).
local _link_src
for _link_src in "$agents_source" "$claude_source" "$rules_source" "$usage_rules_source"; do
if [[ ! -e "$_link_src" ]]; then
printf 'codegen-scaffold: FATAL: integrate symlink source does not exist: %s\n' "$_link_src" >&2
exit 1
fi
done
if [[ ! -d "$recipes_source" ]]; then
printf 'codegen-scaffold: FATAL: integrate symlink source does not exist: %s\n' "$recipes_source" >&2
exit 1
fi
# AGENTS.md → relative(shared/apps/AGENTS-*.md) from app root
ln -sf "$(_relpath "$agents_source" "$link_base_dir")" "$target_dir/AGENTS.md"
# CLAUDE.md → relative(shared/apps/CLAUDE-*.md) from app root
ln -sf "$(_relpath "$claude_source" "$link_base_dir")" "$target_dir/CLAUDE.md"
# codegen/rules → relative(shared/rules) from <app>/codegen (mkdir -p first)
mkdir -p "$target_dir/codegen"
ln -sfn "$(_relpath "$rules_source" "$link_base_dir/codegen")" "$target_dir/codegen/rules"
# codegen/usage_rules → relative(shared/usage_rules) from <app>/codegen
ln -sfn "$(_relpath "$usage_rules_source" "$link_base_dir/codegen")" "$target_dir/codegen/usage_rules"
# codegen/recipes → relative(shared/recipes) from <app>/codegen
ln -sfn "$(_relpath "$recipes_source" "$link_base_dir/codegen")" "$target_dir/codegen/recipes"
# codegen/recipes-extra → consumer-supplied recipe source (optional; append, never replace recipes)
if [[ -n "$RECIPE_SOURCE" ]]; then
ln -sfn "$RECIPE_SOURCE" "$target_dir/codegen/recipes-extra"
fi
# ── format: Makefile target injection (idempotent) ─────────────────────────
if [[ ! -f "$target_dir/Makefile" ]]; then
case "$STACK" in
phoenix)
printf 'format:\n\tmix format\n\tnpx prettier --write .\n' >"$target_dir/Makefile"
;;
static)
printf 'format:\n\tnpx prettier --write .\n' >"$target_dir/Makefile"
;;
esac
elif ! grep -q '^format:' "$target_dir/Makefile"; then
case "$STACK" in
phoenix)
printf '\nformat:\n\tmix format\n\tnpx prettier --write .\n' >>"$target_dir/Makefile"
;;
static)
printf '\nformat:\n\tnpx prettier --write .\n' >>"$target_dir/Makefile"
;;
esac
fi
# ── README codegen-integration note (idempotent) ───────────────────────────
if [[ ! -f "$target_dir/README.md" ]]; then
printf '# %s\n' "$SLUG" >"$target_dir/README.md"
fi
if ! grep -qF '## Codegen integration' "$target_dir/README.md"; then
printf '\n## Codegen integration\n\nThis project is managed by [codegen](https://github.com/optimum/codegen).\n\nRun AI-assisted development from this project root:\n\n```bash\n# Full build cycle (plan → dev → review → commit)\ncodegen-build\n\n# Single agent call\ncodegen-call "your task here"\n```\n\nNo project-local config needed — `codegen-scaffold integrate` wires agent files and rules automatically.\n' >>"$target_dir/README.md"
fi
# ── .gitignore: machine-local symlinks (idempotent) ────────────────────────
local gitignore="$target_dir/.gitignore"
local marker="# Codegen machine-local symlinks (do not commit)"
if [[ ! -f "$gitignore" ]] || ! grep -qF "$marker" "$gitignore"; then
printf '\n%s\nAGENTS.md\nCLAUDE.md\n/codegen/\n' "$marker" >>"$gitignore"
fi
# ── ci: Makefile target injection (static only, idempotent) ───────────────
if [[ "$STACK" == "static" ]]; then
if ! grep -q '^ci:' "$target_dir/Makefile" 2>/dev/null; then
printf '\nci:\n\t@[ -d node_modules ] || mise exec -- npm install\n\tnpm run lint\n\tnpm run build\n\tnpx prettier --check .\n' >>"$target_dir/Makefile"
fi
fi
# ── .claude/gate-config.sh (both stacks, idempotent) ──────────────────────
# gate-select.sh has no stack-guessing fallback: without an explicit per-app
# GATE_COMMAND a cycle that carries no gate selection of its own resolves to
# __GATE_UNRESOLVED__ and the loop raises. `create` renders a richer config
# from each stack's gate-config.sh.eex BEFORE this stage runs (dev port, app
# slug), so the write-once guard below leaves it untouched; `integrate` has
# no template step at all, so plant the floor here — for BOTH stacks, not
# just static. Write once; never overwrite an existing one.
if [[ ! -f "$target_dir/.claude/gate-config.sh" ]]; then
mkdir -p "$target_dir/.claude"
printf '#!/usr/bin/env bash\n# Per-app gate configuration. Generated by codegen-scaffold.\nGATE_STACK=%s\nGATE_COMMAND="make ci"\n' "$STACK" >"$target_dir/.claude/gate-config.sh"
fi
# ── PROJECT_CONTEXT.md render (idempotent: write once) ────────────────────
local pc_template
case "$STACK" in
phoenix)
pc_template="$SCRIPT_DIR/shared/apps/PROJECT_CONTEXT-phoenix-template.md"
;;
static)
pc_template="$SCRIPT_DIR/shared/apps/PROJECT_CONTEXT-static-template.md"
;;
esac
if [[ ! -f "$target_dir/PROJECT_CONTEXT.md" ]]; then
local app_name_display="${APP_NAME:-$SLUG}"
local pc_content
pc_content="$(cat "$pc_template")"
pc_content="${pc_content//\{\{PROJECT_NAME\}\}/$app_name_display}"
pc_content="${pc_content//\{\{SITE_NAME\}\}/$app_name_display}"
printf '%s\n' "$pc_content" >"$target_dir/PROJECT_CONTEXT.md"
fi
# ── context/ docs (idempotent: write once) ────────────────────────────────
# PROJECT_CONTEXT.md's index PROMISES these exist ("always present"). A dangling
# index row is a hard failure at the curator's context-index-parity-scan, run
# AFTER the developer and reviewer have run — both before the first curator
# invocation (seeding a same-cycle violation into that first prompt) and again
# post-turn as the backstop. The agents own the content from the first build
# onward — these are only the stubs that make the index true on day zero.
mkdir -p "$target_dir/context"
# Both stacks' indexes promise development.md; only static's promises core.md.
# Seed exactly what the stack's own index row names — never more.
if [[ ! -f "$target_dir/context/development.md" ]]; then
cp "$SCRIPT_DIR/shared/apps/context-development-$STACK.md" "$target_dir/context/development.md"
fi
if [[ "$STACK" == "static" && ! -f "$target_dir/context/core.md" ]]; then
cp "$SCRIPT_DIR/shared/apps/context-core-static.md" "$target_dir/context/core.md"
fi
# ── restart_server.sh render (both stacks; only when --restart-rpc-cmd set) ─
local restart_template="$SCRIPT_DIR/shared/scaffold/restart_server.sh.eex"
local eex_render="$SCRIPT_DIR/shared/scaffold/phoenix/eex_render.sh"
if [[ -n "$RESTART_RPC_CMD" && ! -f "$target_dir/restart_server.sh" ]]; then
"$eex_render" "$restart_template" "$target_dir/restart_server.sh" \
"restart_rpc_cmd=$RESTART_RPC_CMD"
chmod +x "$target_dir/restart_server.sh"
elif [[ -z "$RESTART_RPC_CMD" && ! -f "$target_dir/restart_server.sh" ]]; then
# Local-dev only: no platform RPC section
cat >"$target_dir/restart_server.sh" <<'RESTARTEOF'
#!/usr/bin/env bash
# restart_server.sh — stop running server processes (local dev).
#
# Generated by codegen-scaffold (no platform RPC configured).
set -euo pipefail
printf '[restart_server] stopping server processes (best-effort)...\n'
pkill -f 'phx.server\|mix phx\|npm run serve' 2>/dev/null || true
printf '[restart_server] done — start your server manually\n'
RESTARTEOF
chmod +x "$target_dir/restart_server.sh"
fi
# ── codegen/usage_rules_INDEX.md render (phoenix only, idempotent) ────────
if [[ "$STACK" == "phoenix" ]]; then
local index_src="$SCRIPT_DIR/shared/scaffold/usage_rules_INDEX.md"
local index_dst="$target_dir/codegen/usage_rules_INDEX.md"
if [[ ! -f "$index_dst" ]]; then
mkdir -p "$target_dir/codegen"
cp "$index_src" "$index_dst"
fi
fi
# ── codegen/pitches/{draft,ready,shipped}/ + priv/plts/ (recreated every run) ─
# Both dirs sit under unnegated .gitignore boundaries (/codegen/,
# /priv/plts/), so no sentinel file (.gitkeep/.keep) can ever survive a
# clone — a sentinel there only looks solved. This integrate stage is the
# durable recreator: three launchers (claude-shape/-experiment/-build)
# resolve straight out of codegen/pitches/{draft,ready,shipped}, and the
# ship move needs shipped/ to exist. mkdir -p is idempotent; no sentinel
# is written.
for _pitch_dir in draft ready shipped; do
mkdir -p "$target_dir/codegen/pitches/$_pitch_dir"
done
mkdir -p "$target_dir/priv/plts"
# ── codegen/manifest.yaml provenance (ALWAYS REWRITE; not write-once) ──────
# Records which codegen scaffold schema wired this app so codegen-build can
# refuse builds whose stamped version is behind the repo's current version.
# Machine-local: /codegen/ is gitignored in the app (see .gitignore block above).
# An unreadable SCHEMA_VERSION is fatal, not a "unknown" sentinel — codegen-build's
# staleness refusal only fires on a positively-parsed integer, so "unknown"
# permanently and silently disables that refusal for this app.
local schema_version_file="$SCRIPT_DIR/shared/scaffold/SCHEMA_VERSION"
if [[ ! -f "$schema_version_file" ]] || [[ -z "$(cat "$schema_version_file" 2>/dev/null)" ]]; then
echo "[codegen-scaffold] FATAL: $schema_version_file missing or empty — cannot stamp codegen/manifest.yaml with a real schema version" >&2
exit 1
fi
local schema_version scaffolded_sha scaffolded_at
schema_version="$(cat "$schema_version_file")"
scaffolded_sha="$(git -C "$SCRIPT_DIR/shared" rev-parse --short HEAD 2>/dev/null || echo unknown)"
scaffolded_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
mkdir -p "$target_dir/codegen"
printf 'scaffold_schema_version: %s\nscaffolded_from_sha: %s\nscaffolded_at: %s\n' \
"$schema_version" "$scaffolded_sha" "$scaffolded_at" >"$target_dir/codegen/manifest.yaml"
printf '[codegen-scaffold] integrate complete for stack=%s at %s\n' "$STACK" "$target_dir"
}
# ── do_create: transactional scaffold + integrate ─────────────────────────────
do_create() {
# Target app will be created at CWD/SLUG (scaffold.sh creates SLUG subdir inside parent)
local parent_dir="$CWD"
local target_dir="$parent_dir/$SLUG"
if [[ -d "$target_dir" ]]; then
printf 'codegen-scaffold: target already exists: %s\n' "$target_dir" >&2
exit 1
fi
# Transactional temp parent (sibling of target → same filesystem → atomic mv)
local TEMP_PARENT
TEMP_PARENT="$(mktemp -d "$(dirname "$target_dir")/.scaffold.tmp.XXXXXX")"
if [[ -z "${TEMP_PARENT:-}" ]]; then
printf 'codegen-scaffold: mktemp failed to create temp parent\n' >&2
exit 1
fi
trap 'rm -rf "${TEMP_PARENT:-}"' EXIT
case "$STACK" in
phoenix)
local phoenix_scaffold="$SCRIPT_DIR/shared/scaffold/phoenix/scaffold.sh"
if [[ ! -x "$phoenix_scaffold" ]]; then
printf 'codegen-scaffold: phoenix scaffold not found or not executable: %s\n' "$phoenix_scaffold" >&2
exit 1
fi
# scaffold.sh creates <slug> inside the directory it runs in;
# pass TEMP_PARENT as the target dir so it scaffolds into TEMP_PARENT/SLUG
local phoenix_extra_flags=()
[[ -n "$NO_ECTO" ]] && phoenix_extra_flags+=("--no-ecto")
[[ -n "$WITH_APPSIGNAL" ]] && phoenix_extra_flags+=("--with-appsignal")
[[ -n "$GITHUB_URL" ]] && phoenix_extra_flags+=("--github-url" "$GITHUB_URL")
if [[ ${#PHX_PASSTHROUGH_FLAGS[@]} -gt 0 ]]; then
phoenix_extra_flags+=("--")
phoenix_extra_flags+=("${PHX_PASSTHROUGH_FLAGS[@]}")
printf '[codegen-scaffold] phx.new passthrough flags: %s\n' "${PHX_PASSTHROUGH_FLAGS[*]}"
fi
"$phoenix_scaffold" "$SLUG" "$TEMP_PARENT/$SLUG" \
--elixir-version "$ELIXIR_VERSION" \
--node-version "$NODE_VERSION" \
--otp-version "$OTP_VERSION" \
"${phoenix_extra_flags[@]+"${phoenix_extra_flags[@]}"}"
;;
static)
local static_scaffold="$SCRIPT_DIR/shared/scaffold/static/scaffold.sh"
if [[ ! -x "$static_scaffold" ]]; then
printf 'codegen-scaffold: static scaffold not found or not executable: %s\n' "$static_scaffold" >&2
exit 1
fi
"$static_scaffold" "$SLUG" "$TEMP_PARENT/$SLUG" --app-name "$APP_NAME"
;;
esac
# ── Initialise git repo ──────────────────────────────────────────────────
# Phoenix may skip git init when phx.new runs under an existing parent repo.
# Ensure the temp app has its own repo before committing, or git add/commit
# will dirty the caller's repo with .scaffold.tmp paths.
local git_ceiling_dir
git_ceiling_dir="$(cd "$parent_dir" && pwd -P)"
if [[ ! -d "$TEMP_PARENT/$SLUG/.git" ]]; then
GIT_CEILING_DIRECTORIES="$git_ceiling_dir" git -C "$TEMP_PARENT/$SLUG" init -q
fi
# Run integrate stage against the temp app dir
run_integrate_stage "$TEMP_PARENT/$SLUG" "$target_dir"
# ── Initial commit (both stacks) ─────────────────────────────────────────
# Both stacks commit here so all integrate-stage files
# (PROJECT_CONTEXT.md, restart_server.sh, usage_rules_INDEX.md) are included.
(
cd "$TEMP_PARENT/$SLUG"
export GIT_CEILING_DIRECTORIES="$git_ceiling_dir"
git add -A
git commit -m "Initial commit"
) || {
printf 'codegen-scaffold: initial git commit failed\n' >&2
exit 1
}
# Atomic move into final position
mkdir -p "$parent_dir"
mv "$TEMP_PARENT/$SLUG" "$target_dir"
# Re-trust after atomic mv: mise trust is keyed to the config file's absolute path;
# a mv invalidates trust recorded against the pre-move temp path. Phoenix-only
# (.mise.toml is never rendered for static); tool ABSENCE stays a quiet skip,
# tool FAILURE (mise present, .mise.toml present, trust genuinely fails) is loud —
# an untrusted .mise.toml means the app's .env never loads locally.
if [[ -f "$target_dir/.mise.toml" ]] && command -v mise >/dev/null 2>&1; then
local _mise_retrust_stderr_file
_mise_retrust_stderr_file="$(mktemp)"
if ! mise trust "$target_dir/.mise.toml" >/dev/null 2>"$_mise_retrust_stderr_file"; then
printf 'codegen-scaffold: ERROR: mise re-trust after mv failed — .mise.toml will not load .env; real stderr:\n' >&2
cat "$_mise_retrust_stderr_file" >&2
rm -f "$_mise_retrust_stderr_file"
exit 1
fi
rm -f "$_mise_retrust_stderr_file"
fi
# Success — disarm trap
trap - EXIT
rm -rf "$TEMP_PARENT"
printf '[codegen-scaffold] create complete for stack=%s at %s\n' "$STACK" "$target_dir"
}
# ── do_integrate: wire existing app in place ──────────────────────────────────
do_integrate() {
if [[ ! -d "$CWD" ]]; then
printf 'codegen-scaffold: --cwd directory does not exist: %s\n' "$CWD" >&2
exit 1
fi
# Self-build guard (content-marker, location-independent): when integrating
# the codegen repo itself, re-symlinking would clobber the committed
# AGENTS.md/CLAUDE.md symlinks and re-append .gitignore entries. Detect the
# codegen root by the presence of BOTH generator artifacts (never present in
# a downstream scaffolded app), regardless of where this launcher lives.
if [[ -f "$CWD/harnesses/claude/manifest.yaml" && -f "$CWD/templates/generator/generate.sh" ]]; then
printf 'codegen-scaffold: self-build detected (codegen repo markers present in --cwd); skipping integrate (clean no-op)\n' >&2
exit 0
fi
run_integrate_stage "$CWD"
}
# ── Dispatch ──────────────────────────────────────────────────────────────────
case "$SUBCOMMAND" in
create)
do_create
;;
integrate)
do_integrate
;;
esac