-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlumivero-api_setup.sh
More file actions
executable file
·1768 lines (1575 loc) · 70 KB
/
Copy pathlumivero-api_setup.sh
File metadata and controls
executable file
·1768 lines (1575 loc) · 70 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 sh
#
# lumivero-api_setup.sh — Lumivero API environment checklist.
#
# Walks a list of requirements (tools + environment settings), reports the
# status of each with a clear pass/fail/warn marker and, where a fix is known,
# offers to install or repair it.
#
# Delivery model: this script is served from GitHub Pages and run with
# curl -fsSL https://lumivero.github.io/scripts/lumivero-api_setup.sh | sh
# so it must stay POSIX-sh clean, self-contained, and safe under `set -eu`.
#
set -eu
# ----------------------------------------------------------------------------
# Presentation: ANSI colour + emoji status icons. Degrades gracefully when
# stdout is not a terminal, when NO_COLOR is set, or on a dumb terminal.
# ----------------------------------------------------------------------------
ESC=$(printf '\033')
# The line-clear sequence is only meaningful on a terminal.
if [ -t 1 ]; then
CLR="${ESC}[2K"
else
CLR=""
fi
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != "dumb" ]; then
RESET="${ESC}[0m"
BOLD="${ESC}[1m"
DIM="${ESC}[2m"
RED="${ESC}[31m"
GREEN="${ESC}[32m"
YELLOW="${ESC}[33m"
BLUE="${ESC}[34m"
else
RESET=""
BOLD=""
DIM=""
RED=""
GREEN=""
YELLOW=""
BLUE=""
fi
ICON_PASS="✅"
ICON_FAIL="❌"
ICON_WARN="⚠️ "
ICON_FIX="🔧"
ICON_PEND="⏳"
ICON_INFO="ℹ️ "
ICON_DONE="🚀"
# ----------------------------------------------------------------------------
# Shared state.
# ----------------------------------------------------------------------------
OS_FAMILY="" # macos | debian — set by check_os, consumed by install_pkg
CHECK_DETAIL="" # short context a check may surface beside its result
PROFILES_CHANGED="" # shell-startup files actually modified this run (space-separated)
RESTART_REQUIRED="" # set by a fix whose effect only reaches a fresh login session
REPO_DIR="" # where check_repo found the repo; consumed by branch selection
ASSUME_YES="" # 1 ⇒ auto-accept fixes without prompting (set by resolve_assume_yes)
ASSUME_YES_EXPLICIT="" # 1 ⇒ assume-yes was requested *explicitly* (--yes/-y or
# SETUP_ASSUME_YES=1) ⇒ "no interaction at all", so the
# branch menu is skipped too. Stays empty for an auto-
# assumed piped (curl … | sh) run, which still shows it.
CHECK_TOTAL=0
PASS_COUNT=0
FIXED_COUNT=0
WARN_COUNT=0
FAIL_COUNT=0
# ----------------------------------------------------------------------------
# Output helpers.
# ----------------------------------------------------------------------------
clear_line() {
if [ -t 1 ]; then
printf '\r%s' "$CLR"
fi
}
# Transient "in progress" line, only shown on a terminal; replaced by the result.
print_pending() {
if [ -t 1 ]; then
printf ' %s %s%s …%s' "$ICON_PEND" "$DIM" "$1" "$RESET"
fi
}
# print_result <ok|fixed|warn|fail|info> <label> [detail]
print_result() {
_state="$1"
_label="$2"
_detail="${3:-}"
clear_line
case "$_state" in
ok) _icon="$ICON_PASS"; _color="$GREEN" ;;
fixed) _icon="$ICON_FIX"; _color="$GREEN" ;;
warn) _icon="$ICON_WARN"; _color="$YELLOW" ;;
fail) _icon="$ICON_FAIL"; _color="$RED" ;;
*) _icon="$ICON_INFO"; _color="$BLUE" ;;
esac
if [ -n "$_detail" ]; then
printf ' %s %s%s%s %s(%s)%s\n' "$_icon" "$_color" "$_label" "$RESET" "$DIM" "$_detail" "$RESET"
else
printf ' %s %s%s%s\n' "$_icon" "$_color" "$_label" "$RESET"
fi
}
print_fixing() {
printf ' %s %sFixing: %s …%s\n' "$ICON_FIX" "$DIM" "$1" "$RESET"
}
banner() {
printf '\n%s%s Lumivero environment checklist%s\n' "$BOLD" "$ICON_DONE" "$RESET"
printf '%sChecking required tools and settings …%s\n\n' "$DIM" "$RESET"
}
print_summary() {
printf '\n%s%s%s\n' "$DIM" "────────────────────────────────────────────" "$RESET"
printf '%sSummary%s %d checks ' "$BOLD" "$RESET" "$CHECK_TOTAL"
printf '%s%s %d passed%s' "$GREEN" "$ICON_PASS" "$PASS_COUNT" "$RESET"
if [ "$FIXED_COUNT" -gt 0 ]; then
printf ' %s%s %d fixed%s' "$GREEN" "$ICON_FIX" "$FIXED_COUNT" "$RESET"
fi
if [ "$WARN_COUNT" -gt 0 ]; then
printf ' %s%s %d warning(s)%s' "$YELLOW" "$ICON_WARN" "$WARN_COUNT" "$RESET"
fi
if [ "$FAIL_COUNT" -gt 0 ]; then
printf ' %s%s %d failed%s' "$RED" "$ICON_FAIL" "$FAIL_COUNT" "$RESET"
fi
printf '\n'
}
# When a fix actually wrote to a shell-startup file this run, the exported
# variables and PATH edits only reach *new* shells — the developer's current
# session won't see them. Print a closing notice naming the files that changed
# so they know to open a new terminal. No-op when nothing was modified.
print_reload_notice() {
[ -n "$PROFILES_CHANGED" ] || return 0
_files=""
# shellcheck disable=SC2086 # deliberate word-splitting over the file list
for _f in $PROFILES_CHANGED; do
_files="${_files}${_files:+, }${_f##*/}"
done
printf '\n%s%s Shell profile updated — open a new terminal%s\n' \
"$BOLD" "$ICON_INFO" "$RESET"
printf '%sChanges to %s apply to new shells only. Open a new terminal\n' "$DIM" "$_files"
printf 'session (or start a new shell) for them to take effect.%s\n' "$RESET"
}
# Ask a yes/no question. Returns success without prompting when ASSUME_YES is
# set — a piped curl … | sh run, an explicit --yes/-y, or SETUP_ASSUME_YES=1
# (resolve_assume_yes decides this once, up front). Otherwise reads the answer
# from the controlling terminal so it works even when the script itself arrives
# on stdin; declines quietly when no terminal is present (headless run).
confirm() {
if [ "$ASSUME_YES" = "1" ]; then
return 0
fi
# Need an interactive terminal; bail out quietly if one cannot be opened
# (e.g. headless CI, or a /dev/tty that exists but is not connected).
if ! { true >/dev/tty; } 2>/dev/null; then
return 1
fi
printf '%s %s %s [y/N] %s' "$YELLOW" "$ICON_INFO" "$1" "$RESET" >/dev/tty
if ! read -r _reply </dev/tty 2>/dev/null; then
printf '\n' >/dev/tty 2>/dev/null
return 1
fi
case "$_reply" in
[Yy] | [Yy][Ee][Ss]) return 0 ;;
*) return 1 ;;
esac
}
# Decide up front whether fixes are auto-accepted (ASSUME_YES=1) or the developer
# is asked per fix. A piped run (curl … | sh) always assumes yes and never
# prompts for fixes — there is no comfortable way to ask 13 y/n questions when the
# script itself arrives on stdin, so "yes" is the only useful default. A direct run
# (sh script.sh, ./script.sh) prompts unless --yes/-y is passed or
# SETUP_ASSUME_YES=1 is set in the environment.
#
# Those last two are an *explicit* "don't interact with me at all" — recorded in
# ASSUME_YES_EXPLICIT — and so also suppress the interactive branch menu. A piped
# run only auto-accepts *fixes*: the developer is still at a terminal, so the
# branch menu (a genuine choice, read from /dev/tty) is still offered. Called once
# from main() before any check runs; confirm() reads ASSUME_YES and
# select_repo_branch() reads ASSUME_YES_EXPLICIT.
resolve_assume_yes() {
if running_piped; then
ASSUME_YES=1
return 0
fi
if [ "${SETUP_ASSUME_YES:-0}" = "1" ]; then
ASSUME_YES=1
ASSUME_YES_EXPLICIT=1
return 0
fi
for _arg in "$@"; do
case "$_arg" in
--yes | -y)
ASSUME_YES=1
ASSUME_YES_EXPLICIT=1
return 0
;;
esac
done
}
# ----------------------------------------------------------------------------
# Environment probes.
# ----------------------------------------------------------------------------
is_wsl() {
[ -n "${WSL_INTEROP:-}" ] && return 0
[ -n "${WSL_DISTRO_NAME:-}" ] && return 0
grep -qiE '(microsoft|wsl)' /proc/version 2>/dev/null
}
# True when this script was piped into the shell (curl … | sh): the shell then
# reads the script from standard input, so $0 is the shell's own name (sh, dash,
# bash, …) rather than a path to this file — which is what a direct run
# (sh script.sh, ./script.sh) leaves in $0. We test $0's basename so an absolute
# shell path (/bin/sh) and a login shell (-sh) are recognised too. This is what
# resolve_assume_yes keys the prompt-less default off, so it must not depend on a
# terminal: a direct run with redirected stdin (CI) is still a direct run.
running_piped() {
_self="${0##*/}" # basename of $0
_self="${_self#-}" # a login shell prepends '-' (e.g. -sh)
case "$_self" in
sh | bash | dash | ash | ksh | mksh | zsh | busybox) return 0 ;;
esac
return 1
}
# Native Windows is unsupported: the API environment requires Ubuntu under WSL.
# A POSIX `sh` exists on Windows only via a Cygwin/MinGW/MSYS shell (Git Bash et
# al.), where `uname -s` reports CYGWIN*/MINGW*/MSYS* — whereas Ubuntu under WSL
# reports Linux (and is recognised by is_wsl). So when we see one of those shells
# we are on Windows but not in WSL: stop immediately, before any check/fix runs
# (we must never try to install Docker here), and explain how to get Ubuntu onto
# WSL. Exits the script non-zero on a match.
bail_if_windows_without_wsl() {
case "$(uname -s)" in
CYGWIN* | MINGW* | MSYS*) ;; # a Windows shell; WSL would report Linux
*) return 0 ;;
esac
printf '\n%s%s Windows detected — Ubuntu under WSL is required%s\n\n' \
"$BOLD" "$ICON_FAIL" "$RESET"
printf '%sThe Lumivero API environment runs on Ubuntu under WSL (Windows\n' "$DIM"
printf 'Subsystem for Linux), not directly on Windows. Set that up first,\n'
printf 'then run this script again from inside the Ubuntu shell.%s\n\n' "$RESET"
printf '%s%s Install Ubuntu under WSL%s\n' "$BOLD" "$ICON_INFO" "$RESET"
printf ' 1. Open %sPowerShell%s or %sCommand Prompt%s as Administrator.\n' \
"$BOLD" "$RESET" "$BOLD" "$RESET"
printf ' 2. Run %swsl --install -d Ubuntu%s\n' "$BOLD" "$RESET"
printf ' 3. Reboot if prompted, then launch %sUbuntu%s from the Start menu\n' \
"$BOLD" "$RESET"
printf ' and create your UNIX username and password.\n'
printf ' 4. From inside that Ubuntu shell, re-run this script:\n'
printf ' %scurl -fsSL https://lumivero.github.io/scripts/lumivero-api_setup.sh | sh%s\n\n' \
"$BOLD" "$RESET"
printf '%sMore detail: https://learn.microsoft.com/windows/wsl/install%s\n\n' \
"$DIM" "$RESET"
exit 1
}
# ----------------------------------------------------------------------------
# Reusable primitives for the checks registered in main().
# ----------------------------------------------------------------------------
have_cmd() {
command -v "$1" >/dev/null 2>&1
}
# Remember that a shell-startup file was actually changed this run, so the run
# can finish by telling the developer to open a new terminal for the change to
# take effect. Dedups by path, so a file touched by several fixes is named once.
record_profile_changed() {
case " ${PROFILES_CHANGED} " in
*" $1 "*) ;; # already recorded
*) PROFILES_CHANGED="${PROFILES_CHANGED}${PROFILES_CHANGED:+ }$1" ;;
esac
}
# Append a line to a file unless it is already present, creating the file if
# missing. Idempotent, so it is safe to call on every run (e.g. to put a tool's
# bin directory on PATH in a shell rc file). Records the file as changed only
# when a line is actually appended, so a no-op re-run triggers no reload notice.
ensure_line_in_file() {
_file="$1"
_line="$2"
if grep -qF "$_line" "$_file" 2>/dev/null; then
return 0
fi
printf '%s\n' "$_line" >>"$_file"
record_profile_changed "$_file"
}
# Where each kind of export belongs, by shell convention. The aim is that the
# lines reach *every* shell a developer opens. The trap to avoid: bash reads
# ~/.bash_profile only for a *login* shell, so a fresh WSL window picks up a
# change written there, but typing `bash` in the current session — an
# interactive non-login shell, which reads only ~/.bashrc — does not. We
# therefore cover both bash startup files.
#
# * zsh reads ~/.zshenv for every shell (login or not), so it alone carries an
# env var; a PATH edit additionally goes in the login file ~/.zprofile.
# * bash has no always-sourced file, so env vars and PATH edits are written to
# BOTH ~/.bash_profile (login) and ~/.bashrc (interactive non-login) — each
# file carries the line independently, so neither needs to source the other.
#
# A regular env var (export VAR=value) is idempotent, so re-sourcing it in a
# nested shell is harmless. A PATH prepend is not — unguarded, it duplicates the
# entry every time ~/.bashrc is re-sourced — so persist_path_line is given the
# self-guarding line form (see DEVCONTAINER_PATH_LINE) that prepends only when
# the directory is absent.
#
# Both helpers append idempotently, so a fix run under either shell sets up the
# other too.
# Persist a regular environment-variable line so every shell sees it: zsh
# ~/.zshenv (always sourced) + bash ~/.bash_profile (login) and ~/.bashrc
# (interactive non-login).
persist_env_line() {
ensure_line_in_file "${HOME}/.zshenv" "$1"
ensure_line_in_file "${HOME}/.bash_profile" "$1"
ensure_line_in_file "${HOME}/.bashrc" "$1"
}
# Persist a PATH line so every shell sees it: zsh ~/.zprofile (login) + bash
# ~/.bash_profile (login) and ~/.bashrc (interactive non-login). The line must be
# the self-guarding form (see DEVCONTAINER_PATH_LINE) so re-sourcing ~/.bashrc in
# a nested shell cannot prepend the directory twice.
persist_path_line() {
ensure_line_in_file "${HOME}/.zprofile" "$1"
ensure_line_in_file "${HOME}/.bash_profile" "$1"
ensure_line_in_file "${HOME}/.bashrc" "$1"
}
# True when <line> (a PATH export persisted by persist_path_line) is already
# present in every startup file it writes — zsh ~/.zprofile and bash
# ~/.bash_profile + ~/.bashrc. This is the read counterpart of persist_path_line
# and the same file-as-source-of-truth test the env-var checks use
# (check_github_token et al. grep the dotfile rather than the live environment):
# once the line is persisted, a new shell puts the directory on PATH, so a check
# can treat a tool installed at its known location as satisfied even when the
# current `curl … | sh` process — which cannot see edits to the parent shell's
# startup files — does not yet have it on PATH. Requiring all three (not only the
# login files) means an install persisted before ~/.bashrc was added re-runs its
# fix once to backfill it, then stays satisfied. Without this, such a check would
# report "not on PATH" and re-run its fix on every invocation.
path_line_persisted() {
for _rc in "${HOME}/.zprofile" "${HOME}/.bash_profile" "${HOME}/.bashrc"; do
grep -qF "$1" "$_rc" 2>/dev/null || return 1
done
return 0
}
# Install a package by Homebrew formula (macOS) or apt package (Debian/Ubuntu).
# Usage: install_pkg <brew-formula> <apt-package>
# Sets CHECK_DETAIL and returns non-zero when it cannot install.
install_pkg() {
_brew="$1"
_apt="$2"
case "$OS_FAMILY" in
macos)
if ! have_cmd brew; then
CHECK_DETAIL="Homebrew required; install it from https://brew.sh"
return 1
fi
brew install "$_brew"
;;
debian)
if have_cmd sudo; then
sudo apt-get update && sudo apt-get install -y "$_apt"
else
apt-get update && apt-get install -y "$_apt"
fi
;;
*)
CHECK_DETAIL="no automated installer for this environment"
return 1
;;
esac
}
# Run a command as root: via sudo when available, directly otherwise (e.g. when
# already root inside a container). Mirrors the sudo handling in install_pkg.
run_root() {
if have_cmd sudo; then
sudo "$@"
else
"$@"
fi
}
# ----------------------------------------------------------------------------
# Individual checks. Each returns 0 when satisfied and may set CHECK_DETAIL.
# ----------------------------------------------------------------------------
# Check #1: are we on a supported operating system? Also records OS_FAMILY so
# later fixes know which package manager to use. There is no fix for this one.
check_os() {
_os="$(uname -s)"
case "$_os" in
Darwin)
OS_FAMILY="macos"
CHECK_DETAIL="macOS"
return 0
;;
CYGWIN* | MINGW* | MSYS*)
CHECK_DETAIL="unsupported Windows shell ${_os}; use Ubuntu under WSL"
return 1
;;
Linux)
if [ ! -r /etc/os-release ]; then
CHECK_DETAIL="/etc/os-release missing; need Debian or Ubuntu"
return 1
fi
# shellcheck disable=SC1091
. /etc/os-release
_distro="${ID:-unknown}"
if is_wsl; then
if [ "$_distro" != "ubuntu" ]; then
CHECK_DETAIL="WSL distro '${_distro}'; only Ubuntu under WSL is supported"
return 1
fi
OS_FAMILY="debian"
CHECK_DETAIL="Ubuntu under Windows WSL"
return 0
fi
case "$_distro" in
debian)
OS_FAMILY="debian"
CHECK_DETAIL="Debian Linux"
return 0
;;
ubuntu)
OS_FAMILY="debian"
CHECK_DETAIL="Ubuntu Linux"
return 0
;;
pop)
# Pop!_OS is Ubuntu-derived and apt-based, so it uses the debian family.
OS_FAMILY="debian"
CHECK_DETAIL="Pop!_OS Linux"
return 0
;;
*)
CHECK_DETAIL="unsupported Linux distribution '${_distro}'"
return 1
;;
esac
;;
*)
CHECK_DETAIL="unsupported OS ${_os}"
return 1
;;
esac
}
# Docker must be both installed (the `docker` CLI on PATH) and available (its
# daemon reachable, i.e. actually running). On macOS we don't auto-install —
# the developer is pointed at OrbStack — so the detail strings differ per OS.
check_docker() {
if ! have_cmd docker; then
if [ "$OS_FAMILY" = "macos" ]; then
CHECK_DETAIL="not installed; install OrbStack (https://orbstack.dev) and start it, then re-run"
else
CHECK_DETAIL="not installed"
fi
return 1
fi
if docker info >/dev/null 2>&1; then
CHECK_DETAIL="installed and running"
return 0
fi
if [ "$OS_FAMILY" = "macos" ]; then
CHECK_DETAIL="installed but not running; start OrbStack and re-run"
else
CHECK_DETAIL="installed but daemon not reachable; start Docker (you may need to log out and back in for the docker group) and re-run"
fi
return 1
}
# Resolve which Docker apt repository and release codename to use for this
# Debian-family distro. Docker publishes repos for 'debian' and 'ubuntu' only, so
# Pop!_OS (ID=pop) — Ubuntu-derived — uses the ubuntu repo keyed to its upstream
# Ubuntu codename (UBUNTU_CODENAME). Prints "<repo> <codename>" on success;
# nothing (and returns non-zero) when the distro or codename can't be resolved.
# os-release is sourced in a subshell so its vars don't leak into our globals and
# unset ones don't trip `set -u`.
docker_apt_target() {
[ -r /etc/os-release ] || return 1
(
# shellcheck disable=SC1091
. /etc/os-release
case "${ID:-}" in
debian)
[ -n "${VERSION_CODENAME:-}" ] || exit 1
printf 'debian %s\n' "$VERSION_CODENAME"
;;
ubuntu)
_cn="${VERSION_CODENAME:-${UBUNTU_CODENAME:-}}"
[ -n "$_cn" ] || exit 1
printf 'ubuntu %s\n' "$_cn"
;;
pop)
_cn="${UBUNTU_CODENAME:-${VERSION_CODENAME:-}}"
[ -n "$_cn" ] || exit 1
printf 'ubuntu %s\n' "$_cn"
;;
*) exit 1 ;;
esac
)
}
# Install Docker on Linux from Docker's official apt repository
# (https://docs.docker.com/engine/install/), the same keyring + sources-list
# shape as the VS Code fix, then add the current user to the docker group. We use
# the apt repo rather than the get.docker.com convenience script specifically to
# avoid that script's hardcoded "WSL DETECTED" banner and its forced 20-second
# `sleep` — the apt repo install has no such prompt. On macOS there is no
# automated fix: the developer is asked to install OrbStack and start it.
fix_docker() {
if [ "$OS_FAMILY" != "debian" ]; then
CHECK_DETAIL="install OrbStack (https://orbstack.dev), start it, then re-run"
return 1
fi
_target="$(docker_apt_target)" || {
CHECK_DETAIL="could not determine the Docker apt repository for this distribution"
return 1
}
_repo="${_target%% *}"
_codename="${_target#* }"
# curl fetches the signing key; ca-certificates lets it trust download.docker.com.
run_root apt-get update || return 1
run_root apt-get install -y ca-certificates curl || return 1
# Docker's ASCII-armored key is read directly by apt via signed-by (no gpg
# dearmor step), so it keeps its .asc extension.
run_root install -m 0755 -d /etc/apt/keyrings || return 1
run_root curl -fsSL "https://download.docker.com/linux/${_repo}/gpg" -o /etc/apt/keyrings/docker.asc || return 1
run_root chmod a+r /etc/apt/keyrings/docker.asc || return 1
_arch="$(dpkg --print-architecture)"
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/%s %s stable\n' \
"$_arch" "$_repo" "$_codename" \
| run_root tee /etc/apt/sources.list.d/docker.list >/dev/null || return 1
run_root apt-get update || return 1
run_root apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin || return 1
_user="${USER:-$(id -un)}"
run_root usermod -aG docker "$_user" || return 1
# The install succeeded, but the freshly added 'docker' group membership only
# takes effect in a new login session — the daemon isn't usable in this one,
# so re-checking now would fail no matter what. Raise RESTART_REQUIRED instead:
# the driver stops the run here and asks the developer to restart and re-run.
CHECK_DETAIL="installed — restart your session to use it"
RESTART_REQUIRED="Docker is installed, but its 'docker' group only takes effect in a new login session. Log out and back in (or close and reopen your terminal), then re-run this script."
}
# Default location the official devcontainers/cli install script writes to: a
# self-contained build (bundling its own Node) under ~/.devcontainers/bin. The
# binary can therefore exist there before that directory is on PATH.
DEVCONTAINER_BIN_DIR="${HOME}/.devcontainers/bin"
# The PATH line the fix persists for the devcontainer CLI. A single constant so
# the check (path_line_persisted) and the fix (persist_path_line) test and write
# the exact same string — the same single-source-of-truth pattern as
# GITHUB_TOKEN_LINE. It is the self-guarding form: a one-line `case` that prepends
# the directory only when it is not already on PATH, so it is safe to source from
# ~/.bashrc on every interactive shell (including nested ones) without stacking
# duplicate entries. Literal $HOME/$PATH so the startup shell expands them later,
# not this script now (hence the SC2016 disable).
# shellcheck disable=SC2016
DEVCONTAINER_PATH_LINE='case ":$PATH:" in *":$HOME/.devcontainers/bin:"*) ;; *) export PATH="$HOME/.devcontainers/bin:$PATH" ;; esac'
# devcontainer CLI — installed and runnable. Satisfied when it is on the live
# PATH, or when it is installed at its default location and that directory's
# PATH line is already persisted to the startup files (a fresh login shell will
# then have it on PATH — see path_line_persisted). The latter is why a fixed
# install is not re-fixed on every run: this `curl … | sh` process cannot see
# PATH edits made to the parent shell's startup files, so have_cmd alone would
# keep reporting "not on PATH". A binary present but with no persisted PATH line
# is reported separately so the fix knows to repair PATH only.
check_devcontainer() {
_new_term=""
if have_cmd devcontainer; then
_dc="devcontainer"
elif [ -x "${DEVCONTAINER_BIN_DIR}/devcontainer" ] && path_line_persisted "$DEVCONTAINER_PATH_LINE"; then
_dc="${DEVCONTAINER_BIN_DIR}/devcontainer"
_new_term=" (open a new terminal to use it)"
elif [ -x "${DEVCONTAINER_BIN_DIR}/devcontainer" ]; then
CHECK_DETAIL="installed but ${DEVCONTAINER_BIN_DIR} is not on PATH"
return 1
else
CHECK_DETAIL="not installed"
return 1
fi
_ver="$("$_dc" --version 2>/dev/null)" || _ver=""
CHECK_DETAIL="${_ver:-installed}${_new_term}"
return 0
}
# Install the devcontainer CLI via the official script when missing, then put
# its bin directory on PATH: persisted for future bash and zsh sessions and
# exported into the current process so the immediate re-check sees the binary.
fix_devcontainer() {
if [ ! -x "${DEVCONTAINER_BIN_DIR}/devcontainer" ] && ! have_cmd devcontainer; then
if ! curl -fsSL https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh | sh; then
CHECK_DETAIL="install script failed"
return 1
fi
fi
persist_path_line "$DEVCONTAINER_PATH_LINE"
case ":${PATH}:" in
*":${DEVCONTAINER_BIN_DIR}:"*) ;;
*)
PATH="${DEVCONTAINER_BIN_DIR}:${PATH}"
export PATH
;;
esac
}
# Azure CLI — installed and actually runnable (`az version` exits 0). A broken
# install (e.g. missing Python deps) is reported as such so the fix re-installs.
check_az() {
if ! have_cmd az; then
CHECK_DETAIL="not installed"
return 1
fi
_ver="$(az version --query '"azure-cli"' -o tsv 2>/dev/null)" || _ver=""
if az version >/dev/null 2>&1; then
CHECK_DETAIL="${_ver:-installed}"
return 0
fi
CHECK_DETAIL="installed but not working; re-install to repair"
return 1
}
# Install the Azure CLI: Microsoft's Debian convenience script on Linux (adds
# their apt repo and installs the `azure-cli` package), Homebrew on macOS.
fix_az() {
case "$OS_FAMILY" in
debian)
if have_cmd sudo; then
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash || return 1
else
curl -sL https://aka.ms/InstallAzureCLIDeb | bash || return 1
fi
;;
macos)
install_pkg azure-cli azure-cli || return 1
;;
*)
CHECK_DETAIL="no automated installer for this environment"
return 1
;;
esac
}
# The plain command-line tools the workflow needs — each only has to be on PATH.
# They are checked and installed as one set, so when several are missing they are
# installed in a single package-manager command (one `brew install …` on macOS,
# one `apt-get install …` on Debian/Ubuntu) rather than one invocation per tool.
#
# Command name doubles as the Homebrew formula and the apt package for every tool
# here except sops, which has no usable apt package on Ubuntu/Debian (it ships
# only as a GitHub-release binary) — so on Linux sops is installed from that
# release instead (see install_sops_linux); on macOS `brew install sops` works.
CLI_TOOLS="git jq make sops"
# Satisfied when every tool in CLI_TOOLS is on PATH. CHECK_DETAIL summarises the
# set — the tools present, or which are missing — so this one line stands in for
# the per-tool lines it replaces.
check_cli_tools() {
_present=""
_missing=""
# shellcheck disable=SC2086 # CLI_TOOLS is a deliberate space-separated list
for _t in $CLI_TOOLS; do
if have_cmd "$_t"; then
_present="${_present}${_present:+, }${_t}"
else
_missing="${_missing}${_missing:+, }${_t}"
fi
done
if [ -n "$_missing" ]; then
CHECK_DETAIL="missing: ${_missing}"
return 1
fi
CHECK_DETAIL="${_present}"
return 0
}
# Install whatever tools in CLI_TOOLS are missing, in as few commands as
# possible: one `brew install` (macOS) or one `apt-get install` (Debian/Ubuntu)
# covering every missing tool at once. sops on Linux is the exception — it has no
# usable apt package, so it is fetched from its official release binary by
# install_sops_linux. Sets CHECK_DETAIL and returns non-zero when it cannot
# install; the driver re-runs check_cli_tools afterwards to confirm.
fix_cli_tools() {
_missing=""
# shellcheck disable=SC2086 # CLI_TOOLS is a deliberate space-separated list
for _t in $CLI_TOOLS; do
have_cmd "$_t" || _missing="${_missing}${_missing:+ }${_t}"
done
[ -n "$_missing" ] || return 0
case "$OS_FAMILY" in
macos)
if ! have_cmd brew; then
CHECK_DETAIL="Homebrew required; install it from https://brew.sh"
return 1
fi
# Command name == Homebrew formula for every tool in the set.
# shellcheck disable=SC2086 # install every missing formula in one command
brew install $_missing || return 1
;;
debian)
# Split the miss into apt packages (command name == package name) and sops,
# which has no apt package and is installed from its release binary instead.
_apt=""
_need_sops=""
for _t in $_missing; do
if [ "$_t" = "sops" ]; then
_need_sops=1
else
_apt="${_apt}${_apt:+ }${_t}"
fi
done
# One apt-get install covering every apt-installable tool that is missing.
if [ -n "$_apt" ]; then
if have_cmd sudo; then
# shellcheck disable=SC2086 # install every missing package in one command
sudo apt-get update && sudo apt-get install -y $_apt || return 1
else
# shellcheck disable=SC2086 # install every missing package in one command
apt-get update && apt-get install -y $_apt || return 1
fi
fi
if [ -n "$_need_sops" ]; then
install_sops_linux || return 1
fi
;;
*)
CHECK_DETAIL="no automated installer for this environment"
return 1
;;
esac
}
# Install sops on Debian/Ubuntu from its official GitHub release binary, because
# there is no usable sops apt package there. The latest version is read from the
# /releases/latest redirect (no API token and no JSON parsing — so this does not
# depend on jq, which may itself be installing in the same pass), the matching
# Linux binary for the machine architecture is downloaded, and it is placed on
# PATH at /usr/local/bin. Sets CHECK_DETAIL and returns non-zero on any failure.
install_sops_linux() {
_arch="$(dpkg --print-architecture 2>/dev/null)" || _arch=""
case "$_arch" in
amd64 | arm64) ;;
*)
CHECK_DETAIL="no sops release binary for architecture '${_arch:-unknown}'"
return 1
;;
esac
# /releases/latest redirects to /releases/tag/<version>; the version is the
# last path segment of the resolved URL.
_url="$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/getsops/sops/releases/latest 2>/dev/null)" || _url=""
_ver="${_url##*/}"
case "$_ver" in
v[0-9]*) ;;
*)
CHECK_DETAIL="could not determine the latest sops release"
return 1
;;
esac
_dl="https://github.com/getsops/sops/releases/download/${_ver}/sops-${_ver}.linux.${_arch}"
_tmp="$(mktemp 2>/dev/null)" || {
CHECK_DETAIL="could not create a temporary file for the sops download"
return 1
}
if ! curl -fsSL "$_dl" -o "$_tmp"; then
rm -f "$_tmp"
CHECK_DETAIL="failed to download sops ${_ver} from ${_dl}"
return 1
fi
if ! run_root install -m 0755 "$_tmp" /usr/local/bin/sops; then
rm -f "$_tmp"
CHECK_DETAIL="failed to install sops to /usr/local/bin (need write access)"
return 1
fi
rm -f "$_tmp"
}
# Visual Studio Code — the editor the Dev Containers workflow runs from. It is
# auto-installed only on Linux (Debian/Ubuntu), from Microsoft's official apt
# repository; on macOS it is not installed for you (get it from
# https://code.visualstudio.com/ or `brew install --cask visual-studio-code`).
# Detected by the `code` CLI on PATH or, on macOS, the installed .app bundle —
# VS Code on macOS ships the `code` command only after you run "Shell Command:
# Install 'code' command in PATH", so the app can be present without it.
check_vscode() {
if have_cmd code; then
_ver="$(code --version 2>/dev/null | head -n1)" || _ver=""
CHECK_DETAIL="${_ver:+v${_ver}, }installed"
return 0
fi
if [ "$OS_FAMILY" = "macos" ]; then
if [ -d "/Applications/Visual Studio Code.app" ] || [ -d "${HOME}/Applications/Visual Studio Code.app" ]; then
CHECK_DETAIL="app installed (the 'code' CLI is not on PATH)"
return 0
fi
CHECK_DETAIL="not installed — get it from https://code.visualstudio.com/ or 'brew install --cask visual-studio-code'"
return 1
fi
CHECK_DETAIL="not installed"
return 1
}
# Install VS Code on Debian/Ubuntu from Microsoft's official apt repository
# (https://code.visualstudio.com/docs/setup/linux) — the same keyring + sources
# list shape as the GitHub CLI fix above. On macOS there is no automated install:
# the developer is pointed at the download (or Homebrew cask).
fix_vscode() {
if [ "$OS_FAMILY" != "debian" ]; then
CHECK_DETAIL="install VS Code from https://code.visualstudio.com/ (or 'brew install --cask visual-studio-code')"
return 1
fi
# gpg dearmors the signing key, wget fetches it — install either if missing.
have_cmd gpg || run_root apt-get install -y gpg || {
CHECK_DETAIL="could not install gpg (needed to dearmor the VS Code signing key)"
return 1
}
have_cmd wget || run_root apt-get install -y wget || {
CHECK_DETAIL="could not install wget (needed to fetch the VS Code signing key)"
return 1
}
run_root mkdir -p -m 755 /etc/apt/keyrings || return 1
wget -qO- https://packages.microsoft.com/keys/microsoft.asc \
| gpg --dearmor \
| run_root tee /etc/apt/keyrings/packages.microsoft.gpg >/dev/null || return 1
run_root chmod go+r /etc/apt/keyrings/packages.microsoft.gpg || return 1
_arch="$(dpkg --print-architecture)"
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/packages.microsoft.gpg] https://packages.microsoft.com/repos/code stable main\n' "$_arch" \
| run_root tee /etc/apt/sources.list.d/vscode.list >/dev/null || return 1
run_root apt-get update || return 1
run_root apt-get install -y code || return 1
}
# DONT_PROMPT_WSL_INSTALL silences the prompt VS Code shows every time `code` is
# launched from a WSL shell ("install Visual Studio Code in Windows and uninstall
# the Linux version in WSL"). VS Code only tests that the variable is *defined*,
# so any value works; we export 1. It is a regular environment variable, so it
# lives in the same shell startup files as the others (zsh ~/.zshenv; bash
# ~/.bash_profile and ~/.bashrc). It is meaningful only under WSL — main()
# registers this check only there (mirroring is_wsl), so the check/fix below never
# need to re-test the platform.
WSL_CODE_PROMPT_LINE='export DONT_PROMPT_WSL_INSTALL=1'
# Satisfied when that exact line is present in every env-var file persist_env_line
# writes (~/.zshenv, ~/.bash_profile, ~/.bashrc) — the same fixed-string test
# ensure_line_in_file uses to append, so the check and the fix agree.
check_wsl_code_prompt() {
for _rc in "${HOME}/.zshenv" "${HOME}/.bash_profile" "${HOME}/.bashrc"; do
if ! grep -qF "$WSL_CODE_PROMPT_LINE" "$_rc" 2>/dev/null; then
CHECK_DETAIL="not set in ${_rc##*/}"
return 1
fi
done
CHECK_DETAIL="DONT_PROMPT_WSL_INSTALL set in your shell startup files"
return 0
}
# Persist the export line into the env-var files (idempotent append). A fixed
# value, so there is nothing to update later.
fix_wsl_code_prompt() {
persist_env_line "$WSL_CODE_PROMPT_LINE"
}
# The Azure Container Registry every developer needs pull/push access to.
ACR_NAME="uluruscacr"
# Logged in to Azure *and* able to authenticate against the ACR. The probe is
# non-interactive: `az account show` confirms an active Azure session, then
# `az acr login` exchanges that session for a registry token (it docker-logs in,
# so this also relies on Docker — checked earlier). The interactive `az login`
# lives in the fix, never here, so a check never blocks waiting for a browser.
check_acr() {
if ! have_cmd az; then
CHECK_DETAIL="Azure CLI not installed"
return 1
fi
if ! az account show >/dev/null 2>&1; then
CHECK_DETAIL="not logged in to Azure — run 'az login && az acr login --name ${ACR_NAME}', or file an /ithelp ticket for access to ACR ${ACR_NAME}"
return 1
fi
if az acr login --name "$ACR_NAME" >/dev/null 2>&1; then
CHECK_DETAIL="logged in; ACR ${ACR_NAME} accessible"
return 0
fi
CHECK_DETAIL="logged in to Azure but cannot access ACR ${ACR_NAME} — file an /ithelp ticket to request access"
return 1
}
# Run the interactive login the developer was asked for: `az login` (opens a
# browser / device-code flow on the terminal) followed by `az acr login` against
# the registry. The driver re-runs check_acr afterwards to confirm it took.
fix_acr() {
if az login && az acr login --name "$ACR_NAME"; then
return 0
fi
CHECK_DETAIL="login failed — run 'az login && az acr login --name ${ACR_NAME}', or file an /ithelp ticket for access to ACR ${ACR_NAME}"
return 1
}
# GitHub CLI — installed and authenticated. `gh auth status` exits 0 only when
# there is a usable credential, so it doubles as the login probe; the
# interactive `gh auth login` lives in the fix, never here, so a check never
# blocks waiting for a browser.
check_gh() {
if ! have_cmd gh; then
CHECK_DETAIL="not installed"
return 1
fi
if ! gh auth status >/dev/null 2>&1; then
CHECK_DETAIL="installed but not logged in — run 'gh auth login'"
return 1
fi
_ver="$(gh --version 2>/dev/null | head -n1 | cut -d' ' -f3)" || _ver=""
CHECK_DETAIL="${_ver:+v${_ver}, }logged in"
return 0
}
# Install the GitHub CLI when missing — Homebrew on macOS, the official GitHub
# apt repository on Debian/Ubuntu (the distro package lags badly) — then run the
# interactive `gh auth login`. Login input is read from /dev/tty so the prompts
# work under `curl … | sh`, where the script itself occupies stdin.
fix_gh() {
if ! have_cmd gh; then
case "$OS_FAMILY" in
macos)
install_pkg gh gh || return 1
;;
debian)
have_cmd wget || run_root apt-get install -y wget || {
CHECK_DETAIL="could not install wget (needed to fetch the GitHub CLI keyring)"
return 1
}
run_root mkdir -p -m 755 /etc/apt/keyrings || return 1
wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| run_root tee /etc/apt/keyrings/githubcli-archive-keyring.gpg >/dev/null || return 1