# Abhako installer for Windows (PowerShell 5.1 or 7, Docker Desktop). # # irm https://abhako.com/install.ps1 | iex # # Installs (or updates) Abhako from GitHub and starts it with Docker Compose. Release versions use # the prebuilt image ghcr.io/gegenschuss/abhako:; if that cannot be pulled, it is built locally. # To update later: run update.ps1 in the install folder (or this command again). Your .env and data\ # are kept, and data\tasks.db is backed up to data\backups\ first. # # Options (environment variables, set before running): # $env:ABHAKO_DIR install folder (default: $env:USERPROFILE\abhako) # $env:ABHAKO_PORT port on 127.0.0.1 (default: 3040) # $env:ABHAKO_VERSION release tag (v1.0.0) or branch (default: the latest release) # $env:ABHAKO_BUILD=1 always build locally instead of pulling the prebuilt image # # Source: https://github.com/Gegenschuss/abhako (MIT) # Everything runs inside one script block that is invoked on the last line, so a download cut off # halfway through does nothing. & { Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' # Invoke-WebRequest is very slow with the progress bar in PS 5.1 try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { } $Repo = 'Gegenschuss/abhako' $ImageRepo = if ($env:ABHAKO_IMAGE_REPO) { $env:ABHAKO_IMAGE_REPO } else { 'ghcr.io/gegenschuss/abhako' } # override only for mirrors / tests $Marker = '# generated by the Abhako installer' function Say([string]$m) { Write-Host $m } function Step([string]$m) { Write-Host ''; Write-Host "==> $m" -ForegroundColor Cyan } function Warn([string]$m) { Write-Host "Note: $m" -ForegroundColor Yellow } function Fail([string]$m) { Write-Host ''; Write-Host "Error: $m" -ForegroundColor Red; throw 'Abhako installation stopped.' } function Test-Cmd([string]$n) { [bool](Get-Command $n -ErrorAction SilentlyContinue) } # Run a native command, stream its output, return the exit code (stderr does not throw in PS 5.1). function Invoke-Native([string]$exe, [string[]]$argv, [string]$log) { $old = $ErrorActionPreference; $ErrorActionPreference = 'Continue' try { if ($log) { & $exe @argv 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log | Out-Host } else { & $exe @argv 2>&1 | ForEach-Object { "$_" } | Out-Host } return $LASTEXITCODE } finally { $ErrorActionPreference = $old } } function Quiet([string]$exe, [string[]]$argv) { $old = $ErrorActionPreference; $ErrorActionPreference = 'Continue' try { & $exe @argv *> $null; return ($LASTEXITCODE -eq 0) } catch { return $false } finally { $ErrorActionPreference = $old } } $Dir = if ($env:ABHAKO_DIR) { $env:ABHAKO_DIR } else { Join-Path $env:USERPROFILE 'abhako' } # default: the latest GitHub release (the main branch if there is none) $Version = $env:ABHAKO_VERSION if (-not $Version) { try { $Version = (Invoke-RestMethod -UseBasicParsing -Headers @{ Accept = 'application/vnd.github+json' } -Uri "https://api.github.com/repos/$Repo/releases/latest").tag_name } catch { } if (-not $Version) { $Version = 'main' } } if ($Version -match '^\d+\.\d+\.\d+$') { $Version = "v$Version" } if ($Version -notmatch '^[A-Za-z0-9._/][A-Za-z0-9._/-]*$') { Fail 'ABHAKO_VERSION must be a tag or branch name.' } $Override = Join-Path $Dir 'docker-compose.override.yml' # keep a port chosen on an earlier run unless ABHAKO_PORT is given $Port = '3040' if ($env:ABHAKO_PORT) { $Port = $env:ABHAKO_PORT } elseif ((Test-Path $Override) -and ((Get-Content $Override -TotalCount 1) -like "$Marker*")) { $m = Select-String -Path $Override -Pattern '"127\.0\.0\.1:(\d+):3040"' | Select-Object -First 1 if ($m) { $Port = $m.Matches[0].Groups[1].Value } } if ($Port -notmatch '^\d+$') { Fail 'ABHAKO_PORT must be a number.' } Write-Host '' Write-Host 'Abhako installer' -ForegroundColor White if ($env:ABHAKO_VERSION) { Say "Installs version $Version from GitHub into $Dir" } elseif ($Version -eq 'main') { Say "Installs the development version from GitHub (branch main, no release found) into $Dir" } else { Say "Installs the latest release ($Version) from GitHub into $Dir" } $Update = Test-Path (Join-Path $Dir 'docker-compose.yml') if ($Update) { Say 'Existing installation found: updating (your .env and data\ are kept, the database is backed up first).' } elseif ((Test-Path $Dir) -and (Get-ChildItem -Force $Dir | Select-Object -First 1)) { Fail "$Dir exists and is not an Abhako installation. Choose another folder with `$env:ABHAKO_DIR." } # --- Docker --- Step 'Checking Docker' if (-not (Test-Cmd 'docker')) { Fail "Docker is not installed. Install Docker Desktop for Windows:`n https://docs.docker.com/desktop/setup/install/windows-install/`nStart it once, then run this installer again." } if (-not (Quiet 'docker' @('info'))) { Fail 'Docker is installed but not running. Start Docker Desktop, wait until it says "running", then run this installer again.' } $ComposeV2 = Quiet 'docker' @('compose', 'version') if (-not $ComposeV2 -and -not (Test-Cmd 'docker-compose')) { Fail 'Docker Compose is missing. Update Docker Desktop (it includes Compose).' } function Compose([string[]]$a, [string]$log) { if ($ComposeV2) { return Invoke-Native 'docker' (@('compose') + $a) $log } return Invoke-Native 'docker-compose' $a $log } Say 'Docker is running.' # --- backup before an update: SQLite backup inside the running container, else a plain copy --- $db = Join-Path $Dir 'data\tasks.db' if ($Update -and (Test-Path $db)) { Step 'Backing up your database' $bdir = Join-Path $Dir 'data\backups' New-Item -ItemType Directory -Force -Path $bdir | Out-Null $name = 'tasks-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.db' Push-Location $Dir try { $py = "import sqlite3; s = sqlite3.connect('/data/tasks.db'); d = sqlite3.connect('/data/backups/$name'); s.backup(d); d.close()" $live = if ($ComposeV2) { Quiet 'docker' @('compose', 'exec', '-T', 'abhako', 'python', '-c', $py) } else { Quiet 'docker-compose' @('exec', '-T', 'abhako', 'python', '-c', $py) } } finally { Pop-Location } $target = Join-Path $bdir $name if ($live -and (Test-Path $target) -and ((Get-Item $target).Length -gt 0)) { Say "Saved data\backups\$name (copy of the running database)" } else { try { Copy-Item $db $target -Force; if (Test-Path "$db-wal") { Copy-Item "$db-wal" "$target-wal" -Force } } catch { Fail "Could not back up $db. Nothing was changed." } Say "Saved data\backups\$name" } Get-ChildItem -Path $bdir -Filter 'tasks-*.db' | Sort-Object LastWriteTime -Descending | Select-Object -Skip 10 | ForEach-Object { Remove-Item -Force $_.FullName, "$($_.FullName)-wal" -ErrorAction SilentlyContinue } } # --- download --- $useGit = (Test-Cmd 'git') -and ((Test-Path (Join-Path $Dir '.git')) -or -not (Test-Path $Dir)) if ($useGit) { if (Test-Path (Join-Path $Dir '.git')) { Step "Updating $Dir to $Version (git)" if ((Invoke-Native 'git' @('-C', $Dir, 'fetch', '--quiet', '--depth', '1', 'origin', $Version)) -ne 0) { Fail "Could not fetch '$Version' from GitHub. Is it an existing tag or branch?" } if ((Invoke-Native 'git' @('-C', $Dir, 'checkout', '--quiet', '--force', 'FETCH_HEAD')) -ne 0) { Fail "Could not update the files in $Dir. Check 'git -C $Dir status'." } } else { Step "Downloading Abhako $Version (git clone)" if ((Invoke-Native 'git' @('clone', '--quiet', '--depth', '1', '--branch', $Version, "https://github.com/$Repo.git", $Dir)) -ne 0) { Fail "Could not clone '$Version' from GitHub. Is it an existing tag or branch?" } } $Rev = (& git -C $Dir rev-parse --short HEAD) } else { Step "Downloading Abhako $Version (zip)" $tmp = Join-Path ([IO.Path]::GetTempPath()) ('abhako-' + [Guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $tmp | Out-Null try { $zip = Join-Path $tmp 'src.zip' try { Invoke-WebRequest -UseBasicParsing -Uri "https://github.com/$Repo/archive/$Version.zip" -OutFile $zip } catch { Fail "Could not download '$Version' from GitHub. Is it an existing tag or branch?" } Expand-Archive -Path $zip -DestinationPath (Join-Path $tmp 'x') -Force $src = Get-ChildItem (Join-Path $tmp 'x') -Directory | Select-Object -First 1 if (-not $src -or -not (Test-Path (Join-Path $src.FullName 'docker-compose.yml'))) { Fail 'The download does not look like Abhako.' } New-Item -ItemType Directory -Force -Path $Dir | Out-Null # overwrite the program files, keep .env, data\ and the override file Copy-Item -Path (Join-Path $src.FullName '*') -Destination $Dir -Recurse -Force Get-ChildItem -Force -File $src.FullName | Where-Object { $_.Name -like '.*' } | ForEach-Object { Copy-Item -Path $_.FullName -Destination $Dir -Recurse -Force } } finally { Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue } $Rev = "$Version (zip)" } Set-Content -Path (Join-Path $Dir '.abhako-version') -Value $Version -Encoding ASCII New-Item -ItemType Directory -Force -Path (Join-Path $Dir 'data') | Out-Null # --- .env --- $envFile = Join-Path $Dir '.env' if (Test-Path $envFile) { Say 'Keeping your existing .env' } else { $tz = 'UTC' try { $win = (Get-TimeZone).Id $iana = $null if ($win -match '^[A-Za-z_]+/[A-Za-z0-9_/+-]+$') { $iana = $win } # already an IANA id (PowerShell 7 on some systems) if (-not $iana -and [TimeZoneInfo].GetMethod('TryConvertWindowsIdToIanaId', [Type[]]@([string], [string].MakeByRefType()))) { [void][TimeZoneInfo]::TryConvertWindowsIdToIanaId($win, [ref]$iana) } if (-not $iana) { $map = @{ 'W. Europe Standard Time' = 'Europe/Berlin'; 'Central Europe Standard Time' = 'Europe/Budapest'; 'Romance Standard Time' = 'Europe/Paris'; 'GMT Standard Time' = 'Europe/London'; 'UTC' = 'UTC'; 'Eastern Standard Time' = 'America/New_York'; 'Central Standard Time' = 'America/Chicago'; 'Mountain Standard Time' = 'America/Denver'; 'Pacific Standard Time' = 'America/Los_Angeles' } if ($map.ContainsKey($win)) { $iana = $map[$win] } } if ($iana) { $tz = $iana } } catch { } $lines = Get-Content (Join-Path $Dir '.env.example') | ForEach-Object { if ($_ -match '^TZ=') { "TZ=$tz" } elseif ($_ -match '^PUBLIC_URL=') { "PUBLIC_URL=http://localhost:$Port" } else { $_ } } # UTF-8 without BOM and LF line endings, as Docker expects [IO.File]::WriteAllText($envFile, (($lines -join "`n") + "`n"), (New-Object Text.UTF8Encoding($false))) Say "Created .env (TZ=$tz, PUBLIC_URL=http://localhost:$Port)" if ($tz -eq 'UTC') { Warn "Time zone set to UTC. Change TZ in $envFile if needed, then run the installer again." } } # --- image: release tags have a prebuilt one, branches are built locally --- $script:Image = '' if ($env:ABHAKO_BUILD -ne '1' -and $Version -match '^v\d+\.\d+\.\d+$') { $script:Image = "${ImageRepo}:$($Version.Substring(1))" } # --- override (image, port, network fallback); the tracked docker-compose.yml publishes 127.0.0.1:3040 --- function Write-Override([bool]$bridge) { if ((Test-Path $Override) -and -not ((Get-Content $Override -TotalCount 1) -like "$Marker*")) { Warn 'Keeping your own docker-compose.override.yml (not written by this installer).' if ($Port -ne '3040') { Warn "ABHAKO_PORT=$Port is ignored; set the port in that file yourself." } if ($script:Image) { Warn 'Building locally instead of using the prebuilt image (set ABHAKO_IMAGE in .env to use it).'; $script:Image = '' } return } if (-not $bridge -and (Test-Path $Override) -and (Select-String -Path $Override -Pattern '^ network_mode: bridge' -Quiet)) { $bridge = $true } if ($Port -eq '3040' -and -not $bridge -and -not $script:Image) { Remove-Item -Force $Override -ErrorAction SilentlyContinue; return } $o = @("$Marker (safe to edit: remove the first line and it is left alone)", 'services:', ' abhako:') if ($script:Image) { $o += " image: $($script:Image)" } if ($Port -ne '3040') { $o += ' ports: !override'; $o += " - `"127.0.0.1:${Port}:3040`"" } if ($bridge) { $o += ' network_mode: bridge' } [IO.File]::WriteAllText($Override, (($o -join "`n") + "`n"), (New-Object Text.UTF8Encoding($false))) } Write-Override $false # --- pull (or build) and start --- $upArgs = @('up', '-d', '--build') if ($script:Image) { Step "Downloading the prebuilt image $($script:Image)" if ((Invoke-Native 'docker' @('pull', $script:Image)) -eq 0) { $upArgs = @('up', '-d') } else { Warn 'The prebuilt image could not be downloaded. Building it locally instead (takes a minute).' $script:Image = '' Write-Override $false } } Step 'Starting' $log = Join-Path $Dir '.install.log' Push-Location $Dir try { $rc = Compose $upArgs $log if ($rc -ne 0 -and (Select-String -Path $log -Pattern 'address pool' -Quiet)) { Warn "Docker has no free address range for a new network. Retrying on Docker's default bridge network (written to docker-compose.override.yml)." Write-Override $true $rc = Compose $upArgs $log } if ($rc -ne 0 -and (Select-String -Path $log -Pattern 'address already in use|port is already allocated|access permissions' -Quiet)) { Fail "Port $Port is already in use. Pick another one, for example: `$env:ABHAKO_PORT = '3050'" } if ($rc -ne 0) { Fail "docker compose up failed (see above, log in $log)." } Remove-Item -Force $log -ErrorAction SilentlyContinue } finally { Pop-Location } # --- health --- Step 'Waiting for Abhako to start' $ok = $false for ($i = 0; $i -lt 60; $i++) { try { $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 3 -Uri "http://127.0.0.1:$Port/api/health" if ($r.StatusCode -eq 200) { $ok = $true; break } } catch { } Start-Sleep -Seconds 2 } if (-not $ok) { Fail "Abhako did not answer on http://127.0.0.1:$Port within 2 minutes. Check the logs: cd `"$Dir`"; docker compose logs --tail 50" } # --- update.ps1 in the install folder --- $upd = @( '# Updates this Abhako installation (written by the Abhako installer): backs up data\tasks.db to', '# data\backups\, fetches the latest release (or $env:ABHAKO_VERSION = ''v1.2.3''), pulls or builds and restarts.', '$env:ABHAKO_DIR = $PSScriptRoot', '$url = if ($env:ABHAKO_INSTALLER_URL) { $env:ABHAKO_INSTALLER_URL } else { ''https://abhako.com/install.ps1'' }', 'Invoke-RestMethod -UseBasicParsing -Uri $url | Invoke-Expression') [IO.File]::WriteAllText((Join-Path $Dir 'update.ps1'), (($upd -join "`r`n") + "`r`n"), (New-Object Text.UTF8Encoding($false))) $url = "http://localhost:$Port" Write-Host '' Write-Host "Abhako is running: $url (version $Rev)" -ForegroundColor Green Write-Host '' Say 'Open it now: the first account you create becomes the admin.' Say '' Say "Folder: $Dir (settings in .env, your data in data\)" if ($script:Image) { Say "Image: $($script:Image) (prebuilt)" } else { Say 'Image: abhako:local (built on this computer)' } Say "Update: powershell -ExecutionPolicy Bypass -File `"$Dir\update.ps1`" (backs up data\tasks.db first; admins also see new versions in Settings > Help)" Say "Stop: cd `"$Dir`"; docker compose down" $img = if ($script:Image) { $script:Image } else { 'abhako:local' } Say "Uninstall: cd `"$Dir`"; docker compose down; docker image rm $img" Say ' then delete the folder (this deletes your tasks)' Say 'Use it from other devices: put it behind a reverse proxy with HTTPS, see' Say " https://github.com/$Repo#reverse-proxy" }