@atomiton-dev/gap-cli 0.0.0-stage → 0.9.0-mvp
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -2
- package/bin/gap.js +47 -0
- package/installer/CHANNEL +1 -0
- package/installer/VERSION +1 -0
- package/installer/docker-compose.yml +86 -0
- package/installer/env.template +13 -0
- package/installer/gap +365 -0
- package/installer/gap-lib.ps1 +1248 -0
- package/installer/gap-lib.sh +1203 -0
- package/installer/gap.ps1 +361 -0
- package/installer/install.ps1 +262 -0
- package/installer/install.sh +252 -0
- package/lib/launcher.js +31 -0
- package/lib/postinstall.js +137 -0
- package/package.json +31 -4
|
@@ -0,0 +1,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)
|