<# .SYNOPSIS Lean 101 deployment script — ships an app to a Digital Ocean droplet over SSH. .DESCRIPTION Tars the local source tree, uploads it to the droplet, and runs docker compose up --build. No git required on the server. The same script handles first-time setup and subsequent updates. Designed to be swappable across Lean 101 apps. Override -AppName and -AppSlug (and any defaults derived from them) to deploy a different app. .PARAMETER RemoteHost Hostname or IP of the Digital Ocean droplet. Required. .PARAMETER RemoteUser SSH user. Defaults to 'root'. .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). .PARAMETER RemotePath Absolute path on the droplet. Defaults to '/srv/lean101-'. .PARAMETER BackendContainer Backend container name to inspect for health. Defaults to 'lean101--backend'. .PARAMETER PortEnvKey Env var name in the env file that holds the published port. Defaults to '_APP_PORT' (e.g. CLIENTS_APP_PORT). .PARAMETER EnvFile Local path to the production env file. Defaults to '.env.production'. .PARAMETER SshKey Optional path to an SSH private key. .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. .PARAMETER ComposeFile Compose file name on the remote host. Defaults to 'docker-compose.production.yml'. .PARAMETER Seed Run 'python -m app.seed' inside the backend container after the stack is up. .PARAMETER Logs 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). .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). .EXAMPLE ./deploy/Deploy.ps1 -RemoteHost 209.38.24.231 .EXAMPLE ./deploy/Deploy.ps1 -RemoteHost 209.38.24.231 -Seed -Logs .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' #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string] $RemoteHost, [string] $RemoteUser = "root", [string] $AppName = "Clients", [string] $AppSlug = "clients", [string] $RemotePath, [string] $BackendContainer, [string] $PortEnvKey, [string] $EnvFile = ".env.production", [string] $SshKey, [string] $Password, [string] $ComposeFile = "docker-compose.production.yml", [switch] $Seed, [switch] $Logs, [switch] $SkipBuild, [switch] $Force, [switch] $NoBanner ) $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest # ── 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" } $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 ─────────────────────────────────────────────────────────────────── function Get-RepoRoot { $dir = Split-Path -Parent $PSScriptRoot if (-not $dir) { $dir = (Get-Location).Path } return $dir } 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 } $RepoRoot = Get-RepoRoot $SshTarget = "$RemoteUser@$RemoteHost" $SshOpts = @("-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=no") if ($SshKey) { $SshOpts += @("-i", $SshKey) } # 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 } } 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 } } 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 # ── Resolve paths ───────────────────────────────────────────────────────────── Push-Location $RepoRoot try { $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." } $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" # ── Connectivity check ────────────────────────────────────────────────────── 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." } } # ── Package source files ──────────────────────────────────────────────────── Write-Step "Packaging source tree (excluding node_modules, caches, secrets)" $TarFile = Join-Path $env:TEMP "$AppStack-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss').tar.gz" $excludes = @( "--exclude=./node_modules", "--exclude=./frontend/node_modules", "--exclude=./frontend/.svelte-kit", "--exclude=./frontend/build", "--exclude=./.git", "--exclude=./.pytest_cache", "--exclude=./__pycache__", "--exclude=./backend/__pycache__", "--exclude=./backend/app/__pycache__", "--exclude=./backend/.pytest_cache", "--exclude=./backend/.tmp", "--exclude=./backend/.venv", "--exclude=./backend/data_entry_app_backend.egg-info", "--exclude=./**/__pycache__", "--exclude=./*.pyc", "--exclude=./.env", "--exclude=./.env.production", "--exclude=./.env.alpha", "--exclude=./data_entry_app.db", "--exclude=./*.db" ) Invoke-Spinner -Label "tar -czf $(Split-Path -Leaf $TarFile)" -FilePath 'tar' -ArgList (@('-czf', $TarFile) + $excludes + @('-C', $RepoRoot, '.')) $TarSize = [math]::Round((Get-Item $TarFile).Length / 1MB, 1) Write-Info "Archive: $TarFile ($($C.Bold)$TarSize MB$($C.Reset))" # ── Upload env file ───────────────────────────────────────────────────────── 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" # ── Upload and extract source ──────────────────────────────────────────────── Write-Step "Uploading source archive ($TarSize MB)" Invoke-Scp $TarFile "/tmp/$AppStack-deploy.tar.gz" -Label "scp archive $($Glyph.Arrow) /tmp/" Remove-Item $TarFile -Force 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" # ── Docker compose up ─────────────────────────────────────────────────────── $ComposeArgs = "--env-file .env.production -f $ComposeFile" $BuildFlag = if ($SkipBuild) { "--no-build" } else { "--build" } $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 } # ── Health check ──────────────────────────────────────────────────────────── Write-Step "Waiting for backend health check ($BackendContainer)" $healthScript = @" set -e cd '$RemotePath' for i in `$(seq 1 30); do status=`$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' $BackendContainer 2>/dev/null || echo missing) case "`$status" in healthy|running) echo "backend is `$status"; exit 0 ;; *) printf '.'; sleep 4 ;; esac done echo; echo 'backend did not become healthy in time' >&2; exit 1 "@ 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" if ($Seed) { Write-Step "Seeding reference data" Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs exec -T backend python -m app.seed" -Label "python -m app.seed" } # ── Final status ──────────────────────────────────────────────────────────── Write-Step "Stack status" 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" if ($Logs) { Write-Step "Recent logs (last 60 lines)" 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." } # ── 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)" Write-Host "" throw } finally { Pop-Location }