@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.
- package/README.md +132 -2
- package/bin/gap.js +47 -0
- package/installer/CHANNEL +1 -0
- package/installer/VERSION +1 -0
- package/installer/docker-compose.yml +86 -0
- package/installer/env.template +13 -0
- package/installer/gap +365 -0
- package/installer/gap-lib.ps1 +1248 -0
- package/installer/gap-lib.sh +1203 -0
- package/installer/gap.ps1 +361 -0
- package/installer/install.ps1 +262 -0
- package/installer/install.sh +252 -0
- package/lib/launcher.js +31 -0
- package/lib/postinstall.js +137 -0
- package/package.json +31 -4
|
@@ -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
|
+
}
|