-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsetup.sh
More file actions
executable file
·2378 lines (2195 loc) · 128 KB
/
Copy pathsetup.sh
File metadata and controls
executable file
·2378 lines (2195 loc) · 128 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
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
# setup.sh — the first-run wizard: four questions, generated secrets, a ready-to-launch .env
# (SPEC F132.1-.6, STORY-344).
#
# Lives at repo root, peer of launch.sh — wraps it, never re-implements compose ORCHESTRATION
# (up/pull/down stay launch.sh's alone; adoption mode's own drift probes, below, may still run
# READ-ONLY `docker`/`docker compose` inspection — config/ps/exec-a-select-query — through the
# GW_DOCKER_CMD seam, F137/T319). Plain bash, plain numbered prompts, no whiptail/dialog
# dependency.
#
# Virgin box (no .env yet at this script's target path) -> the four-question interview, secrets
# generation, and one atomic .env write. Existing box (a .env is already there) -> routes to
# setup_adoption_mode: verify (read-only drift report) by default, or verify-then-repair under
# --repair (F137, STORY-346, T319 — see that function's own header, further down, for the full
# contract). This script's write to ENV_FILE only EVER happens on the virgin path — adoption
# mode never opens it for writing at all.
#
# Idempotency = derive, don't record (F132.4): there is no wizard state file anywhere. The one
# fact this script ever persists is .env itself — its presence *is* the "already set up" signal,
# and every other fact this run needs (SDK version, RAM, arch, audio file count) is read fresh
# from the machine every time, never cached to disk. Abandoning the interview at any point
# (closed stdin, Ctrl-C) leaves .env exactly as it was found: the target is written in ONE
# atomic step (a temp file next to it, then `mv`) only after every question has been answered —
# never incrementally, so a killed run can never leave a half-written .env.
#
# Seams (test-only; each defaults to the real path/value):
# GW_ENV_FILE — target .env path (default .env). Its presence/absence is also the
# virgin-vs-existing signal — same convention tools/preflight.sh and
# launch.sh already read this key with.
# GW_MEMINFO_FILE — RAM source for the topology recommendation (default /proc/meminfo —
# tools/preflight.sh's own seam of the same name, reused here).
# GW_ARCH — overrides `uname -m` for the topology recommendation's arm64/SBC check.
# GW_FIND_CMD — overrides the `find` binary count_audio_files shells out to (default
# find) — same seam name as tools/preflight.sh's own (sourced above),
# whose preflight_media_deep reads it independently; this script's specs
# drive both through the one env var.
# GW_LAUNCH_CMD — the command this script execs once ready to launch (default
# ./launch.sh; T318, STORY-345). Invoked BARE, no arguments — GW_PRESET,
# just written, IS the topology (F132.5). Story345's specs point this at a
# scripted stub instead of the real launch.sh, which would otherwise try
# to talk to a real Docker daemon.
# GW_STREAM_URL — the mount wait_for_on_air_bg polls for first audio (default
# http://localhost:8000/stream — the same mount launch.sh's own
# access-points printout names). Story345's specs point this at a scratch
# loopback HTTP server instead of a real Icecast.
# GW_ONAIR_TIMEOUT_SECONDS — overrides GW_ONAIR_TIMEOUT_SECONDS_DEFAULT (below) — the poll
# budget wait_for_on_air_bg gives up after. Exists so Story345's poll-
# timeout (sad-path) spec doesn't have to wait the real, generous
# production budget out.
# GW_DOCKER_CMD — the `docker` binary this script's adoption-mode drift probes invoke
# (default docker; T319, STORY-346). Mirrors GW_LAUNCH_CMD's own shape:
# every adoption-mode `docker ...`/`docker compose ...` call in this file
# goes through this seam, so Story346's specs can point it at a scripted
# stub and prove exactly which subcommands ran (read-only in verify mode,
# never more than the confirmed fix in repair mode) with no real daemon
# anywhere in the loop. tools/preflight.sh's OWN docker calls (preflight_docker
# et al, sourced above) are NOT behind this seam — that file is shared with
# build.sh/launch.sh and owns its own SKIP_PREFLIGHT escape hatch instead;
# adoption mode runs under SKIP_PREFLIGHT=1 in Story346's specs precisely so
# preflight's own (already-tested-elsewhere) checks stay out of this file's
# own facts.
# GW_API_URL — the local api's base URL install_first_beds's login + pack-install calls
# target (default http://127.0.0.1:GW_API_PORT_DEFAULT — SPEC F165.7, PLAN
# T428). Same idiom as GW_STREAM_URL just above: curl itself stays the real
# binary everywhere in this file, never stubbed — a test points this one URL
# at a scratch loopback server instead (Story345's own MountStub shape) so
# Story405's specs never touch a real api.
# stdin — the interview's answer channel: a caller pipes newline-terminated
# answers in, one per prompt. Doubles as adoption-mode repair's per-item
# confirm channel (T319) when --repair runs without --yes.
# The .NET SDK probe (Q1) needs no seam of its own: like build.sh's own check, it is just
# `dotnet` present-or-absent on PATH (Gh019's idiom) — a scratch PATH with no dotnet stub
# already proves the pinned-only branch.
#
# Sources tools/preflight.sh for preflight_env_value (routing/topology reads) and its two
# hard-fail entry points, preflight_docker + preflight_env_secrets — run AFTER the .env write,
# before this script ever launches anything, so a machine or .env problem is caught before a
# single docker/compose call happens (a failure here still leaves the just-written .env in
# place; only the machine, not the file, is in question).
#
# T318/STORY-345 (F132.7-.8): once preflight clears, this script hands off to launch.sh itself
# via invoke_launch (GW_LAUNCH_CMD, default ./launch.sh) — bare, no topology flags, same
# convention resolve_preset_and_topology already documents. The mount poller (wait_for_on_air_bg)
# runs CONCURRENTLY with invoke_launch, not after it (T318 review round-1 BLOCKING finding F1):
# a staged pinned launch (home*) is already broadcasting minutes before launch.sh itself
# returns — it spends that gap pulling/converging the catch-up stage — so timing launch.sh's
# own wall clock would measure the WRONG thing entirely. launch.sh's own exit code still
# decides what happens next (the T316 rider, pinned in main): 0 = on air, proceed straight to
# the clock; 4 = DEGRADED-BUT-AIRING (the core is up, the catch-up stage didn't fully converge)
# — same clock/handoff path, but the handoff names the degradation and the catch-up command
# instead of pretending all is well; anything else means the launch genuinely failed — the
# poller is killed, no "On air" line is ever printed (even if it had already fired before the
# failure — the ordering race), and this script exits with launch.sh's own code after pointing
# at `docker compose ps` / `logs`.
#
# The clock (F132.7): t0 is stamped once, right as the interview starts (T0_SECONDS in main,
# bash's own $SECONDS builtin — a pure elapsed-time counter, no `date`/external dependency on
# this path at all) — not at launch, not at the mount poll. wait_for_on_air_bg is started in
# the background (`&`) in the SAME breath as invoke_launch, so its very first sample doubles as
# a stale-mount gate's snapshot: a mount that ALREADY serves audio at that instant can only be
# evidence of something other than this run — round-3 review BLOCKING finding B2: the gate is
# UNIVERSAL, never scoped to a preset (see wait_for_on_air_bg's own header for why "immediate
# 200 = success" is a claim about TIMING, never about which preset is running). Polls
# GW_STREAM_URL every ~1s via curl (F12: the poll interval IS the measurement's granularity now,
# not a separate 2s+2s blind spot) for an HTTP 200 with a nonzero body, up to
# GW_ONAIR_TIMEOUT_SECONDS_DEFAULT seconds (generous — a first run's image pulls can take
# minutes), ignoring any ambient proxy env (`--noproxy '*'` — a stray HTTP_PROXY must never make
# a loopback poll silently fail). curl is required (already a fact of any box that can run this
# stack's own compose healthchecks); its absence degrades to an honest can't-verify message plus
# the full handoff under launch.sh's own exit code, never a fabricated pass or a hardcoded
# failure (round-3 review finding N4). The moment audio is first detected, the poller prints a
# subordinate progress line straight to stdout — wording clearly distinct from the authoritative
# claim below, so an owner on a fresh Pi isn't left staring at a pull log for minutes while
# already live; the authoritative claim itself is still only made once main() has confirmed
# launch.sh's own exit code. Success prints "🎙️ On air in M:SS" and appends one greppable line
# to SETUP_LOG_FILE — a plain file next to ENV_FILE, gitignored, never the secrets file itself —
# that line's ISO timestamp is the one place this whole feature actually calls `date`
# (append_setup_log).
#
# The handoff (F132.8, print_handoff): the admin URL (hostname-first, then the always-works
# LAN-IP line for other devices on the network, localhost demoted to a last-resort fallback —
# finding 2, gate-run round 2: Dean's own ruling OVERRIDES the earlier T318 review F6 call,
# which rejected the bare hostname outright; his reasoning: this wizard is read over SSH more
# often than not, so a URL that resolves back to the READER's own machine (localhost) is
# actively wrong there, while the plain hostname resolves for other devices via the home
# router's own mDNS/LLMNR), the generated ADMIN_PASSWORD shown exactly this once (T318
# review F2: read straight from SECRET_ADMIN_UI, the value this run itself generated — never
# read back via preflight_env_value, whose process-env-wins precedence can print an ambient
# caller's ADMIN_PASSWORD instead of the one actually written to .env), the persona-shelf deep
# link, what's still arriving in the background (derived from GW_PRESET + ADMIN_PROFILE — never
# hardcoded to one topology's services, and never a service this wizard could not possibly have
# composed — T318 review F3), and the exact next-run commands (T318 review F5: ./setup.sh's own
# line is worded for what it actually does on this branch — routes to a stub — not a verify
# promise STORY-346 hasn't shipped yet).
#
# tools/preflight.sh's own EXIT trap (F134.6's pass/warn summary table) fires after all of the
# above, on any exit path — see the trap setup right below the source line for why this script
# chains onto it rather than replacing it (T317 review LOW finding).
#
# Adoption mode: verify & repair for existing boxes (F137, STORY-346, T319, setup_adoption_mode
# et al. near the bottom of this file). An existing ENV_FILE routes here instead of the interview
# — never both. Two invocation shapes:
# ./setup.sh VERIFY — read-only. Runs the F134 preflight (preflight_docker +
# preflight_env_secrets) plus six drift probes (.env completeness
# vs .env.example, unapplied schema migrations vs the repo's db/
# max, stale locally-built images — gh-#351, informational only per
# Dean's ruling, never a fix — orphaned profile containers, and
# disk-prune advice), prints one report, and NEVER writes a file,
# starts/stops/recreates a container, or pulls/prunes anything.
# Deliberate divergences (a DB-stored settings override, an operator
# COMPOSE_FILE customization) print as INFO, never as a finding.
# ./setup.sh --repair [--yes] REPAIR — runs the same verify pass first (nothing above changes),
# then walks every mechanically-fixable finding: prints its exact
# command, warns first if it will stop/restart a container (F137.3),
# and waits for a per-item y/N — or applies every one of them
# without prompting under --yes (F137.2). A finding with no safe,
# scriptable fix (env completeness, stale-image ages) stays
# report-only in both modes; the operator edits/rebuilds by hand.
# ./setup.sh --offline first-run only — skip the background-music pack install
# (F5, round-2 review: this line is what makes -h/--help actually
# name the flag, matching the unknown-argument arm further down).
#
# Exit codes (adoption mode only — the interview path's own vocabulary, above, is unaffected):
# 0 verify: no drift found (deliberate divergences, if any, are INFO-only — F137.4's
# do-no-harm gate). repair: every finding was applied (or none existed to begin with).
# 2 bad invocation (unknown flag).
# 3 tools/preflight.sh's own preflight_fail (a genuine machine/secrets problem — same
# meaning it always has).
# 5 verify: drift was found and reported (nothing was changed — do-no-harm still holds).
# repair: at least one finding is still outstanding after the pass (declined, or its fix
# command itself failed) — re-run ./setup.sh --repair to retry.
set -euo pipefail
cd "$(dirname "$0")"
. tools/preflight.sh
ENV_FILE="${GW_ENV_FILE:-.env}"
SECRET_LENGTH=40 # comfortably over F132.3's >=32-char floor
# GW_STREAM_PORT_DEFAULT — the handoff's own display constant (finding 1, post-v5.3.0 gate run):
# named here, not inline, so both print_handoff's stream block and the poll-timeout diagnostic
# share one source. Deliberately NOT derived from GW_STREAM_URL (the wait_for_on_air_bg poll
# seam, which Story345's specs point at a scratch loopback server on a random port) — the
# handoff has to show the real listener-facing URL a production box actually serves, and
# compose.yaml's own icecast port mapping ("8000:8000") is a fixed literal, not something any
# .env key configures — there is no seam to derive it from. 8000 is also the port launch.sh's
# own access-points printout already names for the same mount.
#
# The one overlay that DOES override this — compose.demo.yaml's `icecast: ports: !override []`
# (unpublishes 8000 entirely; Caddy reaches icecast over the internal `core` network instead,
# right for the public-appliance box that overlay is for) — is unreachable from this wizard: the
# public appliance stays flag-only (`--pinned`, SPEC F136.5), never something this interview can
# select, and resolve_preset_and_topology hardcodes GW_PREFLIGHT_DEMO_VALUE="0" for exactly that
# reason (this wizard has no path to compose.demo.yaml at all). 8000 is therefore not just a
# fallback guess for the runs this script can actually produce — it's the fact.
GW_STREAM_PORT_DEFAULT=8000
# GW_API_PORT_DEFAULT — install_first_beds's own fallback for GW_API_URL (SPEC F165.7, PLAN
# T428), same reasoning as GW_STREAM_PORT_DEFAULT just above: compose.yaml's own "8080:8080"
# mapping is a fixed literal no .env key configures, and launch.sh's own access-points printout
# already names 8080 for the same api.
GW_API_PORT_DEFAULT=8080
# Adoption mode's own CLI surface (T319, STORY-346) — parsed in main(), before the virgin-vs-
# existing routing decision. Meaningless on the virgin (interview) path; a first-run box simply
# ignores them (no --repair-only validation gate here — the least surprising behaviour for an
# operator who passes them out of habit before ever installing).
SETUP_REPAIR=0
SETUP_YES=0
# First-run's own CLI surface (SPEC F165.7, PLAN T428) — parsed in main() alongside adoption
# mode's own flags, above. Meaningless in adoption mode (an existing box never re-runs
# install_first_beds at all — see that function's own header): --offline only ever changes
# install_first_beds's own first branch on the virgin/interview path.
SETUP_OFFLINE=0
# usage — dumps this file's own header comment (the launch.sh idiom), for -h/--help.
usage() {
awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0"
}
# T318/F132.7 — the on-air timing log: a plain file beside ENV_FILE (gitignored, never the
# secrets file itself — see append_setup_log). One greppable line per run.
SETUP_LOG_FILE="$(dirname "$ENV_FILE")/setup.log"
# Generous: a first run's core image pull (db/icecast/engine/api, +piper when selected) can
# take several minutes on a slow link before the mount ever starts serving audio. Overridable
# via GW_ONAIR_TIMEOUT_SECONDS (Story345's poll-timeout spec never waits this long for real).
GW_ONAIR_TIMEOUT_SECONDS_DEFAULT=900
# T318 review MEDIUM finding F8: a SET-but-garbage GW_ONAIR_TIMEOUT_SECONDS (a typo, a stray
# non-numeric override) used to go unvalidated straight into wait_for_on_air's `$(( ))`
# arithmetic, deep inside the poller — fail loudly here instead, at parse time, before a
# single question is asked. Unset is fine (falls through to the default above).
if [ -n "${GW_ONAIR_TIMEOUT_SECONDS:-}" ] && ! [[ "$GW_ONAIR_TIMEOUT_SECONDS" =~ ^[0-9]+$ ]]; then
echo "setup.sh: GW_ONAIR_TIMEOUT_SECONDS must be a non-negative integer (got '${GW_ONAIR_TIMEOUT_SECONDS}')." >&2
exit 1
fi
# --- EXIT trap: chain onto preflight's own (T317 review MEDIUM finding: stranded temp
# secrets) -----------------------------------------------------------------------------------
# tools/preflight.sh (sourced above) already registered `trap preflight_print_report EXIT` —
# bash keeps exactly one EXIT trap, so replacing it outright (a bare `trap ... EXIT` here)
# would silently drop that summary table (that file's own header CAUTIONs exactly this
# footgun). This registers ONE trap that does both jobs, in order: clean up any still-live
# `.env.setup.*` temp write (SETUP_TMP_ENV_FILE — set by apply_env_write only for the window
# between mktemp and mv, so a signal or a hard failure mid-write never leaves a secret-laden
# stray file on disk), THEN print whatever preflight recorded.
#
# T318 review BLOCKING finding F1: the same chaining discipline now also covers the background
# mount poller (SETUP_POLLER_PID, set only for the window main() has one actually running) and
# its stamp file (SETUP_ONAIR_STAMP_FILE) — a Ctrl-C (or any other signal) during the wait must
# never leave an orphan poller running after this script itself has exited.
#
# Finding 5 (post-v5.3.0 gate run): main() also calls preflight_print_report explicitly, right
# after the checks that populate it, so the table renders before the on-air line instead of
# waiting for this trap to fire at true process exit (after the handoff). This trap's own call
# below is therefore usually a no-op by the time it runs — preflight_print_report's own
# idempotency guard (tools/preflight.sh) is what makes that safe — but it stays here unconditionally
# for every path that never reaches the explicit call (a hard preflight_fail, in particular).
SETUP_TMP_ENV_FILE=""
SETUP_POLLER_PID=""
SETUP_ONAIR_STAMP_FILE=""
# install_first_beds's own cookie jar (SPEC F165.7, PLAN T428) — same tracker-variable idiom as
# SETUP_TMP_ENV_FILE above: set only for the window a jar file actually exists, so the shared
# EXIT trap can rm it unconditionally on every exit path (a Ctrl-C mid-curl-call must never leave
# a session cookie sitting on disk).
SETUP_PACK_INSTALL_COOKIE_JAR=""
# discard_poller — N2 (round-3 review): the kill/reap/rm-stamp/clear-PID sequence every path
# that stops trusting the background poller needs (a genuine launch failure, an operator
# Ctrl-C, a poll timeout, a clean join, and the shared EXIT trap below) — extracted once so the
# four call sites can never drift apart. Idempotent: safe to call with either tracker already
# empty (kill/wait on an already-reaped PID, or rm on an already-removed/never-created stamp
# file, are both no-ops), which is exactly what lets the EXIT trap call it unconditionally on
# every exit path.
discard_poller() {
if [ -n "$SETUP_POLLER_PID" ]; then
kill "$SETUP_POLLER_PID" 2>/dev/null || true
wait "$SETUP_POLLER_PID" 2>/dev/null || true
SETUP_POLLER_PID=""
fi
if [ -n "$SETUP_ONAIR_STAMP_FILE" ]; then
rm -f "$SETUP_ONAIR_STAMP_FILE"
SETUP_ONAIR_STAMP_FILE=""
fi
}
setup_exit_trap() {
discard_poller
[ -n "$SETUP_TMP_ENV_FILE" ] && rm -f "$SETUP_TMP_ENV_FILE"
[ -n "$SETUP_PACK_INSTALL_COOKIE_JAR" ] && rm -f "$SETUP_PACK_INSTALL_COOKIE_JAR"
# Adoption mode's own verify_print_report (T319) is called explicitly from
# setup_adoption_mode, not chained here — its own report has to print BEFORE that function's
# green/drift-found verdict line, and this trap only ever fires AFTER a function's own exit
# call, which would put the table below the verdict instead of above it.
preflight_print_report
}
trap setup_exit_trap EXIT
# =============================================================================
# Reality probes — every one of these reads the machine or the filesystem fresh;
# nothing here is ever cached (F132.4).
# =============================================================================
# check_dotnet10_sdk — Q1's gate: the build-your-own path is offered only when a .NET 10 SDK is
# on PATH right now (mirrors tools/preflight.sh's preflight_dotnet_sdk probe, but never hard-
# fails — an absent SDK just narrows Q1's menu instead of stopping the wizard).
check_dotnet10_sdk() {
command -v dotnet >/dev/null 2>&1 || return 1
dotnet --list-sdks 2>/dev/null | grep -q '^10\.'
}
# count_audio_files <dir> — the same case-insensitive .flac/.mp3 rule as
# tools/preflight.sh's preflight_media_deep (F134.5), reusing its GW_PREFLIGHT_AUDIO_EXTENSIONS
# array (sourced above) so the two lists can never drift apart. The walk itself is duplicated,
# not shared, because this script may not edit tools/preflight.sh.
#
# Prints a count, OR nothing at all (empty string) when `find` is missing — the same
# `command -v` guard parity as preflight_media_deep's own check (T317 review MEDIUM finding:
# this used to silently degrade a "couldn't check" machine to a verified-looking 0, which then
# showed interview_music's no-music/Jamendo lane over what might be a full library the probe
# simply couldn't see). Callers must treat an empty result as "unknown", never as zero.
count_audio_files() {
local dir="$1" find_cmd="${GW_FIND_CMD:-find}"
command -v "$find_cmd" >/dev/null 2>&1 || return 0
local find_expr=() ext first=1
for ext in "${GW_PREFLIGHT_AUDIO_EXTENSIONS[@]}"; do
if [ "$first" -eq 1 ]; then
find_expr+=(-iname "*.${ext}")
first=0
else
find_expr+=(-o -iname "*.${ext}")
fi
done
local count=0
while IFS= read -r _; do
count=$((count + 1))
done < <("$find_cmd" "$dir" -type f \( "${find_expr[@]}" \) 2>/dev/null)
printf '%s' "$count"
}
detect_ram_gib() {
local meminfo="${GW_MEMINFO_FILE:-/proc/meminfo}" kib
[ -r "$meminfo" ] || return 1
kib="$(grep -m1 '^MemTotal:' "$meminfo" 2>/dev/null | grep -oE '[0-9]+' || true)"
[ -n "$kib" ] || return 1
printf '%s' $((kib / 1024 / 1024))
}
detect_arch() {
printf '%s' "${GW_ARCH:-$(uname -m)}"
}
# recommend_topology <ram_gib-or-empty> <arch> — SPEC F132.2: under tools/preflight.sh's own
# F134.4 RAM floor (GW_PREFLIGHT_RAM_MIN_GIB, reused rather than redefined here), OR SBC-class
# arm64, recommends piper-only; everything else recommends full. The owner can always override
# at the prompt.
#
# "SBC-class arm64" is arm64 GATED ON low/unknown RAM (T317 review LOW finding), never bare
# arch: plenty of arm64 machines (Apple Silicon dev boxes, beefy ARM cloud servers) comfortably
# run Full, and the old bare-arch check over-recommended piper-only on every one of them
# regardless of headroom. A KNOWN sufficient RAM reading on arm64 falls through to the RAM
# check below and recommends full same as any other arch; an UNREADABLE /proc/meminfo on
# arm64 is treated as circumstantial SBC evidence (the class of device this heuristic exists
# for tends to be exactly the kind where that probe is flaky) and still recommends piper-only.
recommend_topology() {
local ram_gib="$1" arch="$2"
if [ -n "$ram_gib" ] && [ "$ram_gib" -lt "${GW_PREFLIGHT_RAM_MIN_GIB:-6}" ]; then
printf 'piper-only'
return
fi
case "$arch" in
aarch64 | arm64)
if [ -z "$ram_gib" ]; then
printf 'piper-only'
return
fi
;;
esac
printf 'full'
}
# =============================================================================
# Secrets (F132.3)
# =============================================================================
# gen_secret [length] — an alnum-only /dev/urandom string (default $SECRET_LENGTH, >=32 per
# F132.3), safe to drop unquoted into .env (no shell-hostile characters). Reads bounded 256-byte
# chunks rather than piping urandom straight into `tr | head -c`: an unbounded upstream reader
# meeting a downstream `head -c` that closes early raises SIGPIPE in the upstream command, which
# under `set -o pipefail` would abort this script on what is otherwise a perfectly successful
# secret. Bounding the read at the SOURCE (this head, not the sink) means every command in the
# pipeline reaches its own natural EOF — no SIGPIPE, ever.
gen_secret() {
local length="${1:-$SECRET_LENGTH}" secret=""
while [ "${#secret}" -lt "$length" ]; do
secret="${secret}$(head -c 256 /dev/urandom | LC_ALL=C tr -dc 'A-Za-z0-9')"
done
printf '%s' "${secret:0:$length}"
}
# =============================================================================
# The interview (F132.2) — plain numbered prompts, exactly four questions.
# =============================================================================
# prompt <question text, printed as-is> <result-var-name> [default]
# Reads one line from stdin into the CALLER's variable (bash's dynamic scoping — the caller
# declares it `local` first). On EOF (abandonment: a piped answer stream running dry, or a
# genuine Ctrl-D) this aborts immediately WITHOUT writing anything — .env is only ever written
# in the single atomic step at the very end of the interview, so an abort here always leaves the
# target exactly as it was found (F132.4/AC4).
#
# CAUTION: every local this function declares is double-underscore-prefixed on purpose. Every
# caller in this script passes "answer" as the result-var name — an unprefixed local here named
# (say) `answer` would shadow that same-named variable one frame up, so `printf -v` would set
# THIS function's own local instead of the caller's (bash resolves a bare name to the nearest
# scope in the call stack, innermost first).
prompt() {
local __resultvar="$2" __default="${3:-}" __answer
printf '%s' "$1"
if ! IFS= read -r __answer; then
echo >&2
echo "setup.sh: input ended before the interview finished — aborting. Nothing was written; re-run setup.sh to try again." >&2
exit 1
fi
[ -n "$__answer" ] || __answer="$__default"
printf -v "$__resultvar" '%s' "$__answer"
}
# print_could_not_verify_count <dir> — the "couldn't check" message (T317 review MEDIUM
# finding), shared by both call sites in interview_music so the two can never drift apart.
print_could_not_verify_count() {
local dir="$1"
echo " Could not verify the audio file count under ${dir} (find not found on this machine) — continuing; confirm manually that it has .flac/.mp3 files before launching."
}
# print_no_music_lane <dir> — F132.6: GenWave downloads no audio, ever. This is a starting
# list — Dean finalizes the actual copy at review.
print_no_music_lane() {
local dir="$1"
echo
echo " No .flac/.mp3 files found yet under ${dir} (those are the only supported formats)."
echo " GenWave downloads no audio itself — bring your own, or grab CC-licensed tracks from:"
echo " - Jamendo https://www.jamendo.com/"
echo " - Free Music Archive https://freemusicarchive.org/"
echo " - ccMixter https://ccmixter.org/"
echo " You are responsible for the licensing terms of anything you add."
echo
}
IMAGES_MODE="pinned"
# Q1 — pinned vs build-your-own. The build path is offered ONLY when check_dotnet10_sdk finds a
# .NET 10 SDK right now; otherwise this is pure information, no prompt at all.
interview_images() {
echo
echo "1) How should GenWave run?"
if check_dotnet10_sdk; then
echo " [1] Pinned images (recommended) — published GHCR images, no build step"
echo " [2] Build from source — a .NET 10 SDK was detected on this machine"
local answer
prompt " Choose [1]: " answer "1"
case "$answer" in
2) IMAGES_MODE="dev" ;;
*) IMAGES_MODE="pinned" ;;
esac
else
echo " Running from pinned published images (no .NET 10 SDK detected — build-from-source needs one)."
IMAGES_MODE="pinned"
fi
}
MEDIA_DIR_ANSWER=""
# Q2 — where's the music. Validates the path (exists, readable), then counts audio files; zero
# files routes into the F132.6 no-music lane with its own re-check loop.
interview_music() {
echo
echo "2) Where is your music library?"
local answer
while :; do
prompt " Absolute path (.flac/.mp3 files): " answer ""
if [ -z "$answer" ]; then
echo " A path is required."
continue
fi
case "$answer" in
*'$'*)
echo " '${answer}' contains '\$' — \$ is interpolated by compose in .env values, so part of this path would silently resolve to something else. Rename the directory or symlink it to a \$-free path, then try again."
continue
;;
esac
# A path containing spaces is fine — MEDIA_DIR is written UNQUOTED (build_env_content)
# and GenWave's own shell tooling (launch.sh/preflight's `cut -d= -f2-` reader) reads the
# literal text after `=`, spaces included, with no shell re-parsing along that path (T317
# review MEDIUM finding: a prior space rejection here was based on a shell-quoting
# assumption that doesn't apply to how these values are actually read).
if [ ! -d "$answer" ]; then
echo " '${answer}' does not exist or is not a directory — try again."
continue
fi
if [ ! -r "$answer" ] || [ ! -x "$answer" ]; then
echo " '${answer}' is not readable by this user — fix its permissions and try again."
continue
fi
break
done
MEDIA_DIR_ANSWER="$answer"
local count nm_choice
count="$(count_audio_files "$MEDIA_DIR_ANSWER")"
if [ -z "$count" ]; then
# "Couldn't check" (T317 review MEDIUM finding), distinct from a verified zero — never the
# no-music lane's Jamendo lecture over a library the probe simply couldn't see.
print_could_not_verify_count "$MEDIA_DIR_ANSWER"
return
fi
while [ "$count" -eq 0 ]; do
print_no_music_lane "$MEDIA_DIR_ANSWER"
prompt " [1] I've added files — check again [2] Continue anyway (airs the Please-Stand-By loop until music lands) Choose [1]: " nm_choice "1"
case "$nm_choice" in
2) break ;;
*)
count="$(count_audio_files "$MEDIA_DIR_ANSWER")"
if [ -z "$count" ]; then
print_could_not_verify_count "$MEDIA_DIR_ANSWER"
return
fi
;;
esac
done
}
TOPOLOGY="full"
# Q3 — topology preset, recommended from detected RAM/arch; the owner may override.
interview_topology() {
echo
echo "3) Topology preset"
local ram_gib arch recommended default_choice answer
ram_gib="$(detect_ram_gib || true)"
arch="$(detect_arch)"
recommended="$(recommend_topology "$ram_gib" "$arch")"
echo " Detected: ${ram_gib:-unknown} GiB RAM, arch ${arch} — recommended: ${recommended}"
echo " [1] Full — kokoro (richer TTS voice, needs more RAM)"
echo " [2] Piper-only — lighter footprint, Piper voice instead of kokoro"
default_choice=1
[ "$recommended" = "piper-only" ] && default_choice=2
prompt " Choose [${default_choice}]: " answer "$default_choice"
case "$answer" in
2) TOPOLOGY="piper-only" ;;
*) TOPOLOGY="full" ;;
esac
}
ADMIN_PROFILE="admin"
# Q4 — optional profiles. admin is on by default; logging/tunnel are pointers to DEPLOYMENT.md,
# never interview questions (F132.2).
interview_profiles() {
echo
echo "4) Optional profiles"
local answer
prompt " Enable the Admin UI? [Y/n]: " answer "y"
case "$answer" in
[Nn]*) ADMIN_PROFILE="" ;;
*) ADMIN_PROFILE="admin" ;;
esac
echo " Logging and Cloudflare Tunnel are optional add-ons — see DEPLOYMENT.md to enable them later."
}
# =============================================================================
# apply — the one true mutation (F132.4/AC4)
# =============================================================================
SECRET_POSTGRES=""
SECRET_LIBRARY_DB=""
SECRET_STATION_DB=""
SECRET_ICECAST_SOURCE=""
SECRET_ICECAST_ADMIN=""
SECRET_ADMIN_UI=""
GW_PRESET=""
GW_PREFLIGHT_TOPOLOGY_VALUE=""
GW_PREFLIGHT_DEMO_VALUE=""
apply_generate_secrets() {
SECRET_POSTGRES="$(gen_secret)"
SECRET_LIBRARY_DB="$(gen_secret)"
SECRET_STATION_DB="$(gen_secret)"
SECRET_ICECAST_SOURCE="$(gen_secret)"
SECRET_ICECAST_ADMIN="$(gen_secret)"
SECRET_ADMIN_UI="$(gen_secret)"
}
# resolve_preset_and_topology — the ONE function both the .env write (GW_PRESET) and the
# preflight inputs (GW_PREFLIGHT_TOPOLOGY/GW_PREFLIGHT_DEMO, exported in main) read their
# values from — the T316 one-source lesson: two independent readers deriving "what did the
# interview choose" is exactly the split-brain that bit launch.sh there (a hardcoded `.env`
# reader beside a GW_ENV_FILE-aware comment claiming parity it didn't have). SPEC F132.5's
# closed vocabulary v2 (re-amended 2026-08-18 at the T317 review, Dean's split-overlays
# ruling, SPEC F136.5): home | home-piper-only | dev | dev-piper-only. launch.sh is the ONLY
# reader of GW_PRESET in the whole repo. `home*` stacks base + compose.pinned.yaml (published
# GHCR images, the wizard's LAN station) — never compose.demo.yaml, which stays flag-only
# (`--pinned`) for the public appliance; GW_PREFLIGHT_DEMO_VALUE is therefore always "0" here
# (F134.3a: preflight's demo input is caller-resolved, never a hardcoded guess) — this
# wizard's interview has no path to the demo/public-appliance shape at all.
resolve_preset_and_topology() {
GW_PREFLIGHT_TOPOLOGY_VALUE="$TOPOLOGY"
GW_PREFLIGHT_DEMO_VALUE="0"
case "${IMAGES_MODE}:${TOPOLOGY}" in
pinned:full) GW_PRESET="home" ;;
pinned:piper-only) GW_PRESET="home-piper-only" ;;
dev:full) GW_PRESET="dev" ;;
dev:piper-only) GW_PRESET="dev-piper-only" ;;
*)
echo "setup.sh: internal error — unrecognized images/topology combination '${IMAGES_MODE}:${TOPOLOGY}'" >&2
exit 1
;;
esac
}
# build_env_content — an ALLOWLIST (T317 review findings B1/B2), not a .env.example
# template pass: emits ONLY the six generated secrets, MEDIA_DIR, COMPOSE_PROFILES, and
# GW_PRESET, plus two commented pointers (#PUBLIC_HOST=, #TUNNEL_TOKEN=) for the public-
# appliance overlay. Nothing else from .env.example is copied — STATION_NAME,
# LIBRARY_ENRICHMENT_CONCURRENCY, and every other optional/documentation line stay OUT: this
# wizard has no answer for them (their C#/compose defaults already cover the wizard's target,
# a LAN station), and copying them anyway would mean fabricating values it was never asked
# about. PUBLIC_HOST/TUNNEL_TOKEN are written blank and COMMENTED — never a fabricated value —
# so compose.demo.yaml's `${VAR:?}` guards stay armed until an operator deliberately opts into
# that overlay (SPEC F136.5: the public appliance stays flag-only, `--pinned`) and fills them
# in themselves. Written CLEAN per the T316 rider: unquoted values, LF line endings, no
# trailing whitespace — a quoted or CRLF GW_PRESET makes launch.sh exit 2 with the \r
# invisible in its own error message.
build_env_content() {
printf '# .env — written by setup.sh (SPEC F132.2-.5). See .env.example for the full set of\n'
printf '# variables this stack understands and what each one does; re-running setup.sh never\n'
printf '# overwrites this file (see the header above) -- edit it directly for anything beyond\n'
printf '# these four questions.\n'
printf '\n'
printf '%s\n' "COMPOSE_PROFILES=${ADMIN_PROFILE}"
printf '%s\n' "MEDIA_DIR=${MEDIA_DIR_ANSWER}"
printf '\n'
printf '%s\n' "POSTGRES_PASSWORD=${SECRET_POSTGRES}"
printf '%s\n' "LIBRARY_DB_PASSWORD=${SECRET_LIBRARY_DB}"
printf '%s\n' "STATION_DB_PASSWORD=${SECRET_STATION_DB}"
printf '%s\n' "ICECAST_SOURCE_PASSWORD=${SECRET_ICECAST_SOURCE}"
printf '%s\n' "ICECAST_ADMIN_PASSWORD=${SECRET_ICECAST_ADMIN}"
printf '%s\n' "ADMIN_PASSWORD=${SECRET_ADMIN_UI}"
printf '\n'
printf '# for the public appliance — see DEPLOYMENT.md\n'
printf '#PUBLIC_HOST=\n'
printf '# for the public appliance — see DEPLOYMENT.md\n'
printf '#TUNNEL_TOKEN=\n'
printf '\n'
printf '# Written by setup.sh (SPEC F132.5) — launch.sh is the ONLY reader of this key in the\n'
printf '# whole repo. Closed vocabulary: home | home-piper-only | dev | dev-piper-only.\n'
printf 'GW_PRESET=%s\n' "$GW_PRESET"
}
# apply_env_write — builds the COMPLETE file in a temp path next to the target (same
# filesystem, so `mv` is an atomic rename) and only then moves it into place. Abandonment
# before this point leaves nothing; abandonment during it is impossible for anything outside
# this script to observe (the target is untouched until the rename). SETUP_TMP_ENV_FILE (set
# for exactly this window) is what the shared EXIT trap near the top of this script cleans up
# if the process is killed between mktemp and mv — e.g. a signal mid-write (T317 review
# MEDIUM finding: stranded temp secrets). Named `.env.setup.*` so it matches the .gitignore
# entry that keeps a leftover from ever being staged by accident.
apply_env_write() {
local dir
dir="$(dirname "$ENV_FILE")"
[ -d "$dir" ] || mkdir -p "$dir"
SETUP_TMP_ENV_FILE="$(mktemp "${dir}/.env.setup.XXXXXX")"
build_env_content > "$SETUP_TMP_ENV_FILE"
mv -f "$SETUP_TMP_ENV_FILE" "$ENV_FILE"
SETUP_TMP_ENV_FILE=""
}
# =============================================================================
# Routing (F132.1/AC6) + the ready-to-launch handoff
# =============================================================================
# =============================================================================
# Adoption mode: verify & repair for existing boxes (F137, STORY-346, T319)
# =============================================================================
#
# The report: two parallel row arrays (status/label/message), one row per probe/preflight check
# — the SAME shape tools/preflight.sh's own GW_PREFLIGHT_ROW_* arrays already use, kept as a
# SEPARATE set here (GW_VERIFY_ROW_*) since these rows are adoption-mode's own drift probes, not
# F134's machine checks (preflight prints its own table separately, unchanged).
GW_VERIFY_ROW_STATUS=()
GW_VERIFY_ROW_LABEL=()
GW_VERIFY_ROW_MESSAGE=()
# verify_record PASS|WARN|INFO|UNKNOWN "<label>" "<message>" — PASS/INFO/UNKNOWN rows are
# report-only. A WARN row is a "finding" for exit-code purposes (F137's honest-exit rule) whether
# or not it also carries a repairable command — see verify_add_finding below for the repairable
# subset.
verify_record() {
GW_VERIFY_ROW_STATUS+=("$1")
GW_VERIFY_ROW_LABEL+=("$2")
GW_VERIFY_ROW_MESSAGE+=("$3")
}
# The repairable subset of findings — parallel arrays again, PLUS one dynamically-named array
# PER finding (GW_VERIFY_FINDING_CMD_<index>) holding its exact command as real argv, not a
# joined string. This is the T316 "one array source, never a printed twin" discipline applied
# here: verify_run_repair's own display line and its own execution both read the SAME array via
# a nameref (see verify_run_repair), so a finding's PRINTED command and its EXECUTED command can
# never diverge — there is only ever one copy.
GW_VERIFY_FINDING_ID=()
GW_VERIFY_FINDING_LABEL=()
GW_VERIFY_FINDING_MESSAGE=()
GW_VERIFY_FINDING_RESTARTS=()
GW_VERIFY_FINDING_COUNT=0
# verify_add_finding <id> <label> <message> <restarts:0|1> <command...>
# Records a WARN report row (verify_record) AND a repairable finding in the same call — every
# repairable finding IS a WARN row; a few probes below call verify_record directly instead for a
# PASS/INFO/UNKNOWN row, or for an advisory WARN that has no safe scripted fix at all (env
# completeness, stale image ages) and so is never offered to verify_run_repair.
verify_add_finding() {
local id="$1" label="$2" message="$3" restarts="$4"
shift 4
local idx="$GW_VERIFY_FINDING_COUNT"
GW_VERIFY_FINDING_ID+=("$id")
GW_VERIFY_FINDING_LABEL+=("$label")
GW_VERIFY_FINDING_MESSAGE+=("$message")
GW_VERIFY_FINDING_RESTARTS+=("$restarts")
# Dynamic array naming is the only way bash stores a per-index ARRAY (not a scalar) without a
# second, parallel, string-joined copy of the same command — exactly the plan/real divergence
# class T316 closed off for launch.sh's own UP1_ARGS. "$@" is expanded INSIDE the eval'd array
# literal, so every argument becomes its own quoted element regardless of embedded spaces —
# never a format-string or word-splitting hazard; every caller here is this script's own probe
# code, nothing attacker-controlled ever reaches this eval.
eval "GW_VERIFY_FINDING_CMD_${idx}=(\"\$@\")"
GW_VERIFY_FINDING_COUNT=$((GW_VERIFY_FINDING_COUNT + 1))
verify_record WARN "$label" "$message"
}
# verify_print_report — one line per recorded row, emoji-marked by status (PASS/INFO/UNKNOWN vs
# a finding).
#
# F4 (round-3 review): this comment used to claim it was "chained onto the shared EXIT trap" —
# false; setup_exit_trap's own remarks (near the top of this file) say the opposite. The real
# contract: setup_adoption_mode calls this explicitly, ordered BEFORE that function's own
# green/drift-found verdict line, so the table always prints above the verdict rather than below
# it (an EXIT-trap ordering could never guarantee that). Because the call is explicit, not
# trap-driven, an abort mid-probe (a hard crash before setup_adoption_mode reaches this line)
# drops the table entirely — nothing here rescues that path the way a trap-fired call would.
verify_print_report() {
[ "${#GW_VERIFY_ROW_STATUS[@]}" -gt 0 ] || return 0
echo
echo "==> Verify: existing-install drift report (SPEC F137)"
local i status label message symbol
for i in "${!GW_VERIFY_ROW_STATUS[@]}"; do
status="${GW_VERIFY_ROW_STATUS[$i]}"
label="${GW_VERIFY_ROW_LABEL[$i]}"
message="${GW_VERIFY_ROW_MESSAGE[$i]}"
case "$status" in
PASS) symbol="✅" ;;
INFO) symbol="ℹ️ " ;;
UNKNOWN) symbol="❓" ;;
*) symbol="⚠️ " ;;
esac
printf ' %s %-24s %s\n' "$symbol" "$label" "$message"
done
}
# --- shared plumbing the probes below build on --------------------------------------------
# GW_VERIFY_COMPOSE_FILE_VALUE — COMPOSE_FILE (gh-#309, written by launch.sh's
# persist_compose_file after a successful launch), read ONCE per verify run (round-2 review N6:
# five separate call sites used to each re-read it independently via preflight_env_value — every
# derivation below now reads this one cached copy instead). Populated by
# verify_resolve_env_facts, which MUST run before any of the functions below (setup_adoption_mode
# calls it first, ahead of even preflight_docker/preflight_env_secrets, which also consume the
# topology/demo values derived from it).
GW_VERIFY_COMPOSE_FILE_VALUE=""
# GW_VERIFY_COMPOSE_ARGS — this box's own last-launched file set, straight from
# GW_VERIFY_COMPOSE_FILE_VALUE — NEVER a second, hand-derived resolution of GW_PRESET (F132.5:
# launch.sh is the ONLY reader of that key in the whole repo; adoption mode reads COMPOSE_FILE
# instead, a different key entirely, which is also a strictly BETTER signal here — it names what
# this box actually last ran, not merely what an interview once chose). Empty when the box has
# never completed a launch — every probe that needs it degrades to UNKNOWN rather than guessing.
#
# T321 wire finding 2: also carries `--env-file "$ENV_FILE"`, ahead of the `-f` pairs, once
# COMPOSE_FILE resolves — every `docker compose` call in this file goes through this ONE array
# (never a per-call-site flag), so every render/ps/exec now interpolates `${VAR:?}`-class
# compose refs (compose.demo.yaml's PUBLIC_HOST, etc.) from the SAME file setup.sh's own reads
# already honor via GW_ENV_FILE — not compose's own default of `.env` in $PWD, which a caller
# running this script from outside the checkout (T321 run 1: GW_ENV_FILE pointed elsewhere,
# no .env in cwd at all) leaves silently unset, degrading three probes to UNKNOWN even though
# GW_ENV_FILE itself was honored correctly everywhere else. Added only alongside the `-f`
# pairs (never on its own) — an empty array still means "never launched" to every probe that
# checks its length (verify_orphaned_containers, verify_compose_overrides).
GW_VERIFY_COMPOSE_ARGS=()
verify_resolve_env_facts() {
GW_VERIFY_COMPOSE_FILE_VALUE="$(preflight_env_value COMPOSE_FILE)"
GW_VERIFY_COMPOSE_ARGS=()
if [ -n "$GW_VERIFY_COMPOSE_FILE_VALUE" ]; then
# `read -ra` (not an unquoted `local -a files=($compose_file)`) splits on IFS without ALSO
# globbing each resulting word — round-2 review N6: a COMPOSE_FILE value containing a `*`
# would otherwise expand against whatever happens to be in the current directory. ':' is
# COMPOSE_FILE's own separator here (COMPOSE_PATH_SEPARATOR's Linux default — confirmed
# against launch.sh's own persist_compose_file, `paste -sd:`, the only writer of this key;
# `docker compose ls`'s comma-joined CONFIG FILES column is that command's own display
# rendering, not the value actually persisted in .env).
local -a files=()
local IFS=':'
read -ra files <<< "$GW_VERIFY_COMPOSE_FILE_VALUE"
unset IFS
GW_VERIFY_COMPOSE_ARGS=(--env-file "$ENV_FILE")
local f
for f in "${files[@]}"; do
GW_VERIFY_COMPOSE_ARGS+=(-f "$f")
done
fi
}
# verify_compose_file_is_stacked <basename> — true iff this box's own COMPOSE_FILE names a file
# whose OWN BASENAME is exactly <basename> (T321 wire finding 1 follow-up, reviewer-proven):
# every derivation below used to test `case ":${GW_VERIFY_COMPOSE_FILE_VALUE}:" in
# *"compose.demo.yaml"*)` — a plain SUBSTRING test against the whole colon-joined string — which
# false-positives on compose.demo.yaml.bak, overlays/compose.demo.yaml.local, or
# my-compose.demo.yaml: none of those stack the actual overlay this repo ships, yet the old
# substring test called every one of them a match. Fixed by scanning GW_VERIFY_COMPOSE_ARGS'
# own `-f` pairs (the same scan verify_compose_overrides already uses) and comparing each
# element's BASENAME, not its full value, against <basename> exactly — deliberately basename,
# not the whole element, because the Pi 4's own persisted COMPOSE_FILE is PATH-QUALIFIED
# (`/home/dmills/genwave/compose.demo.yaml`, the box's real, live shape: launch.sh's own
# compose_file_value records whatever `-f` argument that launch actually ran with, and this
# box's own launches always ran from its checkout's own absolute path) — basename comparison
# classifies that box exactly the same as one that persisted a bare `compose.demo.yaml`, one
# rule for both shapes. This repo has never shipped two different compose*.yaml files under
# different directories sharing one basename, so a basename collision is not a risk this
# comparison has to guard against. MUST run after verify_resolve_env_facts (every caller in
# this file already does — setup_adoption_mode calls it first, ahead of every probe).
verify_compose_file_is_stacked() {
local want="$1" i f
for ((i = 0; i < ${#GW_VERIFY_COMPOSE_ARGS[@]}; i++)); do
[ "${GW_VERIFY_COMPOSE_ARGS[$i]}" = "-f" ] || continue
f="${GW_VERIFY_COMPOSE_ARGS[$((i + 1))]}"
[ "${f##*/}" = "$want" ] && return 0
done
return 1
}
# verify_topology_from_compose_file / verify_demo_from_compose_file — the F134.3a preflight
# inputs, derived from the SAME cached COMPOSE_FILE value above rather than GW_PRESET (same
# single-reader reasoning) — this is what lets adoption mode still run "the F134 preflight" (its
# own F137.1 contract) with a topology-aware disk/port check, on a box this script never
# interviewed.
verify_topology_from_compose_file() {
if verify_compose_file_is_stacked "compose.piper-only.yaml"; then
printf 'piper-only'
else
printf 'full'
fi
}
verify_demo_from_compose_file() {
if verify_compose_file_is_stacked "compose.demo.yaml"; then
printf '1'
else
printf '0'
fi
}
# verify_resolve_db_container_id — resolves the db service's container id under this box's own
# file set into GW_VERIFY_DB_CONTAINER_ID (empty when it can't be determined: docker unreachable,
# db not running under this project). Every read-only docker/docker compose call in adoption mode
# goes through GW_DOCKER_CMD (default docker — see this file's own header) so Story346's specs
# never need a real daemon.
#
# F8 (round-3 review): memoized — this file has two call sites (verify_migrations,
# verify_db_settings_overrides) that both want the SAME db container id, and calling out to
# `docker compose ... ps -q db` twice per run for one unchanging fact was pure waste (N6's own
# "one source, not two independent readers" discipline, applied here to a lazily-resolved fact
# rather than an eagerly-resolved one like GW_VERIFY_COMPOSE_FILE_VALUE — not every verify run
# reaches either call site at all: verify_migrations short-circuits to UNKNOWN before ever needing
# it once the marker itself can't be established, so resolving it unconditionally up front would
# add a docker call some runs never needed in the first place).
#
# A PLAIN function call, never `$(verify_resolve_db_container_id)` — bash runs a command
# substitution in its OWN subshell, so a naive "memoize inside the function, callers capture its
# stdout via $(...)" shape (this fix's own first draft) silently never memoizes anything at all:
# every $(...) call forks a fresh subshell, sets the RESOLVED flag inside THAT subshell's own
# copy, then the subshell exits and takes the mutation with it — the parent shell's flag never
# flips. Every caller below calls this bare, then reads GW_VERIFY_DB_CONTAINER_ID directly.
GW_VERIFY_DB_CONTAINER_ID=""
GW_VERIFY_DB_CONTAINER_ID_RESOLVED=0
verify_resolve_db_container_id() {
if [ "$GW_VERIFY_DB_CONTAINER_ID_RESOLVED" != "1" ]; then
local docker_cmd="${GW_DOCKER_CMD:-docker}"
GW_VERIFY_DB_CONTAINER_ID="$("$docker_cmd" compose "${GW_VERIFY_COMPOSE_ARGS[@]}" ps -q db 2>/dev/null || true)"
GW_VERIFY_DB_CONTAINER_ID_RESOLVED=1
fi
}
# verify_db_psql <sql> — a single-column, single-row read-only query against the running db
# service (never a write — every call site below passes a plain `select`). Prints the trimmed
# result on success; returns 1 (nothing printed) when the query itself failed for any reason —
# callers treat that as UNKNOWN, never a hard failure (the T318 "report honestly, never die
# mid-report" lesson, applied here).
#
# B1 (round-2 review): `exec -T db psql ...` with no `-U` lands as the CONTAINER's own default
# exec user — root, on postgres:16.4 (that image sets no USER directive) — and a bare `psql`
# then tries to connect as role "root", which does not exist: `FATAL: role "root" does not
# exist`, on every real box, always. Fixed the same way db/*-migration.sh's own init scripts
# already do it (they run inside this exact container too): read POSTGRES_USER/POSTGRES_DB from
# the CONTAINER's own environment (Postgres's entrypoint always sets both, so this never has to
# guess or hardcode a role name) via `sh -c`, not the exec'd process's caller-supplied identity.
# `$sql` is passed as `sh -c`'s own positional `$1` (the `_` placeholder fills `$0`), never
# interpolated into the `-c` string itself, so a `$sql` containing a shell metacharacter can
# never widen what actually runs.
#
# B5 (round-2 review, defense in depth): every call site in this file passes a literal `select`
# — refuse anything else outright here too, so a future non-select call is impossible, not
# merely untested by the allowlisted-argv fact (Story346_AdoptionVerifyRepair.cs).
#
# F2 (round-3 review): the `^select` check alone is live-reproof bypassable — psql happily runs
# a `;`-separated statement list in one `-tAc`, so `select 1; delete from station.settings` still
# starts with `select ` and sailed straight through (reviewer-proven: printed `1`, then `DELETE
# 0`). A bare `;` anywhere in `$sql` is refused outright now too — every real call site here is a
# single, plain `select ... ` with no reason to ever contain one.
verify_db_psql() {
local sql="$1" docker_cmd="${GW_DOCKER_CMD:-docker}" out
[[ "$sql" =~ ^[[:space:]]*[Ss][Ee][Ll][Ee][Cc][Tt][[:space:]] ]] || return 1
[[ "$sql" == *';'* ]] && return 1
out="$("$docker_cmd" compose "${GW_VERIFY_COMPOSE_ARGS[@]}" exec -T db \
sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -tAc "$1"' _ "$sql" 2>/dev/null)" || return 1
printf '%s' "$out" | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'
}
# verify_env_file_value <key> — B6 fix (round-2 review, a T318 F2 regression): unlike
# preflight_env_value (process-env-wins, correct for the interview/preflight seam contract this
# probe is NOT part of), this probe reports drift found IN ${ENV_FILE} — reading the process
# environment first is a false-both-ways trap on an adopted box: an ambient exported value
# (a caller's own shell, a systemd unit's Environment=) makes a real placeholder in the file
# read as green, and an ambient garbage value over a clean file fabricates drift that was never
# actually written anywhere. Same grep/tail/cut shape as preflight_env_value, minus the env-var
# layer — the file is the only source of truth for what THIS probe reports.
verify_env_file_value() {
local name="$1"
[ -f "$ENV_FILE" ] || return 0
grep -E "^${name}=" "$ENV_FILE" | tail -n1 | cut -d= -f2- || true
}
# verify_env_key_is_needed <key> — B3 fix (round-2 review): true for every .env.example key
# except the handful .env.example itself documents as overlay/profile-gated — PUBLIC_HOST (only
# read under compose.demo.yaml) and TUNNEL_TOKEN (only read once COMPOSE_PROFILES=tunnel is
# active) — and even then only when THIS box's own COMPOSE_FILE/COMPOSE_PROFILES don't actually
# stack that overlay. Reviewer-proven bug this closes: T317's build_env_content deliberately
# writes both COMMENTED (F136.5's split-overlays ruling — the wizard never fabricates
# PUBLIC_HOST), so the old unconditional scope flagged every wizard-written box as "missing"
# and steered a home operator toward the exact public-appliance value that ruling removed.
# Never keys off GW_PRESET (F132.5: adoption mode reads COMPOSE_FILE, this box's own
# last-launched fact, not the interview's).
verify_env_key_is_needed() {
local key="$1"
case "$key" in
PUBLIC_HOST)
[ "$(verify_demo_from_compose_file)" = "1" ]
;;
TUNNEL_TOKEN)
case ",$(preflight_env_value COMPOSE_PROFILES)," in
*,tunnel,*) return 0 ;;
*) return 1 ;;
esac
;;
*)
return 0
;;
esac
}
# --- probe 1: .env completeness vs .env.example (F137.1) ----------------------------------
# Reports KEY NAMES ONLY — never a value (hard rule: an operator pasting verify output into an
# issue must never leak a secret). Scoped to .env.example's own UNCOMMENTED keys — a commented
# line there (e.g. #STATION_NAME=...) documents an OPTIONAL setting; its absence from a real
# .env is normal, not drift. Advisory only: the six required secrets + ADMIN_PASSWORD already
# have their own hard-fail floor in preflight_env_secrets (F134.1), which this box's own verify
# pass runs first (setup_adoption_mode) — this probe only ever adds NEW information about the
# remaining, non-required keys.
verify_env_completeness() {
local example=".env.example"
if [ ! -f "$example" ]; then
verify_record UNKNOWN ".env completeness" "${example} not found in this checkout — skipped."
return
fi