@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,1248 @@
1
+ # Shared by install.ps1 and gap.ps1 (#1750, D27, D28). Dot-sourced, never run; the caller sets
2
+ # $GapSource to the folder it runs from (the unzipped installer, or ~\.gap once installed).
3
+ #
4
+ # Written for Windows PowerShell 5.1 (the one every Windows 10/11 has): no ??, no ternary, no &&.
5
+ # ASCII only, because 5.1 reads a script that has no BOM as ANSI.
6
+
7
+ $ErrorActionPreference = 'Continue' # native commands write progress to stderr; check $LASTEXITCODE instead
8
+
9
+ # Plain script variables, not environment variables: a GAP_* variable in the user's shell must not
10
+ # change them.
11
+ # D28: the installed instance (.env, docker-compose.yml, gap.ps1) lives in $GapHome, never in the
12
+ # unzipped folder, so that moving or deleting that folder cannot lose the database password.
13
+ $GapHome = Join-Path $HOME '.gap'
14
+ $LauncherDir = Join-Path $GapHome 'bin'
15
+ $GapProject = 'gap'
16
+ $GapPortFirst = 8061
17
+ $GapPortLast = 8070
18
+ $GapUpTimeout = 300 # D26: seconds to wait for the three services to become healthy
19
+ # Windows PowerShell 5.1 has no $IsWindows; PowerShell 7 does. False only when these scripts are
20
+ # exercised with pwsh on macOS or Linux, where there is no user PATH in a registry to change.
21
+ $OnWindows = ($PSVersionTable.PSVersion.Major -lt 6) -or $IsWindows
22
+
23
+ # Only .env decides version, port and password (A4, E40): drop GAP_* and COMPOSE_* from this process.
24
+ Get-ChildItem Env: | Where-Object { $_.Name -match '^(GAP|COMPOSE)_' } | ForEach-Object { Remove-Item -LiteralPath "Env:$($_.Name)" }
25
+
26
+ # Windows Terminal and PowerShell 7 draw the check mark; the old console font may not.
27
+ $Tick = if ($env:WT_SESSION -or $PSVersionTable.PSVersion.Major -ge 7) { [string][char]0x2713 } else { 'OK' }
28
+
29
+ function Write-Ok($message) { Write-Host "[$Tick] $message" }
30
+ function Write-Warn($message) { Write-Host "[!] $message" }
31
+
32
+ function Get-EnvPath { Join-Path $GapHome '.env' }
33
+
34
+ # docker compose against this folder.
35
+ function Invoke-Dc {
36
+ & docker compose -p $GapProject --project-directory $GapHome -f (Join-Path $GapHome 'docker-compose.yml') --env-file (Get-EnvPath) @args
37
+ }
38
+
39
+ # .env is read by Compose: UTF-8 without a BOM (a BOM would glue itself to the first key) and LF.
40
+ function Write-EnvFile($text) {
41
+ [IO.File]::WriteAllText((Get-EnvPath), $text, (New-Object Text.UTF8Encoding($false)))
42
+ }
43
+
44
+ function Get-EnvValue($key) {
45
+ foreach ($line in [IO.File]::ReadAllLines((Get-EnvPath))) {
46
+ if ($line.StartsWith("$key=")) { return $line.Substring($key.Length + 1) }
47
+ }
48
+ return ''
49
+ }
50
+
51
+ # Get-ImageRef PREFIX NAME TAG: the name Docker knows an image by. PREFIX is the registry part with its trailing
52
+ # slash (the npm channel: ghcr.io/atomiton-gap/) or empty (the ZIP channel: the local name, as `docker load` made
53
+ # it). -f, not "$prefix$name:$tag": PowerShell would read `$name:` as a variable in a scope called name.
54
+ function Get-ImageRef($prefix, $name, $tag) { '{0}{1}:{2}' -f $prefix, $name, $tag }
55
+
56
+ # Get-InstalledPrefix: the PREFIX of the images that are on this machine, as the .env written by the install says
57
+ # (#1986). Empty when .env has no such key (an install by a ZIP of #1750) or has it empty. Not "the prefix of the
58
+ # channel running this script": an npm install over a machine that a ZIP installed rewrites .env. Use it to find
59
+ # what is installed (uninstall); what to pull is the channel's own matter.
60
+ function Get-InstalledPrefix { Get-EnvValue 'GAP_IMAGE_PREFIX' }
61
+
62
+ # --- Which installer channel runs this script (#1986, #2011) ----------------------------------------------
63
+ # The npm package carries a CHANNEL file that says `npm`, next to the scripts (installer\build-npm-package.sh). A ZIP
64
+ # has no such file, and neither has the copy of gap.ps1 in ~\.gap, so both are the `zip` channel.
65
+ $NpmImagePrefix = 'ghcr.io/atomiton-gap/'
66
+
67
+ function Get-Channel {
68
+ $file = Join-Path $GapSource 'CHANNEL'
69
+ if ((Test-Path -LiteralPath $file) -and (([IO.File]::ReadAllText($file)).Trim() -eq 'npm')) { return 'npm' }
70
+ return 'zip'
71
+ }
72
+
73
+ # Get-ChannelPrefix / Get-ChannelPullPolicy: what the channel being run pulls from, and how Compose treats a missing
74
+ # image. Never read from .env: an npm install over a machine that a ZIP installed finds an empty prefix there, and would
75
+ # pull a gap-kernel:<V> that no registry has. Get-InstalledPrefix (above) is for finding what is installed.
76
+ function Get-ChannelPrefix { if ((Get-Channel) -eq 'npm') { $NpmImagePrefix } else { '' } }
77
+ function Get-ChannelPullPolicy { if ((Get-Channel) -eq 'npm') { 'missing' } else { 'never' } }
78
+
79
+ # Get-InstallCommand: what the user types to install again, in the words of this channel.
80
+ # Get-InstallName: the same command as Get-InstallCommand, short, for the sentence "... then run <it> again" of install.ps1's
81
+ # own error messages. The ZIP keeps the bare script name those messages always had; npm has no install.ps1 to name (it
82
+ # sits inside node_modules), only `gap install`.
83
+ function Get-InstallName { if ((Get-Channel) -eq 'npm') { 'gap install' } else { 'install.ps1' } }
84
+
85
+ function Get-InstallCommand {
86
+ if ((Get-Channel) -eq 'npm') { return 'gap install' }
87
+ return "powershell -ExecutionPolicy Bypass -File `"$(Join-Path $GapSource 'install.ps1')`""
88
+ }
89
+
90
+ # Get-PullErrorKind TEXT: unauthorized | denied | notfound | network | other, for what `docker pull` printed. The texts
91
+ # are the ones measured on ghcr.io (03/11/2026, spike S6, on macOS: the text comes from the Docker daemon, which is
92
+ # the same on Windows, but this has not been measured there): `error from registry: unauthorized` is no credential at
93
+ # all, `error from registry: denied` is a credential that was refused or has no right to the package (not told apart by
94
+ # text), `<ref>: not found` is a missing tag or package, `failed to do request` is a network that did not answer.
95
+ # Anything else is `other`: its text is shown as Docker gave it.
96
+ function Get-PullErrorKind($text) {
97
+ $t = "$text".ToLower()
98
+ if ($t.Contains('failed to do request')) { return 'network' }
99
+ if ($t.Contains('error from registry: unauthorized')) { return 'unauthorized' }
100
+ if ($t.Contains('error from registry: denied')) { return 'denied' }
101
+ if ($t.Contains(': not found')) { return 'notfound' }
102
+ return 'other'
103
+ }
104
+
105
+ # Write-LoginSteps / Write-DeniedHelp: what to do about `unauthorized` and `denied`. Shared by Write-PullFailure (a pull that
106
+ # failed), doctor (access read with `docker manifest inspect`) and `gap login` (the same check, right after the login). None
107
+ # of them prints or asks for a token here: `gap login` asks for it and hands it to Docker on stdin.
108
+ function Write-LoginSteps {
109
+ Write-Host 'Action:'
110
+ Write-Host ' 1. Create a GitHub personal access token (classic) that has the read:packages permission:'
111
+ Write-Host ' https://github.com/settings/tokens/new?scopes=read:packages'
112
+ Write-Host ' 2. Log in to the registry: gap login'
113
+ Write-Host ' and enter your GitHub username and the token when it asks.'
114
+ }
115
+
116
+ # $script:LoginJustDone is set by `gap login` before it checks the images: the token was accepted a second ago, so an expired
117
+ # or revoked token is not what the refusal means, and saying so would send the user to make a second token for nothing.
118
+ $script:LoginJustDone = $false
119
+
120
+ function Write-DeniedHelp {
121
+ if ($script:LoginJustDone) {
122
+ Write-Host "You are logged in, but your GitHub account has not been given access to GAP's images."
123
+ Write-Host 'Ask the GAP administrator to add you to the team that can read them. If you were added just now,'
124
+ Write-Host 'accept the invitation GitHub emailed you, then check again with: gap doctor'
125
+ return
126
+ }
127
+ Write-Host 'Either your token is no longer valid (expired or revoked), or your GitHub account has not been given'
128
+ Write-Host "access to GAP's images. Ask the GAP administrator to add you to the team that can read them."
129
+ Write-Host 'If you were added, accept the invitation GitHub emailed you. If you are already on the team,'
130
+ Write-Host 'create a new token with read:packages and log in again:'
131
+ Write-Host ' gap login'
132
+ }
133
+
134
+ # Get-LoginErrorKind TEXT: rejected | network | other, for what `docker login` printed when it failed (the same words, and the
135
+ # same order, as classify_login_error in gap-lib.sh). Measured with Docker 29 on ghcr.io (05/11/2026), on macOS: a made-up
136
+ # token ends with `Error response from daemon: Get "https://ghcr.io/v2/": denied: denied`; an unreachable registry with
137
+ # `... Client.Timeout exceeded ...` or `... no such host`. A bare "denied" is not enough for `rejected`: an unreadable Docker
138
+ # config says `permission denied` too, and that is not a bad token.
139
+ function Get-LoginErrorKind($text) {
140
+ $t = "$text".ToLowerInvariant()
141
+ foreach ($marker in 'no such host', 'client.timeout', 'deadline exceeded', 'connection refused', 'i/o timeout', 'dial tcp', 'failed to do request') {
142
+ if ($t.Contains($marker)) { return 'network' }
143
+ }
144
+ if ($t.Contains('": denied') -or $t.Contains('": unauthorized')) { return 'rejected' }
145
+ return 'other'
146
+ }
147
+
148
+ # Write-PullFailure IMAGE KIND TEXT [RETRY]: say what to do about a pull that failed. Nothing was written yet when this runs,
149
+ # which the message says. RETRY is the command to run again when it is not the installer's own: `gap update` names itself, so
150
+ # that the way on is not `gap install`, which would move the version with no backup and no guard.
151
+ function Write-PullFailure($image, $kind, $text, $retry = '') {
152
+ $version = ([IO.File]::ReadAllText((Join-Path $GapSource 'VERSION'))).Trim()
153
+ switch ($kind) {
154
+ 'unauthorized' {
155
+ Write-Warn "GAP's images are private and this machine is not logged in to ghcr.io"
156
+ Write-LoginSteps
157
+ }
158
+ 'denied' {
159
+ Write-Warn "ghcr.io refused access to $image"
160
+ Write-DeniedHelp
161
+ }
162
+ 'notfound' {
163
+ Write-Warn "$image was not found in the registry"
164
+ Write-Host "This GAP package is version $version. That version may not be published yet; check the version you were given."
165
+ }
166
+ 'network' {
167
+ Write-Warn "Could not reach ghcr.io while pulling $image"
168
+ Write-Host 'Check your internet connection (and proxy settings). Images and layers already downloaded are kept.'
169
+ }
170
+ default {
171
+ Write-Warn "Pulling $image failed"
172
+ @("$text" -split "`r?`n" | Where-Object { $_ }) | Select-Object -Last 5 | ForEach-Object { Write-Host $_ }
173
+ }
174
+ }
175
+ if ($retry) {
176
+ Write-Host 'Nothing was changed.'
177
+ Write-Host "Then run: $retry"
178
+ } else {
179
+ Write-Host 'Nothing was installed and no data was changed.'
180
+ Write-Host "Then run: $(Get-InstallCommand)"
181
+ }
182
+ }
183
+
184
+ # Invoke-PullImages VERSION: pull the images this version needs from the registry of the channel (npm), the ones that are
185
+ # not on this machine yet. Returns $false after Write-PullFailure has said what to do. Runs before anything is written to
186
+ # GapHome, so a user who may not pull is left with nothing half installed. Docker's own output is not shown: a failure
187
+ # is explained in our words (the text is shown only for a kind we do not know). RETRY: see Write-PullFailure.
188
+ function Invoke-PullImages($version, $retry = '') {
189
+ $prefix = Get-ChannelPrefix
190
+ foreach ($image in (Get-ImageRef $prefix 'gap-kernel' $version), (Get-ImageRef $prefix 'gap-admin-ui' $version), (Get-ImageRef $prefix 'gap-postgres' '16')) {
191
+ & docker image inspect $image *> $null
192
+ if ($LASTEXITCODE -eq 0) { continue }
193
+ Write-Host "Pulling $image, this can take a few minutes..."
194
+ $out = & docker pull $image 2>&1
195
+ if ($LASTEXITCODE -ne 0) {
196
+ $text = @($out) -join "`n"
197
+ Write-PullFailure $image (Get-PullErrorKind $text) $text $retry
198
+ return $false
199
+ }
200
+ }
201
+ return $true
202
+ }
203
+
204
+ # Set-EnvValue KEY VALUE: set one line of .env, keep the others (and their secrets) untouched. A KEY
205
+ # that is not in the file yet is added at the end (#1986: an .env written before a key existed, such as
206
+ # the GAP_IMAGE_PREFIX of the npm channel, must be able to receive it).
207
+ function Set-EnvValue($key, $value) {
208
+ $found = $false
209
+ # An explicit list, not `$x = foreach (...)`: that gives $null for an empty file and a bare string for
210
+ # a one-line file, and `@($null)` would put an empty first line in front of the new key.
211
+ $out = New-Object 'System.Collections.Generic.List[string]'
212
+ foreach ($line in [IO.File]::ReadAllLines((Get-EnvPath))) {
213
+ if ($line.StartsWith("$key=")) { $found = $true; $out.Add("$key=$value") } else { $out.Add($line) }
214
+ }
215
+ if (-not $found) { $out.Add("$key=$value") }
216
+ Write-EnvFile (($out -join "`n") + "`n")
217
+ }
218
+
219
+ function Get-UiUrl { "http://127.0.0.1:$(Get-EnvValue 'GAP_UI_PORT')" }
220
+
221
+ function Get-OsName {
222
+ $arch = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLower()
223
+ "Windows $arch"
224
+ }
225
+
226
+ # Prints the failing step, then what to do about it. $rerun = command to run again afterwards;
227
+ # $needsUp = the caller is going to start the stack, which also checks that Compose has
228
+ # `up --wait-timeout`. Returns $true when everything is in place.
229
+ function Test-Prereqs($rerun, $needsUp) {
230
+ $lines = @("[$Tick] OS: $(Get-OsName)")
231
+ $action = ''
232
+ if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
233
+ $lines += '[!] Docker CLI not found'
234
+ $action = 'Install Docker Desktop (https://www.docker.com/products/docker-desktop/), then open a new terminal'
235
+ } else {
236
+ $lines += "[$Tick] Docker CLI detected"
237
+ $osType = & docker info --format '{{.OSType}}' 2>$null
238
+ if ($LASTEXITCODE -ne 0) {
239
+ $lines += '[!] Docker daemon stopped'
240
+ $action = 'Start Docker Desktop and wait until it says it is running'
241
+ } elseif ("$osType".Trim() -ne 'linux') {
242
+ $lines += "[$Tick] Docker daemon running"
243
+ $lines += '[!] Docker is set to Windows containers'
244
+ $action = 'Switch Docker Desktop to Linux containers (tray icon, then Switch to Linux containers)'
245
+ } else {
246
+ & docker compose version *> $null
247
+ if ($LASTEXITCODE -ne 0) {
248
+ $lines += "[$Tick] Docker daemon running"
249
+ $lines += '[!] Docker Compose not found'
250
+ $action = 'Update Docker Desktop (it includes Compose v2)'
251
+ } elseif ($needsUp -and ((& docker compose up --help 2>$null | Out-String) -notmatch '--wait-timeout')) {
252
+ # Start-GapStack needs `up --wait --wait-timeout`; an older Compose would fail there with
253
+ # "unknown flag" and the user would be told that a service is unhealthy.
254
+ $lines += "[$Tick] Docker daemon running"
255
+ $lines += '[!] Docker Compose is too old'
256
+ $action = 'Update Docker Desktop (it includes Compose v2)'
257
+ }
258
+ }
259
+ }
260
+ if (-not $action) { return $true }
261
+ $lines | ForEach-Object { Write-Host $_ }
262
+ Write-Host "Action: $action"
263
+ Write-Host "Then run: $rerun"
264
+ return $false
265
+ }
266
+
267
+ # Test-PortInUse P: something accepts connections on 127.0.0.1:P, or Windows will not let us bind
268
+ # it (Hyper-V and WSL reserve some ranges; Docker then fails with "forbidden by its access
269
+ # permissions" even though nothing listens).
270
+ function Test-PortInUse($port) {
271
+ $client = New-Object Net.Sockets.TcpClient
272
+ try {
273
+ $pending = $client.BeginConnect('127.0.0.1', $port, $null, $null)
274
+ if ($pending.AsyncWaitHandle.WaitOne(500) -and $client.Connected) { return $true }
275
+ } catch {
276
+ } finally {
277
+ $client.Close()
278
+ }
279
+ $listener = New-Object Net.Sockets.TcpListener([Net.IPAddress]::Loopback, $port)
280
+ try {
281
+ $listener.Start()
282
+ return $false
283
+ } catch {
284
+ return $true
285
+ } finally {
286
+ try { $listener.Stop() } catch { }
287
+ }
288
+ }
289
+
290
+ # Test-PortOwnedByGap P: a container of this project publishes P.
291
+ function Test-PortOwnedByGap($port) {
292
+ $id = & docker ps -q --filter "label=com.docker.compose.project=$GapProject" --filter "publish=$port" 2>$null
293
+ return [bool]$id
294
+ }
295
+
296
+ # Select-Port [STORED]: keep STORED when it is free or already ours, else the first free port of the
297
+ # range (D9). Returns $null only when the whole range is taken.
298
+ function Select-Port($stored) {
299
+ if ($stored -and ((-not (Test-PortInUse $stored)) -or (Test-PortOwnedByGap $stored))) { return [int]$stored }
300
+ foreach ($port in $GapPortFirst..$GapPortLast) {
301
+ if (-not (Test-PortInUse $port)) { return $port }
302
+ }
303
+ return $null
304
+ }
305
+
306
+ # Get-SvcState SERVICE: Healthy | Starting | Unhealthy | Restarting | Running | Stopped
307
+ function Get-SvcState($service) {
308
+ $id = Invoke-Dc ps -aq $service 2>$null | Select-Object -First 1
309
+ if (-not $id) { return 'Stopped' }
310
+ $state = & docker inspect --format '{{.State.Status}}/{{if .State.Health}}{{.State.Health.Status}}{{end}}' $id 2>$null
311
+ switch -Wildcard ("$state".Trim()) {
312
+ 'running/healthy' { return 'Healthy' }
313
+ 'running/starting' { return 'Starting' }
314
+ 'running/unhealthy' { return 'Unhealthy' }
315
+ 'running/' { return 'Running' }
316
+ 'restarting/*' { return 'Restarting' }
317
+ default { return 'Stopped' }
318
+ }
319
+ }
320
+
321
+ # Test-HttpOk PATH: HTTP 200 from the Admin UI (so through nginx, the path the user takes). No proxy:
322
+ # a corporate proxy setting must not intercept 127.0.0.1.
323
+ function Test-HttpOk($path) {
324
+ try {
325
+ $request = [Net.WebRequest]::Create("$(Get-UiUrl)$path")
326
+ $request.Proxy = $null
327
+ $request.Timeout = 5000
328
+ $response = $request.GetResponse()
329
+ try { return ([int]$response.StatusCode -eq 200) } finally { $response.Close() }
330
+ } catch {
331
+ return $false
332
+ }
333
+ }
334
+
335
+ function Format-Arg($value) { '"' + $value + '"' }
336
+
337
+ # Get-DescendantIds ID: every process below ID, deepest first (pwsh on macOS or Linux only).
338
+ function Get-DescendantIds($id) {
339
+ foreach ($child in @(& pgrep -P $id 2>$null)) {
340
+ if ($child) {
341
+ Get-DescendantIds $child
342
+ $child
343
+ }
344
+ }
345
+ }
346
+
347
+ # Stop-ProcessTree PROC: the process and everything it started. docker.exe runs Compose as a separate
348
+ # docker-compose.exe (a grandchild of this script), which Kill() alone would leave waiting on its own.
349
+ function Stop-ProcessTree($proc) {
350
+ if ($OnWindows) {
351
+ & taskkill /PID $proc.Id /T /F *> $null
352
+ return
353
+ }
354
+ foreach ($id in @(Get-DescendantIds $proc.Id)) { & kill -TERM $id *> $null }
355
+ try { $proc.Kill() } catch { }
356
+ }
357
+
358
+ # The user PATH is read and written in the registry directly, not with
359
+ # [Environment]::SetEnvironmentVariable: that would store it as REG_SZ with every %VAR% already
360
+ # expanded, freezing entries such as %USERPROFILE%\AppData\Local\Microsoft\WindowsApps.
361
+ function Get-UserPathRaw {
362
+ $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment')
363
+ if (-not $key) { return '' }
364
+ try {
365
+ return [string]$key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
366
+ } finally {
367
+ $key.Close()
368
+ }
369
+ }
370
+
371
+ function Set-UserPathRaw($value) {
372
+ $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
373
+ try {
374
+ if ($value) { $key.SetValue('Path', $value, [Microsoft.Win32.RegistryValueKind]::ExpandString) }
375
+ else { $key.DeleteValue('Path', $false) }
376
+ } finally {
377
+ $key.Close()
378
+ }
379
+ # Removing a user variable that does not exist changes nothing, but .NET then broadcasts
380
+ # WM_SETTINGCHANGE, so Explorer and the terminals it starts from now on see the new PATH.
381
+ [Environment]::SetEnvironmentVariable('GAP_INSTALLER_PATH_REFRESH', $null, 'User')
382
+ }
383
+
384
+ # Test-LauncherEntry ENTRY: a PATH entry that is the launcher folder (also when written with %VAR%).
385
+ function Test-LauncherEntry($entry) {
386
+ [Environment]::ExpandEnvironmentVariables($entry).TrimEnd('\') -ieq $LauncherDir
387
+ }
388
+
389
+ # --- Does `gap` mean GAP in the user's own PowerShell? --------------------------------------------
390
+ # install.cmd runs install.ps1 with -NoProfile, so it cannot see an alias or function called `gap`
391
+ # that the user's profile defines, and PowerShell prefers those to gap.cmd on the PATH. So each
392
+ # PowerShell the user may open (Windows PowerShell, PowerShell 7) is started the way a terminal
393
+ # window starts it, with its profile, and asked how it resolves `gap`. Like opening a terminal,
394
+ # that runs the profile, under the machine's own execution policy (see Get-GapResolution).
395
+ $GapProbeTimeoutMs = 10000
396
+
397
+ # Get-GapResolution EXE: how EXE resolves `gap`, as @{ Kind = CommandType or 'None'; Source = path }.
398
+ # $null when it cannot say: a profile that exits, or hangs (killed after $GapProbeTimeoutMs).
399
+ # - The question goes in -EncodedCommand: Windows PowerShell 5.1 mangles inner double quotes in arguments.
400
+ # - The answer goes to a UTF-8 file, not to stdout: 5.1 writes redirected output in the console's
401
+ # code page, which turns a user folder such as C:\Users\Nguyen-with-accents into question marks.
402
+ # - `-ExecutionPolicy Bypass` (install.cmd) is kept in $env:PSExecutionPolicyPreference and would be
403
+ # inherited, loading a profile that the user's own windows refuse to run; it is left out here.
404
+ function Get-GapResolution($exe) {
405
+ $answerFile = [IO.Path]::GetTempFileName()
406
+ $out = [IO.Path]::GetTempFileName()
407
+ $err = [IO.Path]::GetTempFileName()
408
+ $target = "'" + $answerFile.Replace("'", "''") + "'"
409
+ $question = '$c = Get-Command gap -ErrorAction SilentlyContinue | Select-Object -First 1; ' +
410
+ '$a = if ($c) { "$($c.CommandType)|$($c.Source)" } else { ''None|'' }; ' +
411
+ "[IO.File]::WriteAllText($target, `$a, (New-Object Text.UTF8Encoding(`$false)))"
412
+ $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($question))
413
+ $policy = $env:PSExecutionPolicyPreference
414
+ try {
415
+ Remove-Item Env:PSExecutionPolicyPreference -ErrorAction SilentlyContinue
416
+ try {
417
+ $proc = Start-Process -FilePath $exe -ArgumentList @('-NoLogo', '-NonInteractive', '-EncodedCommand', $encoded) `
418
+ -NoNewWindow -PassThru -RedirectStandardOutput $out -RedirectStandardError $err
419
+ } finally {
420
+ if ($policy) { $env:PSExecutionPolicyPreference = $policy }
421
+ }
422
+ $null = $proc.Handle # keeps the handle so the process can be waited on and killed
423
+ if (-not $proc.WaitForExit($GapProbeTimeoutMs)) { Stop-ProcessTree $proc; return $null }
424
+ $answer = [IO.File]::ReadAllText($answerFile, [Text.Encoding]::UTF8)
425
+ $bar = $answer.IndexOf('|')
426
+ if ($bar -lt 1) { return $null }
427
+ return @{ Kind = $answer.Substring(0, $bar); Source = $answer.Substring($bar + 1) }
428
+ } catch {
429
+ return $null
430
+ } finally {
431
+ Remove-Item -LiteralPath $answerFile, $out, $err -Force -ErrorAction SilentlyContinue
432
+ }
433
+ }
434
+
435
+ # Get-ProbeShells: the PowerShells found on this machine, as @{ Name; Path }.
436
+ function Get-ProbeShells {
437
+ foreach ($candidate in @(@{ Exe = 'powershell'; Name = 'Windows PowerShell' }, @{ Exe = 'pwsh'; Name = 'PowerShell 7' })) {
438
+ $found = Get-Command $candidate.Exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
439
+ if ($found) { @{ Name = $candidate.Name; Path = $found.Source } }
440
+ }
441
+ }
442
+
443
+ # Test-NpmShim SOURCE: SOURCE is the command `npm install -g` made for this package. On Windows npm puts a shim (gap.cmd,
444
+ # gap.ps1) in its global folder, next to node_modules\@atomiton-dev\gap-cli, and this script runs from the installer
445
+ # folder inside that package. Compared by place, not by what the shim says. Not checked on Windows: the layout is
446
+ # npm's documented one, it has not been looked at there.
447
+ function Test-NpmShim($source) {
448
+ if ((Get-Channel) -ne 'npm') { return $false }
449
+ $package = [IO.Path]::GetFullPath((Split-Path -Parent $GapSource)).TrimEnd('\')
450
+ $shimDir = [IO.Path]::GetDirectoryName($source)
451
+ $expected = [IO.Path]::GetFullPath((Join-Path $shimDir 'node_modules\@atomiton-dev\gap-cli')).TrimEnd('\')
452
+ return ($expected -ieq $package)
453
+ }
454
+
455
+ # Get-GapResolutionReport: asks each PowerShell (Get-ProbeShells) and returns @{ Asked; Shadowed; NotFound; Lines }:
456
+ # Lines say what to do where `gap` is not gap.cmd, and are empty where it is; Shadowed says there was
457
+ # such a place. Asked is $false when no PowerShell could be asked, so that the caller can fall back
458
+ # to what it knows without one.
459
+ # Nothing is printed here: a profile may clear the console, so the caller asks before its summary.
460
+ function Get-GapResolutionReport {
461
+ $asked = $false
462
+ $shadowed = $false
463
+ $notFound = $false
464
+ $lines = @()
465
+ $launcher = Join-Path $LauncherDir 'gap.cmd'
466
+ # What works when `gap` does not: the ZIP's launcher, or (npm) the script the package provides, which needs the
467
+ # flags that gap.cmd carries itself.
468
+ $fallback = if ((Get-Channel) -eq 'npm') { "powershell -NoProfile -ExecutionPolicy Bypass -File `"$(Join-Path $GapSource 'gap.ps1')`"" } else { $launcher }
469
+ $fallbackHint = if ((Get-Channel) -eq 'npm') { "powershell -NoProfile -ExecutionPolicy Bypass -File `"$(Join-Path $GapSource 'gap.ps1')`" status" } else { '& "$HOME\.gap\bin\gap.cmd" status' }
470
+ foreach ($shell in @(Get-ProbeShells)) {
471
+ $found = Get-GapResolution $shell.Path
472
+ if (-not $found) { continue }
473
+ $asked = $true
474
+ $kind = $found.Kind
475
+ # The install does not mind: this folder is not on the PATH of a window opened before, and the PATH note covers it.
476
+ # doctor does (NotFound): it says `gap` is not found.
477
+ if ($kind -eq 'None') { $notFound = $true; continue }
478
+ if ($kind -eq 'Application' -or $kind -eq 'ExternalScript') {
479
+ if ([IO.Path]::GetDirectoryName($found.Source) -ieq $LauncherDir) { continue }
480
+ if (Test-NpmShim $found.Source) { continue }
481
+ $shadowed = $true
482
+ $lines += " Warning: in $($shell.Name), `"gap`" runs $($found.Source) (another program), not GAP."
483
+ if ((Get-Channel) -eq 'npm') { $lines += " Run GAP with $fallback status, or put npm's global folder first on your PATH." }
484
+ else { $lines += " Run GAP with $launcher status, or put $LauncherDir first on your PATH." }
485
+ continue
486
+ }
487
+ $shadowed = $true
488
+ $what = if ($kind -eq 'Alias') { 'an alias' } elseif ($kind -eq 'Function') { 'a function' } else { "a PowerShell command ($kind)" }
489
+ $lines += " Warning: in $($shell.Name), `"gap`" is $what that runs something else, so `"gap status`" does not run GAP there."
490
+ if ($kind -eq 'Alias' -or $kind -eq 'Function') {
491
+ # The profile folder may not exist yet (the alias can come from another profile), and an alias
492
+ # made with -Option ReadOnly only goes with -Force.
493
+ $lines += ' To fix that for good, run this once in that window, then open a new one:'
494
+ $lines += " New-Item -Force -ItemType Directory (Split-Path `$PROFILE) | Out-Null; Add-Content `$PROFILE 'Remove-Item -Force Alias:gap, Function:gap -ErrorAction SilentlyContinue'"
495
+ }
496
+ $lines += " Until then, run GAP with: $fallbackHint"
497
+ }
498
+ return @{ Asked = $asked; Shadowed = $shadowed; NotFound = $notFound; Lines = $lines }
499
+ }
500
+
501
+ # Start-GapStack: docker compose up -d --wait, bounded (D26). A bare `up -d` hangs forever when the
502
+ # Kernel crash-loops (A4.11), and --wait alone would sit out the whole timeout, so a Restarting
503
+ # service stops the wait early. Returns $true when all three services are healthy.
504
+ function Start-GapStack {
505
+ $out = [IO.Path]::GetTempFileName()
506
+ $err = [IO.Path]::GetTempFileName()
507
+ try {
508
+ $composeArgs = @('compose', '-p', $GapProject,
509
+ '--project-directory', (Format-Arg $GapHome),
510
+ '-f', (Format-Arg (Join-Path $GapHome 'docker-compose.yml')),
511
+ '--env-file', (Format-Arg (Get-EnvPath)),
512
+ 'up', '-d', '--wait', '--wait-timeout', "$GapUpTimeout")
513
+ $proc = Start-Process -FilePath 'docker' -ArgumentList $composeArgs -NoNewWindow -PassThru `
514
+ -RedirectStandardOutput $out -RedirectStandardError $err
515
+ $null = $proc.Handle # keeps the handle so ExitCode is readable after the process ends
516
+ while (-not $proc.HasExited) {
517
+ foreach ($service in 'postgres', 'kernel', 'admin-ui') {
518
+ if ((Get-SvcState $service) -eq 'Restarting') {
519
+ Stop-ProcessTree $proc
520
+ Write-Warn "$service keeps restarting"
521
+ Write-Host "Run: gap logs $service"
522
+ if ($service -eq 'kernel') {
523
+ Write-Host 'If .env was edited, the database password may no longer match the existing data (see: gap logs postgres).'
524
+ }
525
+ return $false
526
+ }
527
+ }
528
+ Start-Sleep -Seconds 3
529
+ }
530
+ $proc.WaitForExit()
531
+ if ($proc.ExitCode -ne 0) {
532
+ Write-Warn 'GAP did not start: a service is not healthy'
533
+ (@(Get-Content -LiteralPath $out) + @(Get-Content -LiteralPath $err)) | Select-Object -Last 5 | ForEach-Object { Write-Host $_ }
534
+ Write-Host 'Run: gap logs kernel'
535
+ if ((Get-SvcState 'kernel') -ne 'Healthy' -and (Get-SvcState 'postgres') -eq 'Healthy') {
536
+ Write-Host 'If .env was edited, the database password may no longer match the existing data (see: gap logs postgres).'
537
+ }
538
+ return $false
539
+ }
540
+ return $true
541
+ } finally {
542
+ Remove-Item -LiteralPath $out, $err -Force -ErrorAction SilentlyContinue
543
+ }
544
+ }
545
+
546
+ function Open-Url($url) {
547
+ try { Start-Process $url } catch { }
548
+ }
549
+
550
+ # --- gap version and gap doctor (#1986, #2012) -------------------------------------------------------------
551
+ # Both only read. doctor never starts, stops or pulls anything, never writes .env, a launcher or the user PATH. It does
552
+ # run the user's own PowerShell the way a new window starts it (Get-GapResolutionReport), as install.ps1 does.
553
+ # NOT RUN: there is no Windows (and no pwsh) on the machine this was written on; see the notes at the places that rest on
554
+ # something that was not looked at.
555
+ $GapMinNodeMajor = 18 # the same number as MIN_NODE_MAJOR in npm\lib\launcher.js, which refuses to start below it
556
+
557
+ function Test-Installed { Test-Path -LiteralPath (Get-EnvPath) }
558
+
559
+ # Get-CliVersion: the version of the npm package that runs this script. Empty on the ZIP channel (the VERSION file of an
560
+ # unzipped folder is the ZIP's own, not a package).
561
+ function Get-CliVersion {
562
+ if ((Get-Channel) -ne 'npm') { return '' }
563
+ $file = Join-Path $GapSource 'VERSION'
564
+ if (-not (Test-Path -LiteralPath $file)) { return '' }
565
+ return ([IO.File]::ReadAllText($file)).Trim()
566
+ }
567
+
568
+ # Get-InstalledVersion: GAP_VERSION of the install in $GapHome. Empty when GAP is not installed; 'unknown' when there is an
569
+ # .env that does not say (no number is guessed).
570
+ function Get-InstalledVersion {
571
+ if (-not (Test-Installed)) { return '' }
572
+ $v = Get-EnvValue 'GAP_VERSION'
573
+ if ($v) { return $v }
574
+ return 'unknown'
575
+ }
576
+
577
+ # Compare-GapVersion A B: -1, 0 or 1 (A older than, equal to, newer than B); $null when it cannot tell. The same reading as
578
+ # version_cmp in gap-lib.sh: major.minor.patch with an optional -suffix, the numbers compared as numbers, the suffix the same
579
+ # (compared exactly, case too: -ceq, not -eq), nine digits at most. Callers compare the result with -eq, never as a boolean.
580
+ $script:VersionPattern = '^([0-9]{1,9})\.([0-9]{1,9})\.([0-9]{1,9})(-[A-Za-z0-9._-]+)?$'
581
+ function Compare-GapVersion($a, $b) {
582
+ $ma = [regex]::Match("$a", $script:VersionPattern)
583
+ $mb = [regex]::Match("$b", $script:VersionPattern)
584
+ if (-not $ma.Success -or -not $mb.Success) { return $null }
585
+ if ($ma.Groups[4].Value -cne $mb.Groups[4].Value) { return $null }
586
+ foreach ($i in 1, 2, 3) {
587
+ $x = [int64]$ma.Groups[$i].Value
588
+ $y = [int64]$mb.Groups[$i].Value
589
+ if ($x -gt $y) { return 1 }
590
+ if ($x -lt $y) { return -1 }
591
+ }
592
+ return 0
593
+ }
594
+
595
+ # Test-PackageOlder: the npm package that runs this script is older than the installed GAP, by numbers (false when equal,
596
+ # newer, not comparable, or on the ZIP channel). `gap install` refuses to go back to it.
597
+ function Test-PackageOlder {
598
+ if ((Get-Channel) -ne 'npm') { return $false }
599
+ $pkg = Get-CliVersion
600
+ $installed = Get-InstalledVersion
601
+ if (-not $pkg -or -not $installed) { return $false }
602
+ return ((Compare-GapVersion $installed $pkg) -eq 1)
603
+ }
604
+
605
+ # Test-PackageNewer: the mirror of Test-PackageOlder: the npm package that runs this script is newer than the installed GAP, by
606
+ # numbers (`gap update` moves to it). False when equal, older, not comparable, or on the ZIP channel.
607
+ function Test-PackageNewer {
608
+ if ((Get-Channel) -ne 'npm') { return $false }
609
+ $pkg = Get-CliVersion
610
+ $installed = Get-InstalledVersion
611
+ if (-not $pkg -or -not $installed) { return $false }
612
+ return ((Compare-GapVersion $installed $pkg) -eq -1)
613
+ }
614
+
615
+ # Write-NewerPackageNote: one line for `gap status` and `gap start`.
616
+ function Write-NewerPackageNote {
617
+ if (Test-PackageNewer) { Write-Host "A newer version ($(Get-CliVersion)) is in the npm package. Run: gap update" }
618
+ }
619
+
620
+ function Write-OlderPackageHelp {
621
+ Write-Host ' gap does not go back: what a newer version did to the database cannot be undone.'
622
+ Write-Host ' Install the newer package first: npm install -g @atomiton-dev/gap-cli@latest'
623
+ }
624
+
625
+ # Get-ManifestErrorKind TEXT: unauthorized | denied | notfound | network | other, for what `docker manifest inspect` printed.
626
+ # The same texts as classify_manifest_error in gap-lib.sh, measured on ghcr.io on macOS (04/11/2026); `docker manifest
627
+ # inspect` is part of the Docker CLI, which is the same code on Windows, but this has not been measured there. A valid token
628
+ # that may not read the package prints the single word `denied`; it counts only as the WHOLE text, because `permission denied`
629
+ # (config file, Docker socket) is not the registry.
630
+ function Get-ManifestErrorKind($text) {
631
+ $t = "$text".ToLower()
632
+ if ($t.Contains('failed to configure transport') -or $t.Contains('error pinging v2 registry') -or $t.Contains('failed to do request')) { return 'network' }
633
+ if ($t.Contains('": unauthorized')) { return 'unauthorized' }
634
+ if ($t.Contains('": denied')) { return 'denied' }
635
+ if ($t.Trim() -eq 'denied') { return 'denied' }
636
+ if ($t.Contains('manifest unknown')) { return 'notfound' }
637
+ return 'other'
638
+ }
639
+
640
+ $script:DoctorProblems = 0 # `[!]` lines of this run
641
+ $script:DoctorDocker = $false # $true when Docker is ready: what needs it is skipped otherwise
642
+
643
+ # Add-DoctorProblem MESSAGE: a `[!]` line, counted. Write-DoctorUnsure MESSAGE: something that could not be checked; it says
644
+ # nothing is wrong, so it is not counted. The lines that say what to do come after them, indented.
645
+ function Add-DoctorProblem($message) { Write-Warn $message; $script:DoctorProblems++ }
646
+ function Write-DoctorUnsure($message) { Write-Host "[?] $message" }
647
+
648
+ # Get-DoctorInstallHint: the command that installs, in the words of this channel.
649
+ function Get-DoctorInstallHint {
650
+ if ((Get-Channel) -eq 'npm') { return 'gap install' }
651
+ $ps1 = Join-Path $GapSource 'install.ps1'
652
+ if (Test-Path -LiteralPath $ps1) { return "powershell -ExecutionPolicy Bypass -File `"$ps1`"" }
653
+ return 'install.ps1 (in the GAP installer folder)'
654
+ }
655
+
656
+ function Test-DoctorNode {
657
+ if ((Get-Channel) -ne 'npm') { Write-Ok 'Node.js is not needed for the ZIP installer'; return }
658
+ $v = ''
659
+ if (Get-Command node -ErrorAction SilentlyContinue) { $v = "$(& node -v 2>$null)".Trim() }
660
+ $major = 0
661
+ if ($v -match '^v(\d+)\.') { $major = [int]$Matches[1] }
662
+ if ($major -eq 0) {
663
+ Add-DoctorProblem 'Node.js not found'
664
+ Write-Host " Action: install Node.js $GapMinNodeMajor or newer from https://nodejs.org"
665
+ } elseif ($major -lt $GapMinNodeMajor) {
666
+ Add-DoctorProblem "Node.js $v is too old: GAP's npm package needs Node $GapMinNodeMajor or newer"
667
+ Write-Host ' Action: install a current Node.js from https://nodejs.org'
668
+ } else {
669
+ Write-Ok "Node.js $v"
670
+ }
671
+ }
672
+
673
+ # Test-DoctorDocker: Test-Prereqs as every command runs it (it prints the failing step and what to do), but a failure does not
674
+ # end doctor: what does not need Docker is still looked at. (Test-Prereqs does not look for curl on Windows.)
675
+ function Test-DoctorDocker {
676
+ if (Test-Prereqs 'gap doctor' $true) {
677
+ $script:DoctorDocker = $true
678
+ Write-Ok "OS: $(Get-OsName)"
679
+ Write-Ok 'Docker is ready (daemon running, Linux containers, Compose v2)'
680
+ } else {
681
+ $script:DoctorDocker = $false
682
+ $script:DoctorProblems++
683
+ if ((Get-Channel) -eq 'npm') {
684
+ Write-DoctorUnsure "Access to GAP's images and the state of the GAP services were not checked: Docker is not ready"
685
+ } else {
686
+ Write-DoctorUnsure 'The state of the GAP services was not checked: Docker is not ready'
687
+ }
688
+ }
689
+ }
690
+
691
+ # Test-DoctorPorts: what `gap install` and `gap start` would find: Select-Port itself decides, so the two cannot disagree.
692
+ function Test-DoctorPorts {
693
+ $stored = ''
694
+ if (Test-Installed) { $stored = Get-EnvValue 'GAP_UI_PORT' }
695
+ $port = Select-Port $stored
696
+ if (-not $port) {
697
+ Add-DoctorProblem 'No free port for the Admin UI'
698
+ Write-Host " Action: free a port between $GapPortFirst and $GapPortLast"
699
+ } elseif (-not $stored) {
700
+ Write-Ok "Port $port is free for the Admin UI"
701
+ } elseif ("$port" -ne "$stored") {
702
+ Write-Ok "The Admin UI port $stored is used by another program; the next gap start will use $port"
703
+ } elseif (Test-PortInUse $port) {
704
+ Write-Ok "The Admin UI port $stored is in use by GAP"
705
+ } else {
706
+ Write-Ok "The Admin UI port $stored is free"
707
+ }
708
+ }
709
+
710
+ # Test-DoctorGapCommand: does `gap` mean GAP in the user's own PowerShell? Each PowerShell is asked as install.ps1 asks it (a
711
+ # new window: its profile runs); only when none can be asked is this process's own view used. The npm channel's global
712
+ # folder is `npm prefix -g` itself on Windows (npm puts gap.cmd there), not a bin subfolder: NOT looked at on Windows.
713
+ function Test-DoctorGapCommand {
714
+ $npm = ((Get-Channel) -eq 'npm')
715
+ # A ZIP that is not installed yet: install.ps1 puts `gap` on the PATH, and "GAP is not installed" already says to run it.
716
+ if (-not $npm -and -not (Test-Installed)) { return }
717
+ $zipLauncher = Join-Path $LauncherDir 'gap.cmd'
718
+ if ($npm -and (Test-Path -LiteralPath $zipLauncher)) {
719
+ Add-DoctorProblem "$zipLauncher is a launcher from a ZIP install of GAP and may run instead of the npm command"
720
+ Write-Host ' Use one channel per machine; the data is shared. To keep only the npm command, delete that file.'
721
+ }
722
+ $report = Get-GapResolutionReport
723
+ $found = $null
724
+ if (-not $report.Asked) {
725
+ $c = Get-Command gap -ErrorAction SilentlyContinue | Select-Object -First 1
726
+ if (-not $c) {
727
+ $report.NotFound = $true
728
+ } else {
729
+ $found = $c
730
+ if ($c.CommandType -eq 'Application' -or $c.CommandType -eq 'ExternalScript') {
731
+ $isLauncher = ([IO.Path]::GetDirectoryName($c.Source) -ieq $LauncherDir)
732
+ $ours = if ($npm) { Test-NpmShim $c.Source } else { $isLauncher }
733
+ # The ZIP launcher in front of the npm command is the one reported above, not a second problem.
734
+ if (-not $ours -and -not ($npm -and $isLauncher)) { $report.Shadowed = $true }
735
+ } else {
736
+ $report.Shadowed = $true # an alias or a function
737
+ }
738
+ }
739
+ }
740
+ if ($report.Shadowed) {
741
+ Add-DoctorProblem 'In your PowerShell, "gap" does not run GAP'
742
+ if ($report.Asked) { $report.Lines | ForEach-Object { Write-Host $_ } }
743
+ else { Write-Host " It runs $($found.Source). Run GAP with: powershell -NoProfile -ExecutionPolicy Bypass -File `"$(Join-Path $GapSource 'gap.ps1')`" status" }
744
+ }
745
+ # Both can be true: one PowerShell has its own `gap`, another has none (the bash side reports each shell as well).
746
+ if ($report.NotFound) {
747
+ Add-DoctorProblem '"gap" is not found: it is not on the PATH'
748
+ if ($npm) {
749
+ $prefix = ''
750
+ if (Get-Command npm -ErrorAction SilentlyContinue) { $prefix = "$(& npm prefix -g 2>$null)".Trim() }
751
+ if ($prefix) { Write-Host " Action: add this folder to your PATH: $prefix" }
752
+ else { Write-Host " Action: add npm's global folder to your PATH (find it with: npm prefix -g)" }
753
+ } else {
754
+ Write-Host " Action: add this folder to your user PATH: $LauncherDir"
755
+ }
756
+ Write-Host " Until then, run GAP with: powershell -NoProfile -ExecutionPolicy Bypass -File `"$(Join-Path $GapSource 'gap.ps1')`" status"
757
+ }
758
+ if (-not $report.Shadowed -and -not $report.NotFound) {
759
+ Write-Ok '"gap" runs GAP in your PowerShell'
760
+ }
761
+ }
762
+
763
+ # Join-DoctorRefs REFS: the references, comma-separated.
764
+ function Join-DoctorRefs($refs) { (@($refs) -join ', ') }
765
+
766
+ # Test-DoctorRegistry: the npm channel pulls from a private registry, so say whether this machine may read the three images.
767
+ # `docker manifest inspect` reads a manifest and downloads no layer (it is not `docker pull`). The three run side by side, as
768
+ # in gap-lib.sh: Docker stops waiting for an unreachable registry after 30 seconds by itself (measured on macOS), once, not
769
+ # three times over. The prefix is the channel's, not .env's.
770
+ $script:RegistryReadable = $false # $true when Test-DoctorRegistry could read all three images (`gap login` exits 0 only then)
771
+
772
+ function Test-DoctorRegistry {
773
+ $script:RegistryReadable = $false
774
+ if ((Get-Channel) -ne 'npm' -or -not $script:DoctorDocker) { return }
775
+ $version = Get-CliVersion
776
+ if (-not $version) { Write-DoctorUnsure "GAP's images were not checked: this package has no VERSION file"; return }
777
+ $prefix = Get-ChannelPrefix
778
+ $registryHost = $prefix.Split('/')[0]
779
+ $refs = @((Get-ImageRef $prefix 'gap-kernel' $version), (Get-ImageRef $prefix 'gap-admin-ui' $version), (Get-ImageRef $prefix 'gap-postgres' '16'))
780
+ Write-Host 'Checking access to the image registry...'
781
+ $jobs = @()
782
+ try {
783
+ foreach ($ref in $refs) {
784
+ $out = [IO.Path]::GetTempFileName()
785
+ $err = [IO.Path]::GetTempFileName()
786
+ $proc = Start-Process -FilePath 'docker' -ArgumentList @('manifest', 'inspect', $ref) -NoNewWindow -PassThru `
787
+ -RedirectStandardOutput $out -RedirectStandardError $err
788
+ $null = $proc.Handle # keeps the handle so ExitCode is readable after the process ends
789
+ $jobs += @{ Ref = $ref; Proc = $proc; Out = $out; Err = $err }
790
+ }
791
+ $unauthorized = @(); $denied = @(); $notFound = @(); $network = @(); $other = @(); $otherText = ''
792
+ foreach ($job in $jobs) {
793
+ $job.Proc.WaitForExit()
794
+ if ($job.Proc.ExitCode -eq 0) { continue }
795
+ $text = (@(Get-Content -LiteralPath $job.Err) -join "`n")
796
+ switch (Get-ManifestErrorKind $text) {
797
+ 'unauthorized' { $unauthorized += $job.Ref }
798
+ 'denied' { $denied += $job.Ref }
799
+ 'notfound' { $notFound += $job.Ref }
800
+ 'network' { $network += $job.Ref }
801
+ default { $other += $job.Ref; $otherText = $text }
802
+ }
803
+ }
804
+ } finally {
805
+ foreach ($job in $jobs) { Remove-Item -LiteralPath $job.Out, $job.Err -Force -ErrorAction SilentlyContinue }
806
+ }
807
+ if (($unauthorized.Count + $denied.Count + $notFound.Count + $network.Count + $other.Count) -eq 0) {
808
+ Write-Ok "You can read GAP's images in $registryHost"
809
+ $script:RegistryReadable = $true
810
+ return
811
+ }
812
+ if ($unauthorized.Count -gt 0) {
813
+ Add-DoctorProblem "GAP's images are private and this machine is not logged in to $registryHost"
814
+ Write-LoginSteps
815
+ }
816
+ if ($denied.Count -gt 0) {
817
+ Add-DoctorProblem "$registryHost refused access to $(Join-DoctorRefs $denied)"
818
+ Write-DeniedHelp
819
+ }
820
+ if ($notFound.Count -gt 0) {
821
+ Add-DoctorProblem "$(Join-DoctorRefs $notFound) was not found in the registry"
822
+ Write-Host "This GAP package is version $version. That version may not be published yet; check the version you were given."
823
+ }
824
+ if ($network.Count -gt 0) {
825
+ Write-DoctorUnsure "Could not reach ${registryHost}: access to $(Join-DoctorRefs $network) was not checked"
826
+ Write-Host ' Check your internet connection (and proxy settings). Nothing is wrong with GAP itself.'
827
+ }
828
+ if ($other.Count -gt 0) {
829
+ Write-DoctorUnsure "Could not check access to $(Join-DoctorRefs $other)"
830
+ @("$otherText" -split "`r?`n" | Where-Object { $_ }) | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" }
831
+ }
832
+ }
833
+
834
+ # Test-DoctorServices: the three services, as `gap status` reads them (Get-SvcState), and the Admin UI through its own address.
835
+ function Test-DoctorServices {
836
+ $pg = Get-SvcState 'postgres'
837
+ $kernel = Get-SvcState 'kernel'
838
+ $ui = Get-SvcState 'admin-ui'
839
+ if ($pg -eq 'Stopped' -and $kernel -eq 'Stopped' -and $ui -eq 'Stopped') {
840
+ Add-DoctorProblem 'GAP is not running'
841
+ Write-Host ' Run: gap start'
842
+ return
843
+ }
844
+ foreach ($item in @(@('postgres', 'PostgreSQL', $pg), @('kernel', 'GAP Kernel', $kernel), @('admin-ui', 'GAP Admin UI', $ui))) {
845
+ $service = $item[0]; $name = $item[1]; $state = $item[2]
846
+ switch ($state) {
847
+ 'Healthy' { }
848
+ 'Restarting' {
849
+ Add-DoctorProblem "$service keeps restarting"
850
+ Write-Host " Run: gap logs $service"
851
+ if ($service -eq 'kernel') {
852
+ Write-Host ' If .env was edited, the database password may no longer match the existing data (see: gap logs postgres).'
853
+ }
854
+ }
855
+ 'Stopped' { Add-DoctorProblem "$name is stopped"; Write-Host ' Run: gap start' }
856
+ 'Starting' { Add-DoctorProblem "$name is still starting"; Write-Host ' Wait a minute, then run: gap doctor' }
857
+ default { Add-DoctorProblem "$name is $($state.ToLower())"; Write-Host " Run: gap logs $service" }
858
+ }
859
+ }
860
+ if (-not ($pg -eq 'Healthy' -and $kernel -eq 'Healthy' -and $ui -eq 'Healthy')) { return }
861
+ Write-Ok 'PostgreSQL, GAP Kernel and GAP Admin UI are healthy'
862
+ if (Test-HttpOk '/system/health') {
863
+ Write-Ok "The Admin UI answers at $(Get-UiUrl)"
864
+ } else {
865
+ Add-DoctorProblem "The Admin UI does not answer at $(Get-UiUrl)"
866
+ Write-Host ' Run: gap logs kernel'
867
+ }
868
+ }
869
+
870
+ # Test-DoctorInstall: is GAP installed here, in the version this package expects, and running.
871
+ function Test-DoctorInstall {
872
+ if (-not (Test-Installed)) {
873
+ $hasData = $false
874
+ if ($script:DoctorDocker) {
875
+ & docker volume inspect "${GapProject}_pgdata" *> $null
876
+ $hasData = ($LASTEXITCODE -eq 0)
877
+ }
878
+ if ($hasData) {
879
+ Add-DoctorProblem "GAP is not installed here, but data from an earlier install is in the Docker volume ${GapProject}_pgdata"
880
+ Write-Host " Run: $(Get-DoctorInstallHint) (it keeps that data)"
881
+ } else {
882
+ Add-DoctorProblem "GAP is not installed in $GapHome"
883
+ Write-Host " Run: $(Get-DoctorInstallHint)"
884
+ }
885
+ return
886
+ }
887
+ $installed = Get-InstalledVersion
888
+ $pkg = Get-CliVersion
889
+ if ($installed -eq 'unknown') {
890
+ Add-DoctorProblem "GAP_VERSION is missing from $(Get-EnvPath)"
891
+ Write-Host " Run: $(Get-DoctorInstallHint)"
892
+ } else {
893
+ Write-Ok "GAP $installed is installed in $GapHome"
894
+ if ($pkg -and $pkg -ne $installed) {
895
+ Add-DoctorProblem "The installed version ($installed) is not the version of this package ($pkg)"
896
+ if (Test-PackageNewer) { Write-Host ' Run: gap update (it backs up first)' }
897
+ elseif (Test-PackageOlder) { Write-Host ' Install the newer package: npm install -g @atomiton-dev/gap-cli@latest' }
898
+ else { Write-Host " Run: $(Get-DoctorInstallHint)" }
899
+ }
900
+ }
901
+ if (-not (Test-Path -LiteralPath (Join-Path $GapHome 'docker-compose.yml'))) {
902
+ Add-DoctorProblem "docker-compose.yml is missing in $GapHome"
903
+ Write-Host " Run: $(Get-DoctorInstallHint) again to put it back"
904
+ return
905
+ }
906
+ if ($script:DoctorDocker) { Test-DoctorServices }
907
+ }
908
+
909
+ # --- gap update (#1986, #2014) -------------------------------------------------------------------------------------
910
+ # The same command as `gap update` of the bash gap (gap-lib.sh, gap), in the same order: npm channel only, the versions compared
911
+ # as numbers, no step back, the new images pulled first, then a checked backup, then the new compose file and version, then the
912
+ # stack. Nothing is rolled back and nothing is deleted.
913
+ # What is not the same, on purpose:
914
+ # - Windows PowerShell 5.1 turns what a native command writes into text, which would corrupt a dump or a tar archive. So the
915
+ # backup never uses a PowerShell pipeline, `>` or Out-File for docker's output: .NET starts docker, and copies its output
916
+ # stream byte for byte into the file (gzip by .NET for the dump; tar gzips inside the container). The checks of the backup
917
+ # are .NET too.
918
+ # - the commands that go back to the old version are written for PowerShell (Copy-Item, docker cp, a bind mount), because
919
+ # `gunzip -c file | docker ...` and `cat file | docker ...` of the bash text are pipelines of a binary file.
920
+ # - the backup folder is not chmod-ed: on Windows it inherits the permissions of the user's folder (~\.gap is under it).
921
+ # NOT RUN on Windows or on Windows PowerShell 5.1: what rests on them is the lookup of docker.exe by Process.Start, the free
922
+ # space of a drive (DriveInfo), and the way `C:\...` paths are given to `docker cp` and `-v`. The rest was run with pwsh 7 on
923
+ # Linux against a fake docker (test_installer_update_powershell.py) and against a real Docker.
924
+
925
+ $script:BackupDir = ''
926
+ $script:PgDumpEnd = '-- PostgreSQL database cluster dump complete'
927
+ $script:BackupSpaceMarginPercent = 5
928
+
929
+ # Get-DcArgs: the start of every `docker compose` call of the backup (the same project, folder, files as Invoke-Dc), as an
930
+ # array of arguments for Invoke-DockerStream; a path with a space is quoted.
931
+ function Get-DcArgs {
932
+ @('compose', '-p', $GapProject,
933
+ '--project-directory', (Format-Arg $GapHome),
934
+ '-f', (Format-Arg (Join-Path $GapHome 'docker-compose.yml')),
935
+ '--env-file', (Format-Arg (Get-EnvPath)))
936
+ }
937
+
938
+ # Invoke-DockerStream ARGS STREAM: run docker with the arguments, standard input closed (what `-T </dev/null` does in bash),
939
+ # and copy its standard output byte for byte into STREAM. Returns an object with ExitCode and Error (what it wrote to standard
940
+ # error, as text). Never throws: a docker that cannot be started is ExitCode 127 and says why.
941
+ function Invoke-DockerStream($dockerArgs, $outStream) {
942
+ $proc = $null
943
+ try {
944
+ $psi = New-Object Diagnostics.ProcessStartInfo
945
+ $psi.FileName = 'docker'
946
+ $psi.Arguments = (@($dockerArgs) -join ' ')
947
+ $psi.UseShellExecute = $false
948
+ $psi.CreateNoWindow = $true
949
+ $psi.RedirectStandardInput = $true
950
+ $psi.RedirectStandardOutput = $true
951
+ $psi.RedirectStandardError = $true
952
+ $proc = [Diagnostics.Process]::Start($psi)
953
+ $proc.StandardInput.Close()
954
+ $errTask = $proc.StandardError.ReadToEndAsync() # read at the same time: a full error pipe would stop docker
955
+ $proc.StandardOutput.BaseStream.CopyTo($outStream)
956
+ $proc.WaitForExit()
957
+ return [pscustomobject]@{ ExitCode = $proc.ExitCode; Error = $errTask.Result }
958
+ } catch {
959
+ return [pscustomobject]@{ ExitCode = 127; Error = "$($_.Exception.Message)" }
960
+ } finally {
961
+ if ($proc) { $proc.Dispose() }
962
+ }
963
+ }
964
+
965
+ # Get-DockerText ARGS: the standard output of docker as text, or $null when it did not exit 0.
966
+ function Get-DockerText($dockerArgs) {
967
+ $mem = New-Object IO.MemoryStream
968
+ try {
969
+ $result = Invoke-DockerStream $dockerArgs $mem
970
+ if ($result.ExitCode -ne 0) { return $null }
971
+ return [Text.Encoding]::UTF8.GetString($mem.ToArray())
972
+ } finally {
973
+ $mem.Dispose()
974
+ }
975
+ }
976
+
977
+ # Get-LineKb TEXT WHICH: the number in front of the first (First) or last (Last) line of `du -sk`, or $null.
978
+ function Get-LineKb($text, $which) {
979
+ if ($null -eq $text) { return $null }
980
+ $lines = @("$text" -split "`r?`n" | Where-Object { $_ -match '^[0-9]+\b' })
981
+ if ($lines.Count -eq 0) { return $null }
982
+ $line = if ($which -eq 'First') { $lines[0] } else { $lines[$lines.Count - 1] }
983
+ return [int64](($line -split '\s+')[0])
984
+ }
985
+
986
+ # Get-FreeKb PATH: kilobytes free on the drive that holds PATH, or $null when that cannot be read.
987
+ function Get-FreeKb($path) {
988
+ try {
989
+ $name = if ($OnWindows) { [IO.Path]::GetPathRoot($path) } else { $path }
990
+ $drive = New-Object IO.DriveInfo($name)
991
+ return [int64]($drive.AvailableFreeSpace / 1024)
992
+ } catch {
993
+ return $null
994
+ }
995
+ }
996
+
997
+ # Read-GzipEnds FILE KEEP: decompress the whole file and return its first 512 bytes (Head), its last KEEP bytes (Tail) and its
998
+ # decompressed length (Length); $null when it is not a readable gzip. Reading all of it is what checks the gzip (its CRC).
999
+ function Read-GzipEnds($file, $keep) {
1000
+ $fs = $null
1001
+ $gz = $null
1002
+ try {
1003
+ $fs = [IO.File]::OpenRead($file)
1004
+ $gz = New-Object IO.Compression.GZipStream($fs, [IO.Compression.CompressionMode]::Decompress)
1005
+ $buffer = New-Object byte[] 1048576
1006
+ $head = New-Object byte[] 512
1007
+ $headLen = 0
1008
+ $tail = New-Object byte[] 0
1009
+ $total = [int64]0
1010
+ while ($true) {
1011
+ $n = $gz.Read($buffer, 0, $buffer.Length)
1012
+ if ($n -le 0) { break }
1013
+ if ($headLen -lt 512) {
1014
+ $take = [Math]::Min(512 - $headLen, $n)
1015
+ [Array]::Copy($buffer, 0, $head, $headLen, $take)
1016
+ $headLen += $take
1017
+ }
1018
+ $total += $n
1019
+ $all = New-Object byte[] ($tail.Length + $n)
1020
+ [Array]::Copy($tail, 0, $all, 0, $tail.Length)
1021
+ [Array]::Copy($buffer, 0, $all, $tail.Length, $n)
1022
+ if ($all.Length -gt $keep) {
1023
+ $tail = New-Object byte[] $keep
1024
+ [Array]::Copy($all, $all.Length - $keep, $tail, 0, $keep)
1025
+ } else {
1026
+ $tail = $all
1027
+ }
1028
+ }
1029
+ return [pscustomobject]@{ Head = $head; Tail = $tail; Length = $total }
1030
+ } catch {
1031
+ return $null
1032
+ } finally {
1033
+ if ($gz) { $gz.Dispose() }
1034
+ if ($fs) { $fs.Dispose() }
1035
+ }
1036
+ }
1037
+
1038
+ # Test-DumpWhole FILE: a readable gzip whose text ends with the line pg_dumpall writes last. An empty or cut dump fails here.
1039
+ function Test-DumpWhole($file) {
1040
+ $ends = Read-GzipEnds $file 4096
1041
+ if (-not $ends -or $ends.Length -eq 0) { return $false }
1042
+ $lines = @([Text.Encoding]::UTF8.GetString($ends.Tail) -split "`n" | Select-Object -Last 6)
1043
+ foreach ($line in $lines) {
1044
+ if ($line.TrimEnd("`r") -ceq $script:PgDumpEnd) { return $true }
1045
+ }
1046
+ return $false
1047
+ }
1048
+
1049
+ # Test-ArchiveWhole FILE: a readable gzip that holds a whole tar archive: a first header with a name and a checksum that is
1050
+ # right, a length of whole 512-byte blocks, and the two empty blocks that end every archive. A gzip of something else, or of
1051
+ # half an archive, fails here (the exit status of tar alone proves nothing: GNU tar says nothing about a gzip that is no tar).
1052
+ # A real backup of the app files has at least the entry "./", even when the volume is empty.
1053
+ function Test-ArchiveWhole($file) {
1054
+ $ends = Read-GzipEnds $file 1024
1055
+ if (-not $ends -or $ends.Length -lt 1536 -or ($ends.Length % 512) -ne 0) { return $false }
1056
+ foreach ($b in $ends.Tail) { if ($b -ne 0) { return $false } }
1057
+ $head = $ends.Head
1058
+ if ($head[0] -eq 0) { return $false }
1059
+ $field = [Text.Encoding]::ASCII.GetString($head, 148, 8).Trim([char]0, ' ')
1060
+ try { $stored = [Convert]::ToInt64($field, 8) } catch { return $false }
1061
+ $sum = [int64]0
1062
+ for ($i = 0; $i -lt 512; $i++) {
1063
+ if ($i -ge 148 -and $i -lt 156) { $sum += 32 } else { $sum += $head[$i] }
1064
+ }
1065
+ return ($sum -eq $stored)
1066
+ }
1067
+
1068
+ # Stop-Backup MESSAGE LINES: say what is wrong, remove what this backup wrote, return $false.
1069
+ function Stop-Backup($message, $lines = @()) {
1070
+ Write-Warn $message
1071
+ foreach ($line in @($lines)) { if ($line) { Write-Host $line } }
1072
+ if ($script:BackupDir -and (Test-Path -LiteralPath $script:BackupDir)) {
1073
+ Remove-Item -LiteralPath $script:BackupDir -Recurse -Force -ErrorAction SilentlyContinue
1074
+ }
1075
+ $script:BackupDir = ''
1076
+ Write-Host 'Nothing was changed in GAP: the version, .env and your data are as they were.'
1077
+ return $false
1078
+ }
1079
+
1080
+ function Get-LastLines($text, $count) {
1081
+ (@("$text" -split "`r?`n" | Where-Object { $_ } | Select-Object -Last $count)) -join "`n"
1082
+ }
1083
+
1084
+ # New-GapBackup: sets $script:BackupDir (<GapHome>\backups\<UTC time>) and returns $true, or returns $false after saying why and
1085
+ # leaving nothing behind. Starts only Postgres if it is not running (the dump goes through it); the Kernel is not needed, the
1086
+ # app files are read with a throw-away container of the old Kernel image.
1087
+ # 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 free
1088
+ # space of the backup folder has to cover the size of the database plus the size of gap_data, plus
1089
+ # $script:BackupSpaceMarginPercent for tar and gzip overhead. A size that cannot be measured is said and does not stop the
1090
+ # backup, which is checked when it is done.
1091
+ function New-GapBackup {
1092
+ $root = Join-Path $GapHome 'backups'
1093
+ if ((Get-SvcState 'postgres') -ne 'Healthy') {
1094
+ Write-Host 'Starting the database for the backup...'
1095
+ Invoke-Dc up -d --wait --wait-timeout 120 postgres *> $null
1096
+ if ($LASTEXITCODE -ne 0) {
1097
+ return (Stop-Backup 'The database did not start, so it cannot be backed up' @('Run: gap logs postgres'))
1098
+ }
1099
+ }
1100
+ if (-not (Test-Path -LiteralPath $root)) {
1101
+ try { $null = New-Item -ItemType Directory -Path $root -ErrorAction Stop } catch {
1102
+ return (Stop-Backup "Cannot create $root" @("Check the permissions of $GapHome."))
1103
+ }
1104
+ }
1105
+ $pgKb = Get-LineKb (Get-DockerText ((Get-DcArgs) + @('exec', '-T', 'postgres', 'du', '-sk', '/var/lib/postgresql/data'))) 'First'
1106
+ $dataKb = Get-LineKb (Get-DockerText ((Get-DcArgs) + @('run', '--rm', '--no-deps', '-T', '--entrypoint', 'du', 'kernel', '-sk', '/data'))) 'Last'
1107
+ $freeKb = Get-FreeKb $root
1108
+ if ($null -ne $pgKb -and $null -ne $dataKb -and $null -ne $freeKb) {
1109
+ $needKb = [int64](($pgKb + $dataKb) * (100 + $script:BackupSpaceMarginPercent) / 100)
1110
+ if ($freeKb -lt $needKb) {
1111
+ $needMb = [int64][Math]::Floor(($needKb + 1023) / 1024)
1112
+ $freeMb = [int64][Math]::Floor($freeKb / 1024)
1113
+ return (Stop-Backup 'Not enough free space for the backup' @(
1114
+ "About $needMb MB are needed (the database and the app files), $freeMb MB are free in $root.",
1115
+ 'Free some space, then run: gap update'))
1116
+ }
1117
+ } else {
1118
+ Write-Host '[?] The free space needed for the backup could not be measured; the backup is checked when it is done'
1119
+ }
1120
+ # Written under a ".partial" name and renamed only when everything has been checked: a Ctrl-C or a crash must not leave a
1121
+ # half written folder that looks like a backup.
1122
+ $stamp = (Get-Date).ToUniversalTime().ToString("yyyyMMdd'T'HHmmss'Z'", [Globalization.CultureInfo]::InvariantCulture)
1123
+ $final = Join-Path $root $stamp
1124
+ $dir = "$final.partial"
1125
+ try { $null = New-Item -ItemType Directory -Path $dir -ErrorAction Stop } catch {
1126
+ return (Stop-Backup "Cannot create $dir")
1127
+ }
1128
+ $script:BackupDir = $dir
1129
+
1130
+ Write-Host 'Backing up the database...'
1131
+ $dumpFile = Join-Path $dir 'db.sql.gz'
1132
+ $result = $null
1133
+ $fs = [IO.File]::Create($dumpFile)
1134
+ try {
1135
+ $gz = New-Object IO.Compression.GZipStream($fs, [IO.Compression.CompressionMode]::Compress)
1136
+ try { $result = Invoke-DockerStream ((Get-DcArgs) + @('exec', '-T', 'postgres', 'pg_dumpall', '-U', 'postgres')) $gz }
1137
+ finally { $gz.Dispose() }
1138
+ } finally {
1139
+ $fs.Dispose()
1140
+ }
1141
+ if ($result.ExitCode -ne 0) {
1142
+ return (Stop-Backup 'The backup of the database failed' @((Get-LastLines $result.Error 3)))
1143
+ }
1144
+ if (-not (Test-DumpWhole $dumpFile)) { return (Stop-Backup 'The backup of the database is empty or cut short') }
1145
+
1146
+ Write-Host "Backing up the app files (volume ${GapProject}_data)..."
1147
+ $dataFile = Join-Path $dir 'data.tar.gz'
1148
+ $fs = [IO.File]::Create($dataFile)
1149
+ try {
1150
+ $result = Invoke-DockerStream ((Get-DcArgs) + @('run', '--rm', '--no-deps', '-T', '--entrypoint', 'tar', 'kernel', '-czf', '-', '-C', '/data', '.')) $fs
1151
+ } finally {
1152
+ $fs.Dispose()
1153
+ }
1154
+ if ($result.ExitCode -ne 0) {
1155
+ # tar says 1 when a file changed while it was read: the Kernel was writing. Nothing is wrong with the data.
1156
+ return (Stop-Backup 'The backup of the app files failed' @((Get-LastLines $result.Error 3), 'If GAP was busy, run: gap stop and then: gap update'))
1157
+ }
1158
+ if (-not (Test-ArchiveWhole $dataFile)) { return (Stop-Backup 'The backup of the app files is empty or unreadable') }
1159
+
1160
+ try {
1161
+ [IO.File]::Copy((Get-EnvPath), (Join-Path $dir '.env'), $true)
1162
+ [IO.File]::Copy((Join-Path $GapHome 'docker-compose.yml'), (Join-Path $dir 'docker-compose.yml'), $true)
1163
+ } catch {
1164
+ return (Stop-Backup 'Could not copy .env and the compose file into the backup')
1165
+ }
1166
+ try { [IO.Directory]::Move($dir, $final) } catch {
1167
+ return (Stop-Backup "Could not finish the backup folder $final")
1168
+ }
1169
+ $script:BackupDir = $final
1170
+ return $true
1171
+ }
1172
+
1173
+ # Copy-FileReplacing SRC DEST: replace DEST in one step, so that nothing reads half a file. Throws when it cannot.
1174
+ function Copy-FileReplacing($src, $dest) {
1175
+ $temp = "$dest.new"
1176
+ [IO.File]::Copy($src, $temp, $true)
1177
+ # [NullString]::Value, not $null: PowerShell hands a $null to a .NET string parameter as an empty string, which Replace refuses
1178
+ if (Test-Path -LiteralPath $dest) { [IO.File]::Replace($temp, $dest, [NullString]::Value) } else { [IO.File]::Move($temp, $dest) }
1179
+ }
1180
+
1181
+ # Test-GapHealth [TRIES] [PAUSE]: the Admin UI answers on /health, /system/health and / (the path the user will take), for up
1182
+ # to TRIES x PAUSE seconds each (15 x 2: about 30 seconds). Returns $false after saying which one did not. (install.ps1 does
1183
+ # the same inline; this is `gap update`'s.)
1184
+ function Test-GapHealth($tries = 15, $pause = 2) {
1185
+ foreach ($path in '/health', '/system/health', '/') {
1186
+ $n = 0
1187
+ while (-not (Test-HttpOk $path)) {
1188
+ $n++
1189
+ if ($n -ge $tries) {
1190
+ Write-Warn "Health check failed: $(Get-UiUrl)$path"
1191
+ Write-Host 'Run: gap logs kernel'
1192
+ return $false
1193
+ }
1194
+ Start-Sleep -Seconds $pause
1195
+ }
1196
+ }
1197
+ return $true
1198
+ }
1199
+
1200
+ # Write-FilesBackHelp BACKUP: the two lines that put the old .env and compose file back (the first two of the restore).
1201
+ function Write-FilesBackHelp($backup) {
1202
+ Write-Host (' Copy-Item "{0}" "{1}"' -f (Join-Path $backup '.env'), (Get-EnvPath))
1203
+ Write-Host (' Copy-Item "{0}" "{1}"' -f (Join-Path $backup 'docker-compose.yml'), (Join-Path $GapHome 'docker-compose.yml'))
1204
+ }
1205
+
1206
+ # Write-RestoreHelp BACKUP OLDVERSION: how to go back to OLDVERSION with the data of the backup. The same order as the bash text
1207
+ # (spike S4 ran it for real): no ON_ERROR_STOP (psql would stop on the one error that is expected), restore only into an empty
1208
+ # database volume with only Postgres running (a restore over data doubles the rows of tables without a primary key), and the
1209
+ # password after the restore is the one of the backup, which is the one in the backup's .env. In PowerShell: the dump goes into
1210
+ # the container with `docker cp` and is unzipped there (no pipe of a binary file), and the app files are read from the backup
1211
+ # folder, mounted into a throw-away Kernel container. Nothing is run; the paths are written in full and quoted.
1212
+ function Write-RestoreHelp($backup, $oldVersion) {
1213
+ $dcx = 'docker compose -p {0} --project-directory "{1}" -f "{2}" --env-file "{3}"' -f $GapProject, $GapHome, (Join-Path $GapHome 'docker-compose.yml'), (Get-EnvPath)
1214
+ Write-Host ''
1215
+ Write-Host "To go back to GAP $oldVersion with the data of the backup (this erases the current database and app files first, because"
1216
+ Write-Host 'the new version may already have changed them; a restore over existing data would duplicate rows). In PowerShell:'
1217
+ Write-FilesBackHelp $backup
1218
+ Write-Host " $dcx down -v"
1219
+ Write-Host " $dcx up -d --wait postgres"
1220
+ Write-Host " `$pg = $dcx ps -q postgres"
1221
+ Write-Host (' docker cp "{0}" "${{pg}}:/tmp/db.sql.gz"' -f (Join-Path $backup 'db.sql.gz'))
1222
+ Write-Host " $dcx exec -T postgres sh -c `"gunzip -c /tmp/db.sql.gz | psql -U postgres -q`""
1223
+ Write-Host (' {0} run --rm --no-deps -T -v "{1}:/backup:ro" --entrypoint tar kernel -xzf /backup/data.tar.gz -C /data' -f $dcx, $backup)
1224
+ Write-Host ' gap start'
1225
+ Write-Host "Do not add psql's ON_ERROR_STOP: the restore prints exactly one error, role `"postgres`" already exists, and that is"
1226
+ Write-Host "expected (and a few small set_config tables, which are normal). The images of $oldVersion are still on this machine."
1227
+ }
1228
+
1229
+ # Write-UpdateFailed OLD NEW BACKUP: the new version did not come up. Nothing is rolled back (the Kernel may already have
1230
+ # migrated the database, and a migration is not undone): say where the backup is, what .env says now, and how to go back.
1231
+ function Write-UpdateFailed($old, $new, $backup) {
1232
+ Write-Warn "The update to GAP $new did not finish, and GAP $old was not restored automatically"
1233
+ Write-Host " The backup made before the update: $backup"
1234
+ Write-Host " .env now says GAP_VERSION=$(Get-EnvValue 'GAP_VERSION'). Nothing was rolled back and nothing was deleted."
1235
+ Write-Host ' First look at what the new version says: gap logs kernel'
1236
+ Write-RestoreHelp $backup $old
1237
+ }
1238
+
1239
+ # Write-UpdateNotApplied OLD NEW BACKUP [REASON]: the new .env or compose file could not be written. The stack was not touched (the old
1240
+ # version still runs as before); .env and the compose file may be half moved, and the backup holds the old ones.
1241
+ function Write-UpdateNotApplied($old, $new, $backup, $reason = '') {
1242
+ Write-Warn "The update to GAP $new could not be applied: the files in $GapHome could not be written"
1243
+ if ($reason) { Write-Host " $reason" }
1244
+ Write-Host " The running stack was not touched; GAP $old runs as before. The backup made before: $backup"
1245
+ Write-Host " .env now says GAP_VERSION=$(Get-EnvValue 'GAP_VERSION'). To put the old .env and compose file back:"
1246
+ Write-FilesBackHelp $backup
1247
+ Write-Host " Check the permissions of $GapHome, then run: gap update"
1248
+ }