-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvox
More file actions
executable file
·4063 lines (3627 loc) · 144 KB
/
vox
File metadata and controls
executable file
·4063 lines (3627 loc) · 144 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
set -euo pipefail
# ─────────────────────────────────────────────────────────────────────────────
# vox — Multi-Command Deploy CLI: Any Repo → Docker + Cloudflare Tunnel
# ─────────────────────────────────────────────────────────────────────────────
# Usage:
# ./vox setup # Interactive first-time setup
# ./vox setup --repo URL --token TOKEN --hostname DOMAIN [--env .env] [--branch main]
# ./vox deploy # Pull latest + rebuild + zero-downtime restart
# ./vox logs [--follow]# Tail container logs
# ./vox status # Show running containers, health, uptime
# ./vox stop # Stop all services
# ./vox start # Start services
# ./vox restart # Restart services
# ./vox rollback [n] # Roll back to previous deploy
# ./vox env # Show current env vars (masked secrets)
# ./vox env set K=V # Set an env var and redeploy
# ./vox env unset K # Remove an env var and redeploy
# ./vox destroy # Tear down everything
# ./vox history # Show deploy history
# ./vox watch # Start auto-redeploy watcher (fg-detached)
# ./vox watch stop|status # Manage the watcher
# ./vox watch logs [--follow] # Tail the watcher log
# ./vox watch once # One fetch + deploy-if-needed (cron/webhook)
# ./vox watch install-service # systemd unit so watcher survives reboots
# ./vox help # Show usage
# ─────────────────────────────────────────────────────────────────────────────
BOLD='\033[1m'
DIM='\033[2m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
MAGENTA='\033[0;35m'
NC='\033[0m'
# ─────────────────────────────────────────────────────────────────────────────
# BUILDKIT / COMPOSE STREAMING — force real-time, line-by-line output
# ─────────────────────────────────────────────────────────────────────────────
# Without these, `docker compose build` uses BuildKit's fancy TTY progress
# mode which buffers everything and surfaces nothing until the user interrupts.
# Symptom: "Building container..." followed by a silent spinner that only
# dumps output after Ctrl+C.
#
# Setting BUILDKIT_PROGRESS=plain + passing --progress=plain forces every
# buildkit stage to flush its stdout/stderr line-by-line so users see each
# layer, RUN step, and warning in real time.
export DOCKER_BUILDKIT="${DOCKER_BUILDKIT:-1}"
export COMPOSE_DOCKER_CLI_BUILD="${COMPOSE_DOCKER_CLI_BUILD:-1}"
export BUILDKIT_PROGRESS="${BUILDKIT_PROGRESS:-plain}"
# Line-buffer python subprocesses too (vox_save_config, vox_config_get, etc.)
export PYTHONUNBUFFERED="${PYTHONUNBUFFERED:-1}"
get_cols() { tput cols 2>/dev/null || echo 80; }
banner() {
local cols
cols=$(get_cols)
local inner=$((cols - 4))
[[ "$inner" -lt 20 ]] && inner=20
local hbar=""
for ((i=0; i<inner+2; i++)); do hbar="${hbar}─"; done
local text="$1"
local plain_text
plain_text=$(echo -e "$text" | sed 's/\x1b\[[0-9;]*m//g')
local tlen=${#plain_text}
local pad=$((inner - tlen))
[[ "$pad" -lt 0 ]] && pad=0
local spaces=""
for ((i=0; i<pad; i++)); do spaces="${spaces} "; done
echo ""
echo -e "${CYAN}╭${hbar}╮${NC}"
echo -e "${CYAN}│${NC} ${BOLD}${text}${NC}${spaces} ${CYAN}│${NC}"
echo -e "${CYAN}╰${hbar}╯${NC}"
echo ""
}
info() { echo -e " ${GREEN}[INFO]${NC} $1"; }
warn() { echo -e " ${YELLOW}[WARN]${NC} $1"; }
err() { echo -e " ${RED}[ERR]${NC} $1"; }
detect() { echo -e " ${MAGENTA}[AUTO]${NC} $1"; }
step() { echo -e " ${CYAN}[STEP]${NC} $1"; }
prompt() { echo -en "${BOLD}$1${NC}"; }
# ─────────────────────────────────────────────────────────────────────────────
# STREAMING WRAPPERS — surface real-time state during long-running subprocesses
# ─────────────────────────────────────────────────────────────────────────────
# These wrap slow docker operations so the user sees every line of output as
# it happens instead of a silent spinner. They also mirror output to a log
# file for post-mortem review and return the child process's real exit code
# (not `tee`'s) via PIPESTATUS.
stream_open() {
local label="${1:-output}"
local cols inner hbar
cols=$(get_cols)
inner=$((cols - 8 - ${#label}))
[[ "$inner" -lt 10 ]] && inner=10
hbar=""
for ((i=0; i<inner; i++)); do hbar="${hbar}─"; done
echo ""
echo -e " ${DIM}┌─ ${label} ${hbar}${NC}"
}
stream_close() {
local status="${1:-done}"
local color="${2:-$DIM}"
local cols inner hbar
cols=$(get_cols)
inner=$((cols - 8 - ${#status}))
[[ "$inner" -lt 10 ]] && inner=10
hbar=""
for ((i=0; i<inner; i++)); do hbar="${hbar}─"; done
echo -e " ${DIM}└─${NC} ${color}${status}${NC} ${DIM}${hbar}${NC}"
echo ""
}
# Pick the right --progress flag style based on compose version.
# docker compose v2 supports --progress=plain on `build`; docker-compose v1
# does not, so for v1 we rely on the BUILDKIT_PROGRESS=plain env var alone.
_compose_progress_flag() {
if [[ "${COMPOSE_CMD:-docker compose}" == "docker compose" ]]; then
echo "--progress=plain"
else
echo ""
fi
}
# Ensure VOX_DIR exists before writing logs (works even before setup runs).
_ensure_log_dir() {
local d="${VOX_DIR:-.vox}"
mkdir -p "$d" 2>/dev/null || true
echo "$d"
}
# compose_build [extra-args...] — run `docker compose build` with streaming
# output, a mirrored log file, and a proper exit code.
compose_build() {
local log_dir build_log progress_flag
log_dir=$(_ensure_log_dir)
build_log="$log_dir/build.log"
progress_flag=$(_compose_progress_flag)
info "Building container image (streaming output below, log: $build_log)"
stream_open "docker compose build"
# 2>&1 → merge stderr into stdout (buildkit writes progress to stderr)
# tee → mirror to build log for post-mortem inspection
# PIPESTATUS → return the real build exit code, not tee's
set +e
if [[ -n "$progress_flag" ]]; then
$COMPOSE_CMD build $progress_flag "$@" 2>&1 | tee "$build_log"
else
$COMPOSE_CMD build "$@" 2>&1 | tee "$build_log"
fi
local rc=${PIPESTATUS[0]}
set -e
if [[ "$rc" -eq 0 ]]; then
stream_close "build ok" "$GREEN"
else
stream_close "build FAILED (exit $rc)" "$RED"
err "Build failed — full log at $build_log"
fi
return "$rc"
}
# compose_up [extra-args...] — run `docker compose up -d` with streaming
# output so the user sees network/volume/container creation in real time.
compose_up() {
info "Starting services (streaming output below)"
stream_open "docker compose up -d"
set +e
local output
output=$($COMPOSE_CMD up -d --remove-orphans "$@" 2>&1)
local rc=$?
echo "$output"
set -e
# Handle container name conflict from a stale/orphaned container
if [[ "$rc" -ne 0 ]] && echo "$output" | grep -q 'Conflict.*container name.*already in use'; then
local stale_id
stale_id=$(echo "$output" | grep -oP 'already in use by container "\K[a-f0-9]+' | head -1)
if [[ -n "$stale_id" ]]; then
warn "Removing stale container $stale_id from a previous project"
docker rm -f "$stale_id" 2>/dev/null || true
set +e
$COMPOSE_CMD up -d --remove-orphans "$@" 2>&1
rc=$?
set -e
fi
fi
if [[ "$rc" -ne 0 ]] && echo "$output" | grep -qiE 'address already in use|port is already allocated|bind.*failed|listen EADDRINUSE'; then
warn "A tracked port appears occupied; releasing this vox project's stale containers and retrying once"
compose_release_project_resources
set +e
$COMPOSE_CMD up -d --remove-orphans "$@" 2>&1
rc=$?
set -e
fi
if [[ "$rc" -eq 0 ]]; then
stream_close "up ok" "$GREEN"
else
stream_close "up FAILED (exit $rc)" "$RED"
fi
return "$rc"
}
# compose_down [extra-args...] — streamed `docker compose down` for symmetry.
compose_down() {
info "Stopping services (streaming output below)"
stream_open "docker compose down"
set +e
$COMPOSE_CMD down --remove-orphans "$@" 2>&1
local rc=$?
set -e
if [[ "$rc" -eq 0 ]]; then
stream_close "down ok" "$GREEN"
else
stream_close "down FAILED (exit $rc)" "$RED"
fi
return "$rc"
}
compose_project_name() {
local dir
dir="${WORK_DIR:-$(pwd)}"
basename "$dir" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_-' '-'
}
compose_release_project_resources() {
local project
project="$(compose_project_name)"
set +e
$COMPOSE_CMD down --remove-orphans --timeout 20 >/dev/null 2>&1
docker ps -aq \
--filter "label=com.docker.compose.project=${project}" \
--filter "status=exited" \
--filter "status=created" \
--filter "status=dead" \
| xargs -r docker rm -f >/dev/null 2>&1
set -e
}
# ─────────────────────────────────────────────────────────────────────────────
# INSTALL HELPERS — run install_cmd when lockfiles change (package updates)
# ─────────────────────────────────────────────────────────────────────────────
# Hash a lockfile (or empty string if missing) for change detection.
hash_lockfile() {
local f="$1"
if [[ -f "$f" ]]; then
sha256sum "$f" 2>/dev/null | awk '{print $1}' || true
fi
}
# Run the project's install command if lockfiles have changed since last deploy.
# Returns 0 on success or when skipped, 1 on failure.
run_install_if_needed() {
local repo_path="$1"
local install_cmd="$2"
local force="${3:-}"
if [[ -z "$install_cmd" ]]; then
return 0
fi
local hash_file="$VOX_DIR/last-install-hash"
local current_hash=""
# Detect lockfiles in priority order
local lockfiles=(
"$repo_path/package-lock.json"
"$repo_path/pnpm-lock.yaml"
"$repo_path/yarn.lock"
"$repo_path/bun.lockb"
"$repo_path/bun.lock"
"$repo_path/Cargo.lock"
"$repo_path/Pipfile.lock"
"$repo_path/poetry.lock"
"$repo_path/go.sum"
"$repo_path/Gemfile.lock"
"$repo_path/composer.lock"
)
for lf in "${lockfiles[@]}"; do
if [[ -f "$lf" ]]; then
current_hash="${current_hash}$(hash_lockfile "$lf")"
fi
done
local last_hash=""
if [[ -f "$hash_file" ]]; then
last_hash=$(cat "$hash_file" 2>/dev/null || true)
fi
if [[ "$force" == "force" ]] || [[ "$current_hash" != "$last_hash" ]]; then
info "Lockfile change detected — running install command..."
info " $install_cmd"
set +e
(cd "$repo_path" && eval "$install_cmd")
local rc=$?
set -e
if [[ "$rc" -eq 0 ]]; then
echo "$current_hash" > "$hash_file"
info "Install completed successfully"
return 0
else
err "Install command failed (exit $rc)"
return 1
fi
fi
return 0
}
# `sudo` writes its password prompt to /dev/tty by default, so redirecting
# stderr to /dev/null does NOT hide the prompt — but it DOES hide every other
# diagnostic, which made earlier silent hangs impossible to debug. We split
# "is sudo available without a password" from "actually run a privileged
# command" and put a hard timeout on every privileged call so an unattended
# auto-deploy can never block on a missing password.
# ─────────────────────────────────────────────────────────────────────────────
SUDO_OK="" # "yes" once we know sudo works (cached or root); "no" if unavailable
# Wrap a command in `timeout` if the binary exists; otherwise run it bare.
with_timeout() {
local secs="$1"; shift
if command -v timeout &>/dev/null; then
timeout --foreground "$secs" "$@"
else
"$@"
fi
}
# ─────────────────────────────────────────────────────────────────────────────
# DOCKER PREFLIGHT — fail loudly and fast when the daemon is wedged
# ─────────────────────────────────────────────────────────────────────────────
# Background: on systems with Docker's data root on ZFS + overlay2 storage
# driver, `dockerd` can deadlock in the kernel during container cleanup and
# get stuck in D-state. Once that happens, every `docker` command will hang
# for its full RPC timeout (2+ minutes) before returning. We refuse to let
# vox inherit that pain: a 5-second probe tells us immediately whether the
# daemon is alive, and if it isn't we print the exact remediation steps and
# exit instead of silently hanging forever.
preflight_docker() {
# Cheap: does the binary even exist?
if ! command -v docker &>/dev/null; then
err "docker CLI not found in PATH — install docker first."
return 127
fi
# Is the socket present at all?
local sock=/var/run/docker.sock
if [[ ! -S "$sock" ]]; then
err "Docker socket $sock not present — is dockerd running?"
err "Try: sudo systemctl status docker"
return 1
fi
# Bounded probe: `docker info` normally returns in <1s on a healthy daemon.
# If it takes more than 5s, dockerd is either starting, overloaded, or
# wedged in D-state — either way we don't want to inherit the wait.
if with_timeout 5 docker info &>/dev/null; then
return 0
fi
# Daemon is unresponsive. Before giving up, print a focused diagnosis so
# the user isn't left wondering why vox bailed.
echo ""
err "Docker daemon is not responding (timed out after 5s)."
echo ""
# Look for D-state dockerd — the classic overlay2-on-ZFS deadlock signature.
local wedged=""
wedged=$(ps -eo pid,stat,comm 2>/dev/null | awk '$3=="dockerd" && $2 ~ /D/ {print $1}' | head -5)
if [[ -n "$wedged" ]]; then
err "Found dockerd processes stuck in D-state (uninterruptible I/O wait):"
while IFS= read -r p; do echo " PID $p"; done <<< "$wedged"
echo ""
err "This usually means overlay2 cleanup deadlocked against ZFS."
err "D-state processes cannot be killed — the only fix is a reboot."
echo ""
err "Permanent fix (do this after the reboot, before starting docker):"
echo " sudo tee /etc/docker/daemon.json > /dev/null <<'JSON'"
echo " {"
echo " \"data-root\": \"/data/docker\","
echo " \"storage-driver\": \"zfs\""
echo " }"
echo " JSON"
echo " sudo rm -rf /data/docker/overlay2 # discard old layers"
echo " sudo systemctl start docker"
else
err "Docker daemon exists but isn't responding. Possible causes:"
err " • Daemon still starting → wait ~30s and retry"
err " • Daemon crashed → sudo systemctl status docker"
err " • Socket permissions → check group membership for 'docker'"
fi
echo ""
return 1
}
# Run preflight once per invocation; cache the result so multiple subcommands
# in a single vox call don't each pay the probe cost.
DOCKER_PREFLIGHT_DONE=""
require_docker() {
[[ "$DOCKER_PREFLIGHT_DONE" == "yes" ]] && return 0
if preflight_docker; then
DOCKER_PREFLIGHT_DONE="yes"
return 0
fi
exit 1
}
# Ensure we can run privileged commands non-interactively.
# $1 = "interactive" → may prompt for the password once and cache it
# $1 = "auto" → never prompt; just probe and remember the answer
# Returns 0 if privileged commands will work, 1 otherwise.
ensure_sudo() {
local mode="${1:-auto}"
# Already cached.
[[ "$SUDO_OK" == "yes" ]] && return 0
[[ "$SUDO_OK" == "no" ]] && return 1
# Running as root → no sudo needed.
if [[ "$(id -u)" == "0" ]]; then
SUDO_OK="yes"
return 0
fi
# No sudo binary at all → give up.
if ! command -v sudo &>/dev/null; then
SUDO_OK="no"
warn "sudo not found — privileged steps will be skipped."
return 1
fi
# Passwordless sudo already works (root, NOPASSWD, or fresh timestamp).
if sudo -n true 2>/dev/null; then
SUDO_OK="yes"
return 0
fi
# Need a password. In auto mode we refuse to block.
if [[ "$mode" != "interactive" ]]; then
SUDO_OK="no"
return 1
fi
# Interactive: prompt the user once and cache the credential.
echo ""
warn "Some setup steps need root privileges (apt-get, nvidia-ctk, systemctl)."
info "You will be prompted for your sudo password once; it will be cached."
if with_timeout 120 sudo -v; then
SUDO_OK="yes"
return 0
else
SUDO_OK="no"
warn "sudo authentication failed or timed out — privileged steps will be skipped."
return 1
fi
}
# Run a privileged command with a hard timeout. Stderr is intentionally NOT
# suppressed so the user always sees real failures. Returns the command's
# exit code, or 126 if sudo is unavailable.
sudo_run() {
local secs="$1"; shift
if [[ "$(id -u)" == "0" ]]; then
with_timeout "$secs" "$@"
return $?
fi
if [[ "$SUDO_OK" != "yes" ]]; then
return 126
fi
with_timeout "$secs" sudo -n "$@"
return $?
}
ask() {
local varname="$1" text="$2" default="${3:-}"
if [[ -n "$default" ]]; then
prompt " $text [$default]: "
else
prompt " $text: "
fi
read -r input
eval "$varname=\"${input:-$default}\""
}
ask_secret() {
local varname="$1" text="$2" default="${3:-}"
if [[ -n "$default" ]]; then
prompt " $text [$default]: "
else
prompt " $text: "
fi
read -rs input
echo ""
eval "$varname=\"${input:-$default}\""
}
confirm() {
prompt " $1 [Y/n] (auto-no in 3s): "
if read -r -t 3 yn; then
case "${yn,,}" in
n|no|"") return 1 ;;
*) return 0 ;;
esac
else
echo ""
return 1
fi
}
# ═════════════════════════════════════════════════════════════════════════════
# DYNAMIC BOX RENDERING — adapts to terminal width
# ═════════════════════════════════════════════════════════════════════════════
BOX_LINES=()
box_line() {
BOX_LINES+=("$1")
}
box_render() {
# Get terminal width, default 80
local cols
cols=$(tput cols 2>/dev/null || echo 80)
# Box inner width = terminal - 4 (border + padding each side)
local inner=$((cols - 4))
[[ "$inner" -lt 20 ]] && inner=20
# Top border
local hbar=""
for ((i=0; i<inner+2; i++)); do hbar="${hbar}─"; done
echo -e "${CYAN}╭${hbar}╮${NC}"
# Content lines — pad/truncate to fit
for line in "${BOX_LINES[@]}"; do
# Strip ANSI for length calculation
local plain
plain=$(echo -e "$line" | sed 's/\x1b\[[0-9;]*m//g')
local len=${#plain}
if [[ "$len" -le "$inner" ]]; then
# Pad with spaces
local pad=$((inner - len))
local spaces=""
for ((i=0; i<pad; i++)); do spaces="${spaces} "; done
echo -e "${CYAN}│${NC} ${line}${spaces} ${CYAN}│${NC}"
else
# Truncate
echo -e "${CYAN}│${NC} ${line:0:$inner} ${CYAN}│${NC}"
fi
done
# Bottom border
echo -e "${CYAN}╰${hbar}╯${NC}"
# Reset
BOX_LINES=()
}
# ═════════════════════════════════════════════════════════════════════════════
# PROJECT AUTO-DETECTION ENGINE
# ═════════════════════════════════════════════════════════════════════════════
detect_project() {
local repo="$1"
# Result variables (globals)
DETECTED_TYPE="unknown"
DETECTED_BASE_IMAGE=""
DETECTED_SYSTEM_DEPS=""
DETECTED_INSTALL_CMD=""
DETECTED_BUILD_CMD=""
DETECTED_START_CMD=""
DETECTED_PORT="3000"
DETECTED_HAS_DOCKERFILE="false"
DETECTED_COPY_DEPS="" # files to copy for dep install layer
DETECTED_IGNORE_PATTERNS=""
DETECTED_DATA_DIRS="" # dirs that should be volumes
DETECTED_NATIVE_MODULES="" # native node modules needing rebuild
# ── Check for existing Docker config ──
if [[ -f "$repo/Dockerfile" ]]; then
DETECTED_HAS_DOCKERFILE="true"
detect "Found existing Dockerfile"
fi
for cfile in docker-compose.yml docker-compose.yaml compose.yml compose.yaml; do
if [[ -f "$repo/$cfile" ]]; then
detect "Found existing $cfile (will generate a new one with tunnel)"
break
fi
done
# ── Node.js ──
if [[ -f "$repo/package.json" ]]; then
DETECTED_TYPE="node"
DETECTED_BASE_IMAGE="node:22-slim"
# If repo has Python files (transcription sidecar, ML, etc.), use Debian slim
# Alpine's musl breaks numpy/scipy/whisper pip wheels
if compgen -G "$repo/*.py" > /dev/null 2>&1 || [[ -f "$repo/requirements.txt" ]] || [[ -f "$repo/Pipfile" ]]; then
DETECTED_BASE_IMAGE="node:22-slim"
detect "Python files detected — using node:22-slim (glibc) instead of alpine (musl)"
fi
DETECTED_COPY_DEPS="package.json package-lock.json* yarn.lock* pnpm-lock.yaml*"
DETECTED_IGNORE_PATTERNS="node_modules\n.next\n.nuxt\ndist\nbuild\n.env.local\n.git"
# Read package.json for details
local pkg="$repo/package.json"
# Detect package manager
local pkg_mgr="npm"
if [[ -f "$repo/pnpm-lock.yaml" ]]; then
pkg_mgr="pnpm"
DETECTED_BASE_IMAGE="node:22-slim"
DETECTED_SYSTEM_DEPS="RUN corepack enable && corepack prepare pnpm@latest --activate"
elif [[ -f "$repo/yarn.lock" ]]; then
pkg_mgr="yarn"
fi
# ── Parse package.json scripts with a single python3 call ──
# Extracts: scripts object, dependencies, devDependencies, main
local pkg_json_data
pkg_json_data=$(python3 -c "
import json, sys
d = json.load(open('$pkg'))
scripts = d.get('scripts', {})
deps = {**d.get('dependencies', {}), **d.get('devDependencies', {})}
# Print script names one per line, prefixed
for k in scripts:
print(f'script:{k}={scripts[k]}')
for k in deps:
print(f'dep:{k}')
print(f'main:{d.get(\"main\", \"\")}')
" 2>/dev/null || true)
# Helper: check if a script exists
has_script() { echo "$pkg_json_data" | grep -q "^script:$1=" 2>/dev/null; }
get_script() { echo "$pkg_json_data" | grep "^script:$1=" 2>/dev/null | head -1 | sed 's/^script:[^=]*=//'; }
has_dep() { echo "$pkg_json_data" | grep -q "^dep:$1$" 2>/dev/null; }
# Detect install command — always --ignore-scripts for Docker layer caching
# (postinstall scripts often reference source files not yet copied)
if has_script "postinstall"; then
detect "postinstall script found: $(get_script postinstall)"
detect "Will use --ignore-scripts for Docker layer caching"
fi
# ── Lockfile-drift-tolerant install command ──
# Strict-lockfile mode (`npm ci`, `pnpm --frozen-lockfile`, `yarn
# --frozen-lockfile`) is the right default for reproducible images —
# but it makes the whole deploy fail any time a dep version is bumped
# in the manifest without re-running the host install to refresh the
# lockfile. That has caused production retry loops here (omnius 1.0.199
# in lock vs 1.0.200 in package.json triggered every-15s build failures
# for an hour until manually fixed). Each command therefore falls back
# to a permissive install on strict-mode failure, with a logged warning
# so the drift is visible in build logs. The warning text "[vox]"
# prefix and "lockfile likely drifted" phrase are grepped by ops, so
# don't reword without updating dashboards.
case "$pkg_mgr" in
pnpm) DETECTED_INSTALL_CMD='pnpm install --frozen-lockfile --ignore-scripts || (echo "[vox] pnpm frozen-lockfile failed (lockfile likely drifted) — falling back to pnpm install" && pnpm install --ignore-scripts)' ;;
yarn) DETECTED_INSTALL_CMD='yarn install --frozen-lockfile --ignore-scripts || (echo "[vox] yarn frozen-lockfile failed (lockfile likely drifted) — falling back to yarn install" && yarn install --ignore-scripts)' ;;
*) DETECTED_INSTALL_CMD='npm ci --ignore-scripts --legacy-peer-deps || (echo "[vox] npm ci failed (lockfile likely drifted) — falling back to npm install" && npm install --ignore-scripts --legacy-peer-deps --no-audit --no-fund)' ;;
esac
if echo "$DETECTED_BASE_IMAGE" | grep -q "slim\|debian\|ubuntu"; then
DETECTED_SYSTEM_DEPS="${DETECTED_SYSTEM_DEPS:+$DETECTED_SYSTEM_DEPS\n}RUN apt-get update && apt-get install -y --no-install-recommends bash ca-certificates curl postgresql-client && rm -rf /var/lib/apt/lists/*"
detect "Node runtime uses Debian slim — adding bash, curl, CA certs, and psql"
elif echo "$DETECTED_BASE_IMAGE" | grep -q "alpine"; then
DETECTED_SYSTEM_DEPS="${DETECTED_SYSTEM_DEPS:+$DETECTED_SYSTEM_DEPS\n}RUN apk add --no-cache bash ca-certificates curl postgresql-client"
detect "Node runtime uses Alpine — adding bash, curl, CA certs, and psql"
fi
# Check for native modules that need build tools
local has_native=0
local native_modules=""
for mod in argon2 better-sqlite3 bcrypt sharp canvas sqlite3 node-gyp; do
if has_dep "$mod"; then
has_native=1
native_modules="${native_modules:+$native_modules }$mod"
fi
done
if [[ "$has_native" -eq 1 ]]; then
if echo "$DETECTED_BASE_IMAGE" | grep -q "alpine"; then
DETECTED_SYSTEM_DEPS="${DETECTED_SYSTEM_DEPS:+$DETECTED_SYSTEM_DEPS\n}RUN apk add --no-cache python3 make g++ gcc musl-dev"
else
DETECTED_SYSTEM_DEPS="${DETECTED_SYSTEM_DEPS:+$DETECTED_SYSTEM_DEPS\n}RUN apt-get update && apt-get install -y --no-install-recommends python3 python3-venv python3-pip make g++ gcc && rm -rf /var/lib/apt/lists/*"
fi
detect "Native modules detected ($native_modules) — adding build tools"
fi
# Store for Dockerfile generator
DETECTED_NATIVE_MODULES="$native_modules"
# Detect framework from dependencies
local framework=""
if has_dep "next"; then framework="next"
elif has_dep "nuxt"; then framework="nuxt"
elif has_dep "@remix-run/node"; then framework="remix"
elif has_dep "@remix-run/react"; then framework="remix"
elif has_dep "astro"; then framework="astro"
elif has_dep "@sveltejs/kit"; then framework="sveltekit"
elif has_dep "vite"; then framework="vite"
elif has_dep "express"; then framework="express"
elif has_dep "fastify"; then framework="fastify"
elif has_dep "hono"; then framework="hono"
elif has_dep "koa"; then framework="koa"
fi
# Read actual build/start scripts from package.json
local pkg_build_script="" pkg_start_script=""
has_script "build" && pkg_build_script="$(get_script build)"
has_script "start" && pkg_start_script="$(get_script start)"
# ── Detect dangerous lifecycle scripts ──
# prebuild/prestart/predev often contain dev-machine ops (kill-port, etc.)
# that break inside Docker (killing PID 1 = killing the build).
# When found, we bypass npm run and call the underlying command directly.
local has_dangerous_prebuild=0 has_dangerous_prestart=0
local prebuild_script="" prestart_script=""
if has_script "prebuild"; then
prebuild_script="$(get_script prebuild)"
# Flag if it contains kill, port-kill, fuser, lsof, or process management
if echo "$prebuild_script" | grep -qiE 'kill|fuser|lsof|pkill|taskkill'; then
has_dangerous_prebuild=1
warn "prebuild contains process-killing ops — will bypass for Docker"
detect "prebuild: $prebuild_script"
fi
fi
if has_script "prestart"; then
prestart_script="$(get_script prestart)"
if echo "$prestart_script" | grep -qiE 'kill|fuser|lsof|pkill|taskkill'; then
has_dangerous_prestart=1
warn "prestart contains process-killing ops — will bypass for Docker"
detect "prestart: $prestart_script"
fi
fi
# ── Resolve build command ──
# If prebuild is dangerous, run the raw build command directly (e.g. "next build")
# instead of "npm run build" which triggers prebuild → death
resolve_build_cmd() {
if [[ "$has_dangerous_prebuild" -eq 1 ]] && [[ -n "$pkg_build_script" ]]; then
if echo "$pkg_build_script" | grep -qE '^(node|python|ruby|php|java|go|./|/)'; then
echo "$pkg_build_script"
else
echo "npx $pkg_build_script"
fi
elif [[ -n "$pkg_build_script" ]]; then
echo "${pkg_mgr} run build"
else
echo ""
fi
}
# ── Resolve start command ──
# Same logic: if prestart is dangerous, use the raw command
resolve_start_cmd() {
if [[ "$has_dangerous_prestart" -eq 1 ]] && [[ -n "$pkg_start_script" ]]; then
# Run raw command directly, bypassing prestart lifecycle
# Don't prepend npx if already starts with node/python/etc
if echo "$pkg_start_script" | grep -qE '^(node|python|ruby|php|java|go|./|/)'; then
echo "$pkg_start_script"
else
echo "npx $pkg_start_script"
fi
elif [[ -n "$pkg_start_script" ]]; then
echo "${pkg_mgr} start"
else
echo ""
fi
}
# Set build/start per framework
case "$framework" in
next)
DETECTED_TYPE="node/nextjs"
DETECTED_BUILD_CMD="$(resolve_build_cmd)"
DETECTED_START_CMD="$(resolve_start_cmd)"
[[ -z "$DETECTED_BUILD_CMD" ]] && DETECTED_BUILD_CMD="npx next build"
[[ -z "$DETECTED_START_CMD" ]] && DETECTED_START_CMD="npx next start"
DETECTED_PORT="3000"
detect "Next.js detected (build: $DETECTED_BUILD_CMD, start: $DETECTED_START_CMD)"
;;
nuxt)
DETECTED_TYPE="node/nuxt"
DETECTED_BUILD_CMD="$(resolve_build_cmd)"
[[ -z "$DETECTED_BUILD_CMD" ]] && DETECTED_BUILD_CMD="npx nuxt build"
DETECTED_START_CMD="node .output/server/index.mjs"
DETECTED_PORT="3000"
detect "Nuxt detected"
;;
remix)
DETECTED_TYPE="node/remix"
DETECTED_BUILD_CMD="$(resolve_build_cmd)"
DETECTED_START_CMD="$(resolve_start_cmd)"
[[ -z "$DETECTED_BUILD_CMD" ]] && DETECTED_BUILD_CMD="npx remix build"
[[ -z "$DETECTED_START_CMD" ]] && DETECTED_START_CMD="${pkg_mgr} start"
DETECTED_PORT="3000"
detect "Remix detected"
;;
astro)
DETECTED_TYPE="node/astro"
DETECTED_BUILD_CMD="$(resolve_build_cmd)"
[[ -z "$DETECTED_BUILD_CMD" ]] && DETECTED_BUILD_CMD="npx astro build"
DETECTED_START_CMD="node ./dist/server/entry.mjs"
DETECTED_PORT="4321"
detect "Astro detected"
;;
sveltekit)
DETECTED_TYPE="node/sveltekit"
DETECTED_BUILD_CMD="$(resolve_build_cmd)"
[[ -z "$DETECTED_BUILD_CMD" ]] && DETECTED_BUILD_CMD="npx svelte-kit build"
DETECTED_START_CMD="node build"
DETECTED_PORT="3000"
detect "SvelteKit detected"
;;
vite)
if [[ -z "$pkg_start_script" ]]; then
DETECTED_TYPE="node/vite-static"
DETECTED_BUILD_CMD="$(resolve_build_cmd)"
[[ -z "$DETECTED_BUILD_CMD" ]] && DETECTED_BUILD_CMD="npx vite build"
DETECTED_START_CMD="npx serve dist -l 3000"
DETECTED_PORT="3000"
detect "Vite SPA (static) — will serve with 'serve'"
else
DETECTED_TYPE="node/vite-ssr"
DETECTED_BUILD_CMD="$(resolve_build_cmd)"
DETECTED_START_CMD="$(resolve_start_cmd)"
[[ -z "$DETECTED_BUILD_CMD" ]] && DETECTED_BUILD_CMD="npx vite build"
[[ -z "$DETECTED_START_CMD" ]] && DETECTED_START_CMD="${pkg_mgr} start"
DETECTED_PORT="3000"
detect "Vite SSR detected (start: $DETECTED_START_CMD)"
fi
;;
express|fastify|hono|koa)
DETECTED_TYPE="node/$framework"
DETECTED_BUILD_CMD="$(resolve_build_cmd)"
DETECTED_START_CMD="$(resolve_start_cmd)"
[[ -z "$DETECTED_START_CMD" ]] && DETECTED_START_CMD="${pkg_mgr} start"
DETECTED_PORT="3000"
detect "$framework server detected (start: $DETECTED_START_CMD)"
;;
*)
# Generic Node.js — read directly from scripts
DETECTED_BUILD_CMD="$(resolve_build_cmd)"
DETECTED_START_CMD="$(resolve_start_cmd)"
if [[ -z "$DETECTED_START_CMD" ]]; then
local main_entry
main_entry=$(echo "$pkg_json_data" | grep "^main:" | sed 's/^main://')
if [[ -n "$main_entry" ]]; then
DETECTED_START_CMD="node $main_entry"
detect "Generic Node.js (main: $main_entry)"
else
DETECTED_START_CMD="node index.js"
detect "Generic Node.js (no start script, defaulting to index.js)"
fi
else
detect "Generic Node.js (start: $DETECTED_START_CMD)"
fi
;;
esac
# Show all discovered scripts to user
local all_scripts
all_scripts=$(echo "$pkg_json_data" | grep "^script:" | sed 's/^script:/ /' | head -20)
if [[ -n "$all_scripts" ]]; then
detect "package.json scripts:"
echo "$all_scripts" | while IFS= read -r line; do
echo -e " ${MAGENTA}${line}${NC}"
done
fi
# Look for data dirs that should be volumes
if has_dep "better-sqlite3" || has_dep "sqlite3" || has_dep "nedb" || has_dep "lowdb"; then
DETECTED_DATA_DIRS="data"
detect "SQLite/file DB detected — will mount /app/data as volume"
fi
return 0
fi
# ── Python ──
if [[ -f "$repo/requirements.txt" ]] || [[ -f "$repo/pyproject.toml" ]] || [[ -f "$repo/Pipfile" ]] || [[ -f "$repo/setup.py" ]]; then
DETECTED_TYPE="python"
DETECTED_BASE_IMAGE="python:3.12-slim"
DETECTED_IGNORE_PATTERNS=".venv\nvenv\n__pycache__\n*.pyc\n.env.local\n.git\ndist\n*.egg-info"
# Detect dependency file
if [[ -f "$repo/pyproject.toml" ]]; then
DETECTED_COPY_DEPS="pyproject.toml"
if grep -q '\[tool.poetry\]' "$repo/pyproject.toml" 2>/dev/null; then
DETECTED_INSTALL_CMD="pip install poetry && poetry install --no-root --no-interaction"
DETECTED_COPY_DEPS="pyproject.toml poetry.lock*"
elif grep -q '\[project\]' "$repo/pyproject.toml" 2>/dev/null; then
DETECTED_INSTALL_CMD="pip install ."
DETECTED_COPY_DEPS="pyproject.toml setup.cfg* setup.py*"
else
DETECTED_INSTALL_CMD="pip install ."
fi
elif [[ -f "$repo/Pipfile" ]]; then
DETECTED_COPY_DEPS="Pipfile Pipfile.lock*"
DETECTED_INSTALL_CMD="pip install pipenv && pipenv install --deploy --system"
else
DETECTED_COPY_DEPS="requirements.txt"
DETECTED_INSTALL_CMD="pip install --no-cache-dir -r requirements.txt"
fi
# Detect framework
local py_files
py_files=$(cat "$repo/requirements.txt" "$repo/pyproject.toml" "$repo/Pipfile" 2>/dev/null || true)
if echo "$py_files" | grep -qi "django"; then
DETECTED_TYPE="python/django"
DETECTED_BUILD_CMD="python manage.py collectstatic --noinput"
DETECTED_START_CMD="gunicorn --bind 0.0.0.0:8000 --workers 4 config.wsgi:application"
DETECTED_PORT="8000"
# Try to find the wsgi module
local wsgi_file
wsgi_file=$(find "$repo" -name "wsgi.py" -not -path "*/venv/*" -not -path "*/.venv/*" 2>/dev/null | head -1)
if [[ -n "$wsgi_file" ]]; then
local wsgi_module
wsgi_module=$(echo "$wsgi_file" | sed "s|$repo/||" | sed 's|/|.|g' | sed 's|\.py$||')
DETECTED_START_CMD="gunicorn --bind 0.0.0.0:8000 --workers 4 ${wsgi_module}:application"
fi
DETECTED_SYSTEM_DEPS="RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev && rm -rf /var/lib/apt/lists/*"
detect "Django project detected"
elif echo "$py_files" | grep -qi "fastapi"; then
DETECTED_TYPE="python/fastapi"
DETECTED_BUILD_CMD=""
# Find the main app module
local app_entry="main:app"
if [[ -f "$repo/app/main.py" ]]; then
app_entry="app.main:app"
elif [[ -f "$repo/src/main.py" ]]; then
app_entry="src.main:app"
fi
DETECTED_START_CMD="uvicorn $app_entry --host 0.0.0.0 --port 8000"
DETECTED_PORT="8000"
detect "FastAPI project detected"
elif echo "$py_files" | grep -qi "flask"; then
DETECTED_TYPE="python/flask"
DETECTED_BUILD_CMD=""
local flask_app="app:app"
if [[ -f "$repo/app.py" ]]; then
flask_app="app:app"
elif [[ -f "$repo/wsgi.py" ]]; then
flask_app="wsgi:app"
elif [[ -f "$repo/application.py" ]]; then
flask_app="application:app"
fi
DETECTED_START_CMD="gunicorn --bind 0.0.0.0:5000 --workers 4 $flask_app"
DETECTED_PORT="5000"
detect "Flask project detected"
elif echo "$py_files" | grep -qi "streamlit"; then
DETECTED_TYPE="python/streamlit"
DETECTED_BUILD_CMD=""
local st_entry
st_entry=$(find "$repo" -maxdepth 2 -name "*.py" \( -name "app.py" -o -name "main.py" -o -name "streamlit_app.py" \) 2>/dev/null | head -1)
st_entry="${st_entry:-app.py}"
st_entry="${st_entry#"$repo/"}"
DETECTED_START_CMD="streamlit run $st_entry --server.port 8501 --server.address 0.0.0.0"
DETECTED_PORT="8501"
detect "Streamlit project detected"
else
# Generic Python
DETECTED_BUILD_CMD=""
if [[ -f "$repo/manage.py" ]]; then
DETECTED_START_CMD="python manage.py runserver 0.0.0.0:8000"
DETECTED_PORT="8000"
elif [[ -f "$repo/app.py" ]]; then
DETECTED_START_CMD="python app.py"
DETECTED_PORT="8000"
elif [[ -f "$repo/main.py" ]]; then
DETECTED_START_CMD="python main.py"
DETECTED_PORT="8000"
else
DETECTED_START_CMD="python -m http.server 8000"
DETECTED_PORT="8000"
fi
detect "Generic Python project detected"
fi
return 0
fi