From 08b43b427a32296183f87f8078932420f53499f6 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Thu, 20 Aug 2026 19:30:30 +0330 Subject: [PATCH] feat: add native Windows local orchestration --- .gitignore | 2 +- README.md | 2 + docs/LOCAL-DEVELOPMENT.md | 38 +++++++ scripts/LocalDev.psm1 | 206 ++++++++++++++++++++++++++++++++++++++ scripts/local-dev.ps1 | 26 +++++ tests/local-dev.tests.ps1 | 73 ++++++++++++++ 6 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 docs/LOCAL-DEVELOPMENT.md create mode 100644 scripts/LocalDev.psm1 create mode 100644 scripts/local-dev.ps1 create mode 100644 tests/local-dev.tests.ps1 diff --git a/.gitignore b/.gitignore index b8298ab..eb14a95 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ .env .env.* !.env.example +/.local/ /backups/ /certbot/conf/ /certbot/www/ @@ -16,4 +17,3 @@ .vscode/ .DS_Store Thumbs.db - diff --git a/README.md b/README.md index c940fd1..ab0fb2c 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ This repository is the operational control plane for the hackathon project. It w Current state: specifications, operations plan, adaptation contract, runbooks, and task list are approved. Phase 4 deployment implementation is authorized. +For direct Windows development without Docker Compose, see [`docs/LOCAL-DEVELOPMENT.md`](docs/LOCAL-DEVELOPMENT.md). This helper is deliberately separate from the production Compose contract. + ## Repository topology ```text diff --git a/docs/LOCAL-DEVELOPMENT.md b/docs/LOCAL-DEVELOPMENT.md new file mode 100644 index 0000000..06ff53f --- /dev/null +++ b/docs/LOCAL-DEVELOPMENT.md @@ -0,0 +1,38 @@ +# Local development without Docker Compose + +This Windows-only helper runs the API, analytics worker, and frontend directly from the three sibling repositories. PostgreSQL and Redis must already be reachable locally. It does not create databases, run migrations/imports, generate credentials, edit service-owned environment files, or start Docker. + +## Prerequisites + +- Go and npm are on `PATH`. +- PostgreSQL is reachable at `127.0.0.1:5432`. +- Redis is reachable at `127.0.0.1:6379`. +- The backend has `cmd/api` and `cmd/worker`, plus a protected backend-owned `.env`. +- The frontend has a `package.json` `dev` script and its frontend-owned local environment file. +- Dependencies, migrations, the one-time importer, and demo merchant creation have already completed through their owning repositories. + +The helper only reports variable names or file paths when validation fails. It never prints resolved environment values. Runtime process metadata and logs are written under ignored `.local/`. + +## Commands + +Run these from the deployment repository in PowerShell: + +```powershell +.\scripts\local-dev.ps1 check +.\scripts\local-dev.ps1 start +.\scripts\local-dev.ps1 status +.\scripts\local-dev.ps1 stop +``` + +The default sibling locations are `../zarinpal-analytics-backend` and `../zarinpal-analytics-frontend`. Override them with `-BackendPath` and `-FrontendPath` when necessary. + +`stop` terminates only the process trees whose PID and start time match the ignored state file. A stale or reused PID is not terminated. + +## Verification + +```powershell +pwsh -NoProfile -File .\tests\local-dev.tests.ps1 +pwsh -NoProfile -File .\scripts\local-dev.ps1 check +``` + +After `start`, open the frontend URL declared by the frontend development server. Inspect `.local/logs/*.err.log` if a process exits during or after startup. diff --git a/scripts/LocalDev.psm1 b/scripts/LocalDev.psm1 new file mode 100644 index 0000000..07d1707 --- /dev/null +++ b/scripts/LocalDev.psm1 @@ -0,0 +1,206 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Read-DotEnvFile { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Environment file not found: $Path" + } + + $values = @{} + $lineNumber = 0 + foreach ($rawLine in Get-Content -LiteralPath $Path) { + $lineNumber++ + $line = $rawLine.Trim() + if (-not $line -or $line.StartsWith('#')) { continue } + if ($line -notmatch '^([A-Za-z_][A-Za-z0-9_]*)=(.*)$') { + throw "invalid dotenv entry at ${Path}:$lineNumber" + } + + $name = $Matches[1] + $value = $Matches[2] + if ($values.ContainsKey($name)) { + throw "duplicate variable '$name' in $Path" + } + if ($value.Length -ge 2 -and (($value[0] -eq '"' -and $value[-1] -eq '"') -or ($value[0] -eq "'" -and $value[-1] -eq "'"))) { + $value = $value.Substring(1, $value.Length - 2) + } + $values[$name] = $value + } + return $values +} + +function Resolve-LocalDevCommands { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$BackendPath, + [Parameter(Mandatory)][string]$FrontendPath + ) + + foreach ($entrypoint in 'api', 'worker') { + $path = Join-Path $BackendPath "cmd\$entrypoint" + if (-not (Test-Path -LiteralPath $path -PathType Container)) { + throw "Backend entrypoint cmd/$entrypoint is missing under $BackendPath" + } + } + + $packageJsonPath = Join-Path $FrontendPath 'package.json' + if (-not (Test-Path -LiteralPath $packageJsonPath -PathType Leaf)) { + throw "Frontend package.json is missing under $FrontendPath" + } + $packageJson = Get-Content -Raw -LiteralPath $packageJsonPath | ConvertFrom-Json + if (-not $packageJson.scripts -or -not $packageJson.scripts.dev) { + throw "Frontend package.json does not define the required 'dev' script" + } + + return @{ + Api = @{ FilePath = 'go'; Arguments = @('run', './cmd/api'); WorkingDirectory = $BackendPath } + Worker = @{ FilePath = 'go'; Arguments = @('run', './cmd/worker'); WorkingDirectory = $BackendPath } + Frontend = @{ FilePath = 'npm.cmd'; Arguments = @('run', 'dev', '--', '--host', '127.0.0.1'); WorkingDirectory = $FrontendPath } + } +} + +function Test-TcpEndpoint { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$HostName, [Parameter(Mandatory)][int]$Port, [int]$TimeoutMilliseconds = 1000) + + $client = [System.Net.Sockets.TcpClient]::new() + try { + $task = $client.ConnectAsync($HostName, $Port) + return $task.Wait($TimeoutMilliseconds) -and $client.Connected + } + catch { return $false } + finally { $client.Dispose() } +} + +function Get-StatePath { + param([string]$DeploymentPath) + return Join-Path $DeploymentPath '.local\processes.json' +} + +function Get-RecordedProcesses { + param([string]$DeploymentPath) + $statePath = Get-StatePath -DeploymentPath $DeploymentPath + if (-not (Test-Path -LiteralPath $statePath -PathType Leaf)) { return @() } + $items = @(Get-Content -Raw -LiteralPath $statePath | ConvertFrom-Json) + return $items +} + +function Test-RecordedProcess { + param($Record) + $process = Get-Process -Id $Record.Pid -ErrorAction SilentlyContinue + if (-not $process) { return $false } + try { + $actual = $process.StartTime.ToUniversalTime().ToString('o') + return $actual -eq $Record.StartTimeUtc + } + catch { return $false } +} + +function Invoke-LocalDevCheck { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$DeploymentPath, + [Parameter(Mandatory)][string]$BackendPath, + [Parameter(Mandatory)][string]$FrontendPath, + [string]$PostgresHost = '127.0.0.1', + [int]$PostgresPort = 5432, + [string]$RedisHost = '127.0.0.1', + [int]$RedisPort = 6379 + ) + + $null = Resolve-LocalDevCommands -BackendPath $BackendPath -FrontendPath $FrontendPath + foreach ($tool in 'go', 'npm.cmd') { + if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { throw "Required command is not installed or on PATH: $tool" } + } + $backendEnv = Join-Path $BackendPath '.env' + if (-not (Test-Path -LiteralPath $backendEnv -PathType Leaf)) { throw "Backend .env is missing: $backendEnv" } + $null = Read-DotEnvFile -Path $backendEnv + if (-not (Test-TcpEndpoint -HostName $PostgresHost -Port $PostgresPort)) { throw "PostgreSQL is not reachable at ${PostgresHost}:$PostgresPort" } + if (-not (Test-TcpEndpoint -HostName $RedisHost -Port $RedisPort)) { throw "Redis is not reachable at ${RedisHost}:$RedisPort" } + + [pscustomobject]@{ Go = 'ok'; Node = 'ok'; PostgreSQL = 'reachable'; Redis = 'reachable'; Contracts = 'ok' } +} + +function Start-LocalProcess { + param([string]$Name, [hashtable]$Command, [hashtable]$Environment, [string]$LogDirectory) + + $resolved = Get-Command $Command.FilePath -ErrorAction Stop + $previous = @{} + foreach ($entry in $Environment.GetEnumerator()) { + $previous[$entry.Key] = [Environment]::GetEnvironmentVariable($entry.Key, 'Process') + [Environment]::SetEnvironmentVariable($entry.Key, [string]$entry.Value, 'Process') + } + try { + $stdout = Join-Path $LogDirectory "$Name.out.log" + $stderr = Join-Path $LogDirectory "$Name.err.log" + $process = Start-Process -FilePath $resolved.Source -ArgumentList $Command.Arguments -WorkingDirectory $Command.WorkingDirectory -RedirectStandardOutput $stdout -RedirectStandardError $stderr -WindowStyle Hidden -PassThru + } + finally { + foreach ($entry in $previous.GetEnumerator()) { + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, 'Process') + } + } + Start-Sleep -Milliseconds 750 + if ($process.HasExited) { throw "$Name exited during startup; inspect $stderr" } + $process.Refresh() + return [pscustomobject]@{ Name = $Name; Pid = $process.Id; StartTimeUtc = $process.StartTime.ToUniversalTime().ToString('o') } +} + +function Stop-RecordedProcess { + param($Record) + if (-not (Test-RecordedProcess $Record)) { return } + & taskkill.exe /PID $Record.Pid /T /F | Out-Null +} + +function Start-LocalDev { + [CmdletBinding()] + param([string]$DeploymentPath, [string]$BackendPath, [string]$FrontendPath) + + $existing = @(Get-RecordedProcesses -DeploymentPath $DeploymentPath | Where-Object { Test-RecordedProcess $_ }) + if ($existing.Count -gt 0) { throw 'Local services are already recorded as running; use status or stop first' } + + $null = Invoke-LocalDevCheck -DeploymentPath $DeploymentPath -BackendPath $BackendPath -FrontendPath $FrontendPath + $commands = Resolve-LocalDevCommands -BackendPath $BackendPath -FrontendPath $FrontendPath + $backendEnvironment = Read-DotEnvFile -Path (Join-Path $BackendPath '.env') + $runtimeDirectory = Join-Path $DeploymentPath '.local' + $logDirectory = Join-Path $runtimeDirectory 'logs' + New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null + $started = @() + try { + $started += Start-LocalProcess -Name 'api' -Command $commands.Api -Environment $backendEnvironment -LogDirectory $logDirectory + $started += Start-LocalProcess -Name 'worker' -Command $commands.Worker -Environment $backendEnvironment -LogDirectory $logDirectory + $started += Start-LocalProcess -Name 'frontend' -Command $commands.Frontend -Environment @{} -LogDirectory $logDirectory + $started | ConvertTo-Json | Set-Content -LiteralPath (Get-StatePath -DeploymentPath $DeploymentPath) -Encoding utf8 + return $started + } + catch { + foreach ($record in $started) { + Stop-RecordedProcess $record + } + throw + } +} + +function Stop-LocalDev { + [CmdletBinding()] + param([string]$DeploymentPath) + + foreach ($record in @(Get-RecordedProcesses -DeploymentPath $DeploymentPath)) { + Stop-RecordedProcess $record + } + Remove-Item -LiteralPath (Get-StatePath -DeploymentPath $DeploymentPath) -Force -ErrorAction SilentlyContinue +} + +function Get-LocalDevStatus { + [CmdletBinding()] + param([string]$DeploymentPath) + + return @(Get-RecordedProcesses -DeploymentPath $DeploymentPath | ForEach-Object { + [pscustomobject]@{ Name = $_.Name; Pid = $_.Pid; Running = [bool](Test-RecordedProcess $_) } + }) +} + +Export-ModuleMember -Function Read-DotEnvFile, Resolve-LocalDevCommands, Test-TcpEndpoint, Invoke-LocalDevCheck, Start-LocalDev, Stop-LocalDev, Get-LocalDevStatus diff --git a/scripts/local-dev.ps1 b/scripts/local-dev.ps1 new file mode 100644 index 0000000..d18e6c1 --- /dev/null +++ b/scripts/local-dev.ps1 @@ -0,0 +1,26 @@ +[CmdletBinding()] +param( + [Parameter(Position = 0)][ValidateSet('check', 'start', 'stop', 'status')][string]$Action = 'status', + [string]$BackendPath, + [string]$FrontendPath +) + +$ErrorActionPreference = 'Stop' +$deploymentPath = Split-Path $PSScriptRoot -Parent +if (-not $BackendPath) { $BackendPath = Join-Path (Split-Path $deploymentPath -Parent) 'zarinpal-analytics-backend' } +if (-not $FrontendPath) { $FrontendPath = Join-Path (Split-Path $deploymentPath -Parent) 'zarinpal-analytics-frontend' } + +Import-Module (Join-Path $PSScriptRoot 'LocalDev.psm1') -Force + +try { + switch ($Action) { + 'check' { Invoke-LocalDevCheck -DeploymentPath $deploymentPath -BackendPath $BackendPath -FrontendPath $FrontendPath | Format-List } + 'start' { Start-LocalDev -DeploymentPath $deploymentPath -BackendPath $BackendPath -FrontendPath $FrontendPath | Format-Table -AutoSize } + 'stop' { Stop-LocalDev -DeploymentPath $deploymentPath; Write-Output 'Local services stopped.' } + 'status' { Get-LocalDevStatus -DeploymentPath $deploymentPath | Format-Table -AutoSize } + } +} +catch { + Write-Error $_ + exit 1 +} diff --git a/tests/local-dev.tests.ps1 b/tests/local-dev.tests.ps1 new file mode 100644 index 0000000..02836f8 --- /dev/null +++ b/tests/local-dev.tests.ps1 @@ -0,0 +1,73 @@ +$ErrorActionPreference = 'Stop' + +$modulePath = Join-Path $PSScriptRoot '..\scripts\LocalDev.psm1' +Import-Module $modulePath -Force + +function Assert-Equal { + param($Expected, $Actual, [string]$Message) + + if ($Expected -ne $Actual) { + throw "$Message (expected '$Expected', got '$Actual')" + } +} + +function Assert-Throws { + param([scriptblock]$Action, [string]$Pattern, [string]$Message) + + try { + & $Action + } + catch { + if ($_.Exception.Message -notmatch $Pattern) { + throw "$Message (unexpected error: $($_.Exception.Message))" + } + return + } + throw "$Message (no error was thrown)" +} + +$fixture = Join-Path ([System.IO.Path]::GetTempPath()) ("zarinpal-local-dev-" + [guid]::NewGuid()) +New-Item -ItemType Directory -Path $fixture | Out-Null + +try { + $validEnv = Join-Path $fixture '.env' + @( + '# ignored comment' + 'PLAIN=value' + 'QUOTED="value with spaces"' + 'EMPTY=' + ) | Set-Content -LiteralPath $validEnv -Encoding utf8 + + $values = Read-DotEnvFile -Path $validEnv + Assert-Equal 'value' $values.PLAIN 'Plain dotenv values should parse' + Assert-Equal 'value with spaces' $values.QUOTED 'Quoted dotenv values should parse' + Assert-Equal '' $values.EMPTY 'Blank dotenv values should parse' + + $duplicateEnv = Join-Path $fixture 'duplicate.env' + @('KEY=one', 'KEY=two') | Set-Content -LiteralPath $duplicateEnv -Encoding utf8 + Assert-Throws { Read-DotEnvFile -Path $duplicateEnv } 'duplicate variable' 'Duplicate variables must fail closed' + + $invalidEnv = Join-Path $fixture 'invalid.env' + 'NOT A KEY=value' | Set-Content -LiteralPath $invalidEnv -Encoding utf8 + Assert-Throws { Read-DotEnvFile -Path $invalidEnv } 'invalid dotenv entry' 'Malformed variables must fail closed' + + $backend = Join-Path $fixture 'backend' + $frontend = Join-Path $fixture 'frontend' + New-Item -ItemType Directory -Path (Join-Path $backend 'cmd\api') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $backend 'cmd\worker') -Force | Out-Null + New-Item -ItemType Directory -Path $frontend -Force | Out-Null + '{"scripts":{"dev":"vite"}}' | Set-Content -LiteralPath (Join-Path $frontend 'package.json') -Encoding utf8 + + $commands = Resolve-LocalDevCommands -BackendPath $backend -FrontendPath $frontend + Assert-Equal './cmd/api' $commands.Api.Arguments[1] 'API command should follow the backend contract' + Assert-Equal './cmd/worker' $commands.Worker.Arguments[1] 'Worker command should follow the backend contract' + Assert-Equal 'run' $commands.Frontend.Arguments[0] 'Frontend should use its checked-in dev script' + + Remove-Item -LiteralPath (Join-Path $backend 'cmd\worker') -Recurse + Assert-Throws { Resolve-LocalDevCommands -BackendPath $backend -FrontendPath $frontend } 'cmd/worker' 'A missing worker entrypoint must be actionable' + + Write-Output 'local-dev tests passed' +} +finally { + Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue +}