2026-05-04 22:21:07 +12:00
<#
. SYNOPSIS
2026-05-31 20:19:44 +12:00
Lean 101 deployment script — ships an app to a Digital Ocean droplet over SSH .
2026-05-04 22:21:07 +12:00
. DESCRIPTION
2026-05-08 23:07:01 +12:00
Tars the local source tree, uploads it to the droplet, and runs
docker compose up --build . No git required on the server .
2026-05-04 22:21:07 +12:00
2026-05-08 23:07:01 +12:00
The same script handles first-time setup and subsequent updates .
2026-05-04 22:21:07 +12:00
2026-05-31 20:19:44 +12:00
Designed to be swappable across Lean 101 apps . Override -AppName and
-AppSlug (and any defaults derived from them) to deploy a different app .
2026-05-04 22:21:07 +12:00
. PARAMETER RemoteHost
Hostname or IP of the Digital Ocean droplet . Required .
. PARAMETER RemoteUser
2026-05-08 23:07:01 +12:00
SSH user . Defaults to 'root' .
2026-05-04 22:21:07 +12:00
2026-05-31 20:19:44 +12:00
. PARAMETER AppName
Human-readable app name shown in the banner and log output .
Defaults to 'Clients' .
. PARAMETER AppSlug
Lowercase slug used to derive default remote path, container names, env files,
and archive names . Defaults to 'clients' . Override to retarget the script at
a different Lean 101 app (e . g . -AppSlug 'ops' for the Ops portal) .
2026-05-04 22:21:07 +12:00
. PARAMETER RemotePath
2026-05-31 20:19:44 +12:00
Absolute path on the droplet . Defaults to '/srv/lean101-<AppSlug>' .
. PARAMETER BackendContainer
Backend container name to inspect for health . Defaults to 'lean101-<AppSlug>-backend' .
. PARAMETER PortEnvKey
Env var name in the env file that holds the published port .
Defaults to '<APPSLUG_UPPER>_APP_PORT' (e . g . CLIENTS_APP_PORT) .
2026-05-04 22:21:07 +12:00
. PARAMETER EnvFile
2026-05-08 23:07:01 +12:00
Local path to the production env file . Defaults to ' . env . production' .
2026-05-04 22:21:07 +12:00
. PARAMETER SshKey
2026-05-08 23:07:01 +12:00
Optional path to an SSH private key .
2026-05-04 22:21:07 +12:00
2026-05-31 20:19:44 +12:00
. PARAMETER Password
Optional SSH password for password-based auth (no key) . Requires sshpass on
PATH . The password is handed to ssh/scp via the SSHPASS environment variable
rather than an interactive prompt, because the script redirects ssh's I/O .
2026-05-04 22:21:07 +12:00
. PARAMETER ComposeFile
2026-05-08 23:07:01 +12:00
Compose file name on the remote host . Defaults to 'docker-compose . production . yml' .
2026-05-04 22:21:07 +12:00
. PARAMETER Seed
2026-05-08 23:07:01 +12:00
Run 'python -m app . seed' inside the backend container after the stack is up .
2026-05-04 22:21:07 +12:00
. PARAMETER Logs
2026-05-08 23:07:01 +12:00
Tail logs for ~60 lines after deploy to verify the stack came up .
. PARAMETER SkipBuild
Pass --no-build to docker compose (use when only env changed) .
2026-05-04 22:21:07 +12:00
2026-05-31 20:19:44 +12:00
. PARAMETER Force
Skip the remote port-availability preflight . Use when you know the port is
held by this same stack and want to redeploy over it regardless .
. PARAMETER NoBanner
Suppress the ASCII banner (useful in CI) .
2026-05-04 22:21:07 +12:00
. EXAMPLE
2026-05-08 23:07:01 +12:00
. /deploy/Deploy . ps1 -RemoteHost 209 . 38 . 24 . 231
2026-05-04 22:21:07 +12:00
. EXAMPLE
2026-05-08 23:07:01 +12:00
. /deploy/Deploy . ps1 -RemoteHost 209 . 38 . 24 . 231 -Seed -Logs
2026-05-31 20:19:44 +12:00
. EXAMPLE
# Password auth instead of an SSH key (requires sshpass on PATH):
. /deploy/Deploy . ps1 -RemoteHost 209 . 38 . 24 . 231 -Password 'your-password' -Seed -Logs
. EXAMPLE
# Retarget the script at a different Lean 101 app:
. /deploy/Deploy . ps1 -RemoteHost 1 . 2 . 3 . 4 -AppName 'Ops' -AppSlug 'ops'
2026-05-04 22:21:07 +12:00
#>
[ CmdletBinding ( ) ]
param (
[ Parameter ( Mandatory = $true ) ] [ string ] $RemoteHost ,
2026-05-31 20:19:44 +12:00
[ string ] $RemoteUser = " root " ,
[ string ] $AppName = " Clients " ,
[ string ] $AppSlug = " clients " ,
[ string ] $RemotePath ,
[ string ] $BackendContainer ,
[ string ] $PortEnvKey ,
[ string ] $EnvFile = " .env.production " ,
2026-05-04 22:21:07 +12:00
[ string ] $SshKey ,
2026-05-31 20:19:44 +12:00
[ string ] $Password ,
[ string ] $ComposeFile = " docker-compose.production.yml " ,
2026-05-04 22:21:07 +12:00
[ switch ] $Seed ,
2026-05-08 23:07:01 +12:00
[ switch ] $Logs ,
2026-05-31 20:19:44 +12:00
[ switch ] $SkipBuild ,
[ switch ] $Force ,
[ switch ] $NoBanner
2026-05-04 22:21:07 +12:00
)
$ErrorActionPreference = " Stop "
Set-StrictMode -Version Latest
2026-05-31 20:19:44 +12:00
# ── App identity (swappable) ──────────────────────────────────────────────────
$AppSlug = $AppSlug . ToLowerInvariant ( )
$AppStack = " lean101- $AppSlug "
if ( -not $RemotePath ) { $RemotePath = " /srv/ $AppStack " }
if ( -not $BackendContainer ) { $BackendContainer = " $AppStack -backend " }
if ( -not $PortEnvKey ) { $PortEnvKey = " $( $AppSlug . ToUpperInvariant ( ) ) _APP_PORT " }
# ── Palette ───────────────────────────────────────────────────────────────────
# ANSI escapes give us truecolor + dim/bold that Write-Host -ForegroundColor cannot.
$Esc = [ char ] 27
$C = @ {
Reset = " $Esc [0m "
Dim = " $Esc [2m "
Bold = " $Esc [1m "
Italic = " $Esc [3m "
Magenta = " $Esc [38;5;177m " # soft violet
Pink = " $Esc [38;5;213m "
Cyan = " $Esc [38;5;87m "
Teal = " $Esc [38;5;79m "
Green = " $Esc [38;5;120m "
Yellow = " $Esc [38;5;221m "
Red = " $Esc [38;5;203m "
Grey = " $Esc [38;5;244m "
Blue = " $Esc [38;5;117m "
}
2026-05-08 23:07:01 +12:00
2026-05-31 20:19:44 +12:00
$Glyph = @ {
Step = '▸'
OK = '✓'
Warn = '!'
Info = '·'
Fail = '✗'
Arrow = '→'
Spark = '✦'
}
function Write-Banner {
if ( $NoBanner ) { return }
$line = '─' * 62
$title = " Lean 101 Deployment Script "
$sub = " App: $AppName Slug: $AppSlug Target: $RemoteUser @ $RemoteHost "
Write-Host " "
Write-Host ( " $( $C . Magenta ) ╭ $line ╮ $( $C . Reset ) " )
Write-Host ( " $( $C . Magenta ) │ $( $C . Reset ) $( $C . Bold ) $( $C . Pink ) ██╗ ███████╗ █████╗ ███╗ ██╗ ███╗ ██████╗ ███╗ $( $C . Reset ) $( $C . Magenta ) │ $( $C . Reset ) " )
Write-Host ( " $( $C . Magenta ) │ $( $C . Reset ) $( $C . Bold ) $( $C . Pink ) ██║ ██╔════╝██╔══██╗████╗ ██║ ████║██╔═████╗ ██║ $( $C . Reset ) $( $C . Magenta ) │ $( $C . Reset ) " )
Write-Host ( " $( $C . Magenta ) │ $( $C . Reset ) $( $C . Bold ) $( $C . Pink ) ██║ █████╗ ███████║██╔██╗ ██║ ██╔██║██║██╔██║ ██║ $( $C . Reset ) $( $C . Magenta ) │ $( $C . Reset ) " )
Write-Host ( " $( $C . Magenta ) │ $( $C . Reset ) $( $C . Bold ) $( $C . Pink ) ██║ ██╔══╝ ██╔══██║██║╚██╗██║ ╚═╝██║████╔╝██║ ██║ $( $C . Reset ) $( $C . Magenta ) │ $( $C . Reset ) " )
Write-Host ( " $( $C . Magenta ) │ $( $C . Reset ) $( $C . Bold ) $( $C . Pink ) ███████╗███████╗██║ ██║██║ ╚████║ ███████╗╚██████╔╝ ██║ $( $C . Reset ) $( $C . Magenta ) │ $( $C . Reset ) " )
Write-Host ( " $( $C . Magenta ) │ $( $C . Reset ) $( $C . Bold ) $( $C . Pink ) ╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚══════╝ ╚═════╝ ╚═╝ $( $C . Reset ) $( $C . Magenta ) │ $( $C . Reset ) " )
Write-Host ( " $( $C . Magenta ) │ $( $C . Reset ) $( $C . Magenta ) │ $( $C . Reset ) " )
Write-Host ( " $( $C . Magenta ) │ $( $C . Reset ) $( $C . Bold ) $( $C . Cyan ) $( $Glyph . Spark ) $title $( $C . Reset ) " + ( ' ' * ( 60 - $title . Length - 3 ) ) + " $( $C . Magenta ) │ $( $C . Reset ) " )
Write-Host ( " $( $C . Magenta ) │ $( $C . Reset ) $( $C . Dim ) $( $C . Grey ) $sub $( $C . Reset ) " + ( ' ' * [ Math ] :: Max ( 0 , 60 - $sub . Length - 2 ) ) + " $( $C . Magenta ) │ $( $C . Reset ) " )
Write-Host ( " $( $C . Magenta ) ╰ $line ╯ $( $C . Reset ) " )
Write-Host " "
}
# ── Output helpers ────────────────────────────────────────────────────────────
$script:StepIndex = 0
function Write-Step([string]$msg ) {
$script:StepIndex + +
$num = " {0:D2} " -f $script:StepIndex
Write-Host ( " $( $C . Dim ) $( $C . Grey ) [ $num ] $( $C . Reset ) $( $C . Bold ) $( $C . Cyan ) $( $Glyph . Step ) $( $C . Reset ) $( $C . Bold ) $msg $( $C . Reset ) " )
}
function Write-Ok([string]$msg ) { Write-Host ( " $( $C . Green ) $( $Glyph . OK ) $( $C . Reset ) $( $C . Green ) $msg $( $C . Reset ) " ) }
function Write-Warn([string]$msg ) { Write-Host ( " $( $C . Yellow ) $( $Glyph . Warn ) $( $C . Reset ) $( $C . Yellow ) $msg $( $C . Reset ) " ) }
function Write-Info([string]$msg ) { Write-Host ( " $( $C . Dim ) $( $C . Grey ) $( $Glyph . Info ) $msg $( $C . Reset ) " ) }
function Write-Fail([string]$msg ) { Write-Host ( " $( $C . Red ) $( $Glyph . Fail ) $( $C . Reset ) $( $C . Red ) $msg $( $C . Reset ) " ) }
# ── Spinner ───────────────────────────────────────────────────────────────────
$Spinner = @ ( '⠋' , '⠙' , '⠹' , '⠸' , '⠼' , '⠴' , '⠦' , '⠧' , '⠇' , '⠏' )
function Invoke-Spinner {
<#
Runs an external process while animating a braille spinner with elapsed time .
Captures stdout/stderr and surfaces them on failure (or via -Quiet:$false) .
#>
param (
[ Parameter ( Mandatory = $true ) ] [ string ] $Label ,
[ Parameter ( Mandatory = $true ) ] [ string ] $FilePath ,
[ string[] ] $ArgList = @ ( ) ,
[ string ] $StdinFile ,
[ switch ] $ShowOutput
)
$outFile = [ System.IO.Path ] :: GetTempFileName ( )
$errFile = [ System.IO.Path ] :: GetTempFileName ( )
$startParams = @ {
FilePath = $FilePath
NoNewWindow = $true
PassThru = $true
RedirectStandardOutput = $outFile
RedirectStandardError = $errFile
}
if ( $ArgList . Count -gt 0 ) { $startParams . ArgumentList = $ArgList }
if ( $StdinFile ) { $startParams . RedirectStandardInput = $StdinFile }
$proc = Start-Process @startParams
$start = Get-Date
$i = 0
try {
while ( -not $proc . HasExited ) {
$elapsed = ( ( Get-Date ) - $start ) . TotalSeconds
$frame = $Spinner [ $i % $Spinner . Count ]
Write-Host ( " `r $( $C . Cyan ) $frame $( $C . Reset ) $( $C . Dim ) $( $C . Grey ) $Label $( $C . Reset ) $( $C . Teal ) $( '{0,5:0.0}s' -f $elapsed ) $( $C . Reset ) " ) -NoNewline
Start-Sleep -Milliseconds 90
$i + +
}
$proc . WaitForExit ( )
$elapsed = ( ( Get-Date ) - $start ) . TotalSeconds
$stdout = if ( Test-Path $outFile ) { Get-Content $outFile -Raw } else { '' }
$stderr = if ( Test-Path $errFile ) { Get-Content $errFile -Raw } else { '' }
if ( $proc . ExitCode -eq 0 ) {
Write-Host ( " `r $( $C . Green ) $( $Glyph . OK ) $( $C . Reset ) $Label $( $C . Dim ) $( $C . Grey ) $( '{0,5:0.0}s' -f $elapsed ) $( $C . Reset ) " + ( ' ' * 12 ) )
if ( $ShowOutput -and $stdout ) {
foreach ( $ln in ( $stdout -split " `r ? `n " ) ) { if ( $ln ) { Write-Info $ln } }
}
return $stdout
}
else {
Write-Host ( " `r $( $C . Red ) $( $Glyph . Fail ) $( $C . Reset ) $Label $( $C . Dim ) $( $C . Grey ) $( '{0,5:0.0}s' -f $elapsed ) (exit $( $proc . ExitCode ) ) $( $C . Reset ) " + ( ' ' * 8 ) )
if ( $stdout ) { foreach ( $ln in ( $stdout -split " `r ? `n " ) ) { if ( $ln ) { Write-Host " $( $C . Dim ) $( $C . Grey ) $ln $( $C . Reset ) " } } }
if ( $stderr ) { foreach ( $ln in ( $stderr -split " `r ? `n " ) ) { if ( $ln ) { Write-Host " $( $C . Red ) $ln $( $C . Reset ) " } } }
throw " $Label failed (exit $( $proc . ExitCode ) ) "
}
}
finally {
Remove-Item $outFile -Force -ErrorAction SilentlyContinue
Remove-Item $errFile -Force -ErrorAction SilentlyContinue
}
}
# ── Helpers ───────────────────────────────────────────────────────────────────
2026-05-08 23:07:01 +12:00
function Get-RepoRoot {
$dir = Split-Path -Parent $PSScriptRoot
if ( -not $dir ) { $dir = ( Get-Location ) . Path }
return $dir
2026-05-04 22:21:07 +12:00
}
2026-05-31 20:19:44 +12:00
function Get-EnvValue([string ] $path , [ string ] $key ) {
foreach ( $line in Get-Content $path ) {
$trimmed = $line . Trim ( )
if ( -not $trimmed -or $trimmed . StartsWith ( " # " ) ) { continue }
if ( $trimmed -notmatch " = " ) { continue }
$parts = $trimmed -split " = " , 2
if ( $parts [ 0 ] . Trim ( ) -eq $key ) {
return $parts [ 1 ] . Trim ( )
}
}
return $null
}
2026-05-08 23:07:01 +12:00
$RepoRoot = Get-RepoRoot
$SshTarget = " $RemoteUser @ $RemoteHost "
$SshOpts = @ ( " -o " , " StrictHostKeyChecking=accept-new " , " -o " , " BatchMode=no " )
if ( $SshKey ) { $SshOpts + = @ ( " -i " , $SshKey ) }
2026-05-31 20:19:44 +12:00
# Password auth (no SSH key): the spinner runs ssh/scp with redirected I/O, so
# there is no terminal for an interactive password prompt. Feed the password
# non-interactively via sshpass instead, reading it from the SSHPASS env var.
$SshExe = 'ssh'
$ScpExe = 'scp'
$SshPrefix = @ ( )
$ScpPrefix = @ ( )
if ( $Password ) {
if ( -not ( Get-Command sshpass -ErrorAction SilentlyContinue ) ) {
throw " sshpass is required for -Password but was not found on PATH. Install it (e.g. 'scoop install sshpass') or use an SSH key. "
}
$env:SSHPASS = $Password
$SshExe = 'sshpass' ; $SshPrefix = @ ( '-e' , 'ssh' )
$ScpExe = 'sshpass' ; $ScpPrefix = @ ( '-e' , 'scp' )
$SshOpts + = @ ( " -o " , " PubkeyAuthentication=no " , " -o " , " PreferredAuthentications=password " )
}
function Write-RemoteScript([string ] $path , [ string ] $content ) {
# Remote bash chokes on Windows CRLF line endings (each line gets a trailing
# \r) and on a UTF-8 BOM. Write LF-only, no BOM.
$lf = $content -replace " `r `n " , " `n " -replace " `r " , " `n "
[ System.IO.File ] :: WriteAllText ( $path , $lf , ( New-Object System . Text . UTF8Encoding ( $false ) ) )
}
function Invoke-Ssh([string ] $cmd , [ string ] $Label , [ switch ] $ShowOutput ) {
if ( -not $Label ) { $Label = " ssh $( $cmd . Substring ( 0 , [ Math ] :: Min ( 48 , $cmd . Length ) ) ) ... " }
# Send the command over stdin to `bash -s` rather than as a command-line
# argument. Complex commands (quotes, pipes, $(), newlines) get corrupted
# when passed as an argv token through sshpass's cygwin argument parsing;
# stdin keeps the only argv tokens simple ("bash -s").
$tmp = [ System.IO.Path ] :: GetTempFileName ( )
try {
Write-RemoteScript $tmp $cmd
Invoke-Spinner -Label $Label -FilePath $SshExe -ArgList ( @ ( $SshPrefix ) + @ ( $SshOpts ) + @ ( $SshTarget , " bash -s " ) ) -StdinFile $tmp -ShowOutput: $ShowOutput
} finally {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
2026-05-04 22:21:07 +12:00
}
2026-05-31 20:19:44 +12:00
function Invoke-Scp([string ] $local , [ string ] $remote , [ string ] $Label ) {
if ( -not $Label ) { $Label = " scp $( Split-Path -Leaf $local ) $( $Glyph . Arrow ) $remote " }
Invoke-Spinner -Label $Label -FilePath $ScpExe -ArgList ( @ ( $ScpPrefix ) + @ ( $SshOpts ) + @ ( $local , " ${SshTarget} : ${remote} " ) )
}
function Try-Ssh([string ] $cmd ) {
$tmp = [ System.IO.Path ] :: GetTempFileName ( )
try {
Write-RemoteScript $tmp $cmd
Get-Content $tmp -Raw | & $SshExe @SshPrefix @SshOpts $SshTarget " bash -s "
return $LASTEXITCODE
} finally {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
2026-05-04 22:21:07 +12:00
}
2026-05-31 20:19:44 +12:00
function Invoke-SshScript([string ] $script , [ string ] $Label ) {
if ( -not $Label ) { $Label = " ssh (remote script) " }
$tmp = [ System.IO.Path ] :: GetTempFileName ( )
try {
Write-RemoteScript $tmp $script
Invoke-Spinner -Label $Label -FilePath $SshExe -ArgList ( @ ( $SshPrefix ) + @ ( $SshOpts ) + @ ( $SshTarget , " bash -s " ) ) -StdinFile $tmp -ShowOutput
} finally {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
}
# ── Render banner ─────────────────────────────────────────────────────────────
Write-Banner
2026-05-08 23:07:01 +12:00
# ── Resolve paths ─────────────────────────────────────────────────────────────
2026-05-04 22:21:07 +12:00
Push-Location $RepoRoot
try {
2026-05-08 23:07:01 +12:00
$EnvPath = if ( [ System.IO.Path ] :: IsPathRooted ( $EnvFile ) ) { $EnvFile } else { Join-Path $RepoRoot $EnvFile }
if ( -not ( Test-Path $EnvPath ) ) {
throw " Env file not found at ' $EnvPath '. Copy .env.production.example and fill in secrets. "
2026-05-04 22:21:07 +12:00
}
2026-05-31 20:19:44 +12:00
$WorkbookCandidates = @ (
( Join-Path $RepoRoot " input_data\\1.xlsx " ) ,
( Join-Path $RepoRoot " Input Cost Spreadsheet(1).xlsx " )
)
$WorkbookPath = $WorkbookCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if ( -not $WorkbookPath ) {
throw " Workbook not found. Checked: $( $WorkbookCandidates -join ', ' ) . The production seed expects at least one workbook file to exist. "
}
$AppPort = Get-EnvValue $EnvPath $PortEnvKey
if ( -not $AppPort ) { $AppPort = " 8081 " }
$Origin = Get-EnvValue $EnvPath " ORIGIN "
if ( -not $Origin ) { $Origin = " https://clients.lean-101.com.au " }
Write-Step " Preflight "
Write-Info " App : $AppName ( $AppSlug ) "
Write-Info " Remote host : $SshTarget "
Write-Info " Remote path : $RemotePath "
Write-Info " Container : $BackendContainer "
Write-Info " Env file : $EnvPath "
Write-Info " Compose file : $ComposeFile "
Write-Info " Origin : $Origin "
Write-Info " Port ( $PortEnvKey ) : $AppPort "
2026-05-04 22:21:07 +12:00
2026-05-08 23:07:01 +12:00
# ── Connectivity check ──────────────────────────────────────────────────────
2026-05-31 20:19:44 +12:00
Write-Step " Verifying SSH connectivity "
Invoke-Ssh " echo connected as `$ (whoami) on `$ (hostname) " -Label " ssh handshake " -ShowOutput
# ── Remote port availability ───────────────────────────────────────────────
# A re-deploy of this same stack is expected to replace its containers in
# place, so any container belonging to this stack (name == STACK or STACK-*)
# is treated as "ours". Only a genuinely different service triggers an abort.
if ( $Force ) {
Write-Step " Skipping remote port check for $AppStack (-Force) "
}
else {
Write-Step " Checking that remote port $AppPort is free for $AppStack "
$portCheckCmd = @'
set -e
PORT='__APP_PORT__'
STACK='__APP_STACK__'
OWNER=$(docker ps --format '{{.Names}} {{.Ports}}' | grep -m1 ":${PORT}->" | cut -d' ' -f1 || true)
if [ -n "$OWNER" ]; then
case "$OWNER" in
"$STACK"|"$STACK"-*) : ;;
*)
echo "Port $PORT is already owned by container: $OWNER" >&2
exit 2 ;;
esac
fi
'@ . Replace ( '__APP_PORT__' , $AppPort ) . Replace ( '__APP_STACK__' , $AppStack )
try {
Invoke-Ssh $portCheckCmd -Label " port $AppPort availability "
}
catch {
throw " Remote port $AppPort is already in use by a different service. Change $PortEnvKey in ' $EnvPath ', retire the conflicting service, or re-run with -Force to deploy anyway. "
}
}
2026-05-04 22:21:07 +12:00
2026-05-08 23:07:01 +12:00
# ── Package source files ────────────────────────────────────────────────────
2026-05-31 20:19:44 +12:00
Write-Step " Packaging source tree (excluding node_modules, caches, secrets) "
2026-05-08 23:07:01 +12:00
2026-05-31 20:19:44 +12:00
$TarFile = Join-Path $env:TEMP " $AppStack -deploy- $( Get-Date -Format 'yyyyMMdd-HHmmss' ) .tar.gz "
2026-05-08 23:07:01 +12:00
$excludes = @ (
" --exclude=./node_modules " ,
" --exclude=./frontend/node_modules " ,
" --exclude=./frontend/.svelte-kit " ,
" --exclude=./frontend/build " ,
" --exclude=./.git " ,
2026-05-31 20:19:44 +12:00
" --exclude=./.pytest_cache " ,
2026-05-08 23:07:01 +12:00
" --exclude=./__pycache__ " ,
" --exclude=./backend/__pycache__ " ,
" --exclude=./backend/app/__pycache__ " ,
2026-05-31 20:19:44 +12:00
" --exclude=./backend/.pytest_cache " ,
" --exclude=./backend/.tmp " ,
" --exclude=./backend/.venv " ,
" --exclude=./backend/data_entry_app_backend.egg-info " ,
2026-05-08 23:07:01 +12:00
" --exclude=./**/__pycache__ " ,
" --exclude=./*.pyc " ,
" --exclude=./.env " ,
" --exclude=./.env.production " ,
" --exclude=./.env.alpha " ,
" --exclude=./data_entry_app.db " ,
" --exclude=./*.db "
)
2026-05-31 20:19:44 +12:00
Invoke-Spinner -Label " tar -czf $( Split-Path -Leaf $TarFile ) " -FilePath 'tar' -ArgList ( @ ( '-czf' , $TarFile ) + $excludes + @ ( '-C' , $RepoRoot , '.' ) )
2026-05-08 23:07:01 +12:00
$TarSize = [ math ] :: Round ( ( Get-Item $TarFile ) . Length / 1 MB , 1 )
2026-05-31 20:19:44 +12:00
Write-Info " Archive: $TarFile ( $( $C . Bold ) $TarSize MB $( $C . Reset ) ) "
2026-05-08 23:07:01 +12:00
# ── Upload env file ─────────────────────────────────────────────────────────
2026-05-31 20:19:44 +12:00
Write-Step " Uploading env file to droplet "
Invoke-Scp $EnvPath " $RemotePath /.env.production " -Label " scp .env.production $( $Glyph . Arrow ) $RemotePath / "
Invoke-Ssh " chmod 600 ' $RemotePath /.env.production' " -Label " chmod 600 .env.production "
2026-05-04 22:21:07 +12:00
2026-05-08 23:07:01 +12:00
# ── Upload and extract source ────────────────────────────────────────────────
2026-05-31 20:19:44 +12:00
Write-Step " Uploading source archive ( $TarSize MB) "
Invoke-Scp $TarFile " /tmp/ $AppStack -deploy.tar.gz " -Label " scp archive $( $Glyph . Arrow ) /tmp/ "
2026-05-08 23:07:01 +12:00
Remove-Item $TarFile -Force
2026-05-04 22:21:07 +12:00
2026-05-31 20:19:44 +12:00
Write-Step " Extracting archive on server "
Invoke-Ssh " mkdir -p ' $RemotePath ' && tar -xzf /tmp/ $AppStack -deploy.tar.gz -C ' $RemotePath ' && rm /tmp/ $AppStack -deploy.tar.gz " -Label " untar into $RemotePath "
2026-05-04 22:21:07 +12:00
2026-05-08 23:07:01 +12:00
# ── Docker compose up ───────────────────────────────────────────────────────
$ComposeArgs = " --env-file .env.production -f $ComposeFile "
$BuildFlag = if ( $SkipBuild ) { " --no-build " } else { " --build " }
2026-05-04 22:21:07 +12:00
2026-05-31 20:19:44 +12:00
$buildMsg = if ( $SkipBuild ) { " without rebuild " } else { " with --build " }
Write-Step " Bringing the $AppName stack up $buildMsg "
$composeUpCmd = " cd ' $RemotePath ' && docker compose $ComposeArgs up -d $BuildFlag --remove-orphans "
try {
Invoke-Ssh $composeUpCmd -Label " docker compose up $BuildFlag "
}
catch {
Write-Warn " docker compose up failed; collecting remote status and backend logs "
Try-Ssh " cd ' $RemotePath ' && docker compose $ComposeArgs ps "
Try-Ssh " cd ' $RemotePath ' && docker compose $ComposeArgs logs --tail=120 backend "
throw
}
2026-05-04 22:21:07 +12:00
2026-05-08 23:07:01 +12:00
# ── Health check ────────────────────────────────────────────────────────────
2026-05-31 20:19:44 +12:00
Write-Step " Waiting for backend health check ( $BackendContainer ) "
2026-05-04 22:21:07 +12:00
$healthScript = @"
s e t - e
c d ' $RemotePath '
2026-05-08 23:07:01 +12:00
f o r i i n ` $( seq 1 30 ) ; d o
2026-05-31 20:19:44 +12:00
s t a t u s = ` $( docker inspect - -format = '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' $BackendContainer 2 > / dev / null | | echo missing )
2026-05-04 22:21:07 +12:00
c a s e " ` $status " i n
2026-05-08 23:07:01 +12:00
h e a l t h y | r u n n i n g ) e c h o " b a c k e n d i s ` $status " ; e x i t 0 ; ;
* ) p r i n t f ' . ' ; s l e e p 4 ; ;
2026-05-04 22:21:07 +12:00
e s a c
d o n e
2026-05-08 23:07:01 +12:00
e c h o ; e c h o ' b a c k e n d d i d n o t b e c o m e h e a l t h y i n t i m e ' > & 2 ; e x i t 1
2026-05-04 22:21:07 +12:00
"@
2026-05-31 20:19:44 +12:00
Invoke-SshScript $healthScript -Label " backend health (up to ~2 min) "
# ── Seed access ─────────────────────────────────────────────────────────────
Write-Step " Seeding default internal users and permissions "
Invoke-Ssh " cd ' $RemotePath ' && docker compose $ComposeArgs exec -T backend python -m app.seed_access " -Label " python -m app.seed_access "
2026-05-04 22:21:07 +12:00
if ( $Seed ) {
Write-Step " Seeding reference data "
2026-05-31 20:19:44 +12:00
Invoke-Ssh " cd ' $RemotePath ' && docker compose $ComposeArgs exec -T backend python -m app.seed " -Label " python -m app.seed "
2026-05-04 22:21:07 +12:00
}
2026-05-08 23:07:01 +12:00
# ── Final status ────────────────────────────────────────────────────────────
Write-Step " Stack status "
2026-05-31 20:19:44 +12:00
Invoke-Ssh " cd ' $RemotePath ' && docker compose $ComposeArgs ps " -Label " docker compose ps " -ShowOutput
# ── Frontend asset verification ─────────────────────────────────────────────
Write-Step " Verifying published Inter font asset "
$fontCheckBody = @'
set -e
ORIGIN="__ORIGIN__"
fetch() {
URL="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$URL"
return
fi
if command -v wget >/dev/null 2>&1; then
wget -qO- "$URL"
return
fi
echo "Neither curl nor wget is available on the remote host." >&2
exit 1
}
fetch_status() {
URL="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsS -o /dev/null -w "%{http_code}" "$URL"
return
fi
if command -v wget >/dev/null 2>&1; then
wget -S --spider "$URL" 2>&1 | awk '/^ HTTP\// { code=$2 } END { if (code) print code; else print "000" }'
return
fi
echo "000"
}
INDEX_HTML=$(fetch "$ORIGIN")
CSS_PATHS=$(printf '%s' "$INDEX_HTML" | grep -oE '/_app/immutable/assets/[^"]+\.css' | sort -u || true)
if [ -z "$CSS_PATHS" ]; then
echo "Could not find a built CSS asset on $ORIGIN" >&2
exit 1
fi
FONT_URL=""
for CSS_PATH in $CSS_PATHS; do
CSS_URL="${ORIGIN%/}${CSS_PATH}"
CSS_CONTENT=$(fetch "$CSS_URL")
FONT_PATH=$(printf '%s' "$CSS_CONTENT" | grep -oE 'inter-latin-400-normal\.[^")]+\.woff2' | head -n 1 || true)
if [ -n "$FONT_PATH" ]; then
FONT_URL="${CSS_URL%/*}/${FONT_PATH}"
break
fi
done
if [ -z "$FONT_PATH" ]; then
echo "Could not find the Inter Latin 400 woff2 asset in any built CSS asset from $ORIGIN" >&2
exit 1
fi
FONT_STATUS=$(fetch_status "$FONT_URL")
if [ "$FONT_STATUS" != "200" ]; then
echo "Inter font check failed: $FONT_URL returned HTTP $FONT_STATUS" >&2
exit 1
fi
echo "Verified Inter font asset: $FONT_URL"
'@ . Replace ( '__ORIGIN__' , $Origin )
Invoke-SshScript $fontCheckBody -Label " Inter font asset check "
2026-05-04 22:21:07 +12:00
if ( $Logs ) {
Write-Step " Recent logs (last 60 lines) "
2026-05-31 20:19:44 +12:00
Invoke-Ssh " cd ' $RemotePath ' && docker compose $ComposeArgs logs --tail=60 " -Label " docker compose logs --tail=60 " -ShowOutput
}
Write-Step " Published access "
Write-Info " Container port : $( $C . Bold ) $AppPort $( $C . Reset ) "
Write-Info " Origin : $( $C . Bold ) $( $C . Blue ) $Origin $( $C . Reset ) "
if ( $AppPort -ne " 80 " -and $AppPort -ne " 443 " ) {
Write-Warn " This stack is published on port $AppPort . The public domain may still point at another service until you swap the reverse proxy or port mapping. "
2026-05-04 22:21:07 +12:00
}
2026-05-31 20:19:44 +12:00
# ── Done ────────────────────────────────────────────────────────────────────
$line = '─' * 62
Write-Host " "
Write-Host ( " $( $C . Green ) ╭ $line ╮ $( $C . Reset ) " )
$done = " $( $Glyph . Spark ) $AppName deployed $( $Glyph . Arrow ) $Origin "
$pad = [ Math ] :: Max ( 0 , 60 - ( $done . Length - 2 ) ) # subtract 2 for two-byte glyphs
Write-Host ( " $( $C . Green ) │ $( $C . Reset ) $( $C . Bold ) $( $C . Green ) $done $( $C . Reset ) " + ( ' ' * $pad ) + " $( $C . Green ) │ $( $C . Reset ) " )
Write-Host ( " $( $C . Green ) ╰ $line ╯ $( $C . Reset ) " )
Write-Host " "
}
catch {
Write-Host " "
Write-Fail " Deployment aborted: $( $_ . Exception . Message ) "
2026-05-08 23:07:01 +12:00
Write-Host " "
2026-05-31 20:19:44 +12:00
throw
2026-05-04 22:21:07 +12:00
}
finally {
Pop-Location
}