@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,361 @@
1
+ # gap: status / start / stop / logs / uninstall / version / doctor (and update, npm channel) for the local GAP stack (#1750, D27, D28).
2
+ # install.ps1 copies this file to ~\.gap and puts a launcher (gap.cmd) for it on the PATH, so
3
+ # `gap status` works in any terminal. It always works on the instance in ~\.gap ($GapHome, from
4
+ # gap-lib.ps1), also when run from the unzipped installer folder. Reads its arguments from $args
5
+ # (no param block) so that `gap logs kernel -f` reaches docker untouched.
6
+ $GapSource = $PSScriptRoot
7
+ . (Join-Path $GapSource 'gap-lib.ps1')
8
+
9
+ function Show-Usage {
10
+ # `install` is a command of the npm package only: the ZIP's gap has none, its installer is install.cmd / install.ps1.
11
+ $lines = @('Usage: gap <command>', '')
12
+ if ((Get-Channel) -eq 'npm') {
13
+ $lines += ' install install GAP on this machine (pulls the images; run it again any time)'
14
+ $lines += ' login log in to the registry that holds the images (asks for your GitHub username and token)'
15
+ $lines += ' update move GAP to the version of the installed npm package, after a backup'
16
+ }
17
+ $lines += ' status show whether GAP is running and healthy'
18
+ $lines += ' version show the installed GAP version'
19
+ $lines += ' doctor check this machine and the install for problems; changes nothing'
20
+ $lines += ' start start GAP again (after `gap stop` or a reboot)'
21
+ $lines += ' stop stop GAP; your data is kept'
22
+ $lines += ' logs [service] [-f]'
23
+ $lines += ' show logs (service: kernel, postgres or admin-ui)'
24
+ $lines += ' uninstall [--purge] [--yes]'
25
+ $lines += ' remove GAP from this machine; your data is kept'
26
+ $lines += ' unless you add --purge (it asks you to type "delete")'
27
+ $lines
28
+ }
29
+
30
+ function Assert-Installed {
31
+ if (Test-Path -LiteralPath (Get-EnvPath)) { return }
32
+ Write-Warn "GAP is not installed in $GapHome"
33
+ if ((Get-Channel) -eq 'npm') {
34
+ Write-Host 'Run: gap install'
35
+ } elseif (Test-Path -LiteralPath (Join-Path $GapSource 'install.ps1')) {
36
+ Write-Host "Run: powershell -ExecutionPolicy Bypass -File `"$(Join-Path $GapSource 'install.ps1')`""
37
+ } else {
38
+ Write-Host 'Run install.ps1 from the GAP installer folder.'
39
+ }
40
+ exit 1
41
+ }
42
+
43
+ function Invoke-Status {
44
+ if (-not (Test-Prereqs 'gap status')) { exit 1 }
45
+ $pg = Get-SvcState 'postgres'
46
+ $kernel = Get-SvcState 'kernel'
47
+ $ui = Get-SvcState 'admin-ui'
48
+ # D19: the Runtime lives inside the Kernel; /system/health answering 200 proves the database
49
+ # session and the app manager are up. The `status` field is not read: it says "error" on a
50
+ # fresh install that has no app worker yet.
51
+ if ($kernel -eq 'Healthy' -and $ui -eq 'Healthy' -and (Test-HttpOk '/system/health')) { $runtime = 'Healthy' }
52
+ elseif ($kernel -eq 'Stopped') { $runtime = 'Stopped' }
53
+ else { $runtime = 'Unreachable' }
54
+ $overall = 'READY'
55
+ foreach ($state in $pg, $kernel, $runtime, $ui) {
56
+ if ($state -ne 'Healthy') { $overall = 'NOT READY' }
57
+ }
58
+ Write-Host ('{0,-15} {1}' -f 'PostgreSQL', $pg)
59
+ Write-Host ('{0,-15} {1}' -f 'GAP Kernel', $kernel)
60
+ Write-Host ('{0,-15} {1}' -f 'GAP Runtime', $runtime)
61
+ Write-Host ('{0,-15} {1}' -f 'GAP Admin UI', $ui)
62
+ Write-Host "Version: $(Get-EnvValue 'GAP_VERSION')"
63
+ Write-NewerPackageNote
64
+ if ($overall -eq 'READY') { Write-Host "URL: $(Get-UiUrl)" }
65
+ Write-Host "Overall: $overall"
66
+ if ($overall -ne 'READY') { exit 1 }
67
+ }
68
+
69
+ # version and doctor work before GAP is installed: doctor is what to run when the install did not work.
70
+ function Invoke-Version {
71
+ $installed = Get-InstalledVersion
72
+ $pkg = Get-CliVersion
73
+ $shown = if ($installed) { $installed } else { 'not installed' }
74
+ Write-Host "Installed: $shown"
75
+ if ($pkg) { Write-Host "CLI package: $pkg" }
76
+ if ($pkg -and $installed -and $installed -ne 'unknown' -and $installed -ne $pkg) {
77
+ if (Test-PackageNewer) { Write-Host 'The installed version differs from this package. Run: gap update' }
78
+ elseif (Test-PackageOlder) { Write-Host 'The installed version is newer than this package. Install the newer package: npm install -g @atomiton-dev/gap-cli@latest' }
79
+ else { Write-Host 'The installed version differs from this package. Run: gap install' }
80
+ }
81
+ }
82
+
83
+ # Test-Doctor* are in gap-lib.ps1. Exits 0 when no check found a problem, 1 otherwise.
84
+ function Invoke-Doctor {
85
+ $script:DoctorProblems = 0
86
+ Test-DoctorDocker
87
+ Test-DoctorNode
88
+ Test-DoctorPorts
89
+ Test-DoctorGapCommand
90
+ Test-DoctorRegistry
91
+ Test-DoctorInstall
92
+ if ($script:DoctorProblems -eq 0) { Write-Host 'Overall: OK' }
93
+ elseif ($script:DoctorProblems -eq 1) { Write-Host 'Overall: 1 problem' }
94
+ else { Write-Host "Overall: $($script:DoctorProblems) problems" }
95
+ if ($script:DoctorProblems -ne 0) { exit 1 }
96
+ }
97
+
98
+ function Invoke-Start {
99
+ if (-not (Test-Prereqs 'gap start' $true)) { exit 1 }
100
+ $stored = Get-EnvValue 'GAP_UI_PORT'
101
+ $port = Select-Port $stored
102
+ if (-not $port) {
103
+ Write-Warn 'No free port for the Admin UI'
104
+ Write-Host "Action: free a port between $GapPortFirst and $GapPortLast"
105
+ exit 1
106
+ }
107
+ if ("$port" -ne $stored) {
108
+ Write-Warn "Port $stored is in use, using $port instead"
109
+ Set-EnvValue 'GAP_UI_PORT' $port
110
+ }
111
+ if (-not (Start-GapStack)) { exit 1 }
112
+ Write-Ok 'GAP is running'
113
+ Write-Host "Admin UI: $(Get-UiUrl)"
114
+ Write-NewerPackageNote
115
+ Open-Url (Get-UiUrl)
116
+ }
117
+
118
+ function Invoke-Stop {
119
+ if (-not (Test-Prereqs 'gap stop')) { exit 1 }
120
+ Invoke-Dc stop
121
+ if ($LASTEXITCODE -ne 0) { exit 1 }
122
+ Write-Host 'GAP stopped. Your data is kept. Run: gap start'
123
+ }
124
+
125
+ function Invoke-Logs($logArgs) {
126
+ if (-not (Test-Prereqs 'gap logs')) { exit 1 }
127
+ Invoke-Dc logs @logArgs
128
+ exit $LASTEXITCODE
129
+ }
130
+
131
+ # Remove-LauncherFromPath: take ~\.gap\bin out of the user PATH again. Returns $true when it was there.
132
+ function Remove-LauncherFromPath {
133
+ if (-not $OnWindows) { return $false }
134
+ $entries = @((Get-UserPathRaw) -split ';' | Where-Object { $_ })
135
+ $kept = @($entries | Where-Object { -not (Test-LauncherEntry $_) })
136
+ if ($kept.Count -eq $entries.Count) { return $false }
137
+ Set-UserPathRaw ($kept -join ';')
138
+ return $true
139
+ }
140
+
141
+ # D28: stop the stack and remove everything the installer put on this machine. Without --purge the
142
+ # Docker volumes (gap_pgdata, gap_data) stay, and a later install finds and reuses them. With
143
+ # --purge the volumes and this version's GAP images go too (gap-kernel, gap-admin-ui and gap-postgres:16,
144
+ # under the names the install gave them: the registry prefix of .env, empty for a ZIP; the user's own
145
+ # postgres:16, if any, is a different name and is never touched). Nothing is removed until the stack has
146
+ # been taken down. Public since #1986 (#2015): it was hidden in #1750 (Scott, 21/10) and is now in Show-Usage
147
+ # and the READMEs. It removes ~\.gap, the launcher and the PATH entry the installer added, and nothing else: the
148
+ # extracted installer folder (ZIP) and the package folder (npm) are never touched, and `npm uninstall -g` is the
149
+ # user's separate, later step.
150
+ function Invoke-Uninstall($uninstallArgs) {
151
+ $purge = $false
152
+ $yes = $false
153
+ foreach ($arg in $uninstallArgs) {
154
+ switch ("$arg") {
155
+ '--purge' { $purge = $true }
156
+ '--yes' { $yes = $true }
157
+ default { [Console]::Error.WriteLine((Show-Usage | Out-String).TrimEnd()); exit 2 }
158
+ }
159
+ }
160
+ if (-not (Test-Prereqs 'gap uninstall')) { exit 1 }
161
+ $version = Get-EnvValue 'GAP_VERSION'
162
+ if ($purge -and -not $yes) {
163
+ if ([Console]::IsInputRedirected) {
164
+ Write-Warn '--purge erases all GAP data; run it in a terminal, or add --yes to confirm'
165
+ exit 1
166
+ }
167
+ $answer = Read-Host 'This erases ALL GAP data (database and files) and the GAP images. Type "delete" to continue'
168
+ if ($answer -ne 'delete') { Write-Host 'Cancelled. Nothing was changed.'; exit 1 }
169
+ }
170
+ if ($purge) { Invoke-Dc down -v } else { Invoke-Dc down }
171
+ if ($LASTEXITCODE -ne 0) { exit 1 }
172
+ if ($purge) {
173
+ $prefix = Get-InstalledPrefix
174
+ & docker rmi (Get-ImageRef $prefix 'gap-kernel' $version) (Get-ImageRef $prefix 'gap-admin-ui' $version) `
175
+ (Get-ImageRef $prefix 'gap-postgres' '16') *> $null
176
+ }
177
+ $pathRemoved = Remove-LauncherFromPath
178
+ # The running chain is gap.cmd (cmd.exe re-reads a batch file after each command, so deleting it
179
+ # now would end in "The batch file cannot be found") -> gap.ps1. Everything else goes now; the
180
+ # folder that still holds gap.cmd is removed by a separate cmd.exe a moment after this one exits.
181
+ # Anything that is still locked now is caught by that second pass.
182
+ if ($OnWindows) {
183
+ Get-ChildItem -LiteralPath $GapHome -Force | Where-Object { $_.Name -ne 'bin' } |
184
+ Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
185
+ Start-Process -FilePath 'cmd.exe' -WindowStyle Hidden `
186
+ -ArgumentList '/c', "ping -n 3 127.0.0.1 >nul & rmdir /s /q `"$GapHome`""
187
+ } else {
188
+ Remove-Item -LiteralPath $GapHome -Recurse -Force
189
+ }
190
+ Write-Ok 'GAP removed'
191
+ if ($pathRemoved) { Write-Host "Removed $LauncherDir from your user PATH." }
192
+ if ($purge) {
193
+ Write-Host 'All GAP data was erased.'
194
+ } else {
195
+ Write-Host "Your data is kept in the Docker volumes ${GapProject}_pgdata and ${GapProject}_data; installing again brings it back."
196
+ Write-Host 'To erase it for good, install again and run: gap uninstall --purge'
197
+ }
198
+ }
199
+
200
+ # `gap login` (#1986): npm channel only (the ZIP carries its images). The same flow as the bash one (see cmd_login in gap): it asks
201
+ # for a GitHub username and a token, hands the token to `docker login --password-stdin` (on stdin, so it is in no argument list and
202
+ # no log), and then reads the three images the way `gap doctor` does. Needs a terminal. NOT run on Windows PowerShell 5.1 yet.
203
+ function Invoke-Login {
204
+ if ((Get-Channel) -ne 'npm') {
205
+ Write-Warn 'The ZIP has no `gap login`'
206
+ Write-Host 'Its images are inside the ZIP, so there is no registry to log in to.'
207
+ exit 1
208
+ }
209
+ $registryHost = (Get-ChannelPrefix).Split('/')[0]
210
+ if ([Console]::IsInputRedirected) {
211
+ Write-Warn 'gap login asks for your GitHub username and token, so it needs a terminal'
212
+ Write-Host 'Without one, log in with Docker and pipe the token into it:'
213
+ Write-Host " docker login $registryHost -u <your GitHub username> --password-stdin"
214
+ exit 1
215
+ }
216
+ if (-not (Test-Prereqs 'gap login')) { exit 1 }
217
+ Write-Host "GAP's images are in $registryHost, a private registry. You need a GitHub personal access token (classic)"
218
+ Write-Host 'with the read:packages permission. Create one here if you have none:'
219
+ Write-Host ' https://github.com/settings/tokens/new?scopes=read:packages'
220
+ Write-Host ''
221
+ # Trim() drops the blanks a paste brings at either end, as `read` does in the bash version (nothing is removed inside the text).
222
+ $user = "$(Read-Host 'GitHub username')".Trim()
223
+ if (-not $user) { Write-Warn 'No username was given'; exit 1 }
224
+ $secure = Read-Host 'Token (hidden as you type)' -AsSecureString
225
+ $token = ''
226
+ $bstr = [IntPtr]::Zero
227
+ try {
228
+ $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
229
+ $token = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr).Trim()
230
+ } finally {
231
+ if ($bstr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
232
+ }
233
+ if (-not $token) { Write-Warn 'No token was given'; exit 1 }
234
+ $out = $token | & docker login $registryHost -u $user --password-stdin 2>&1
235
+ $rc = $LASTEXITCODE
236
+ # Docker does not print the password; if a text ever carried it, it would not reach the screen.
237
+ $text = (@($out | ForEach-Object { "$_" }) -join "`n").Replace($token, '***')
238
+ $token = ''
239
+ if ($rc -ne 0) {
240
+ switch (Get-LoginErrorKind $text) {
241
+ 'rejected' {
242
+ # ghcr.io ignores the username of a valid token (measured: a made-up name logs in), so only the token is blamed.
243
+ Write-Warn "$registryHost did not accept that token"
244
+ Write-Host ' - Paste the personal access token (classic) itself, not your GitHub password.'
245
+ Write-Host ' - It needs the read:packages permission and must not be expired or revoked.'
246
+ Write-Host ' Nothing was changed. Run gap login to try again.'
247
+ }
248
+ 'network' {
249
+ Write-Warn "Could not reach $registryHost"
250
+ Write-Host ' Check your internet connection (and proxy settings), then run gap login again.'
251
+ }
252
+ default {
253
+ Write-Warn 'docker login failed'
254
+ @($text -split "`r?`n" | Where-Object { $_ }) | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" }
255
+ }
256
+ }
257
+ exit 1
258
+ }
259
+ Write-Ok "Logged in to $registryHost as $user"
260
+ # What Docker said besides "Login Succeeded" is worth reading (it warns when the credential is kept unencrypted).
261
+ @($text -split "`r?`n" | Where-Object { $_ -and $_ -ne 'Login Succeeded' }) | ForEach-Object { Write-Host " $_" }
262
+ $script:LoginJustDone = $true
263
+ $script:DoctorProblems = 0
264
+ $script:DoctorDocker = $true
265
+ Test-DoctorRegistry
266
+ if ($script:DoctorProblems -gt 0) { exit 1 }
267
+ # Exit 0 means logged in AND able to read the images. When the check could not be made neither is claimed: it is said, the
268
+ # status is 1, and no "Next:" is offered.
269
+ if (-not $script:RegistryReadable) {
270
+ Write-Host 'Logged in, but whether your account can read the images could not be checked. Run gap doctor when that is possible.'
271
+ exit 1
272
+ }
273
+ if (-not (Test-Installed)) { Write-Host 'Next: gap install' }
274
+ }
275
+
276
+ # `gap update` (#1986, #2014): the same command as the bash gap's cmd_update, in the same order (see "gap update" in gap-lib.ps1):
277
+ # npm channel only; the versions compared as numbers; no step back; the new images pulled first, then a checked backup, then
278
+ # the new compose file and version, then the stack. Nothing is rolled back and nothing is deleted. If the new version does not
279
+ # come up it says where the backup is and how to go back to the old version with its data (Write-UpdateFailed).
280
+ function Invoke-Update {
281
+ if ((Get-Channel) -ne 'npm') {
282
+ Write-Warn 'The ZIP has no `gap update`'
283
+ Write-Host 'To update: unzip the new installer and run its install.ps1. It keeps your data and your secrets.'
284
+ exit 1
285
+ }
286
+ Assert-Installed
287
+ $installed = Get-InstalledVersion
288
+ $pkg = Get-CliVersion
289
+ if (-not $pkg) {
290
+ Write-Warn 'This package has no VERSION file, so it cannot tell which version it brings'
291
+ exit 1
292
+ }
293
+ $cmp = Compare-GapVersion $installed $pkg
294
+ if ($null -eq $cmp) {
295
+ Write-Warn "Cannot compare the installed version ($installed) with the version of this package ($pkg)"
296
+ Write-Host ' gap update only moves between versions it can compare (major.minor.patch, with the same -suffix).'
297
+ Write-Host ' Nothing was changed.'
298
+ exit 1
299
+ }
300
+ if ($cmp -eq 0) {
301
+ Write-Host "GAP $installed is already the version of this package. Nothing to do."
302
+ return
303
+ }
304
+ if ($cmp -eq 1) {
305
+ Write-Warn "This package ($pkg) is older than the installed GAP ($installed)"
306
+ Write-OlderPackageHelp
307
+ Write-Host ' Nothing was changed.'
308
+ exit 1
309
+ }
310
+ Write-Host "Updating GAP $installed -> $pkg"
311
+ Write-Host ''
312
+ if (-not (Test-Prereqs 'gap update' $true)) { exit 1 }
313
+ if (-not (Invoke-PullImages $pkg 'gap update')) { exit 1 } # has said what to do; the old stack is untouched
314
+ Write-Ok 'Images ready'
315
+ if (-not (New-GapBackup)) { exit 1 } # has said what is wrong; nothing was changed
316
+ $backup = $script:BackupDir
317
+ Write-Ok "Backup saved in $backup"
318
+ # The files first: if they cannot be written the running stack has not been touched, and the way back is two Copy-Item lines,
319
+ # not the restore (which starts with `down -v` and would erase a healthy stack).
320
+ $written = $true
321
+ try {
322
+ Copy-FileReplacing (Join-Path $GapSource 'docker-compose.yml') (Join-Path $GapHome 'docker-compose.yml')
323
+ Set-EnvValue 'GAP_VERSION' $pkg
324
+ Set-EnvValue 'GAP_IMAGE_PREFIX' (Get-ChannelPrefix)
325
+ Set-EnvValue 'GAP_PULL_POLICY' (Get-ChannelPullPolicy)
326
+ } catch {
327
+ $written = $false
328
+ $reason = "$($_.Exception.Message)"
329
+ }
330
+ if (-not $written) {
331
+ Write-UpdateNotApplied $installed $pkg $backup $reason
332
+ exit 1
333
+ }
334
+ if (-not ((Start-GapStack) -and (Test-GapHealth))) {
335
+ Write-UpdateFailed $installed $pkg $backup
336
+ exit 1
337
+ }
338
+ Write-Host ''
339
+ Write-Host "GAP updated: $installed -> $pkg"
340
+ Write-Host " Backup of GAP ${installed}: $backup"
341
+ Write-Host ' It is kept; delete it when you no longer need it. Check GAP with: gap status'
342
+ }
343
+
344
+ $command = ''
345
+ $rest = @()
346
+ if ($args.Count -gt 0) { $command = "$($args[0])" }
347
+ if ($args.Count -gt 1) { $rest = @($args[1..($args.Count - 1)]) }
348
+
349
+ switch ($command) {
350
+ 'version' { if ($rest.Count -gt 0) { [Console]::Error.WriteLine((Show-Usage | Out-String).TrimEnd()); exit 2 }; Invoke-Version }
351
+ 'doctor' { if ($rest.Count -gt 0) { [Console]::Error.WriteLine((Show-Usage | Out-String).TrimEnd()); exit 2 }; Invoke-Doctor }
352
+ 'update' { if ($rest.Count -gt 0) { [Console]::Error.WriteLine((Show-Usage | Out-String).TrimEnd()); exit 2 }; Invoke-Update }
353
+ 'login' { if ($rest.Count -gt 0) { [Console]::Error.WriteLine((Show-Usage | Out-String).TrimEnd()); exit 2 }; Invoke-Login }
354
+ 'status' { Assert-Installed; Invoke-Status }
355
+ 'start' { Assert-Installed; Invoke-Start }
356
+ 'stop' { Assert-Installed; Invoke-Stop }
357
+ 'logs' { Assert-Installed; Invoke-Logs $rest }
358
+ 'uninstall' { Assert-Installed; Invoke-Uninstall $rest }
359
+ { $_ -in 'help', '-h', '--help' } { Show-Usage }
360
+ default { [Console]::Error.WriteLine((Show-Usage | Out-String).TrimEnd()); exit 2 }
361
+ }
@@ -0,0 +1,262 @@
1
+ # Installs the local GAP stack from this folder (Windows). Safe to run again: secrets stay.
2
+ # Folder contents (#1750, D27): docker-compose.yml, env.template, VERSION, images.tar.gz,
3
+ # install.cmd, install.ps1, gap.ps1, gap-lib.ps1, README.TXT, BUILD-INFO.txt.
4
+ #
5
+ # D28: the installed instance lives in ~\.gap (docker-compose.yml, .env, gap.ps1, gap-lib.ps1). This
6
+ # folder is only the installer: after a successful run it can be moved or deleted.
7
+ #
8
+ # #1986 (#2011): the same script is the installer of the npm package `@atomiton-dev/gap-cli` (`gap install`), told
9
+ # apart by the CHANNEL file next to it (gap-lib.ps1: Get-Channel). There the images are pulled from ghcr.io instead of
10
+ # loaded, no launcher and no PATH entry is made (npm put `gap` on the PATH), and only docker-compose.yml is copied
11
+ # into ~\.gap, because this folder is the package that provides `gap` and must stay.
12
+ #
13
+ # Double-click install.cmd, or in a terminal run:
14
+ # powershell -ExecutionPolicy Bypass -File .\install.ps1
15
+ # (a plain .\install.ps1 is refused by the default execution policy of Windows 10/11, and files
16
+ # from a downloaded zip also carry the "from the Internet" mark)
17
+ $GapSource = $PSScriptRoot
18
+ . (Join-Path $GapSource 'gap-lib.ps1')
19
+
20
+ function Stop-Install($headline, $details) {
21
+ Write-Warn $headline
22
+ foreach ($line in $details) { Write-Host $line }
23
+ exit 1
24
+ }
25
+
26
+ # Copy-IntoHome NAME: replace GapHome\NAME in one move, so that a gap.ps1 that is
27
+ # running right now is never half overwritten.
28
+ function Copy-IntoHome($name) {
29
+ $target = Join-Path $GapHome $name
30
+ Copy-Item -LiteralPath (Join-Path $GapSource $name) -Destination "$target.new" -Force -ErrorAction Stop
31
+ Move-Item -LiteralPath "$target.new" -Destination $target -Force -ErrorAction Stop
32
+ }
33
+
34
+ # Sync-Files: copy what the installed instance needs into GapHome (D28). A newer installer replaces
35
+ # the older compose file and scripts; .env is never touched here.
36
+ function Sync-Files {
37
+ New-Item -ItemType Directory -Force -Path $GapHome -ErrorAction Stop | Out-Null
38
+ $src = [IO.Path]::GetFullPath($GapSource).TrimEnd('\')
39
+ $dst = [IO.Path]::GetFullPath($GapHome).TrimEnd('\')
40
+ if ($src -ieq $dst) { return } # running from GapHome itself
41
+ # npm: `Invoke-Dc` needs the compose file here; the scripts stay in the package.
42
+ $names = if ((Get-Channel) -eq 'npm') { @('docker-compose.yml') } else { @('docker-compose.yml', 'gap-lib.ps1', 'gap.ps1') }
43
+ foreach ($name in $names) { Copy-IntoHome $name }
44
+ }
45
+
46
+ # Reset-DbPassword: set the Postgres password to the one in .env (D28, replaces the stop of D24).
47
+ # Postgres keeps the password it was first started with, so a lost .env would lock the Kernel out of
48
+ # its own data. The official image trusts connections made on the local socket inside the container
49
+ # (pg_hba.conf: `local all all trust`), so the password can be set without knowing the old one.
50
+ # Starts only postgres. The result is checked over the container's own IP, which (unlike 127.0.0.1)
51
+ # is not trusted, so a wrong password fails there. Returns $null on success, else the reason.
52
+ function Reset-DbPassword {
53
+ $password = Get-EnvValue 'GAP_DB_PASSWORD'
54
+ $out = Invoke-Dc up -d --wait --wait-timeout 120 postgres 2>&1
55
+ if ($LASTEXITCODE -ne 0) { return "postgres did not start: $((@($out) | Select-Object -Last 2) -join ' ')" }
56
+ $id = Invoke-Dc ps -q postgres | Select-Object -First 1
57
+ if (-not $id) { return 'postgres container not found' }
58
+ $out = "ALTER USER postgres PASSWORD '$password';" | & docker exec -i $id psql -U postgres -v ON_ERROR_STOP=1 -q 2>&1
59
+ if ($LASTEXITCODE -ne 0) { return "setting the password failed: $(@($out) -join ' ')" }
60
+ # No `sh -c "..."` here: Windows PowerShell 5.1 passes an argument with inner double quotes to
61
+ # docker.exe without escaping them, which split that command into pieces and made this check
62
+ # always fail. The IP is read first, and every argument below is free of double quotes.
63
+ $ip = "$(& docker exec $id hostname -i 2>$null)".Trim().Split(' ')[0]
64
+ if (-not $ip) { return "could not read the postgres container's IP" }
65
+ $out = & docker exec -e "PGPASSWORD=$password" $id psql -h $ip -U postgres -tAc 'select 1' 2>&1
66
+ if ($LASTEXITCODE -ne 0) { return "the new password was not accepted: $(@($out) -join ' ')" }
67
+ return $null
68
+ }
69
+
70
+ # Makes `gap` runnable from any terminal: gap.cmd in ~\.gap\bin (cmd.exe cannot run a .ps1 by
71
+ # name, and the execution policy would block one anyway), and that folder on the user PATH.
72
+ # Needs no administrator rights. Returns $true when it had to add the folder to the PATH.
73
+ function Install-Launcher {
74
+ New-Item -ItemType Directory -Force -Path $LauncherDir | Out-Null
75
+ $cmd = "@echo off`r`npowershell -NoProfile -ExecutionPolicy Bypass -File `"$(Join-Path $GapHome 'gap.ps1')`" %*`r`nexit /b %ERRORLEVEL%`r`n"
76
+ [IO.File]::WriteAllText((Join-Path $LauncherDir 'gap.cmd'), $cmd, (New-Object Text.UTF8Encoding($false)))
77
+ if (-not $OnWindows) { return $false }
78
+ $entries = @((Get-UserPathRaw) -split ';' | Where-Object { $_ })
79
+ if ($entries | Where-Object { Test-LauncherEntry $_ }) { return $false }
80
+ Set-UserPathRaw ((@($entries) + $LauncherDir) -join ';')
81
+ return $true
82
+ }
83
+
84
+ Write-Host "Installing GAP from $GapSource"
85
+ Write-Host ''
86
+
87
+ # 1. Prerequisites
88
+ if (-not (Test-Prereqs (Get-InstallCommand) $true)) { exit 1 }
89
+ Write-Ok 'Prerequisites passed'
90
+
91
+ foreach ($name in 'VERSION', 'docker-compose.yml', 'env.template', 'gap.ps1', 'gap-lib.ps1') {
92
+ if (-not (Test-Path -LiteralPath (Join-Path $GapSource $name))) {
93
+ Stop-Install "$name is missing in $GapSource" @('Download the installer again.')
94
+ }
95
+ }
96
+ $version = ([IO.File]::ReadAllText((Join-Path $GapSource 'VERSION'))).Trim()
97
+
98
+ # npm channel: never go back (the same guard as install.sh). An older package over a newer GAP would start an old Kernel on a
99
+ # database a newer one has migrated. Only a comparison that can be made refuses; anything else runs as it always did.
100
+ if ((Get-Channel) -eq 'npm' -and (Test-Installed) -and ((Compare-GapVersion (Get-InstalledVersion) $version) -eq 1)) {
101
+ Write-Warn "This package ($version) is older than the installed GAP ($(Get-InstalledVersion))"
102
+ Write-OlderPackageHelp
103
+ Write-Host ' Nothing was changed.'
104
+ exit 1
105
+ }
106
+
107
+ # 2. Images. npm channel: pulled from the registry, the missing ones only, before anything is written (the user may
108
+ # not be logged in or allowed, and must then be left with nothing half installed). ZIP channel: from the archive in
109
+ # this folder (no network needed). Postgres is packaged as
110
+ # gap-postgres:16, so loading never moves the user's own postgres:16 tag. The archive is loaded when
111
+ # an image is missing, and also when this package differs from the one loaded last time (its
112
+ # BUILD-INFO.txt is recorded in ~\.gap), so a rebuilt package with the same VERSION still replaces
113
+ # the images. Only missing images make images.tar.gz mandatory.
114
+ $pkgId = ''
115
+ $needLoad = $false
116
+ $loadedFile = Join-Path $GapHome 'images.loaded'
117
+ if ((Get-Channel) -eq 'npm') {
118
+ if (-not (Invoke-PullImages $version)) { exit 1 } # has said what to do
119
+ Write-Ok 'Images ready'
120
+ } else {
121
+ $buildInfo = Join-Path $GapSource 'BUILD-INFO.txt'
122
+ if (Test-Path -LiteralPath $buildInfo) { $pkgId = (Get-FileHash -LiteralPath $buildInfo -Algorithm SHA256).Hash }
123
+ foreach ($image in "gap-kernel:$version", "gap-admin-ui:$version", 'gap-postgres:16') {
124
+ & docker image inspect $image *> $null
125
+ if ($LASTEXITCODE -ne 0) { $needLoad = $true }
126
+ }
127
+ if (-not $needLoad -and $pkgId -and (Test-Path -LiteralPath (Join-Path $GapSource 'images.tar.gz'))) {
128
+ $loaded = ''
129
+ if (Test-Path -LiteralPath $loadedFile) { $loaded = ([IO.File]::ReadAllText($loadedFile)).Trim() }
130
+ if ($loaded -ne $pkgId) { $needLoad = $true }
131
+ }
132
+ if ($needLoad) {
133
+ $archive = Join-Path $GapSource 'images.tar.gz'
134
+ if (-not (Test-Path -LiteralPath $archive)) {
135
+ Stop-Install 'images.tar.gz is missing and the images are not on this machine' @('Download the installer again.')
136
+ }
137
+ Write-Host 'Loading images, this can take a minute...'
138
+ $loadOut = & docker load -i $archive 2>&1
139
+ if ($LASTEXITCODE -ne 0) { Stop-Install 'Loading images failed' @("$($loadOut -join "`n")") }
140
+ }
141
+ Write-Ok 'Images loaded'
142
+ }
143
+
144
+ # 3. The installed instance (D28)
145
+ try { Sync-Files } catch {
146
+ Stop-Install "Could not write to $GapHome" @("$($_.Exception.Message)", "Check the permissions of that folder, then run $(Get-InstallName) again.")
147
+ }
148
+ if ($needLoad -and $pkgId) { [IO.File]::WriteAllText($loadedFile, "$pkgId`n") }
149
+
150
+ # 4. Configuration. First run creates .env with fresh secrets; later runs only update version and
151
+ # port (D23, D9) and keep the password, which Postgres has already stored in its volume.
152
+ & docker volume inspect "${GapProject}_pgdata" *> $null
153
+ $hadVolume = ($LASTEXITCODE -eq 0)
154
+ if (Test-Path -LiteralPath (Get-EnvPath)) {
155
+ $storedPort = Get-EnvValue 'GAP_UI_PORT'
156
+ Set-EnvValue 'GAP_VERSION' $version
157
+ } else {
158
+ $storedPort = ''
159
+ $bytes = New-Object byte[] 24
160
+ $rng = [Security.Cryptography.RandomNumberGenerator]::Create()
161
+ $rng.GetBytes($bytes)
162
+ $rng.Dispose()
163
+ $password = ($bytes | ForEach-Object { $_.ToString('x2') }) -join ''
164
+ $template = [IO.File]::ReadAllText((Join-Path $GapSource 'env.template')).Replace("`r`n", "`n")
165
+ Write-EnvFile $template.Replace('@GAP_VERSION@', $version).Replace('@GAP_UI_PORT@', '0').Replace('@GAP_DB_PASSWORD@', $password)
166
+ # D28: data from an earlier install is still in the volume but its password is gone with the old
167
+ # .env (the install folder was moved or deleted). Set the new password inside Postgres.
168
+ if ($hadVolume) {
169
+ $reason = Reset-DbPassword
170
+ if ($reason) {
171
+ Remove-Item -LiteralPath (Get-EnvPath) -Force # back to "no .env", so the next run starts from the same place
172
+ Stop-Install 'GAP data from an earlier install exists, but its database password could not be reset' @(
173
+ $reason,
174
+ "Either: put the old .env back into $GapHome and run $(Get-InstallName) again,",
175
+ 'or: erase the old data (it is lost for good) with',
176
+ " docker compose -p $GapProject down -v",
177
+ "and run $(Get-InstallName) again.")
178
+ }
179
+ Write-Ok 'GAP data from an earlier install was found; its lost .env was rebuilt and the data is kept'
180
+ }
181
+ }
182
+ # Where this channel gets its images from (#1986): the ZIP's own, loaded above under their local names and never
183
+ # pulled (empty prefix, `never`); the npm channel's, pulled above from the registry (its prefix, `missing`). The
184
+ # channel's value, not .env's: written on every run, not only on the first, so that a machine that used another
185
+ # channel (or an older package that did not know these keys) takes this channel's names from now on.
186
+ Set-EnvValue 'GAP_IMAGE_PREFIX' (Get-ChannelPrefix)
187
+ Set-EnvValue 'GAP_PULL_POLICY' (Get-ChannelPullPolicy)
188
+ $port = Select-Port $storedPort
189
+ if (-not $port) {
190
+ Stop-Install 'No free port for the Admin UI' @("Action: free a port between $GapPortFirst and $GapPortLast, then run $(Get-InstallName) again.")
191
+ }
192
+ if ($storedPort -and "$port" -ne $storedPort) { Write-Warn "Port $storedPort is in use, using $port instead" }
193
+ Set-EnvValue 'GAP_UI_PORT' $port
194
+
195
+ # 5. Start. Both lines are true once the Kernel is healthy: it depends on a healthy Postgres, and
196
+ # its entrypoint finishes the schema before the server that answers /health starts (D16).
197
+ if (-not (Start-GapStack)) { exit 1 }
198
+ Write-Ok 'Database initialized'
199
+ Write-Ok 'Migrations completed'
200
+
201
+ # 6. Health checks through the Admin UI port, the path the user will take.
202
+ foreach ($path in '/health', '/system/health', '/') {
203
+ $tries = 0
204
+ while (-not (Test-HttpOk $path)) {
205
+ $tries++
206
+ if ($tries -ge 15) { Stop-Install "Health check failed: $(Get-UiUrl)$path" @('Run: gap logs kernel') }
207
+ Start-Sleep -Seconds 2
208
+ }
209
+ }
210
+ Write-Ok 'Health checks passed'
211
+
212
+ # 7. The gap command
213
+ $other = Get-Command gap -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
214
+ $onNpm = ((Get-Channel) -eq 'npm')
215
+ $pathAdded = $false
216
+ $zipLauncherLeft = $false
217
+ if ($onNpm) {
218
+ # npm put `gap` on the PATH: no launcher, no PATH entry. A gap.cmd left by a ZIP install is only reported (D6);
219
+ # npm never makes this one.
220
+ $zipLauncherLeft = Test-Path -LiteralPath (Join-Path $LauncherDir 'gap.cmd')
221
+ } else {
222
+ $pathAdded = Install-Launcher
223
+ }
224
+ # Does `gap` mean GAP in the user's own PowerShell? An alias or function of that name from the profile
225
+ # is invisible to this script (install.cmd runs it with -NoProfile), so each PowerShell is asked
226
+ # (gap-lib.ps1). Asked before the summary below, so that the summary stays the last thing on the screen.
227
+ Write-Host 'Checking the gap command in your terminal...'
228
+ $shadow = Get-GapResolutionReport
229
+ Write-Host ''
230
+ Write-Host 'GAP Platform is ready!'
231
+ Write-Host ''
232
+ Write-Host " Admin UI: $(Get-UiUrl)"
233
+ Write-Host ' Login: admin@example.com / changeme'
234
+ Write-Host ''
235
+ # Where `gap` is taken (see below), the name gap.cmd still reaches GAP, in PowerShell and in cmd.exe.
236
+ if ($shadow.Shadowed) {
237
+ Write-Host ' Check it any time with: gap.cmd status (also: gap.cmd start, gap.cmd stop, gap.cmd logs)'
238
+ } else {
239
+ Write-Host ' Check it any time with: gap status (also: gap start, gap stop, gap logs)'
240
+ }
241
+ if ($onNpm) {
242
+ # $GapSource is the package that provides `gap`: deleting it would remove the command.
243
+ Write-Host " GAP now runs from $GapHome. Keep the npm package: it is the gap command."
244
+ if ($zipLauncherLeft) {
245
+ Write-Host " Warning: $(Join-Path $LauncherDir 'gap.cmd') is a launcher from a ZIP install of GAP and may run instead of the npm command."
246
+ Write-Host ' Use one channel per machine; the data is shared. To keep only the npm command, delete that file.'
247
+ }
248
+ } else {
249
+ Write-Host " GAP now runs from $GapHome; you can delete the installer folder $GapSource"
250
+ }
251
+ if ($pathAdded) {
252
+ Write-Host " Added $LauncherDir to your user PATH."
253
+ Write-Host " Open a new terminal for that to take effect, or run: $(Join-Path $LauncherDir 'gap.cmd') status"
254
+ }
255
+ $shadow.Lines | ForEach-Object { Write-Host $_ }
256
+ # Only when no PowerShell could be asked, fall back to a gap on this script's own PATH.
257
+ if (-not $shadow.Asked -and $other -and -not "$($other.Source)".StartsWith($LauncherDir)) {
258
+ Write-Host " Note: another `"gap`" is already on your PATH ($($other.Source)) and may run instead; use $(Join-Path $LauncherDir 'gap.cmd')"
259
+ }
260
+
261
+ # 8. Open the browser
262
+ Open-Url (Get-UiUrl)