Files
shopdb-flask/scripts/dev-bootstrap.ps1
benjamin.baker 0524ce7751 add config-driven channel label and dev-workstation bootstrap
A bleeding-edge container running next to a stable one on the same dev box is easy to confuse, and a branch-only badge would force perpetual rebase/cherry-pick drift between main and dev-be. Instead the badge is config-driven: the settings endpoint injects a synthetic env_label from SHOPDB_ENV_LABEL, the sidebar renders a red DEV tag only when it is non-empty, and stable simply leaves it unset. Same code on both channels, no divergence.

dev-bootstrap.ps1 makes a fresh Windows box turnkey: it checks git/Docker (winget-installs with a prompt), verifies the engine is actually running, then builds and starts an isolated per-channel stack so stable and edge coexist on distinct ports, projects, and volumes. docker-compose.channel.yml is a thin overlay so the base compose file stays channel-agnostic.
2026-09-02 05:27:01 -04:00

275 lines
9.3 KiB
PowerShell

<#
.SYNOPSIS
Turnkey ShopDB developer-workstation bootstrap for Windows.
.DESCRIPTION
Stands up a ShopDB stack from a git branch on a fresh dev box with as few
manual steps as possible:
1. Checks the tools a dev needs (git, Docker) and offers to install any
that are missing via winget (with a prompt - nothing is installed
silently). Python is optional and only checked when -CheckPython is
passed, because the app itself runs entirely in the container.
2. Picks a channel:
stable -> builds from the 'main' branch, no DEV badge.
edge -> builds from the 'dev-be' branch, red DEV badge in the UI.
The two channels use different compose project names, host ports, and
named volumes, so they run side by side without colliding.
3. Generates a .env for the channel (dev-grade secrets, NOT for production)
if one does not already exist, then builds and brings up the stack.
Windows-only by design: pre-release audience is Windows. Not a production
installer - see deploy/windows/installer/shopdb-install.ps1 for that.
.PARAMETER Channel
'stable' (main) or 'edge' (dev-be). Defaults to 'stable'.
.PARAMETER Ref
Override the git ref to build. Defaults to 'main' for stable, 'dev-be' for
edge. Useful for testing a feature branch through the edge plumbing.
.PARAMETER CheckPython
Also verify a local Python 3.11+ (only needed if you run tests/tooling on
the host rather than in the container).
.PARAMETER SkipDependencyInstall
Report missing dependencies and stop instead of offering winget installs.
Handy for CI-style dry runs.
.EXAMPLE
./scripts/dev-bootstrap.ps1 -Channel edge
.NOTES
ASCII only, no emoji or smart punctuation, per repo style. PowerShell 5.1+.
#>
[CmdletBinding()]
param(
[ValidateSet('stable', 'edge')]
[string]$Channel = 'stable',
[string]$Ref,
[switch]$CheckPython,
[switch]$SkipDependencyInstall
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Channel -> (branch, compose project, host ports, badge label). Kept in one
# table so adding a channel later is a single edit. Ports are offset for edge so
# both stacks bind distinct host ports; the container-internal ports never move.
$channels = @{
stable = @{
Ref = 'main'
Project = 'shopdb-stable'
ApiPort = 5001
MysqlPort = 3306
EnvLabel = ''
}
edge = @{
Ref = 'dev-be'
Project = 'shopdb-edge'
ApiPort = 5011
MysqlPort = 3316
EnvLabel = 'DEV'
}
}
function Write-Step {
param([string]$Message)
Write-Host ''
Write-Host "==> $Message" -ForegroundColor Cyan
}
function Write-Ok {
param([string]$Message)
Write-Host " OK: $Message" -ForegroundColor Green
}
function Write-Warn {
param([string]$Message)
Write-Host " WARN: $Message" -ForegroundColor Yellow
}
function Test-Command {
param([string]$Name)
return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
}
# Confirm a dependency, offering a winget install when it is missing. Returns
# $true when the dependency is present (or was just installed), $false when the
# dev declined or install failed.
function Confirm-Dependency {
param(
[Parameter(Mandatory)][string]$Command,
[Parameter(Mandatory)][string]$DisplayName,
[Parameter(Mandatory)][string]$WingetId,
[string]$PostInstallNote
)
if (Test-Command $Command) {
Write-Ok "$DisplayName found."
return $true
}
Write-Warn "$DisplayName not found (command '$Command')."
if ($SkipDependencyInstall) {
Write-Warn "Skipping install (-SkipDependencyInstall). Install '$WingetId' manually."
return $false
}
if (-not (Test-Command 'winget')) {
Write-Warn "winget is unavailable, so $DisplayName cannot be auto-installed."
Write-Warn "Install it from the Microsoft Store / App Installer, then re-run."
return $false
}
$answer = Read-Host " Install $DisplayName now via winget ($WingetId)? [y/N]"
if ($answer -notmatch '^(y|yes)$') {
Write-Warn "Skipped $DisplayName. It is required; re-run once it is installed."
return $false
}
Write-Host " Running: winget install --id $WingetId" -ForegroundColor DarkGray
winget install --id $WingetId --exact --accept-source-agreements --accept-package-agreements
if ($LASTEXITCODE -ne 0) {
Write-Warn "winget exited $LASTEXITCODE installing $DisplayName."
return $false
}
if ($PostInstallNote) { Write-Warn $PostInstallNote }
# winget updates the machine PATH, but this session will not see it until
# relaunch, so re-probe rather than claim success outright.
return (Test-Command $Command)
}
# Docker being installed is not the same as the engine running. A stopped
# Docker Desktop passes the command check but fails every compose call.
function Test-DockerEngine {
try {
docker info --format '{{.ServerVersion}}' 2>$null | Out-Null
return ($LASTEXITCODE -eq 0)
} catch {
return $false
}
}
# --- Resolve paths and channel -------------------------------------------------
$repoRoot = Split-Path -Parent $PSScriptRoot
$config = $channels[$Channel]
$branch = if ($Ref) { $Ref } else { $config.Ref }
Write-Host "ShopDB dev bootstrap" -ForegroundColor White
Write-Host " Channel : $Channel" -ForegroundColor White
Write-Host " Branch : $branch" -ForegroundColor White
Write-Host " Project : $($config.Project)" -ForegroundColor White
Write-Host " Repo : $repoRoot" -ForegroundColor White
# --- 1. Dependencies -----------------------------------------------------------
Write-Step 'Checking developer dependencies'
$allPresent = $true
$allPresent = (Confirm-Dependency -Command 'git' -DisplayName 'Git' -WingetId 'Git.Git') -and $allPresent
$allPresent = (Confirm-Dependency -Command 'docker' -DisplayName 'Docker Desktop' -WingetId 'Docker.DockerDesktop' `
-PostInstallNote 'Docker Desktop needs a sign-out/restart and must be started once before use.') -and $allPresent
if ($CheckPython) {
$allPresent = (Confirm-Dependency -Command 'python' -DisplayName 'Python 3.11+' -WingetId 'Python.Python.3.11') -and $allPresent
}
if (-not $allPresent) {
throw 'One or more required dependencies are missing. Resolve the warnings above and re-run.'
}
Write-Step 'Checking the Docker engine is running'
if (-not (Test-DockerEngine)) {
throw 'Docker is installed but the engine is not responding. Start Docker Desktop and re-run.'
}
Write-Ok 'Docker engine is responding.'
# --- 2. Check out the channel branch ------------------------------------------
Write-Step "Preparing the '$branch' branch"
Push-Location $repoRoot
try {
$dirty = git status --porcelain
if ($dirty) {
Write-Warn 'Working tree has uncommitted changes; leaving the current branch as-is.'
Write-Warn "Building from the checked-out tree instead of switching to '$branch'."
} else {
git fetch origin $branch
git checkout $branch
git pull --ff-only origin $branch
Write-Ok "On '$branch', up to date with origin."
}
# --- 3. Channel .env ------------------------------------------------------
Write-Step 'Ensuring a channel .env exists'
$envPath = Join-Path $repoRoot ".env.$($config.Project)"
if (Test-Path $envPath) {
Write-Ok "Reusing existing $([System.IO.Path]::GetFileName($envPath))."
} else {
# Dev-grade secrets: generated locally, never for production. A real
# deploy fills .env from the wiki runbook, not from this script.
$secret = [Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Maximum 256 }))
$jwt = [Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Maximum 256 }))
$dbPass = [Convert]::ToBase64String((1..18 | ForEach-Object { Get-Random -Maximum 256 })) -replace '[^A-Za-z0-9]', 'x'
$rootPass = [Convert]::ToBase64String((1..18 | ForEach-Object { Get-Random -Maximum 256 })) -replace '[^A-Za-z0-9]', 'x'
$apiPort = $config.ApiPort
$mysqlPort = $config.MysqlPort
$lines = @(
"# Auto-generated by scripts/dev-bootstrap.ps1 for the $Channel channel.",
"# DEV-GRADE ONLY. Do not reuse these secrets for a production site.",
"FLASK_APP=wsgi.py",
"FLASK_ENV=production",
"SECRET_KEY=$secret",
"JWT_SECRET_KEY=$jwt",
"DATABASE_URL=mysql+pymysql://shopdb:$dbPass@db:3306/shopdb_flask?charset=utf8mb4",
"CORS_ORIGINS=https://localhost:$apiPort",
"LOG_LEVEL=INFO",
"MYSQL_ROOT_PASSWORD=$rootPass",
"MYSQL_PASSWORD=$dbPass",
"MYSQL_PORT=$mysqlPort",
"API_PORT=$apiPort",
"SHOPDB_ENV_LABEL=$($config.EnvLabel)"
)
Set-Content -Path $envPath -Value $lines -Encoding ASCII
Write-Ok "Wrote $([System.IO.Path]::GetFileName($envPath)) (API on $apiPort, MySQL on $mysqlPort)."
}
# --- 4. Build and start ---------------------------------------------------
Write-Step "Building and starting the '$($config.Project)' stack"
$composeArgs = @(
'compose',
'-p', $config.Project,
'--env-file', $envPath,
'-f', 'docker-compose.yml',
'-f', 'docker-compose.channel.yml'
)
& docker @composeArgs build api
if ($LASTEXITCODE -ne 0) { throw "docker compose build failed ($LASTEXITCODE)." }
& docker @composeArgs up -d
if ($LASTEXITCODE -ne 0) { throw "docker compose up failed ($LASTEXITCODE)." }
Write-Step 'Done'
Write-Ok "The $Channel stack is up."
Write-Host " UI/API : https://localhost:$($config.ApiPort)" -ForegroundColor White
if ($config.EnvLabel) {
Write-Host " Badge : sidebar shows '$($config.EnvLabel)' (edge channel)." -ForegroundColor White
}
Write-Host " Logs : docker compose -p $($config.Project) logs -f api" -ForegroundColor White
Write-Host " Stop : docker compose -p $($config.Project) down" -ForegroundColor White
} finally {
Pop-Location
}