-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsetup.sh
More file actions
executable file
·1859 lines (1659 loc) · 86.4 KB
/
Copy pathsetup.sh
File metadata and controls
executable file
·1859 lines (1659 loc) · 86.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
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
#
# One command to go from a clone to a server you can start.
#
# ./setup.sh set everything up and write ./server
# ./setup.sh --check set up, then boot the server once and shut it down
# ./setup.sh --no-import skip the Godot import pass (fast, for a re-run)
# ./setup.sh --godot PATH use a specific runtime
# ./setup.sh --no-download never fetch a runtime; fail if there is none
# ./setup.sh --no-clone never git clone anything; fail if one is missing
# ./setup.sh --update git pull every addon and game repository first
# ./setup.sh --vendor COPY the addons instead of linking them
# ./setup.sh --addons-dir DIR take the addons from a directory you already have
# them in, rather than from this project's own clones
#
# ./setup.sh --letsencrypt --domain demo.example.com --email ops@example.com
# ...and then get a real certificate for it
#
# ./setup.sh --full THE GUIDED INSTALL. A vanilla Linux box to a server
# somebody can connect to, asking one question at a
# time with an answer already in the brackets
# ./setup.sh --full --yes the same install, every default taken, nobody typing
#
# WHAT IT DOES, AND WHY EACH STEP IS HERE
#
# 1. Finds a Godot 4.7+ runtime, and DOWNLOADS the pinned one when the machine has
# none. That download is tools/fetch-godot.sh and the verification is the whole
# of it: one pinned version, the sha512 checked into git rather than fetched
# from beside the binary, and no path through it that installs something
# unverified. `--no-download` keeps the old behaviour of refusing to fetch.
# 2. Wires in the dot-* addons. Each one is a separate repository and there is no
# way to clone the tree at once, so this is the one place that knowledge lives.
# They go INSIDE this project: a missing one is cloned into addons/.repos/ and
# addons/<name> is a relative link into it, so the whole build is one directory
# with nothing pointing out of it -- which is what a tarball, a `cp -r` and a
# container image's final stage each need. `--addons-dir DIR` takes them from a
# directory you already have instead, and links out to it; `--vendor` copies.
# 3. Copies every built-in game into the build. They are `kind: builtin`, and the
# reason is measured rather than assumed -- see content/lobby/game.yml.
# 4. Runs Godot's import pass. Without it every class_name global is unresolved,
# every cross-file type reference fails, and the whole thing looks like dozens
# of unrelated errors.
# 5. Copies cfg.example/ into cfg/, file by file, for every file cfg/ does not
# already have -- generating an RCON password ONCE if it writes cfg/rcon.yml at
# all, and printing it once. cfg/ is NOT in this repository: it is what one
# deployment decided, so tracking it made `git pull` on a running box stop on
# the operator's own edits. It never overwrites a config file that exists --
# your edits are the configuration, and regenerating on upgrade throws them away
# on the one run nobody is watching -- so it finishes by NAMING any setting the
# templates have gained that your files do not mention.
# 6. Copies export_presets.example.cfg into export_presets.cfg when there is none,
# for the same reason and with the same rule: the editor rewrites that file, so
# it is not tracked either -- and an export preset nobody has is what made
# `./server export-web` fail on a fresh machine for a preset that existed only
# where somebody had made one by hand.
# 7. Writes ./server.
# 8. ONLY WITH --letsencrypt: runs deploy/issue-letsencrypt.sh for a real
# certificate. Opt-in and never implied, because it is the one step here that
# needs root, needs the internet, and can be RATE LIMITED -- five failed
# validations on one hostname locks that name out for an hour, so a setup that
# tried it on every run would punish the re-run that is otherwise free. Every
# argument after `--` goes to that script untouched.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT" || exit 1
GODOT_ARG=""
DO_IMPORT=1
# A box with no network, or a policy that says binaries arrive one way and it is not
# this one. TMC_NO_DOWNLOAD is the same switch for a unit file or a CI job, which
# cannot add an argument to a line somebody else wrote.
DO_DOWNLOAD=1
[ -n "${TMC_NO_DOWNLOAD:-}" ] && DO_DOWNLOAD=0
# Clone the siblings this project needs.
#
# [b]On by default, and it only ever runs when the alternative is failing.[/b] Nothing is
# cloned for a repository that is already beside this one, and nothing is cloned for an
# addon already vendored into ./addons/ -- which is what a release tarball is, and it
# must keep working with no network at all. So the only run that writes anything into
# the parent directory is the run that would otherwise have stopped and printed fifty
# names at somebody.
DO_CLONE=1
# Pull every sibling repository before wiring them in.
#
# OFF by default: `git pull` on somebody's checkout is not a thing to do because they
# typed the usual command, and a developer's tree is often mid-change on purpose.
DO_UPDATE=0
[ -n "${TMC_NO_CLONE:-}" ] && DO_CLONE=0
DO_CHECK=0
VENDOR=0
# Where the dot-* addons come from.
#
# [b]Empty is the default, and it means "inside this project".[/b] An addon that is not
# here is cloned into addons/.repos/<repo> and addons/<name> becomes a RELATIVE link
# into it, so the finished build is one directory with nothing pointing out of it: it
# can be moved, tarred, `cp -r`d or COPYed into a container's final stage and still
# resolve. The old shape -- fifty repositories in the PARENT directory -- wrote into a
# directory this project does not own, which on a box running several servers is one
# clone shared by all of them with nothing saying so.
#
# A directory named here is used instead, and may be either shape: a directory OF
# addons (`DIR/dot_core`, which is what somebody's hand-kept library or another Godot
# project's addons/ looks like) or a directory of the REPOSITORIES
# (`DIR/dot-core/addons/dot_core`, which is what dot-bootstrap and a developer checkout
# look like). Found there, it is linked; not found there, it is cloned there. That is
# the point of a shared directory: `--addons-dir ..` is exactly what this script used
# to do, and one directory serves every server on the box.
ADDONS_DIR="${TMC_ADDONS_DIR:-}"
# The guided install. Everything it decides is asked for, and everything it asks has
# a default, so --full --yes is the same install with nobody typing.
DO_FULL=0
ASSUME_YES=0
# TLS. Nothing here happens without --letsencrypt; the rest only says what.
DO_LETSENCRYPT=0
LE_DOMAINS=()
LE_EMAIL="${TMC_LE_EMAIL:-}"
LE_METHOD="${TMC_LE_METHOD:-}"
LE_STAGING=0
LE_EXTRA=()
while [ $# -gt 0 ]; do
case "$1" in
--godot) GODOT_ARG="${2:-}"; shift 2 ;;
--no-import) DO_IMPORT=0; shift ;;
--no-download) DO_DOWNLOAD=0; shift ;;
--no-clone) DO_CLONE=0; shift ;;
--update) DO_UPDATE=1; shift ;;
--check) DO_CHECK=1; shift ;;
--vendor) VENDOR=1; shift ;;
--addons-dir) ADDONS_DIR="${2:?--addons-dir needs a value}"; shift 2 ;;
--letsencrypt) DO_LETSENCRYPT=1; shift ;;
--domain) LE_DOMAINS+=("${2:?--domain needs a value}"); shift 2 ;;
--email) LE_EMAIL="${2:?--email needs a value}"; shift 2 ;;
--tls-method) LE_METHOD="${2:?--tls-method needs a value}"; shift 2 ;;
--staging) LE_STAGING=1; shift ;;
--full) DO_FULL=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
# Everything after a bare `--` belongs to issue-letsencrypt.sh. That script
# has twenty options and this one is not going to grow a copy of each: a
# wrapper that re-declares the arguments it forwards is a second list to keep
# in step, and the half that drifts is always the one nobody uses often.
--) shift; LE_EXTRA=("$@"); break ;;
# 2..62 is the comment block at the top of this file, printed as help so
# there is one copy of it rather than two that drift. The range moves when
# that block grows; it ends at the last line of step 8.
-h|--help) sed -n '2,62p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) printf 'unknown option: %s\n' "$1" >&2; exit 2 ;;
esac
done
RED=$'\033[31m'; GRN=$'\033[32m'; YLW=$'\033[33m'; DIM=$'\033[2m'; BLD=$'\033[1m'; OFF=$'\033[0m'
step() { printf '\n%s==>%s %s\n' "$BLD" "$OFF" "$1"; }
ok() { printf ' %sok%s %s\n' "$GRN" "$OFF" "$1"; }
warn() { printf ' %s!!%s %s\n' "$YLW" "$OFF" "$1"; }
die() { printf '\n %s%s%s\n\n' "$RED" "$1" "$OFF" >&2; exit "${2:-1}"; }
# --- Asking, and the rules it follows --------------------------------------
#
# Three of them, and they are what separates an installer from a script that runs
# commands at you:
#
# Every question has a DEFAULT, and the default is what is already true -- the
# value in cfg/ if this box has one, the hostname if it has one of those. Pressing
# return through the whole thing is a supported way to install this.
#
# NOTHING IS DONE UNTIL EVERY QUESTION IS ANSWERED. The answers are collected, the
# plan is printed, and one confirmation covers the lot. An installer that acts on
# answer three while asking answer four cannot be stopped at answer five, and half
# an install is worse than none.
#
# --yes takes every default and asks nothing, so the same install runs from a
# provisioning script. A prompt read with no terminal attached is a hang, not a
# question, which is why this is a flag and not a guess about stdin.
# The prompt goes to STDERR because the answer comes back through a command
# substitution, and a prompt printed on stdout would be captured as part of it.
ask() {
local q="$1" d="$2" a=""
if [ "$ASSUME_YES" -eq 1 ]; then printf '%s' "$d"; return 0; fi
printf ' %s%s%s %s[%s]%s ' "$BLD" "$q" "$OFF" "$DIM" "$d" "$OFF" >&2
IFS= read -r a || a=""
printf '%s' "${a:-$d}"
}
# ask_yn "Question" y -> 0 for yes, 1 for no. The capital in the brackets is the
# default, the way every package manager has shown it for twenty years.
ask_yn() {
local q="$1" d="$2" a="" hint
case "$d" in [Yy]*) hint="Y/n" ;; *) hint="y/N" ;; esac
if [ "$ASSUME_YES" -eq 1 ]; then
case "$d" in [Yy]*) return 0 ;; *) return 1 ;; esac
fi
while :; do
printf ' %s%s%s %s[%s]%s ' "$BLD" "$q" "$OFF" "$DIM" "$hint" "$OFF" >&2
IFS= read -r a || a=""
[ -z "$a" ] && a="$d"
case "$a" in
[Yy]|[Yy][Ee][Ss]) return 0 ;;
[Nn]|[Nn][Oo]) return 1 ;;
*) printf ' %sanswer y or n%s\n' "$YLW" "$OFF" >&2 ;;
esac
done
}
PLAN=()
plan() { PLAN+=("$1"); }
# Read a value out of a YAML file so a question can offer what is already there.
# The comment is stripped AFTER the quoted value rather than at the first `#`,
# because `sv_name: "Server #1"` is a legal name and the naive version renames it.
cfg_get() {
local file="$1" key="$2" line v
[ -f "$file" ] || return 1
line="$(grep -m1 -E "^${key}:" "$file" 2>/dev/null)" || return 1
v="${line#*:}"
v="${v#"${v%%[![:space:]]*}"}"
case "$v" in
'"'*) v="${v#\"}"; v="${v%%\"*}" ;;
*) v="${v%%#*}"; v="${v%"${v##*[![:space:]]}"}" ;;
esac
printf '%s' "$v"
}
# What cfg/ already says, or a default when it says nothing. Every question in the
# guided install is built on this: the answer in the brackets is the answer that is
# already true, so a re-run changes nothing unless somebody types something.
cfg_or() {
local v
v="$(cfg_get "$1" "$2" 2>/dev/null)"
[ -n "$v" ] && printf '%s' "$v" || printf '%s' "$3"
}
# Write one key back, keeping the line's comment and the file's mode. `cat >` rather
# than `mv`, because cfg/rcon.yml is 0600 and mv would give it the temporary file's
# permissions -- which is how a password becomes world-readable.
cfg_set() {
local file="$1" key="$2" value="$3" tmp
[ -f "$file" ] || return 1
# A key the file has never heard of is APPENDED, not refused. cfg/ is written
# once and then belongs to the operator, so a server set up a year ago has the
# keys of a year ago -- and this box's cfg/server.yml really is missing
# sv_tickrate, which step 5 reports and nobody has acted on. Refusing there
# means the installer asked a question, was answered, and then quietly did
# nothing with it, which is worse than either doing it or not asking.
if ! grep -qE "^${key}:" "$file"; then
# A file whose last line has no newline -- cfg.example/net.yml ends in a bare
# "# Performance" comment and this is not hypothetical -- would otherwise get
# the new key glued onto the end of that line, where it is a comment and does
# nothing. In command substitution a trailing newline is stripped, so this
# test is empty exactly when the last byte IS one.
[ -n "$(tail -c 1 "$file")" ] && printf '\n' >> "$file"
printf '%s: %s\n' "$key" "$value" >> "$file" || return 1
return 0
fi
tmp="$(mktemp)" || return 1
awk -v k="$key" -v v="$value" '
!done && index($0, k ":") == 1 {
rest = substr($0, length(k) + 2)
c = ""
if (rest ~ /^[[:space:]]*"/) {
q = index(substr(rest, index(rest, "\"") + 1), "\"")
tail = substr(rest, index(rest, "\"") + q + 1)
if (index(tail, "#") > 0) c = " " substr(tail, index(tail, "#"))
} else if (index(rest, "#") > 0) {
c = " " substr(rest, index(rest, "#"))
}
print k ": " v c
done = 1
next
}
{ print }
' "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
cat "$tmp" > "$file"
rm -f "$tmp"
}
# Who is listening on a port, if anybody. Two questions rather than one: `ss` prints
# the process column only to root, so an unprivileged look finds nothing on a port
# that is very much taken -- and answering "free" there is how an installer writes a
# vhost that stops nginx from starting.
#
# Prints the holder's name, or nothing. Returns 0 when something is listening even if
# it could not be named, so a caller can tell "free" from "busy, unknown".
port_holder() {
local port="$1" listening="" holder=""
if command -v ss >/dev/null 2>&1; then
ss -ltnH 2>/dev/null | awk -v p=":$port\$" '$4 ~ p {found=1} END {exit !found}' && listening=1
holder="$($SUDO ss -ltnpH 2>/dev/null \
| awk -v p=":$port\$" '$4 ~ p && match($0, /users:\(\("[^"]+"/) {
print substr($0, RSTART + 9, RLENGTH - 10); exit }')"
elif command -v lsof >/dev/null 2>&1; then
holder="$($SUDO lsof -nP -iTCP:"$port" -sTCP:LISTEN -Fc 2>/dev/null | sed -n 's/^c//p' | head -1)"
[ -n "$holder" ] && listening=1
fi
[ -n "$holder" ] && printf '%s' "$holder"
[ -n "$listening" ]
}
# /usr/sbin is not on a normal user's PATH, so `command -v nginx` answers "not
# installed" for the very user who is about to install a second copy of it.
find_nginx() {
local c
for c in nginx /usr/sbin/nginx /sbin/nginx /usr/local/sbin/nginx; do
command -v "$c" >/dev/null 2>&1 && { command -v "$c"; return 0; }
done
return 1
}
# Root, and how much of it this box will give us. Everything the guided install does
# outside this directory -- packages, nginx, a certificate, a unit file, a firewall
# rule -- needs it, and finding that out at the end is finding it out too late.
SUDO=""
CAN_ROOT=1
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then
SUDO="sudo"
elif command -v sudo >/dev/null 2>&1 && [ "$ASSUME_YES" -eq 0 ] && [ -t 0 ]; then
# sudo WITHOUT -n sits waiting for a password. That is a prompt when somebody is
# here to answer it and a hang when nobody is, so it is allowed only in the first
# case -- which is the one --full is for.
SUDO="sudo"
else
CAN_ROOT=0
fi
# --- 0. The TLS arguments, checked here and used at the end ----------------
#
# Checked BEFORE the first step rather than beside the step that uses them, because
# the certificate is the LAST thing this script does and everything before it takes
# minutes: a download, fifty repositories, an import pass. `--letsencrypt` with a
# misspelled flag and no --domain should cost a second, not a coffee, and the
# version of this that validated in place told people so four minutes in.
LE_SCRIPT="$ROOT/deploy/issue-letsencrypt.sh"
# --full answers these itself, and anything given on the command line becomes the
# default it offers rather than a contradiction.
if [ "$DO_LETSENCRYPT" -eq 0 ] && [ "$DO_FULL" -eq 0 ]; then
if [ "${#LE_DOMAINS[@]}" -gt 0 ] || [ -n "$LE_METHOD" ] || [ "$LE_STAGING" -eq 1 ] \
|| [ "${#LE_EXTRA[@]}" -gt 0 ]; then
die "--domain, --email, --tls-method, --staging and -- are for --letsencrypt,
and it was not given. Add --letsencrypt (or --full), or drop them." 2
fi
elif [ "$DO_LETSENCRYPT" -eq 1 ]; then
[ -x "$LE_SCRIPT" ] || die "--letsencrypt needs deploy/issue-letsencrypt.sh, and it is
not here (or not executable). This is not a complete checkout." 1
[ "${#LE_DOMAINS[@]}" -gt 0 ] || die "--letsencrypt needs at least one --domain" 2
fi
if [ "$DO_FULL" -eq 1 ]; then
for f in deploy/issue-letsencrypt.sh deploy/install-server-tls.sh deploy/install-systemd.sh; do
[ -x "$ROOT/$f" ] || die "--full needs $f, and it is not here (or not executable).
This is not a complete checkout." 1
done
fi
# --- 0b. The guided install, which is all questions and no actions ---------
if [ "$DO_FULL" -eq 1 ]; then
if [ ! -t 0 ] && [ "$ASSUME_YES" -eq 0 ]; then
die "--full asks questions and stdin is not a terminal.
Run it from a terminal, or add --yes to take every default." 2
fi
printf '\n%s A server on this machine, in about ten questions.%s\n' "$BLD" "$OFF"
printf ' %sReturn takes the answer in the brackets. Nothing happens until the end.%s\n\n' "$DIM" "$OFF"
# --- what the server is ---
FULL_NAME="$(ask "Server name" "$(cfg_or cfg/server.yml sv_name 'TMC Test Server')")"
# The games are copied out of the sibling repositories in step 3, so on a first
# run this directory holds the lobby and nothing else. Offering what is here is
# therefore a hint rather than the list -- and the answer is checked again after
# the copy, where the real list exists.
installed_games=""
for gy in content/*/game.yml; do
[ -f "$gy" ] || continue
gid="${gy#content/}"; gid="${gid%/game.yml}"
installed_games="$installed_games $gid"
done
[ -n "$installed_games" ] && printf ' %sgames here now:%s%s\n' "$DIM" "$OFF" "$installed_games" >&2
FULL_GAME="$(ask "Game to boot" "$(cfg_or cfg/server.yml sv_game lobby)")"
FULL_MAXPLAYERS="$(ask "Player slots" "$(cfg_or cfg/server.yml sv_maxplayers 64)")"
FULL_TICKRATE="$(ask "Tickrate" "$(cfg_or cfg/server.yml sv_tickrate 60)")"
FULL_PORT="$(ask "Port the server listens on" "$(cfg_or cfg/net.yml net_port 6064)")"
case "$FULL_MAXPLAYERS" in ''|*[!0-9]*) die "player slots must be a number: $FULL_MAXPLAYERS" 2 ;; esac
case "$FULL_TICKRATE" in ''|*[!0-9]*) die "tickrate must be a number: $FULL_TICKRATE" 2 ;; esac
case "$FULL_PORT" in ''|*[!0-9]*) die "port must be a number: $FULL_PORT" 2 ;; esac
# --- nginx, TLS, and why they are one question ---
#
# A browser on an HTTPS page may not open a plain ws:// socket, so a web client
# needs wss://, which needs a certificate, which needs something in front of the
# server to terminate it. That is one decision, not three, and asking it as three
# is how somebody ends up with a certificate and nothing using it.
FULL_NGINX=0
FULL_PUBLIC_PORT=""
if [ "$CAN_ROOT" -eq 0 ]; then
warn "no root on this box, so nginx, a certificate, a service and the firewall
are all out of reach. Setting the project up only."
else
printf '\n'
if ask_yn "Put nginx in front of it, so browsers can connect over wss://?" y; then
FULL_NGINX=1
DO_LETSENCRYPT=1
guess=""
if command -v hostname >/dev/null 2>&1; then
guess="$(hostname -f 2>/dev/null || hostname 2>/dev/null)"
fi
case "$guess" in
''|localhost|*.local|*.localdomain|*[!a-zA-Z0-9.-]*) guess="" ;;
*.*) ;;
*) guess="" ;;
esac
if [ "${#LE_DOMAINS[@]}" -eq 0 ]; then
d="$(ask "Public hostname clients will connect to" "${guess:-demo.example.com}")"
LE_DOMAINS=("$d")
fi
[ -n "$LE_EMAIL" ] || LE_EMAIL="$(ask "Email for the certificate (expiry warnings)" "")"
[ -n "$LE_METHOD" ] || LE_METHOD="$(ask "Certificate method (webroot, nginx, standalone, dns, manual)" webroot)"
# 443 unless something that is not nginx already has it. It is the port
# that survives a corporate firewall, and a game on a high port is the one
# thing a player on an office network cannot reach.
#
# nginx ALREADY being on 443 is not a reason to avoid it: a second server
# block on the same port with a different name is exactly what SNI is
# for, and a box that already serves a website over HTTPS is the common
# case rather than the awkward one. Anything else holding it is a
# different matter, because nginx cannot have it at all.
# The three cases have to be the same three the check below uses. An
# earlier version asked only whether a NAME came back, so a port that was
# busy but unnameable -- which is every port when this run cannot see the
# process column -- looked free, 443 was offered as the default, and the
# check two lines down then refused the answer the script had just
# suggested. A default that its own validator rejects is worse than no
# default at all.
pp_default=6065
pp_holder="$(port_holder 443)"
if [ $? -ne 0 ] || [ "$pp_holder" = "nginx" ]; then
pp_default=443
fi
FULL_PUBLIC_PORT="$(ask "Public TLS port" "$pp_default")"
case "$FULL_PUBLIC_PORT" in ''|*[!0-9]*) die "public port must be a number: $FULL_PUBLIC_PORT" 2 ;; esac
[ "$FULL_PUBLIC_PORT" = "$FULL_PORT" ] && die "the public TLS port and the server's own port cannot both be $FULL_PORT.
nginx listens on the first and forwards to the second." 2
# [b]This box may already be doing something, and this is where that stops
# being a surprise.[/b] A vhost written for a port another PROCESS holds
# does not fail when it is written -- it fails the next time nginx is
# restarted, which can be weeks later and by somebody else entirely. nginx
# holding it is fine and normal: a second server block on the same port
# with a different name is what SNI is for.
# And the one way sharing a port goes wrong: the same NAME twice. Two
# server blocks with one server_name on one port is a "conflicting server
# name" warning from nginx, after which the first one wins and the new one
# is silently never used -- a vhost that was installed, tested, reloaded
# and does nothing.
if nginx_bin="$(find_nginx)" \
&& $SUDO "$nginx_bin" -T 2>/dev/null \
| grep -qE "^[[:space:]]*server_name[^;]*[[:space:]]${LE_DOMAINS[0]}([[:space:];]|$)"; then
warn "nginx already has a server block naming ${LE_DOMAINS[0]}.
If it is on the same port, nginx keeps the first and ignores the new one.
A name of its own for the game -- play.yourdomain, say -- avoids the whole
question."
fi
FULL_PORT_SHARED=""
if holder="$(port_holder "$FULL_PUBLIC_PORT")"; then
case "${holder:-unknown}" in
nginx) FULL_PORT_SHARED=1 ;;
unknown) die "something is already listening on $FULL_PUBLIC_PORT and this run
cannot see what. Re-run with sudo, or pick another port." 1 ;;
*) die "$holder is already listening on $FULL_PUBLIC_PORT.
nginx cannot have that port as well, and a vhost written for it would break the
next nginx restart rather than this run. Pick another public port." 1 ;;
esac
fi
fi
# --- the service ---
printf '\n'
FULL_SYSTEMD=0
if command -v systemctl >/dev/null 2>&1; then
if ask_yn "Install a systemd service, so it starts on boot?" y; then
FULL_SYSTEMD=1
FULL_UNIT="$(ask "Unit name" "dot-server")"
FULL_RUN_USER="$(ask "Run the server as" "$(stat -c '%U' "$ROOT" 2>/dev/null || echo root)")"
id "$FULL_RUN_USER" >/dev/null 2>&1 || die "no such user: $FULL_RUN_USER" 2
fi
fi
# --- the firewall ---
#
# Only offered when this box HAS one that is switched on. Asking about ufw on
# a machine with no ufw is a question whose every answer is wrong, and
# opening ports in a firewall nobody enabled is a change with no effect that
# still shows up in somebody's audit.
FULL_FIREWALL=""
if command -v ufw >/dev/null 2>&1 && $SUDO ufw status 2>/dev/null | grep -qi '^Status: active'; then
FULL_FIREWALL=ufw
elif command -v firewall-cmd >/dev/null 2>&1 && $SUDO firewall-cmd --state 2>/dev/null | grep -q running; then
FULL_FIREWALL=firewalld
fi
if [ -n "$FULL_FIREWALL" ]; then
printf '\n'
ask_yn "Open the ports in $FULL_FIREWALL?" y || FULL_FIREWALL=""
fi
fi
# --- the plan ---
#
# Printed in full and confirmed once. This is the last moment at which nothing
# has happened, and it is the only screen in the install that matters.
plan "set up the project: runtime, addons, games, cfg/, ./server"
plan "cfg/server.yml: $FULL_NAME, game $FULL_GAME, $FULL_MAXPLAYERS slots, $FULL_TICKRATE tick"
if [ "$FULL_NGINX" -eq 1 ]; then
plan "cfg/net.yml: port $FULL_PORT, bound to 127.0.0.1 (nginx is the way in)"
if find_nginx >/dev/null 2>&1; then
plan "use the nginx that is already on this box, adding one server block to it"
else
plan "install nginx"
fi
command -v certbot >/dev/null 2>&1 || plan "install certbot"
[ -n "$FULL_FIREWALL" ] && plan "open 80 and $FULL_PUBLIC_PORT in $FULL_FIREWALL"
# `${LE_STAGING:+...}` is wrong here and read right for a whole test run:
# LE_STAGING is 0 or 1, and :+ fires on a value being SET rather than being
# true -- so "0" took the branch and the plan promised a staging certificate
# for every install.
staging_note=""
[ "$LE_STAGING" -eq 1 ] && staging_note=" (staging)"
plan "get a certificate for ${LE_DOMAINS[*]} over ${LE_METHOD:-webroot}$staging_note"
if [ -n "$FULL_PORT_SHARED" ]; then
plan "nginx: wss://${LE_DOMAINS[0]}:$FULL_PUBLIC_PORT -> 127.0.0.1:$FULL_PORT (nginx is already on that port; this adds a name to it)"
else
plan "nginx: wss://${LE_DOMAINS[0]}:$FULL_PUBLIC_PORT -> 127.0.0.1:$FULL_PORT"
fi
else
plan "cfg/net.yml: port $FULL_PORT, bound to 0.0.0.0"
[ -n "$FULL_FIREWALL" ] && plan "open $FULL_PORT in $FULL_FIREWALL"
fi
[ "${FULL_SYSTEMD:-0}" -eq 1 ] && plan "install and start the ${FULL_UNIT}.service unit, running as $FULL_RUN_USER"
printf '\n%s This is the whole of it:%s\n\n' "$BLD" "$OFF"
for line in "${PLAN[@]}"; do printf ' %s-%s %s\n' "$GRN" "$OFF" "$line"; done
printf '\n'
if ! ask_yn "Go ahead?" y; then
printf '\n nothing was done.\n\n'
exit 0
fi
fi
# --- 1. The runtime --------------------------------------------------------
step "Godot runtime"
# The pinned version lives in tools/fetch-godot.sh, with the digest that proves it.
PINNED="$(tools/fetch-godot.sh --version 2>/dev/null)"
PINNED_CACHE="${TMC_GODOT_CACHE:-${XDG_CACHE_HOME:-${HOME:-/nonexistent}/.cache}/tmc/godot}/${PINNED:-none}/godot"
# Is this thing a runtime new enough to build with? Prints the version when it is,
# nothing when it is not -- so a caller can test one candidate without dying on it.
godot_version_ok() {
local v
v="$("$1" --version 2>/dev/null | head -1)"
case "$v" in
4.[7-9]*|4.[1-9][0-9]*|5.*) printf '%s' "$v" ;;
*) return 1 ;;
esac
}
# An explicit --godot is not a suggestion. A wrong one that silently became a
# download would be a script quietly ignoring the argument it was given, and the
# operator would never learn that the runtime they meant to test was not the one
# that ran.
if [ -n "$GODOT_ARG" ]; then
if command -v "$GODOT_ARG" >/dev/null 2>&1; then GODOT="$(command -v "$GODOT_ARG")"
elif [ -x "$GODOT_ARG" ]; then GODOT="$GODOT_ARG"
else die "no runtime at $GODOT_ARG" 3
fi
VERSION="$(godot_version_ok "$GODOT")" \
|| die "Godot 4.7 or newer is required; $GODOT is $("$GODOT" --version 2>&1 | head -1)" 3
ok "$GODOT ($VERSION)"
else
# The cache first, then PATH. The downloaded one is checked BEFORE `godot` on
# PATH because it is the version this project is pinned to and the one on PATH
# is whatever the box happens to have -- and if the box's is fine, it was found
# on the first run and no download ever happened.
GODOT=""
VERSION=""
for candidate in "$PINNED_CACHE" "$ROOT/.godot-runtime/${PINNED:-none}/godot" godot godot4 Godot; do
[ -n "$candidate" ] || continue
resolved=""
if command -v "$candidate" >/dev/null 2>&1; then resolved="$(command -v "$candidate")"
elif [ -x "$candidate" ]; then resolved="$candidate"
fi
[ -n "$resolved" ] || continue
if VERSION="$(godot_version_ok "$resolved")"; then GODOT="$resolved"; break; fi
# Found and too old. Remembered rather than reported now: it only matters if
# nothing better turns up, and "4.4 is too old" above "downloaded 4.7.2" is
# an error message about a thing that did not go wrong.
TOO_OLD="$resolved ($("$resolved" --version 2>&1 | head -1))"
done
if [ -n "$GODOT" ]; then
ok "$GODOT ($VERSION)"
elif [ "$DO_DOWNLOAD" -eq 0 ]; then
die "No Godot 4.7+ runtime found${TOO_OLD:+ ($TOO_OLD is too old)}, and downloading is off (--no-download / TMC_NO_DOWNLOAD).
Install Godot 4.7 or newer and put it on PATH, or:
./setup.sh --godot /path/to/godot" 3
else
# [b]This used to be where setup.sh gave up[/b], on the grounds that fetching a
# binary means verifying it and that is a different program with different
# risks. The reasoning was right and the conclusion was backwards: it made
# every fresh box a manual download before anything could be tried, and the
# verification it was avoiding is thirty lines. tools/fetch-godot.sh IS that
# different program -- one pinned version, digests checked into git rather
# than fetched beside the binary, and no path through it that installs
# something unverified.
[ -n "${TOO_OLD:-}" ] && warn "$TOO_OLD is too old"
[ -x tools/fetch-godot.sh ] || die "tools/fetch-godot.sh is missing or not executable" 3
warn "no Godot 4.7 or newer on this machine; fetching the pinned ${PINNED:-runtime}"
GODOT="$(tools/fetch-godot.sh)" || exit 3
VERSION="$(godot_version_ok "$GODOT")" || die "the fetched runtime does not report a usable version" 3
ok "$GODOT ($VERSION)"
fi
fi
# --- Cloning the siblings --------------------------------------------------
#
# [b]Every dot-* project is its own repository and there is no way to clone the tree at
# once.[/b] That is a deliberate shape -- an addon is installable on its own -- and it
# means a fresh machine that has cloned only THIS repository is fifty clones away from a
# build. The message that used to end the run listed all fifty names and offered no way
# to act on it.
#
# [b]This invents no list.[/b] The repository name is the addon name with underscores
# turned into hyphens, which this script already relies on to find them, and the games
# are already named by repository. A second list is this tree's most repeated bug; there
# is not one here.
#
# HTTPS, not SSH. `dot-bootstrap` defaults to `git@github.com:` because it runs on a
# developer's machine with a key loaded; the machine this flag is for is a fresh server
# where that is the one thing not configured. These repositories are public, so HTTPS
# needs no credential at all.
GIT_BASE="${TMC_GIT_BASE:-https://github.com/modcommunity}"
## Fast-forward every named repository that is already on this machine.
##
## [b]It is given DIRECTORIES, not names, and that is what makes one pull find them.[/b]
## An addon can be in this project's own addons/.repos/, in a --addons-dir somebody
## shares between servers, or beside this repository because an older setup.sh wired it
## there -- and a checkout that is pulled only when it happens to be in the layout this
## script was written for is a fix that is installed and still not running.
##
## [b]Each addon is its own clone, and `git pull` here pulls only this one.[/b] That is
## the shape of the family -- fifty-odd repositories, installable separately -- and on a
## server it is a trap: an operator pulls the deploy repo, re-runs setup, and is still
## running last week's dot-cloud, because nothing told them the fix was in a sibling.
## The symptom is a bug that is fixed upstream, fixed in the tree they pulled, and still
## happening, with a stack trace whose line numbers no longer match any file they can
## see. It cost a real afternoon.
##
## `--ff-only`, never a merge: this is a deploy box, and a setup script that can produce
## a conflicted working tree is a setup script that can take a server down. A repository
## with local changes or a diverged branch is reported and skipped, because on the one
## machine where somebody HAS edited an addon in place, quietly discarding it would be
## the worse failure.
update_repos() {
command -v git >/dev/null 2>&1 || die "--update needs git, and this machine has none." 4
local repo dir behind=() failed=()
for dir in "$@"; do
repo="$(basename "$dir")"
[ -d "$dir/.git" ] || continue
if [ -n "$(git -C "$dir" status --porcelain 2>/dev/null)" ]; then
printf ' %s..%s %s (local changes; left alone)\n' "$YLW" "$OFF" "$repo"
continue
fi
# [b]Most of these repositories have no upstream configured.[/b] `git pull`
# with no tracking branch fails with "no upstream configured for branch
# 'main'", which is not a failure to update -- it is a clone that was never
# told where it came from, which is most of them here. So the remote and
# branch are named explicitly, exactly as `push-github.sh` names the current
# branch rather than assuming main.
local branch
branch="$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null)"
if [ -z "$branch" ] || [ "$branch" = "HEAD" ]; then
failed+=("$repo")
continue
fi
if git -C "$dir" pull --ff-only --quiet origin "$branch" 2>/dev/null; then
behind+=("$repo")
else
failed+=("$repo")
fi
done
ok "${#behind[@]} up to date"
if [ ${#failed[@]} -gt 0 ]; then
warn "could not fast-forward: ${failed[*]}"
warn "a diverged branch or no upstream; pull those by hand"
fi
}
## Clone every named repository that is not already in the given directory.
##
## The destination is an argument because there are three of them now: this project's
## own addons/.repos/, a shared --addons-dir, and the parent directory, which is still
## where the GAMES go -- they are content that gets published into dist/ rather than
## code this project compiles, and a game repository under addons/ would be imported as
## part of this project, which is the arrangement the flip to packs removed.
##
## Never touches a checkout that exists -- not even to pull. A setup script that
## silently updated somebody's working tree would be a setup script that can lose work.
clone_repos() {
local dest="$1"; shift
command -v git >/dev/null 2>&1 || die "cloning needs git, and this machine has none." 4
local wanted=("$@") missing=() repo failed=()
for repo in "${wanted[@]}"; do
[ -d "$dest/$repo" ] || missing+=("$repo")
done
[ ${#missing[@]} -gt 0 ] || return 0
mkdir -p "$dest" || die "could not create $dest" 4
printf ' %scloning %d repositories into %s%s\n' \
"$DIM" "${#missing[@]}" "$(cd "$dest" && pwd)" "$OFF"
# [b]Shallow by default.[/b] This path exists to stand a SERVER up: fifty repositories
# of history is bandwidth and disk nobody on that box will ever read, and it is most
# of what the clone costs. `dot-bootstrap` is the developer tool and clones in full.
# TMC_GIT_DEPTH=0 turns this off; `git fetch --unshallow` fixes one after the fact.
local depth=()
[ "${TMC_GIT_DEPTH:-1}" = "0" ] || depth=(--depth "${TMC_GIT_DEPTH:-1}")
for repo in "${missing[@]}"; do
if git clone --quiet "${depth[@]}" "$GIT_BASE/$repo.git" "$dest/$repo" 2>/dev/null; then
printf ' %s+%s %s\n' "$GRN" "$OFF" "$repo"
else
# Collected rather than fatal. One repository that is not published yet --
# which happens, because this list is edited when an addon is written and
# pushed some time after -- should not stop the other forty-nine.
failed+=("$repo")
printf ' %s!!%s %s\n' "$YLW" "$OFF" "$repo"
fi
done
if [ ${#failed[@]} -gt 0 ]; then
warn "could not clone: ${failed[*]}"
warn "check the names, or that they are published, then re-run"
fi
}
# --- 2. The addons ---------------------------------------------------------
#
# name:repository. The repository is the name with underscores turned into hyphens,
# which holds for every one of them and is asserted rather than assumed below.
# dot_stats is here because hungry's module declares its statistics through
# DotStatsSchema. It was missing, and could not be noticed: the vendored copy of
# hungry predated that code by nine days, and the guard against a stale copy --
# tools/check.sh -- named the game repositories by their OLD names too, so it reported
# "no game repositories beside this one" on a machine where all of them were and
# compared nothing. The first `setup.sh` that copied the current hungry turned every
# type reference in its module into a parse error, and a module that will not parse is
# a module that does not load: `changelevel hungry_classic` swapped the world and left
# the lobby's module driving it.
# Every addon any vendored game names. A game that gains a dependency and is not added
# here vendors, imports, and then fails to compile every script that names the missing
# class — dozens of "not declared in the current scope" errors in files nobody touched,
# which reads as a broken project rather than as one missing folder.
ADDONS=(dot_core dot_net dot_server dot_server_query dot_server_security dot_2d dot_ui dot_auth dot_cloud dot_user
dot_user_avatar dot_platform dot_loadout dot_match
dot_player_controller dot_timer dot_map dot_leaderboard dot_stats
dot_props dot_vote dot_combat dot_chat dot_voice dot_moderation
dot_browser dot_npc dot_npc_ai dot_npc_ai_director dot_vehicle
dot_achievements dot_objective dot_effects dot_spectate dot_economy
dot_settings dot_console dot_audio dot_fx dot_lighting
dot_procedural_generation dot_inventory dot_peer_to_peer dot_weapon
dot_physics dot_spawn dot_team dot_player dot_player_class
dot_player_char)
step "dot-* addons"
mkdir -p addons
# This project's own clones, and the default home of every addon.
#
# [b]Under addons/, and hidden.[/b] Each of these is a whole repository with its own
# addons/<name> inside it, so it must not be walked as part of this project: a leading
# dot is skipped by Godot's scanner, and a .gdignore is written into it as well because
# that is the documented switch and the other is a behaviour. Without either, every
# script arrives twice -- once here and once through the link beside it -- and the
# import pass fails on class_name globals that are somehow already declared.
ADDONS_REPOS="$ROOT/addons/.repos"
if [ -n "$ADDONS_DIR" ]; then
# Through a second variable, because the assignment happens before the `||` is
# reached: writing straight into ADDONS_DIR empties it on the failing path and the
# message then names no directory at all.
ADDONS_DIR_ABS="$(cd "$ADDONS_DIR" 2>/dev/null && pwd)" \
|| die "--addons-dir: no such directory: $ADDONS_DIR" 2
ADDONS_DIR="$ADDONS_DIR_ABS"
fi
# Where a MISSING addon is cloned to. A shared directory was asked for by name, so it
# is also where the missing ones are put -- otherwise the first run fills it and every
# run after it quietly starts a second copy inside the project.
ADDONS_CLONE_DEST="${ADDONS_DIR:-$ADDONS_REPOS}"
## Resolve one addon, setting:
##
## SRC the directory that holds it (its plugin.cfg and all)
## SRC_LINK what addons/<name> should point AT, or empty for "leave the link alone"
##
## Returns 1 when this machine does not have it anywhere, which is what drives the
## clone: the list of what to fetch is what is ACTUALLY absent rather than the list of
## addons in the abstract, so a vendored tree with no network fetches nothing.
addon_source() {
local name="$1" repo="${1//_/-}"
SRC=""; SRC_LINK=""; SRC_KIND=""
if [ -n "$ADDONS_DIR" ]; then
# Both shapes of a shared directory: a directory of addons, and a directory of
# the repositories they live in. Guessing wrong is a silent re-clone of fifty
# repositories the box already has, so it checks for both rather than
# documenting which one it wanted.
if [ -d "$ADDONS_DIR/$name" ]; then
SRC="$ADDONS_DIR/$name"
elif [ -d "$ADDONS_DIR/$repo/addons/$name" ]; then
SRC="$ADDONS_DIR/$repo/addons/$name"
fi
# Absolute, deliberately. A shared directory is outside this project by
# definition -- there is no relative path to it that survives the project being
# moved, and an absolute one at least SAYS where it went when it dangles.
if [ -n "$SRC" ]; then SRC_LINK="$SRC"; SRC_KIND="dir"; return 0; fi
fi
if [ -d "$ADDONS_REPOS/$repo/addons/$name" ]; then
SRC="$ADDONS_REPOS/$repo/addons/$name"
# Relative, and pointing INSIDE addons/, which is the whole point of the
# default: nothing in the finished tree points out of it.
SRC_LINK=".repos/$repo/addons/$name"
SRC_KIND="repos"
return 0
fi
# A link this machine already has that still resolves -- addons/<name> ->
# ../../<repo>/addons/<name>, which is every checkout wired by an earlier setup.sh
# and every developer tree dot-bootstrap made. [b]Kept exactly as it is.[/b] Cloning
# fifty repositories a box already has, into a second copy, on the run where
# somebody typed the usual upgrade command, is the one thing a setup script must not
# do -- and two copies of dot-cloud on one machine is the bug where the fix is
# installed, pulled and still not running. `--addons-dir` repoints them on purpose;
# nothing else does.
if [ -L "$ROOT/addons/$name" ] && [ -d "$ROOT/addons/$name" ]; then
SRC="$(cd "$ROOT/addons/$name" && pwd -P)"
SRC_LINK=""
SRC_KIND="link"
return 0
fi
return 1
}
## The git checkout an addon came out of, or nothing. Walks up rather than assuming the
## layout, because there are three of them and a flat directory of addons is not a
## repository at all.
git_root_of() {
local dir="$1"
while [ -n "$dir" ] && [ "$dir" != "/" ]; do
[ -d "$dir/.git" ] && { printf '%s\n' "$dir"; return 0; }
dir="$(dirname "$dir")"
done
return 1
}
## Link, copy or record-as-missing every addon. Run twice: once to find out what is
## absent, and once more after cloning it.
##
## A vendored addon is not missing and is never cloned -- that is the release tarball,
## which has no clones, no network and nothing wrong with it.
resolve_addons() {
MISSING=()
FROM_DIR=0; FROM_REPOS=0; FROM_LINK=0; FROM_VENDOR=0
local name repo link
for name in "${ADDONS[@]}"; do
repo="${name//_/-}"
link="addons/$name"
if addon_source "$name"; then
if [ "$VENDOR" -eq 1 ]; then
# Copied, not linked. A symlink is fine where the thing it points at
# stays put and breaks the moment the directory is moved somewhere it
# is not -- a release tarball, or a container image whose final stage
# copies only this project. The symptom is every dot-* class_name
# unresolved at once, which reads as a broken project rather than as a
# dangling link.
rm -rf "$link"
cp -rL "$SRC" "$link"
elif [ -n "$SRC_LINK" ] && { [ -L "$link" ] || [ ! -e "$link" ]; }; then
ln -sfn "$SRC_LINK" "$link"
fi
case "$SRC_KIND" in
dir) FROM_DIR=$((FROM_DIR + 1)) ;;
repos) FROM_REPOS=$((FROM_REPOS + 1)) ;;
link) FROM_LINK=$((FROM_LINK + 1)) ;;
esac
elif [ -d "$link" ]; then
# already vendored, which is what a release tarball looks like
FROM_VENDOR=$((FROM_VENDOR + 1))
else
MISSING+=("$repo")
fi
done
}
resolve_addons
if [ ${#MISSING[@]} -gt 0 ] && [ "$DO_CLONE" -eq 1 ]; then
if [ "$ADDONS_CLONE_DEST" = "$ADDONS_REPOS" ]; then
mkdir -p "$ADDONS_REPOS"
# Written before the first clone, not after: a run interrupted between the two
# leaves repositories in a directory Godot would then walk.
[ -f "$ADDONS_REPOS/.gdignore" ] || : > "$ADDONS_REPOS/.gdignore"
fi
clone_repos "$ADDONS_CLONE_DEST" "${MISSING[@]}"
resolve_addons
fi
if [ ${#MISSING[@]} -gt 0 ]; then
die "These addon repositories are not on this machine:
${MISSING[*]}
Each dot-* project is a separate repository and there is no way to clone the
tree at once.
They are normally cloned for you into ./addons/.repos/, from $GIT_BASE
over HTTPS; this run could not, or --no-clone was given.
--addons-dir DIR takes them from a directory you already have them in -- either
a directory of addons (DIR/dot_core) or a directory of the repositories
(DIR/dot-core/addons/dot_core) -- and links them from there. --addons-dir .. is
a developer checkout, which is what dot-bootstrap makes.
Or vendor their addons/<name> folders into ./addons/, which is what a release
tarball looks like." 4
fi
# `--update` runs AFTER the addons have been resolved, not before, because the
# directories to pull are wherever they actually turned out to be -- which is the whole
# reason an addon in a shared directory or in an old sibling checkout gets pulled at all
# instead of being silently skipped for not being where this script expected it.
if [ "$DO_UPDATE" -eq 1 ]; then
UPDATE_DIRS=()
for name in "${ADDONS[@]}"; do
addon_source "$name" || continue
root_dir="$(git_root_of "$SRC")" || continue
UPDATE_DIRS+=("$root_dir")
done
# One repository can hold more than one of them -- a shared directory is allowed to
# be another project's addons/ -- and pulling it twice prints it twice.
if [ ${#UPDATE_DIRS[@]} -gt 0 ]; then
mapfile -t UPDATE_DIRS < <(printf '%s\n' "${UPDATE_DIRS[@]}" | sort -u)
update_repos "${UPDATE_DIRS[@]}"
fi
fi
# [b]--vendor throws the clones away once they have been copied.[/b] Vendoring says this