@atomiton-dev/gap-cli 0.0.0-stage → 0.9.0-mvp

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1203 @@
1
+ # Shared by install.sh and gap (#1750, D27, D28). Sourced, never executed; the caller sets GAP_SRC to
2
+ # the folder it runs from (the unzipped installer, or ~/.gap once installed).
3
+ #
4
+ # Written for bash 3.2 (macOS /bin/bash): no associative arrays, no mapfile, no ${var,,}.
5
+
6
+ # Plain variables, not exported: a GAP_* variable in the user's shell must not change them.
7
+ # D28: the installed instance (.env, docker-compose.yml, gap) lives in GAP_HOME, never in the
8
+ # unzipped folder, so that moving or deleting that folder cannot lose the database password.
9
+ GAP_HOME="${HOME:?HOME is not set}/.gap"
10
+ LAUNCHER_DIR="$HOME/.local/bin"
11
+ RC_MARKER="# gap installer"
12
+ RC_EXPORT='export PATH="$HOME/.local/bin:$PATH"'
13
+ # What install.sh may add after RC_MARKER, besides RC_EXPORT, when the user agrees to turn off a `gap`
14
+ # alias or function (check_gap_resolution): one line for POSIX-like shells, two possible ones for fish.
15
+ RC_UNSHADOW='unalias gap 2>/dev/null; unset -f gap 2>/dev/null'
16
+ RC_UNSHADOW_FISH='functions -e gap'
17
+ RC_UNSHADOW_FISH_ABBR='abbr -e gap'
18
+ LAUNCHER_MARK="# gap launcher, written by the GAP installer"
19
+ GAP_PROJECT=gap
20
+ GAP_PORT_FIRST=8061
21
+ GAP_PORT_LAST=8070
22
+ GAP_UP_TIMEOUT=300 # D26: seconds to wait for the three services to become healthy
23
+
24
+ ok() { printf '[✓] %s\n' "$1"; }
25
+ warn() { printf '[!] %s\n' "$1"; }
26
+
27
+ # docker compose against this folder. GAP_* and COMPOSE_* variables from the user's shell are
28
+ # dropped for the call so that only .env decides version, port and password (A4, E40).
29
+ dc() {
30
+ local scrub=() v
31
+ # Two -e expressions, not `\(GAP\|COMPOSE\)`: the sed of macOS has no `\|`, matched nothing, and let a
32
+ # GAP_VERSION from the user's shell override .env.
33
+ while IFS= read -r v; do scrub+=(-u "$v"); done < <(env | sed -n -e 's/^\(GAP_[A-Za-z0-9_]*\)=.*/\1/p' -e 's/^\(COMPOSE_[A-Za-z0-9_]*\)=.*/\1/p')
34
+ env ${scrub[@]+"${scrub[@]}"} docker compose -p "$GAP_PROJECT" --project-directory "$GAP_HOME" \
35
+ -f "$GAP_HOME/docker-compose.yml" --env-file "$GAP_HOME/.env" "$@"
36
+ }
37
+
38
+ env_get() { sed -n "s/^$1=//p" "$GAP_HOME/.env" | head -n 1; }
39
+
40
+ # image_ref PREFIX NAME TAG: the name Docker knows an image by. PREFIX is the registry part with its trailing
41
+ # slash (the npm channel: ghcr.io/atomiton-gap/) or empty (the ZIP channel: the local name, as `docker load` made it).
42
+ image_ref() { printf '%s%s:%s' "$1" "$2" "$3"; }
43
+
44
+ # installed_prefix: the PREFIX of the images that are on this machine, as the .env written by the install says (#1986).
45
+ # Empty when .env has no such key (an install by a ZIP of #1750) or has it empty. This is not "the prefix of the
46
+ # channel running this script": an npm install over a machine that a ZIP installed rewrites .env, and a ZIP of
47
+ # #1750 never knew the key. Use it to find what is installed (uninstall); what to pull is the channel's own matter.
48
+ installed_prefix() { env_get GAP_IMAGE_PREFIX; }
49
+
50
+ # --- Which installer channel runs this script (#1986, #2011) ---------------------------------------------
51
+ # The npm package carries a CHANNEL file that says `npm`, next to the scripts (installer/build-npm-package.sh).
52
+ # A ZIP has no such file, and neither has the copy of gap in ~/.gap, so both are the `zip` channel. This is a file, not
53
+ # a variable, because `dc` drops every GAP_* variable of the shell (F3).
54
+ NPM_IMAGE_PREFIX="ghcr.io/atomiton-gap/"
55
+
56
+ channel() {
57
+ # -f first: a `< missing-file` fails in the shell, before any `2>/dev/null` of the same command takes effect.
58
+ if [ -f "$GAP_SRC/CHANNEL" ] && [ "$(tr -d '[:space:]' < "$GAP_SRC/CHANNEL")" = npm ]; then printf npm; else printf zip; fi
59
+ }
60
+
61
+ # channel_prefix / channel_pull_policy: what the channel being run pulls from, and how Compose treats a missing image.
62
+ # Never read from .env: an npm install over a machine that a ZIP installed finds an empty prefix there, and would pull
63
+ # a `gap-kernel:<V>` that no registry has (F19). installed_prefix (above) is for finding what is installed.
64
+ channel_prefix() { if [ "$(channel)" = npm ]; then printf '%s' "$NPM_IMAGE_PREFIX"; fi; }
65
+ channel_pull_policy() { if [ "$(channel)" = npm ]; then printf missing; else printf never; fi; }
66
+
67
+ # install_cmd: what the user types to install again, in the words of this channel.
68
+ install_cmd() { if [ "$(channel)" = npm ]; then printf 'gap install'; else printf '%s/install.sh' "$GAP_SRC"; fi; }
69
+
70
+ # install_name: the same command as install_cmd, short, for the sentence "... then run <it> again" of install.sh's own
71
+ # error messages. The ZIP keeps the bare script name those messages always had; npm has no install.sh to name (it sits
72
+ # inside node_modules), only `gap install`.
73
+ install_name() { if [ "$(channel)" = npm ]; then printf 'gap install'; else printf 'install.sh'; fi; }
74
+
75
+ # gap_script: the gap that works when `gap` itself does not. The npm package is the only copy of the scripts; a ZIP
76
+ # install copies them into GAP_HOME.
77
+ gap_script() { if [ "$(channel)" = npm ]; then printf '%s/gap' "$GAP_SRC"; else printf '%s/gap' "$GAP_HOME"; fi; }
78
+
79
+ # classify_pull_error TEXT: unauthorized | denied | notfound | network | other, for what `docker pull` printed.
80
+ # The texts are the ones measured on ghcr.io (03/11/2026, spike S6): `error from registry: unauthorized` is no
81
+ # credential at all, `error from registry: denied` is a credential that was refused or has no right to the package
82
+ # (the two cannot be told apart by text), `<ref>: not found` is a missing tag or package, `failed to do request` is a
83
+ # network that did not answer. Anything else is `other`: its text is shown as Docker gave it rather than guessed at.
84
+ # Not matched on a bare "denied": `permission denied while trying to connect to the docker API` is not the registry.
85
+ classify_pull_error() {
86
+ local t
87
+ t=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
88
+ case "$t" in
89
+ *'failed to do request'*) printf network ;;
90
+ *'error from registry: unauthorized'*) printf unauthorized ;;
91
+ *'error from registry: denied'*) printf denied ;;
92
+ *': not found'*) printf notfound ;;
93
+ *) printf other ;;
94
+ esac
95
+ }
96
+
97
+ # print_login_steps / print_denied_help: what to do about `unauthorized` and `denied` (the texts are measured, see
98
+ # classify_pull_error). Shared by report_pull_failure (a pull that failed), doctor (access read with `manifest inspect`) and
99
+ # `gap login` (the same check, right after the login). None of them prints or asks for a token here: `gap login` asks for it
100
+ # and hands it to Docker on stdin.
101
+ print_login_steps() {
102
+ printf 'Action:\n'
103
+ printf ' 1. Create a GitHub personal access token (classic) that has the read:packages permission:\n'
104
+ printf ' https://github.com/settings/tokens/new?scopes=read:packages\n'
105
+ printf ' 2. Log in to the registry: gap login\n'
106
+ printf ' and enter your GitHub username and the token when it asks.\n'
107
+ }
108
+
109
+ # LOGIN_JUST_DONE=1 is set by `gap login` before it checks the images: the token was accepted a second ago, so an expired or
110
+ # revoked token is not what the refusal means, and saying so would send the user to make a second token for nothing.
111
+ print_denied_help() {
112
+ if [ "${LOGIN_JUST_DONE:-0}" = 1 ]; then
113
+ printf 'You are logged in, but your GitHub account has not been given access to GAP'"'"'s images.\n'
114
+ printf 'Ask the GAP administrator to add you to the team that can read them. If you were added just now,\n'
115
+ printf 'accept the invitation GitHub emailed you, then check again with: gap doctor\n'
116
+ return 0
117
+ fi
118
+ printf 'Either your token is no longer valid (expired or revoked), or your GitHub account has not been given\n'
119
+ printf 'access to GAP'"'"'s images. Ask the GAP administrator to add you to the team that can read them.\n'
120
+ printf 'If you were added, accept the invitation GitHub emailed you. If you are already on the team,\n'
121
+ printf 'create a new token with read:packages and log in again:\n'
122
+ printf ' gap login\n'
123
+ }
124
+
125
+ # classify_login_error TEXT: rejected | network | other, for what `docker login` printed when it failed. Measured with Docker 29
126
+ # on ghcr.io (05/11/2026): a made-up token ends with `Error response from daemon: Get "https://ghcr.io/v2/": denied: denied`; an
127
+ # unreachable registry with `... Client.Timeout exceeded ...` or `... dial tcp: lookup <host>: no such host`. Network is tried
128
+ # first (its texts carry the same `Get "<url>":` prefix). `rejected` needs the registry's own `": denied` / `": unauthorized`:
129
+ # a bare "denied" is also what an unreadable ~/.docker/config.json says (`permission denied`), and that is not a bad token.
130
+ classify_login_error() {
131
+ local t
132
+ t=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
133
+ case "$t" in
134
+ *'no such host'*|*'client.timeout'*|*'deadline exceeded'*|*'connection refused'*|*'i/o timeout'*|*'dial tcp'*|*'failed to do request'*) printf network ;;
135
+ *'": denied'*|*'": unauthorized'*) printf rejected ;;
136
+ *) printf other ;;
137
+ esac
138
+ }
139
+
140
+ # report_pull_failure IMAGE KIND TEXT [RETRY]: say what to do about a pull that failed. Nothing was written yet when this runs,
141
+ # which the message says. RETRY is the command to run again when it is not the installer's own: `gap update` names itself, so
142
+ # that the way on is not `gap install`, which would move the version with no backup and no guard.
143
+ report_pull_failure() {
144
+ local image="$1" kind="$2" text="$3" retry="${4:-}" version
145
+ version=$(tr -d '[:space:]' < "$GAP_SRC/VERSION" 2>/dev/null)
146
+ case "$kind" in
147
+ unauthorized)
148
+ warn "GAP's images are private and this machine is not logged in to ghcr.io"
149
+ print_login_steps ;;
150
+ denied)
151
+ warn "ghcr.io refused access to $image"
152
+ print_denied_help ;;
153
+ notfound)
154
+ warn "$image was not found in the registry"
155
+ printf 'This GAP package is version %s. That version may not be published yet; check the version you were given.\n' "$version" ;;
156
+ network)
157
+ warn "Could not reach ghcr.io while pulling $image"
158
+ printf 'Check your internet connection (and proxy settings). Images and layers already downloaded are kept.\n' ;;
159
+ *)
160
+ warn "Pulling $image failed"
161
+ printf '%s\n' "$text" | tail -n 5 ;;
162
+ esac
163
+ if [ -n "$retry" ]; then
164
+ printf 'Nothing was changed.\n'
165
+ printf 'Then run: %s\n' "$retry"
166
+ else
167
+ printf 'Nothing was installed and no data was changed.\n'
168
+ printf 'Then run: %s\n' "$(install_cmd)"
169
+ fi
170
+ }
171
+
172
+ # pull_images VERSION [RETRY]: pull the images this version needs from the registry of the channel (npm), the ones that are
173
+ # not on this machine yet. Returns 1 after report_pull_failure has said what to do. Runs before anything is written to
174
+ # GAP_HOME, so a user who may not pull is left with nothing half installed. Docker's own output is not shown: a
175
+ # failure is explained in our words (the text is shown only when it is a kind we do not know).
176
+ pull_images() {
177
+ local version="$1" retry="${2:-}" prefix ref out
178
+ prefix=$(channel_prefix)
179
+ for ref in "$(image_ref "$prefix" gap-kernel "$version")" "$(image_ref "$prefix" gap-admin-ui "$version")" \
180
+ "$(image_ref "$prefix" gap-postgres 16)"; do
181
+ docker image inspect "$ref" >/dev/null 2>&1 && continue
182
+ printf 'Pulling %s, this can take a few minutes...\n' "$ref"
183
+ if ! out=$(docker pull "$ref" 2>&1); then
184
+ report_pull_failure "$ref" "$(classify_pull_error "$out")" "$out" "$retry"
185
+ return 1
186
+ fi
187
+ done
188
+ }
189
+
190
+ # env_set KEY VALUE: set one line of .env, keep the others (and their secrets) untouched. A KEY that is
191
+ # not in the file yet is added at the end (#1986: an .env written before a key existed, such as the
192
+ # GAP_IMAGE_PREFIX of the npm channel, must be able to receive it). The file stays readable by its owner only.
193
+ # VALUE may be empty but may not contain | & or \ : it goes into a sed expression, where they would be
194
+ # read as the delimiter, the matched text and an escape. Such a value is refused (status 2) and nothing is
195
+ # written, in the replace case and the add case alike.
196
+ env_set() {
197
+ case "$2" in
198
+ *'|'*|*'&'*|*'\'*) printf 'env_set: the value of %s may not contain | & or \\\n' "$1" >&2; return 2 ;;
199
+ esac
200
+ # Never conjure up a .env that holds one key: "is there a .env?" is how the installers tell an installed
201
+ # machine from a fresh one, and a lost .env from a present one.
202
+ [ -f "$GAP_HOME/.env" ] || { printf 'env_set: %s/.env does not exist\n' "$GAP_HOME" >&2; return 1; }
203
+ ( umask 077
204
+ if grep -q "^$1=" "$GAP_HOME/.env"; then
205
+ sed "s|^$1=.*|$1=$2|" "$GAP_HOME/.env" > "$GAP_HOME/.env.tmp"
206
+ else
207
+ # The last line may have no newline; without this the new key would be glued to it.
208
+ { cat "$GAP_HOME/.env"; [ -z "$(tail -c 1 "$GAP_HOME/.env")" ] || printf '\n'; printf '%s=%s\n' "$1" "$2"; } > "$GAP_HOME/.env.tmp"
209
+ fi && mv "$GAP_HOME/.env.tmp" "$GAP_HOME/.env" )
210
+ }
211
+
212
+ ui_url() { printf 'http://127.0.0.1:%s' "$(env_get GAP_UI_PORT)"; }
213
+
214
+ os_name() {
215
+ case "$(uname -s)" in
216
+ Darwin) printf 'macOS %s' "$(uname -m)" ;;
217
+ *) printf '%s %s' "$(uname -s)" "$(uname -m)" ;;
218
+ esac
219
+ }
220
+
221
+ # Prints the failing step, then what to do about it. $1 = command to re-run afterwards; $2 = `up` when
222
+ # the caller is going to start the stack, which also checks that Compose has `up --wait-timeout`.
223
+ check_prereqs() {
224
+ local lines="[✓] OS: $(os_name)"$'\n' action="" info
225
+ if ! command -v docker >/dev/null 2>&1; then
226
+ lines+="[!] Docker CLI not found"
227
+ if [ "$(uname -s)" = Linux ]; then action="Install Docker Engine with the Compose plugin (https://docs.docker.com/engine/install/)"
228
+ else action="Install Docker Desktop (https://www.docker.com/products/docker-desktop/)"; fi
229
+ else
230
+ lines+="[✓] Docker CLI detected"$'\n'
231
+ if ! info=$(docker info 2>&1); then
232
+ if [ "$(uname -s)" = Darwin ]; then
233
+ lines+="[!] Docker daemon stopped"
234
+ action="Start Docker Desktop"
235
+ elif printf '%s' "$info" | grep -qi 'permission denied while trying to connect'; then
236
+ # The usual state of a fresh Docker Engine on Linux: the daemon runs, this user may not use its socket.
237
+ # Not just "permission denied": an unreadable ~/.docker/config.json (left by `sudo docker login`) also
238
+ # prints that, whatever the state of the daemon. Docker 24 to 28 say "... to the Docker daemon socket
239
+ # at", 29 says "... to the docker API at".
240
+ lines+="[!] No permission to use Docker"
241
+ action='Add your user to the docker group (sudo usermod -aG docker $USER), then log out and log in again'
242
+ else
243
+ lines+="[!] Docker daemon stopped"
244
+ action="Start the Docker service (for example: sudo systemctl start docker)"
245
+ fi
246
+ elif ! docker compose version >/dev/null 2>&1; then
247
+ lines+="[✓] Docker daemon running"$'\n'"[!] Docker Compose not found"
248
+ action="Install the Docker Compose plugin (v2) or update Docker Desktop"
249
+ elif [ "${2:-}" = up ] && ! docker compose up --help 2>/dev/null | grep -q -- '--wait-timeout'; then
250
+ # bring_up needs `up --wait --wait-timeout`; without this check an older Compose fails there
251
+ # with "unknown flag" and the user is told that a service is unhealthy.
252
+ lines+="[✓] Docker daemon running"$'\n'"[!] Docker Compose is too old"
253
+ action="Update the Docker Compose plugin (v2) or Docker Desktop"
254
+ elif ! command -v curl >/dev/null 2>&1; then
255
+ lines+="[✓] Docker daemon running"$'\n'"[✓] Docker Compose available"$'\n'"[!] curl not found"
256
+ action="Install curl"
257
+ fi
258
+ fi
259
+ [ -n "$action" ] || return 0
260
+ printf '%s\nAction: %s\nThen run: %s\n' "$lines" "$action" "$1"
261
+ return 1
262
+ }
263
+
264
+ # port_in_use P: something already accepts connections on 127.0.0.1:P.
265
+ port_in_use() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null; }
266
+
267
+ # port_owned_by_gap P: a container of this project publishes P.
268
+ port_owned_by_gap() {
269
+ [ -n "$(docker ps -q --filter "label=com.docker.compose.project=$GAP_PROJECT" --filter "publish=$1" 2>/dev/null)" ]
270
+ }
271
+
272
+ # pick_port [STORED]: keep STORED when it is free or already ours, else the first free port of the
273
+ # range (D9). Fails only when the whole range is taken.
274
+ pick_port() {
275
+ local p
276
+ if [ -n "${1:-}" ] && { ! port_in_use "$1" || port_owned_by_gap "$1"; }; then
277
+ printf '%s' "$1"; return 0
278
+ fi
279
+ p=$GAP_PORT_FIRST
280
+ while [ "$p" -le "$GAP_PORT_LAST" ]; do
281
+ if ! port_in_use "$p"; then printf '%s' "$p"; return 0; fi
282
+ p=$((p + 1))
283
+ done
284
+ return 1
285
+ }
286
+
287
+ # svc_state SERVICE: Healthy | Starting | Unhealthy | Restarting | Running | Stopped
288
+ svc_state() {
289
+ local id
290
+ id=$(dc ps -aq "$1" 2>/dev/null | head -n 1)
291
+ [ -n "$id" ] || { printf 'Stopped'; return; }
292
+ case "$(docker inspect --format '{{.State.Status}}/{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$id" 2>/dev/null)" in
293
+ running/healthy) printf 'Healthy' ;;
294
+ running/starting) printf 'Starting' ;;
295
+ running/unhealthy) printf 'Unhealthy' ;;
296
+ running/) printf 'Running' ;;
297
+ restarting/*) printf 'Restarting' ;;
298
+ *) printf 'Stopped' ;;
299
+ esac
300
+ }
301
+
302
+ # http_ok PATH: HTTP 200 from the Admin UI (so through nginx, the path the user takes).
303
+ http_ok() { curl -fsS -o /dev/null -m 5 "$(ui_url)$1" 2>/dev/null; }
304
+
305
+ # kill_tree PID [SIGNAL]: PID and every process below it, children first (SIGNAL defaults to TERM). A
306
+ # background function runs in a subshell, so killing only that subshell would leave `docker compose`
307
+ # (and the compose plugin it starts) waiting on its own. Without pgrep only PID itself is killed.
308
+ kill_tree() {
309
+ local child sig="${2:-TERM}"
310
+ for child in $(pgrep -P "$1" 2>/dev/null); do kill_tree "$child" "$sig"; done
311
+ kill -s "$sig" "$1" 2>/dev/null
312
+ }
313
+
314
+ # bring_up: docker compose up -d --wait, bounded (D26). A bare `up -d` hangs forever when the
315
+ # Kernel crash-loops (A4.11), and --wait alone would sit out the whole timeout, so a Restarting
316
+ # service stops the wait early.
317
+ bring_up() {
318
+ local log pid s
319
+ log=$(mktemp)
320
+ dc up -d --wait --wait-timeout "$GAP_UP_TIMEOUT" >"$log" 2>&1 &
321
+ pid=$!
322
+ while kill -0 "$pid" 2>/dev/null; do
323
+ for s in postgres kernel admin-ui; do
324
+ if [ "$(svc_state "$s")" = Restarting ]; then
325
+ kill_tree "$pid"; wait "$pid" 2>/dev/null
326
+ warn "$s keeps restarting"
327
+ printf 'Run: gap logs %s\n' "$s"
328
+ [ "$s" = kernel ] && printf 'If .env was edited, the database password may no longer match the existing data (see: gap logs postgres).\n'
329
+ rm -f "$log"; return 1
330
+ fi
331
+ done
332
+ sleep 3
333
+ done
334
+ if ! wait "$pid"; then
335
+ warn "GAP did not start: a service is not healthy"
336
+ tail -n 5 "$log"
337
+ printf 'Run: gap logs kernel\n'
338
+ if [ "$(svc_state kernel)" != Healthy ] && [ "$(svc_state postgres)" = Healthy ]; then
339
+ printf 'If .env was edited, the database password may no longer match the existing data (see: gap logs postgres).\n'
340
+ fi
341
+ rm -f "$log"; return 1
342
+ fi
343
+ rm -f "$log"
344
+ }
345
+
346
+ # launcher_text: the ~/.local/bin/gap launcher. The path is single-quoted for /bin/sh (dash on
347
+ # Debian/Ubuntu does not understand the $'...' that bash's printf %q can produce).
348
+ launcher_text() {
349
+ local q
350
+ q=$(printf '%s' "$GAP_HOME/gap" | sed "s/'/'\\\\''/g")
351
+ printf "#!/bin/sh\n%s\nexec '%s' \"\$@\"\n" "$LAUNCHER_MARK" "$q"
352
+ }
353
+
354
+ # launcher_is_ours FILE: FILE is a launcher this installer wrote: a regular file (not a symlink) with
355
+ # LAUNCHER_MARK, or the two-line launcher of the first version (78aafa6d), which had no mark:
356
+ # `#!/bin/sh` then `exec <~/.gap/gap quoted by bash's printf %q> "$@"`. The second line is matched
357
+ # against today's %q and, since another bash may quote differently, also against the plain path.
358
+ # Anything else at that path belongs to the user and is never overwritten or removed; a file bigger
359
+ # than a launcher is never read whole.
360
+ launcher_is_ours() {
361
+ local line2
362
+ [ -f "$1" ] && [ ! -L "$1" ] || return 1
363
+ [ "$(($(wc -c < "$1")))" -le 1024 ] || return 1
364
+ grep -qxF "$LAUNCHER_MARK" "$1" 2>/dev/null && return 0
365
+ [ "$(($(wc -l < "$1")))" -eq 2 ] && [ "$(head -n 1 "$1")" = '#!/bin/sh' ] || return 1
366
+ line2=$(sed -n 2p "$1")
367
+ case "$line2" in 'exec '*' "$@"') ;; *) return 1 ;; esac
368
+ [ "$line2" = "exec $(printf '%q' "$GAP_HOME/gap") \"\$@\"" ] && return 0
369
+ case "$line2" in *"$GAP_HOME/gap"*) return 0 ;; esac
370
+ return 1
371
+ }
372
+
373
+ # Where a new terminal reads its PATH from, depending on the login shell (install adds the line here).
374
+ # zsh and fish go through shell_startup_file, so that ZDOTDIR and fish's own config file count (fish
375
+ # never reads ~/.profile; it does understand the `export PATH=...` line, checked with fish 4.0).
376
+ rc_files() {
377
+ case "$(basename "${SHELL:-}")" in
378
+ zsh|fish) shell_startup_file "$(basename "$SHELL")"; printf '\n' ;;
379
+ bash) printf '%s\n' "$HOME/.bashrc"; [ "$(uname -s)" = Darwin ] && printf '%s\n' "$HOME/.bash_profile" ;;
380
+ *) printf '%s\n' "$HOME/.profile" ;;
381
+ esac
382
+ }
383
+
384
+ # --- Does `gap` mean GAP in the user's own terminal? ---------------------------------------------
385
+ # install.sh runs in a shell that never reads the user's startup files, so it cannot see an alias or
386
+ # function called `gap` defined there (oh-my-zsh's git plugin has `alias gap='git apply'`). Such an
387
+ # alias wins over the launcher on the PATH: `gap status` then runs something else and fails with a
388
+ # message about patches. So the installer asks the user's own shell, started the way a terminal
389
+ # window starts it, how it resolves the word `gap`. Like opening a terminal, that runs the user's
390
+ # startup files.
391
+ GAP_PROBE_TIMEOUT=10 # seconds a shell may take to start and answer
392
+ # The same question for every POSIX-like shell (bash, zsh, ksh, dash): alias first, then `command -v`
393
+ # (a path, nothing, or the bare word for a function or builtin). fish has no `command -v` for functions.
394
+ GAP_PROBE_POSIX='if alias gap >/dev/null 2>&1; then printf "GAPPROBE:alias:%s\n" "$(alias gap)"; else r=$(command -v gap 2>/dev/null); case $r in "") printf "GAPPROBE:none:\n";; /*) printf "GAPPROBE:file:%s\n" "$r";; *) printf "GAPPROBE:function:\n";; esac; fi'
395
+ GAP_PROBE_FISH='if functions -q gap; echo GAPPROBE:function:; else if abbr -q gap; echo GAPPROBE:abbr:; else if type -q gap; echo GAPPROBE:file:(type -p gap); else; echo GAPPROBE:none:; end'
396
+
397
+ # probe_snippet SHELLNAME: the question for that shell. Fails for a shell this does not know.
398
+ probe_snippet() {
399
+ case "$1" in
400
+ fish) printf '%s' "$GAP_PROBE_FISH" ;;
401
+ bash|zsh|ksh|ksh93|mksh|dash|ash|sh) printf '%s' "$GAP_PROBE_POSIX" ;;
402
+ *) return 1 ;;
403
+ esac
404
+ }
405
+
406
+ # probe_shell SHELL: how SHELL (a path) resolves `gap`. Prints alias:DEFINITION (as the shell shows
407
+ # it: `gap='git apply'` in zsh, `alias gap='git apply'` in bash), function, abbr, none, or file:PATH. Prints nothing when it cannot say: a shell this does not know, or one that exits or hangs
408
+ # while starting (killed after GAP_PROBE_TIMEOUT). The shell reads /dev/null and gets no terminal
409
+ # (setsid, or perl where there is no setsid, as on macOS), so that it cannot take the installer's
410
+ # terminal away from it. DISABLE_AUTO_UPDATE keeps an oh-my-zsh from updating itself in this run.
411
+ probe_shell() {
412
+ local sh="$1" snippet flags=-i out pid dog nap answer
413
+ local detach=()
414
+ snippet=$(probe_snippet "$(basename "$sh")") || return 0
415
+ # A terminal window on macOS starts a login shell; on Linux it usually does not.
416
+ [ "$(uname -s)" = Darwin ] && flags="-l -i"
417
+ if command -v setsid >/dev/null 2>&1; then detach=(setsid)
418
+ elif command -v perl >/dev/null 2>&1; then detach=(perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' --)
419
+ else return 0 # an interactive shell on our terminal could keep it, so do not ask at all
420
+ fi
421
+ out=$(mktemp) || return 0
422
+ # $flags is two words on purpose.
423
+ # shellcheck disable=SC2086
424
+ ${detach[@]+"${detach[@]}"} env DISABLE_AUTO_UPDATE=true DISABLE_UPDATE_PROMPT=true \
425
+ "$sh" $flags -c "$snippet" </dev/null >"$out" 2>/dev/null &
426
+ pid=$!
427
+ # The shell leads its own session, so -PID is its whole process group, which also catches what
428
+ # a hung startup file started when there is no pgrep for kill_tree to find it with.
429
+ ( sleep "$GAP_PROBE_TIMEOUT"; kill_tree "$pid" KILL; kill -s KILL -- "-$pid" ) >/dev/null 2>&1 &
430
+ dog=$!
431
+ { wait "$pid"; } 2>/dev/null
432
+ # The watchdog before its sleep: the other way round it would wake up and still fire, at a probe
433
+ # that has already finished, at what its startup files left running, or at a reused PID.
434
+ nap=$(pgrep -P "$dog" 2>/dev/null)
435
+ kill -s KILL "$dog" 2>/dev/null
436
+ { wait "$dog"; } 2>/dev/null
437
+ # shellcheck disable=SC2086
438
+ [ -z "$nap" ] || kill -s KILL $nap 2>/dev/null
439
+ # Startup files may print before our line, even without ending theirs with a newline.
440
+ answer=$(sed -n 's/.*GAPPROBE:\([a-z]*\):\(.*\)$/\1:\2/p' "$out" | head -n 1)
441
+ rm -f "$out"
442
+ case "$answer" in
443
+ alias:*|function:*|abbr:*|none:*|file:?*) printf '%s\n' "${answer%:}" ;;
444
+ esac
445
+ }
446
+
447
+ # probe_candidates: the login shell, and the shell install.sh was started from when that is another one.
448
+ probe_candidates() {
449
+ local login="${SHELL:-}" parent
450
+ [ -x "$login" ] && printf '%s\n' "$login"
451
+ parent=$(ps -o comm= -p "$PPID" 2>/dev/null | head -n 1)
452
+ parent=${parent#-}; parent=${parent##*/}
453
+ if [ -n "$parent" ] && [ "$parent" != "$(basename "$login")" ] && probe_snippet "$parent" >/dev/null; then
454
+ command -v "$parent" 2>/dev/null
455
+ fi
456
+ return 0
457
+ }
458
+
459
+ # shell_startup_file SHELLNAME: where a line has to go for a new terminal of that shell to run it.
460
+ # bash on macOS starts as a login shell and reads only the first of ~/.bash_profile, ~/.bash_login and
461
+ # ~/.profile that exists; creating ~/.bash_profile next to a ~/.profile would silence the latter.
462
+ shell_startup_file() {
463
+ local f
464
+ case "$1" in
465
+ zsh) printf '%s' "${ZDOTDIR:-$HOME}/.zshrc" ;;
466
+ bash)
467
+ if [ "$(uname -s)" != Darwin ]; then printf '%s' "$HOME/.bashrc"; return; fi
468
+ for f in .bash_profile .bash_login .profile; do
469
+ [ -f "$HOME/$f" ] && { printf '%s' "$HOME/$f"; return; }
470
+ done
471
+ printf '%s' "$HOME/.bash_profile" ;;
472
+ fish) printf '%s' "${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish" ;;
473
+ *) printf '%s' "$HOME/.profile" ;;
474
+ esac
475
+ }
476
+
477
+ # Set by check_gap_resolution, for the summary that install.sh prints after it:
478
+ GAP_CHECK_CMD="gap status" # what works in a new terminal: gap status, command gap status, or the full path
479
+ GAP_FIX_ADDED="" # startup files that got the unshadow line in this run
480
+ GAP_NOTES="" # lines to print after the summary
481
+
482
+ # can_ask: the user can answer a question (install.sh runs in a terminal, not from a script or pipe).
483
+ can_ask() { [ -t 0 ]; }
484
+
485
+ add_note() { GAP_NOTES="$GAP_NOTES$1"$'\n'; }
486
+
487
+ # prefer_check_cmd CMD: keep the most cautious of GAP_CHECK_CMD and CMD (a full path works everywhere,
488
+ # `command gap` gets past an alias or function, plain `gap` needs neither in the way).
489
+ prefer_check_cmd() {
490
+ local rank_new rank_old
491
+ case "$1" in "gap status") rank_new=0 ;; "command gap status") rank_new=1 ;; *) rank_new=2 ;; esac
492
+ case "$GAP_CHECK_CMD" in "gap status") rank_old=0 ;; "command gap status") rank_old=1 ;; *) rank_old=2 ;; esac
493
+ [ "$rank_new" -le "$rank_old" ] || GAP_CHECK_CMD=$1
494
+ }
495
+
496
+ # tilde PATH: PATH with the home folder written as ~, quoted when it is somewhere else.
497
+ tilde() {
498
+ case "$1" in "$HOME"/*) printf '~%s' "${1#"$HOME"}" ;; *) printf "'%s'" "$1" ;; esac
499
+ }
500
+
501
+ # unshadow_line SHELLNAME KIND: the line that turns `gap` back into GAP's launcher in that shell.
502
+ unshadow_line() {
503
+ if [ "$1" != fish ]; then printf '%s' "$RC_UNSHADOW"
504
+ elif [ "$2" = abbr ]; then printf '%s' "$RC_UNSHADOW_FISH_ABBR"
505
+ else printf '%s' "$RC_UNSHADOW_FISH"
506
+ fi
507
+ }
508
+
509
+ # add_unshadow_line FILE LINE: append RC_MARKER and LINE to FILE, once. remove_rc_lines takes them out
510
+ # again. Whether the line works is up to the caller to check: something read later may set gap again.
511
+ add_unshadow_line() {
512
+ grep -qxF "$2" "$1" 2>/dev/null && return 0
513
+ mkdir -p "$(dirname "$1")" 2>/dev/null
514
+ # In braces: a 2>/dev/null after >> would come too late to hide the shell's own "Permission denied".
515
+ { printf '\n%s\n%s\n' "$RC_MARKER" "$2" >> "$1"; } 2>/dev/null
516
+ }
517
+
518
+ # note_answer SHELLNAME ANSWER: the notes for a `gap` that is a program, or that is not found.
519
+ note_answer() {
520
+ local where="${2#*:}"
521
+ case "$2" in
522
+ file:*)
523
+ [ "$where" -ef "$LAUNCHER_DIR/gap" ] && return 0
524
+ # The npm channel: `npm install -g` makes <prefix>/bin/gap a symlink to <package>/bin/gap.js (F18, checked on
525
+ # macOS), and this script runs from <package>/installer.
526
+ if [ "$(channel)" = npm ] && [ "$where" -ef "$GAP_SRC/../bin/gap.js" ]; then return 0; fi
527
+ add_note " Warning: in $1, \"gap\" runs $where (another program), not GAP."
528
+ if [ "$(channel)" = npm ]; then
529
+ add_note " Run GAP with $(gap_script) status, or put npm's global bin folder first on your PATH."
530
+ else
531
+ add_note " Run GAP with $(gap_script) status, or put $LAUNCHER_DIR first on your PATH."
532
+ fi
533
+ prefer_check_cmd "$(gap_script) status" ;;
534
+ none)
535
+ add_note " Note: a new $1 terminal does not find \"gap\" on its PATH."
536
+ if [ "$(channel)" = npm ]; then
537
+ add_note " Run GAP with $(gap_script) status, or add npm's global bin folder to your PATH (find it with: npm prefix -g)."
538
+ else
539
+ add_note " Run GAP with $(gap_script) status, or add $LAUNCHER_DIR to your PATH."
540
+ fi
541
+ prefer_check_cmd "$(gap_script) status" ;;
542
+ esac
543
+ }
544
+
545
+ # offer_unshadow SHELL KIND DEFINITION: `gap` is an alias, function or (fish) abbreviation in SHELL.
546
+ # Says so right away; in a terminal it asks to add the unshadow line to that shell's startup file
547
+ # (Enter means yes), adds it, and asks the shell again to be sure it worked. Without a terminal, or
548
+ # when the answer is no or the line does not help, the notes say how to do it by hand.
549
+ offer_unshadow() {
550
+ local sh="$1" kind="$2" def="${3#alias }" name file shown line noun a value="" reply again
551
+ name=$(basename "$sh")
552
+ file=$(shell_startup_file "$name"); shown=$(tilde "$file")
553
+ line=$(unshadow_line "$name" "$kind")
554
+ case "$kind" in
555
+ alias)
556
+ noun=alias
557
+ # The command behind the alias, when it is plainly quoted: gap='git apply' or gap=ls.
558
+ case "$def" in gap=*) value=${def#gap=} ;; esac
559
+ case "$value" in \'*\') value=${value#\'}; value=${value%\'} ;; esac
560
+ case "$value" in *[\'\"\\]*) value="" ;; esac ;;
561
+ abbr) noun=abbreviation ;;
562
+ *) noun="shell function" ;;
563
+ esac
564
+ case "$noun" in [aeiou]*) a=an ;; *) a=a ;; esac
565
+ if [ "$kind" = alias ] && [ -n "$def" ]; then
566
+ warn "In $name, \"gap\" is already an alias: $def"
567
+ else
568
+ warn "In $name, \"gap\" is already $a $noun."
569
+ fi
570
+ if [ -n "$value" ]; then printf ' So "gap status" would run "%s status", not GAP.\n' "$value"
571
+ else printf ' So "gap status" would not run GAP.\n'; fi
572
+ if can_ask; then
573
+ printf ' Turn that %s off by adding a line to %s?' "$noun" "$shown"
574
+ [ -z "$value" ] || printf ' "%s" itself keeps working.' "$value"
575
+ printf ' [Y/n] '
576
+ read -r reply || reply=n
577
+ case "$reply" in
578
+ ""|[Yy]|[Yy][Ee][Ss])
579
+ if add_unshadow_line "$file" "$line"; then
580
+ again=$(probe_shell "$sh")
581
+ case "$again" in
582
+ alias*|function|abbr)
583
+ # The line is in place but something read after it defines gap again (~/.zlogin, a
584
+ # plugin loaded later): writing it again would not help, so say where to look instead.
585
+ warn "Added the line to $shown, but a new $name terminal still has its own \"gap\""
586
+ prefer_check_cmd "command gap status"
587
+ add_note " In $name, \"gap\" is defined again after the line in $shown (another startup file"
588
+ add_note " or a plugin loaded later). Remove it there to use plain \"gap\"; until then: command gap status"
589
+ return 0 ;;
590
+ *)
591
+ case "$name:$kind" in
592
+ fish:*) ok "Added \"$line\" to $shown" ;;
593
+ *:alias) ok "Added \"unalias gap\" to $shown" ;;
594
+ *) ok "Added \"unset -f gap\" to $shown" ;;
595
+ esac
596
+ GAP_FIX_ADDED="$GAP_FIX_ADDED $shown"
597
+ note_answer "$name" "$again"
598
+ return 0 ;;
599
+ esac
600
+ else
601
+ warn "Could not write to $shown"
602
+ fi ;;
603
+ *) printf ' %s was left unchanged.\n' "$shown" ;;
604
+ esac
605
+ fi
606
+ prefer_check_cmd "command gap status"
607
+ add_note " To use plain \"gap\" in $name, run once, then open a new terminal:"
608
+ add_note " echo '$line' >> $shown"
609
+ }
610
+
611
+ # check_gap_resolution: asks each shell of probe_candidates how it resolves `gap`, offers to fix an
612
+ # alias or function in the way (offer_unshadow), and sets GAP_CHECK_CMD, GAP_FIX_ADDED and GAP_NOTES.
613
+ # Returns 1 when no shell could be asked, so that the caller can fall back to what it knows without one.
614
+ # Not to be run in $(...): it asks questions and sets those variables.
615
+ check_gap_resolution() {
616
+ local sh name answer asked=1 seen=" "
617
+ # The shells come in on fd 3, so that stdin stays the terminal the questions are read from.
618
+ while IFS= read -r sh <&3; do
619
+ name=$(basename "$sh")
620
+ case "$seen" in *" $name "*) continue ;; esac
621
+ seen="$seen$name "
622
+ answer=$(probe_shell "$sh")
623
+ [ -n "$answer" ] || continue
624
+ asked=0
625
+ case "$answer" in
626
+ alias:*) offer_unshadow "$sh" alias "${answer#alias:}" ;;
627
+ function) offer_unshadow "$sh" function "" ;;
628
+ abbr) offer_unshadow "$sh" abbr "" ;;
629
+ *) note_answer "$name" "$answer" ;;
630
+ esac
631
+ done 3< <(probe_candidates)
632
+ return $asked
633
+ }
634
+
635
+ # remove_rc_lines: undo what install.sh appended, in every rc file that could hold it (the login
636
+ # shell may have changed since): the blank line, the marker line and the exact line after it (the
637
+ # export line, or one of the RC_UNSHADOW lines).
638
+ # A marker followed by anything else is left alone. The file is rewritten in place (cat >) so that a
639
+ # symlinked dotfile keeps pointing where it did. Prints the files it changed.
640
+ remove_rc_lines() {
641
+ local rc tmp
642
+ for rc in "$HOME/.zshrc" "${ZDOTDIR:-$HOME}/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile" \
643
+ "${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish"; do
644
+ [ -f "$rc" ] && grep -qF "$RC_MARKER" "$rc" || continue
645
+ tmp=$(mktemp) || continue
646
+ awk -v m="$RC_MARKER" -v e="$RC_EXPORT" -v u1="$RC_UNSHADOW" -v u2="$RC_UNSHADOW_FISH" -v u3="$RC_UNSHADOW_FISH_ABBR" '
647
+ held { held = 0; if ($0 == e || $0 == u1 || $0 == u2 || $0 == u3) next; if (hb) print ""; print m }
648
+ blank { blank = 0; if ($0 == m) { held = 1; hb = 1; next }; print "" }
649
+ $0 == "" { blank = 1; next }
650
+ $0 == m { held = 1; hb = 0; next }
651
+ { print }
652
+ END { if (blank) print ""; if (held) { if (hb) print ""; print m } }' "$rc" > "$tmp"
653
+ if ! cmp -s "$rc" "$tmp"; then cat "$tmp" > "$rc"; printf ' %s' "$rc"; fi
654
+ rm -f "$tmp"
655
+ done
656
+ }
657
+
658
+ open_url() {
659
+ case "$(uname -s)" in
660
+ Darwin) open "$1" >/dev/null 2>&1 || true ;;
661
+ *) if command -v xdg-open >/dev/null 2>&1; then xdg-open "$1" >/dev/null 2>&1 & fi ;;
662
+ esac
663
+ return 0
664
+ }
665
+
666
+ # --- gap version and gap doctor (#1986, #2012) -----------------------------------------------------------------
667
+ # Both only read. doctor never starts, stops or pulls anything, never writes .env, a launcher, a PATH or an rc file: of the
668
+ # functions above it uses none of the ones that write (offer_unshadow, add_unshadow_line, install_launcher, env_set).
669
+ # It does run the user's own shell the way a new terminal starts it (probe_shell), as install.sh does.
670
+ GAP_MIN_NODE_MAJOR=18 # the same number as MIN_NODE_MAJOR in npm/lib/launcher.js, which refuses to start below it
671
+
672
+ is_installed() { [ -f "$GAP_HOME/.env" ]; }
673
+
674
+ # cli_version: the version of the npm package that runs this script. Empty on the ZIP channel: the VERSION file of an
675
+ # unzipped folder is the ZIP's own, not a package, and the copy of gap in ~/.gap has none.
676
+ cli_version() {
677
+ [ "$(channel)" = npm ] && [ -f "$GAP_SRC/VERSION" ] || return 0
678
+ tr -d '[:space:]' < "$GAP_SRC/VERSION"
679
+ }
680
+
681
+ # installed_version: GAP_VERSION of the install in GAP_HOME. Empty when GAP is not installed; `unknown` when there is an
682
+ # .env that does not say (no number is guessed).
683
+ installed_version() {
684
+ local v
685
+ is_installed || return 0
686
+ v=$(env_get GAP_VERSION)
687
+ printf '%s' "${v:-unknown}"
688
+ }
689
+
690
+ # classify_manifest_error TEXT: unauthorized | denied | notfound | network | other, for what `docker manifest inspect`
691
+ # printed (it reads a manifest and downloads no layer). Measured on ghcr.io on 04/11/2026, with no credential at all:
692
+ # `Get "<url>": unauthorized` is a private package, `Get "<url>": denied` is a package that does not exist (or that the
693
+ # caller may not see), `manifest unknown` is a public package without that tag, and `failed to configure transport:
694
+ # error pinging v2 registry: ...` is a registry that did not answer (no such host, connection refused, deadline).
695
+ # With a credential: a token that is revoked or made up prints `Get "<url>": denied: denied`; a valid token that may not
696
+ # read the package prints the single word `denied`, and nothing else; a valid token and a package that does not exist print
697
+ # `manifest unknown`. Anything else falls under `other` and is shown as Docker gave it. The texts differ from the ones of
698
+ # `docker pull` (classify_pull_error): they are not interchangeable. `denied` counts only as the WHOLE text: an unreadable
699
+ # ~/.docker/config.json or the Docker socket also say `permission denied`, which is not the registry.
700
+ classify_manifest_error() {
701
+ local t
702
+ t=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
703
+ case "$t" in
704
+ *'failed to configure transport'*|*'error pinging v2 registry'*|*'failed to do request'*) printf network ;;
705
+ *'": unauthorized'*) printf unauthorized ;;
706
+ *'": denied'*) printf denied ;;
707
+ denied) printf denied ;; # valid token without the right: the whole text is this word
708
+ *'manifest unknown'*) printf notfound ;;
709
+ *) printf other ;;
710
+ esac
711
+ }
712
+
713
+ DOCTOR_PROBLEMS=0 # `[!]` lines of this run
714
+ DOCTOR_DOCKER=0 # 1 when Docker is ready: what needs it is skipped otherwise
715
+
716
+ # doctor_problem MESSAGE: a `[!]` line, counted. doctor_unsure MESSAGE: something that could not be checked; it says nothing
717
+ # is wrong, so it is not counted. The lines that say what to do come after them, indented.
718
+ doctor_problem() { warn "$1"; DOCTOR_PROBLEMS=$((DOCTOR_PROBLEMS + 1)); }
719
+ doctor_unsure() { printf '[?] %s\n' "$1"; }
720
+
721
+ # doctor_install_hint: the command that installs, in the words of this channel (the ZIP's install.sh is not in ~/.gap).
722
+ doctor_install_hint() {
723
+ if [ "$(channel)" = npm ]; then printf 'gap install'
724
+ elif [ -f "$GAP_SRC/install.sh" ]; then printf '%s/install.sh' "$GAP_SRC"
725
+ else printf 'install.sh (in the GAP installer folder)'; fi
726
+ }
727
+
728
+ # doctor_node: the npm channel runs on Node, and the launcher has already refused a Node below the minimum, so this only
729
+ # differs when the script is run without it. The ZIP channel does not use Node.
730
+ doctor_node() {
731
+ local v major
732
+ if [ "$(channel)" != npm ]; then ok "Node.js is not needed for the ZIP installer"; return 0; fi
733
+ v=$(node -v 2>/dev/null) || v=""
734
+ major=${v#v}; major=${major%%.*}
735
+ case "$major" in
736
+ ''|*[!0-9]*)
737
+ doctor_problem "Node.js not found"
738
+ printf ' Action: install Node.js %s or newer from https://nodejs.org\n' "$GAP_MIN_NODE_MAJOR" ;;
739
+ *)
740
+ if [ "$major" -lt "$GAP_MIN_NODE_MAJOR" ]; then
741
+ doctor_problem "Node.js $v is too old: GAP's npm package needs Node $GAP_MIN_NODE_MAJOR or newer"
742
+ printf ' Action: install a current Node.js from https://nodejs.org\n'
743
+ else
744
+ ok "Node.js $v"
745
+ fi ;;
746
+ esac
747
+ }
748
+
749
+ # doctor_docker: check_prereqs as every command runs it (it prints the failing step and what to do, so it is not repeated
750
+ # here), but a failure does not end doctor: what does not need Docker is still looked at.
751
+ doctor_docker() {
752
+ if check_prereqs "gap doctor" up; then
753
+ DOCTOR_DOCKER=1
754
+ ok "OS: $(os_name)"
755
+ ok "Docker is ready (daemon running, Compose v2, curl found)"
756
+ else
757
+ DOCTOR_DOCKER=0
758
+ DOCTOR_PROBLEMS=$((DOCTOR_PROBLEMS + 1))
759
+ if [ "$(channel)" = npm ]; then
760
+ doctor_unsure "Access to GAP's images and the state of the GAP services were not checked: Docker is not ready"
761
+ else
762
+ doctor_unsure "The state of the GAP services was not checked: Docker is not ready"
763
+ fi
764
+ fi
765
+ }
766
+
767
+ # doctor_ports: what `gap install` and `gap start` would find: pick_port itself decides, so the two cannot disagree.
768
+ doctor_ports() {
769
+ local stored="" port
770
+ is_installed && stored=$(env_get GAP_UI_PORT)
771
+ if ! port=$(pick_port "$stored"); then
772
+ doctor_problem "No free port for the Admin UI"
773
+ printf ' Action: free a port between %s and %s\n' "$GAP_PORT_FIRST" "$GAP_PORT_LAST"
774
+ return 0
775
+ fi
776
+ if [ -z "$stored" ]; then ok "Port $port is free for the Admin UI"
777
+ elif [ "$port" != "$stored" ]; then ok "The Admin UI port $stored is used by another program; the next gap start will use $port"
778
+ elif port_in_use "$stored"; then ok "The Admin UI port $stored is in use by GAP"
779
+ else ok "The Admin UI port $stored is free"
780
+ fi
781
+ }
782
+
783
+ # doctor_other_channel: the npm channel only. A launcher that a ZIP install made may run instead of the npm command. Only
784
+ # reported (install.sh does the same); a ~/.local/bin/gap that is not ours is not our business.
785
+ doctor_other_channel() {
786
+ [ "$(channel)" = npm ] || return 0
787
+ launcher_is_ours "$LAUNCHER_DIR/gap" || return 0
788
+ doctor_problem "$LAUNCHER_DIR/gap is a launcher from a ZIP install of GAP and may run instead of the npm command"
789
+ printf ' Use one channel per machine; the data is shared. To keep only the npm command: rm %s/gap\n' "$LAUNCHER_DIR"
790
+ }
791
+
792
+ # doctor_resolution NAME ANSWER: judge one answer of probe_shell about `gap` (alias:DEF, function, abbr, none, file:PATH) in
793
+ # words, and change nothing. NAME is the shell, or "this terminal" when no shell could be asked.
794
+ doctor_resolution() {
795
+ local name="$1" answer="$2" where="${2#*:}" ours=0 kind file shown line noun dir
796
+ case "$answer" in
797
+ file:*)
798
+ if [ "$(channel)" = npm ]; then
799
+ [ "$where" -ef "$GAP_SRC/../bin/gap.js" ] && ours=1
800
+ # A ZIP launcher in front is the one doctor_other_channel has reported already (a file there that is not ours is
801
+ # another program, judged below).
802
+ [ "$where" -ef "$LAUNCHER_DIR/gap" ] && launcher_is_ours "$LAUNCHER_DIR/gap" && return 0
803
+ else
804
+ [ "$where" -ef "$LAUNCHER_DIR/gap" ] && launcher_is_ours "$LAUNCHER_DIR/gap" && ours=1
805
+ fi
806
+ if [ "$ours" = 1 ]; then
807
+ ok "In $name, \"gap\" runs GAP"
808
+ else
809
+ doctor_problem "In $name, \"gap\" runs $where (another program), not GAP"
810
+ if [ "$(channel)" = npm ]; then printf ' Run GAP with %s status, or put npm'"'"'s global bin folder first on your PATH.\n' "$(gap_script)"
811
+ else printf ' Run GAP with %s status, or put %s first on your PATH.\n' "$(gap_script)" "$LAUNCHER_DIR"; fi
812
+ fi ;;
813
+ none)
814
+ doctor_problem "In $name, \"gap\" is not found: it is not on the PATH"
815
+ if [ "$(channel)" = npm ]; then
816
+ dir=$(npm prefix -g 2>/dev/null)
817
+ if [ -n "$dir" ]; then printf ' Action: add this folder to your PATH: %s/bin\n' "$dir"
818
+ else printf ' Action: add npm'"'"'s global bin folder to your PATH (find it with: npm prefix -g)\n'; fi
819
+ else
820
+ file=$(shell_startup_file "$(basename "${SHELL:-}")")
821
+ printf ' Action: add this line to %s: %s\n' "$(tilde "$file")" "$RC_EXPORT"
822
+ fi
823
+ printf ' Until then, run GAP with: %s status\n' "$(gap_script)" ;;
824
+ alias:*|function|abbr) # probe_shell prints function and abbr without a colon
825
+ kind=${answer%%:*}
826
+ case "$kind" in alias) noun="an alias: ${where#alias }" ;; abbr) noun="an abbreviation" ;; *) noun="a shell function" ;; esac
827
+ file=$(shell_startup_file "$name"); shown=$(tilde "$file"); line=$(unshadow_line "$name" "$kind")
828
+ doctor_problem "In $name, \"gap\" is $noun"
829
+ printf ' So "gap status" would not run GAP. To use plain "gap" in %s, run once, then open a new terminal:\n' "$name"
830
+ printf " echo '%s' >> %s\n" "$line" "$shown"
831
+ printf ' Until then, run: command gap status\n' ;;
832
+ esac
833
+ }
834
+
835
+ # doctor_gap_command: does `gap` mean GAP in the user's terminal? Each shell of probe_candidates is asked as install.sh asks
836
+ # it (a new terminal: its startup files run); only when none can be asked is the PATH of this process looked at. A ZIP that is
837
+ # not installed yet is skipped: install.sh puts `gap` on the PATH, and "GAP is not installed" already says to run it.
838
+ doctor_gap_command() {
839
+ local sh name answer found asked=1 seen=" "
840
+ [ "$(channel)" = npm ] || is_installed || return 0
841
+ doctor_other_channel
842
+ while IFS= read -r sh <&3; do
843
+ name=$(basename "$sh")
844
+ case "$seen" in *" $name "*) continue ;; esac
845
+ seen="$seen$name "
846
+ answer=$(probe_shell "$sh")
847
+ [ -n "$answer" ] || continue
848
+ asked=0
849
+ doctor_resolution "$name" "$answer"
850
+ done 3< <(probe_candidates)
851
+ if [ "$asked" != 0 ]; then
852
+ found=$(command -v gap 2>/dev/null)
853
+ case "$found" in /*) answer="file:$found" ;; "") answer=none ;; *) answer=function ;; esac
854
+ doctor_resolution "this terminal" "$answer"
855
+ fi
856
+ }
857
+
858
+ # doctor_join WORDS: the words of a space-separated list, comma-separated.
859
+ doctor_join() { printf '%s' "${1# }" | sed 's/ /, /g'; }
860
+
861
+ # doctor_registry: the npm channel pulls from a private registry, so say whether this machine may read the three images.
862
+ # `docker manifest inspect` reads a manifest and downloads no layer (it is not `docker pull`). The three run side by side:
863
+ # Docker stops waiting for an unreachable registry after 30 seconds by itself (measured), once, not three times over.
864
+ # The prefix is the channel's, not .env's (an install by a ZIP left an empty one there).
865
+ REGISTRY_READABLE=0 # set to 1 by doctor_registry when it could read all three images (`gap login` exits 0 only then)
866
+
867
+ doctor_registry() {
868
+ local version prefix host tmp i ref kind err refs
869
+ REGISTRY_READABLE=0
870
+ local r_unauthorized="" r_denied="" r_notfound="" r_network="" r_other="" other_text=""
871
+ [ "$(channel)" = npm ] && [ "$DOCTOR_DOCKER" = 1 ] || return 0
872
+ version=$(cli_version)
873
+ if [ -z "$version" ]; then doctor_unsure "GAP's images were not checked: this package has no VERSION file"; return 0; fi
874
+ prefix=$(channel_prefix); host=${prefix%%/*}
875
+ refs=("$(image_ref "$prefix" gap-kernel "$version")" "$(image_ref "$prefix" gap-admin-ui "$version")" \
876
+ "$(image_ref "$prefix" gap-postgres 16)")
877
+ tmp=$(mktemp -d) || { doctor_unsure "GAP's images were not checked: no temporary folder"; return 0; }
878
+ printf 'Checking access to the image registry...\n'
879
+ for i in 0 1 2; do
880
+ ( docker manifest inspect "${refs[$i]}" >/dev/null 2>"$tmp/$i.err"; printf '%s' "$?" > "$tmp/$i.rc" ) &
881
+ done
882
+ wait
883
+ for i in 0 1 2; do
884
+ [ "$(cat "$tmp/$i.rc" 2>/dev/null)" = 0 ] && continue
885
+ ref=${refs[$i]}; err=$(cat "$tmp/$i.err" 2>/dev/null)
886
+ case "$(classify_manifest_error "$err")" in
887
+ unauthorized) r_unauthorized="$r_unauthorized $ref" ;;
888
+ denied) r_denied="$r_denied $ref" ;;
889
+ notfound) r_notfound="$r_notfound $ref" ;;
890
+ network) r_network="$r_network $ref" ;;
891
+ *) r_other="$r_other $ref"; [ -n "$other_text" ] || other_text=$err ;;
892
+ esac
893
+ done
894
+ rm -rf "$tmp"
895
+ if [ -z "$r_unauthorized$r_denied$r_notfound$r_network$r_other" ]; then
896
+ ok "You can read GAP's images in $host"
897
+ REGISTRY_READABLE=1
898
+ return 0
899
+ fi
900
+ if [ -n "$r_unauthorized" ]; then
901
+ doctor_problem "GAP's images are private and this machine is not logged in to $host"
902
+ print_login_steps
903
+ fi
904
+ if [ -n "$r_denied" ]; then
905
+ doctor_problem "$host refused access to $(doctor_join "$r_denied")"
906
+ print_denied_help
907
+ fi
908
+ if [ -n "$r_notfound" ]; then
909
+ doctor_problem "$(doctor_join "$r_notfound") was not found in the registry"
910
+ printf 'This GAP package is version %s. That version may not be published yet; check the version you were given.\n' "$version"
911
+ fi
912
+ if [ -n "$r_network" ]; then
913
+ doctor_unsure "Could not reach $host: access to $(doctor_join "$r_network") was not checked"
914
+ printf ' Check your internet connection (and proxy settings). Nothing is wrong with GAP itself.\n'
915
+ fi
916
+ if [ -n "$r_other" ]; then
917
+ doctor_unsure "Could not check access to $(doctor_join "$r_other")"
918
+ [ -z "$other_text" ] || printf '%s\n' "$other_text" | tail -n 2 | sed 's/^/ /'
919
+ fi
920
+ }
921
+
922
+ # doctor_services: the three services, as `gap status` reads them (svc_state), and the Admin UI through its own address.
923
+ doctor_services() {
924
+ local pg kernel ui pair s state name
925
+ pg=$(svc_state postgres); kernel=$(svc_state kernel); ui=$(svc_state admin-ui)
926
+ if [ "$pg$kernel$ui" = StoppedStoppedStopped ]; then
927
+ doctor_problem "GAP is not running"
928
+ printf ' Run: gap start\n'
929
+ return 0
930
+ fi
931
+ for pair in "postgres:$pg" "kernel:$kernel" "admin-ui:$ui"; do
932
+ s=${pair%%:*}; state=${pair#*:}
933
+ case "$s" in postgres) name=PostgreSQL ;; kernel) name="GAP Kernel" ;; *) name="GAP Admin UI" ;; esac
934
+ case "$state" in
935
+ Healthy) ;;
936
+ Restarting)
937
+ doctor_problem "$s keeps restarting"
938
+ printf ' Run: gap logs %s\n' "$s"
939
+ [ "$s" = kernel ] && printf ' If .env was edited, the database password may no longer match the existing data (see: gap logs postgres).\n' ;;
940
+ Stopped)
941
+ doctor_problem "$name is stopped"
942
+ printf ' Run: gap start\n' ;;
943
+ Starting)
944
+ doctor_problem "$name is still starting"
945
+ printf ' Wait a minute, then run: gap doctor\n' ;;
946
+ *)
947
+ doctor_problem "$name is $(printf '%s' "$state" | tr '[:upper:]' '[:lower:]')"
948
+ printf ' Run: gap logs %s\n' "$s" ;;
949
+ esac
950
+ done
951
+ [ "$pg$kernel$ui" = HealthyHealthyHealthy ] || return 0
952
+ ok "PostgreSQL, GAP Kernel and GAP Admin UI are healthy"
953
+ if http_ok /system/health; then ok "The Admin UI answers at $(ui_url)"
954
+ else
955
+ doctor_problem "The Admin UI does not answer at $(ui_url)"
956
+ printf ' Run: gap logs kernel\n'
957
+ fi
958
+ }
959
+
960
+ # doctor_install: is GAP installed here, in the version this package expects, and running.
961
+ doctor_install() {
962
+ local installed pkg
963
+ if ! is_installed; then
964
+ if [ "$DOCTOR_DOCKER" = 1 ] && docker volume inspect "${GAP_PROJECT}_pgdata" >/dev/null 2>&1; then
965
+ doctor_problem "GAP is not installed here, but data from an earlier install is in the Docker volume ${GAP_PROJECT}_pgdata"
966
+ printf ' Run: %s (it keeps that data)\n' "$(doctor_install_hint)"
967
+ else
968
+ doctor_problem "GAP is not installed in $GAP_HOME"
969
+ printf ' Run: %s\n' "$(doctor_install_hint)"
970
+ fi
971
+ return 0
972
+ fi
973
+ installed=$(installed_version); pkg=$(cli_version)
974
+ if [ "$installed" = unknown ]; then
975
+ doctor_problem "GAP_VERSION is missing from $GAP_HOME/.env"
976
+ printf ' Run: %s\n' "$(doctor_install_hint)"
977
+ else
978
+ ok "GAP $installed is installed in $GAP_HOME"
979
+ if [ -n "$pkg" ] && [ "$pkg" != "$installed" ]; then
980
+ doctor_problem "The installed version ($installed) is not the version of this package ($pkg)"
981
+ if package_is_newer; then printf ' Run: gap update (it backs up first)\n'
982
+ elif package_is_older; then printf ' Install the newer package: npm install -g @atomiton-dev/gap-cli@latest\n'
983
+ else printf ' Run: %s\n' "$(doctor_install_hint)"; fi
984
+ fi
985
+ fi
986
+ if [ ! -f "$GAP_HOME/docker-compose.yml" ]; then
987
+ doctor_problem "docker-compose.yml is missing in $GAP_HOME"
988
+ printf ' Run: %s again to put it back\n' "$(doctor_install_hint)"
989
+ return 0
990
+ fi
991
+ [ "$DOCTOR_DOCKER" = 1 ] || return 0
992
+ doctor_services
993
+ }
994
+
995
+ # --- gap update (#1986, #2014) ----------------------------------------------------------------------------------
996
+ # `gap install` run again on a newer package already moves the version; `gap update` is that move with the guards of the
997
+ # issue: npm channel only, versions compared as numbers, no step back, pull first, then a checked backup, then the stack.
998
+
999
+ # put_file SRC DEST MODE: replace DEST in one rename, so that a `gap` that is running right now keeps reading its old
1000
+ # copy instead of a half-written one. (install.sh and `gap update`.)
1001
+ put_file() {
1002
+ cp "$1" "$2.new" && chmod "$3" "$2.new" && mv -f "$2.new" "$2"
1003
+ }
1004
+
1005
+ # health_checks: the Admin UI answers on /health, /system/health and / (the path the user will take), for up to ~30 seconds
1006
+ # each. Returns 1 after saying which one did not. (install.sh and `gap update`.)
1007
+ health_checks() {
1008
+ local p tries
1009
+ for p in /health /system/health /; do
1010
+ tries=0
1011
+ until http_ok "$p"; do
1012
+ tries=$((tries + 1))
1013
+ if [ "$tries" -ge 15 ]; then
1014
+ warn "Health check failed: $(ui_url)$p"
1015
+ printf 'Run: gap logs kernel\n'
1016
+ return 1
1017
+ fi
1018
+ sleep 2
1019
+ done
1020
+ done
1021
+ }
1022
+
1023
+ # version_cmp A B: prints -1, 0 or 1 (A older than, equal to, newer than B) and returns 0; prints nothing and returns 2 when it
1024
+ # cannot tell. Only `major.minor.patch` with an optional `-suffix` is read; the three numbers are compared as numbers (0.10.0
1025
+ # is newer than 0.9.0; a leading zero is not octal) and the suffix has to be the same on both sides (the repo has `-mvp`; a
1026
+ # comparison of `-mvp` with `-rc1` would be a guess). Numbers longer than nine digits are refused: shell arithmetic would wrap.
1027
+ VERSION_RE='^([0-9]{1,9})\.([0-9]{1,9})\.([0-9]{1,9})(-[[:alnum:]._-]+)?$'
1028
+ version_cmp() {
1029
+ local a1 a2 a3 sa b1 b2 b3 sb x y i
1030
+ [[ "$1" =~ $VERSION_RE ]] || return 2
1031
+ a1=${BASH_REMATCH[1]}; a2=${BASH_REMATCH[2]}; a3=${BASH_REMATCH[3]}; sa=${BASH_REMATCH[4]}
1032
+ [[ "$2" =~ $VERSION_RE ]] || return 2
1033
+ b1=${BASH_REMATCH[1]}; b2=${BASH_REMATCH[2]}; b3=${BASH_REMATCH[3]}; sb=${BASH_REMATCH[4]}
1034
+ [ "$sa" = "$sb" ] || return 2
1035
+ for i in 1 2 3; do
1036
+ eval "x=\$a$i y=\$b$i"
1037
+ if [ $((10#$x)) -gt $((10#$y)) ]; then printf 1; return 0; fi
1038
+ if [ $((10#$x)) -lt $((10#$y)) ]; then printf -- -1; return 0; fi
1039
+ done
1040
+ printf 0
1041
+ }
1042
+
1043
+ # package_is_newer: the npm package that runs this script is newer than the installed GAP, by numbers. Says nothing and fails
1044
+ # when the versions are equal, the package is older, they cannot be compared, or this is the ZIP channel.
1045
+ package_is_newer() {
1046
+ local pkg installed
1047
+ [ "$(channel)" = npm ] || return 1
1048
+ pkg=$(cli_version); installed=$(installed_version)
1049
+ [ -n "$pkg" ] && [ -n "$installed" ] || return 1
1050
+ [ "$(version_cmp "$installed" "$pkg" 2>/dev/null)" = -1 ]
1051
+ }
1052
+
1053
+ # package_is_older: the mirror of package_is_newer (`gap install` and `gap update` refuse to go back to it).
1054
+ package_is_older() {
1055
+ local pkg installed
1056
+ [ "$(channel)" = npm ] || return 1
1057
+ pkg=$(cli_version); installed=$(installed_version)
1058
+ [ -n "$pkg" ] && [ -n "$installed" ] || return 1
1059
+ [ "$(version_cmp "$installed" "$pkg" 2>/dev/null)" = 1 ]
1060
+ }
1061
+
1062
+ # older_package_help: what to say to a user whose npm package is older than the installed GAP.
1063
+ older_package_help() {
1064
+ printf ' gap does not go back: what a newer version did to the database cannot be undone.\n'
1065
+ printf ' Install the newer package first: npm install -g @atomiton-dev/gap-cli@latest\n'
1066
+ }
1067
+
1068
+ # newer_package_note: one line for `gap status` and `gap start`.
1069
+ newer_package_note() {
1070
+ package_is_newer || return 0
1071
+ printf 'A newer version (%s) is in the npm package. Run: gap update\n' "$(cli_version)"
1072
+ }
1073
+
1074
+ # The backup of `gap update`. It is what spike S4 ran for real: `pg_dumpall` for every database (the Kernel creates one per
1075
+ # tenant), `tar` of the volume gap_data (the app files the user may have edited: src/, be/, fe/), both through the old images.
1076
+ # Plus the old .env and compose file, because the update replaces the compose file and a restore of the OLD version needs the
1077
+ # old one (and the old password and version). Nothing here is deleted afterwards.
1078
+ BACKUP_DIR=""
1079
+ PG_DUMP_END='-- PostgreSQL database cluster dump complete'
1080
+ BACKUP_SPACE_MARGIN_PERCENT=5
1081
+
1082
+ # backup_fail MESSAGE [LINE...]: say what is wrong, remove what this backup wrote, return 1.
1083
+ backup_fail() {
1084
+ warn "$1"; shift
1085
+ local line
1086
+ for line in "$@"; do [ -z "$line" ] || printf '%s\n' "$line"; done
1087
+ [ -z "$BACKUP_DIR" ] || rm -rf "$BACKUP_DIR"
1088
+ BACKUP_DIR=""
1089
+ printf 'Nothing was changed in GAP: the version, .env and your data are as they were.\n'
1090
+ return 1
1091
+ }
1092
+
1093
+ # pg_size_kb / data_size_kb: kilobytes the database and the gap_data volume take (empty when they cannot be measured).
1094
+ pg_size_kb() {
1095
+ dc exec -T postgres du -sk /var/lib/postgresql/data </dev/null 2>/dev/null | awk 'NR == 1 && $1 ~ /^[0-9]+$/ { print $1 }'
1096
+ }
1097
+ data_size_kb() {
1098
+ dc run --rm --no-deps -T --entrypoint du kernel -sk /data </dev/null 2>/dev/null | awk 'END { if ($1 ~ /^[0-9]+$/) print $1 }'
1099
+ }
1100
+ free_kb() { df -Pk "$1" 2>/dev/null | awk 'NR == 2 && $4 ~ /^[0-9]+$/ { print $4 }'; }
1101
+
1102
+ # dump_is_whole FILE: a readable gzip whose text ends with the line pg_dumpall writes last. An empty or cut dump fails here.
1103
+ dump_is_whole() {
1104
+ [ -s "$1" ] && gzip -t "$1" 2>/dev/null && gzip -dc "$1" 2>/dev/null | tail -n 5 | grep -qxF -- "$PG_DUMP_END"
1105
+ }
1106
+ # archive_is_whole FILE: a readable gzip that tar lists with at least one entry. GNU tar says nothing and exits 0 for a gzip
1107
+ # that holds no tar at all (and for an empty one), so the exit status alone is not a check; a real backup of the app files
1108
+ # always has the entry "./", even when the volume is empty.
1109
+ archive_is_whole() {
1110
+ local st
1111
+ [ -s "$1" ] && gzip -t "$1" 2>/dev/null || return 1
1112
+ tar -tzf "$1" 2>/dev/null | awk 'END { exit NR == 0 }'
1113
+ st=("${PIPESTATUS[@]}")
1114
+ [ "${st[0]}" = 0 ] && [ "${st[1]}" = 0 ]
1115
+ }
1116
+
1117
+ # make_backup: sets BACKUP_DIR (<GAP_HOME>/backups/<UTC time>, 700, files 600) and returns 0, or returns 1 after saying why and
1118
+ # leaving nothing behind. Starts only Postgres if it is not running (the dump goes through it); the Kernel is not needed, the
1119
+ # app files are read with a throw-away container of the old Kernel image.
1120
+ # Room: the backup is at most as big as what is on disk (gzip of a dump, tar of files that are often zips already), so the
1121
+ # free space of the backup folder has to cover the size of the database plus the size of gap_data, plus
1122
+ # BACKUP_SPACE_MARGIN_PERCENT for tar and gzip overhead. A size that cannot be measured is said and does not stop the
1123
+ # backup, which is checked when it is done.
1124
+ make_backup() {
1125
+ local root="$GAP_HOME/backups" dir final pg_kb data_kb need_kb free st err rc
1126
+ if [ "$(svc_state postgres)" != Healthy ]; then
1127
+ printf 'Starting the database for the backup...\n'
1128
+ dc up -d --wait --wait-timeout 120 postgres >/dev/null 2>&1 \
1129
+ || { backup_fail "The database did not start, so it cannot be backed up" "Run: gap logs postgres"; return 1; }
1130
+ fi
1131
+ [ -d "$root" ] || mkdir -m 700 "$root" 2>/dev/null || { backup_fail "Cannot create $root" "Check the permissions of $GAP_HOME."; return 1; }
1132
+ chmod 700 "$root" 2>/dev/null
1133
+ pg_kb=$(pg_size_kb); data_kb=$(data_size_kb); free=$(free_kb "$root")
1134
+ if [ -n "$pg_kb" ] && [ -n "$data_kb" ] && [ -n "$free" ]; then
1135
+ need_kb=$(( (pg_kb + data_kb) * (100 + BACKUP_SPACE_MARGIN_PERCENT) / 100 ))
1136
+ if [ "$free" -lt "$need_kb" ]; then
1137
+ backup_fail "Not enough free space for the backup" \
1138
+ "About $(( (need_kb + 1023) / 1024 )) MB are needed (the database and the app files), $(( free / 1024 )) MB are free in $root." \
1139
+ "Free some space, then run: gap update"
1140
+ return 1
1141
+ fi
1142
+ else
1143
+ printf '[?] The free space needed for the backup could not be measured; the backup is checked when it is done\n'
1144
+ fi
1145
+ # Written under a ".partial" name and renamed only when everything has been checked: a Ctrl-C or a crash must not leave a
1146
+ # half written folder that looks like a backup.
1147
+ final="$root/$(date -u +%Y%m%dT%H%M%SZ)"
1148
+ dir="$final.partial"
1149
+ mkdir -m 700 "$dir" 2>/dev/null || { backup_fail "Cannot create $dir"; return 1; }
1150
+ BACKUP_DIR="$dir"
1151
+ printf 'Backing up the database...\n'
1152
+ dc exec -T postgres pg_dumpall -U postgres </dev/null 2>"$dir/.err" | gzip > "$dir/db.sql.gz"
1153
+ st=("${PIPESTATUS[@]}")
1154
+ chmod 600 "$dir/db.sql.gz" 2>/dev/null
1155
+ if [ "${st[0]}" != 0 ] || [ "${st[1]}" != 0 ]; then
1156
+ err=$(tail -n 3 "$dir/.err" 2>/dev/null)
1157
+ backup_fail "The backup of the database failed" "$err"; return 1
1158
+ fi
1159
+ dump_is_whole "$dir/db.sql.gz" || { backup_fail "The backup of the database is empty or cut short"; return 1; }
1160
+ printf 'Backing up the app files (volume %s_data)...\n' "$GAP_PROJECT"
1161
+ dc run --rm --no-deps -T --entrypoint tar kernel -czf - -C /data . </dev/null 2>"$dir/.err" > "$dir/data.tar.gz"
1162
+ rc=$?
1163
+ chmod 600 "$dir/data.tar.gz" 2>/dev/null
1164
+ if [ "$rc" != 0 ]; then
1165
+ err=$(tail -n 3 "$dir/.err" 2>/dev/null)
1166
+ # tar says 1 when a file changed while it was read: the Kernel was writing. Nothing is wrong with the data.
1167
+ backup_fail "The backup of the app files failed" "$err" "If GAP was busy, run: gap stop and then: gap update"; return 1
1168
+ fi
1169
+ archive_is_whole "$dir/data.tar.gz" || { backup_fail "The backup of the app files is empty or unreadable"; return 1; }
1170
+ cp "$GAP_HOME/.env" "$dir/.env" && cp "$GAP_HOME/docker-compose.yml" "$dir/docker-compose.yml" \
1171
+ || { backup_fail "Could not copy .env and the compose file into the backup"; return 1; }
1172
+ chmod 600 "$dir/.env" "$dir/docker-compose.yml"
1173
+ rm -f "$dir/.err"
1174
+ mv "$dir" "$final" || { backup_fail "Could not finish the backup folder $final"; return 1; }
1175
+ BACKUP_DIR="$final"
1176
+ }
1177
+
1178
+ # print_restore_help BACKUP_DIR OLD_VERSION: how to go back to OLD_VERSION with the data of the backup. These are the commands
1179
+ # that spike S4 ran for real (plus the copy of .env and the compose file of the backup, which the update had replaced).
1180
+ # Three things S4 found are said here: no ON_ERROR_STOP (psql would stop on the one error that is expected), restore only into
1181
+ # an empty database volume with only Postgres running (a restore over data doubles the rows of tables without a primary
1182
+ # key), and the password after the restore is the one of the backup, which is the one in the backup's .env.
1183
+ # Nothing is run; the paths are written in full and quoted, so that the lines can be pasted in any shell.
1184
+ # print_files_back_help BACKUP_DIR: the two lines that put the old .env and compose file back (the first two of the restore).
1185
+ print_files_back_help() {
1186
+ printf ' cp "%s/.env" "%s/.env"\n' "$1" "$GAP_HOME"
1187
+ printf ' cp "%s/docker-compose.yml" "%s/docker-compose.yml"\n' "$1" "$GAP_HOME"
1188
+ }
1189
+
1190
+ print_restore_help() {
1191
+ local b="$1" h="$GAP_HOME" dcx
1192
+ dcx="docker compose -p $GAP_PROJECT --project-directory \"$h\" -f \"$h/docker-compose.yml\" --env-file \"$h/.env\""
1193
+ printf '\nTo go back to GAP %s with the data of the backup (this erases the current database and app files first, because\n' "$2"
1194
+ printf 'the new version may already have changed them; a restore over existing data would duplicate rows):\n'
1195
+ print_files_back_help "$b"
1196
+ printf ' %s down -v\n' "$dcx"
1197
+ printf ' %s up -d --wait postgres\n' "$dcx"
1198
+ printf ' gunzip -c "%s/db.sql.gz" | %s exec -T postgres psql -U postgres -q\n' "$b" "$dcx"
1199
+ printf ' cat "%s/data.tar.gz" | %s run --rm --no-deps -T --entrypoint tar kernel -xzf - -C /data\n' "$b" "$dcx"
1200
+ printf ' gap start\n'
1201
+ printf 'Do not add psql'"'"'s ON_ERROR_STOP: the restore prints exactly one error, `role "postgres" already exists`, and that is\n'
1202
+ printf 'expected (and a few small `set_config` tables, which are normal). The images of %s are still on this machine.\n' "$2"
1203
+ }