Files
data-entry-app/deploy/Deploy.ps1
T
2026-05-31 20:19:44 +12:00

587 lines
26 KiB
PowerShell

<#
.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-<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).
.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
}