<# .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) } # Detect a corporate TLS-inspection root CA (e.g. Zscaler) by inspecting the # certificate chain the host uses to reach the npm registry, then stage it as # corp-root-ca.pem in the build context so the Docker frontend build can trust # it. On a clean network this simply stages a public root, which is harmless. # Writes nothing and returns quietly when detection is not possible. function Set-CorpRootCa { param( [Parameter(Mandatory)][string]$RepoRoot ) $certPath = Join-Path $RepoRoot 'corp-root-ca.pem' try { $request = [System.Net.HttpWebRequest]::Create('https://registry.npmjs.org/') $request.Method = 'HEAD' $request.Timeout = 10000 try { $response = $request.GetResponse(); $response.Close() } catch { } $cert = $request.ServicePoint.Certificate if (-not $cert) { Write-Warn 'Could not read the TLS certificate chain; skipping corp CA staging.' return } $chain = New-Object System.Security.Cryptography.X509Certificates.X509Chain $chain.ChainPolicy.RevocationMode = 'NoCheck' $cert2 = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($cert) [void]$chain.Build($cert2) $root = $chain.ChainElements[$chain.ChainElements.Count - 1].Certificate $issuer = $root.Subject $builder = New-Object System.Text.StringBuilder [void]$builder.AppendLine('-----BEGIN CERTIFICATE-----') [void]$builder.AppendLine([System.Convert]::ToBase64String($root.RawData, 'InsertLineBreaks')) [void]$builder.AppendLine('-----END CERTIFICATE-----') Set-Content -Path $certPath -Value $builder.ToString() -Encoding ASCII Write-Ok "Staged corp-root-ca.pem from root CA: $issuer" } catch { Write-Warn "Corp CA detection failed ($($_.Exception.Message)); continuing without it." } } # 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 'Detecting corporate TLS-inspection CA for the Docker build' Set-CorpRootCa -RepoRoot $repoRoot 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 }