feat: add native Windows local orchestration

This commit is contained in:
2026-08-20 19:30:30 +03:30
parent 67777bde5b
commit 08b43b427a
6 changed files with 346 additions and 1 deletions

2
.gitignore vendored
View File

@@ -6,6 +6,7 @@
.env .env
.env.* .env.*
!.env.example !.env.example
/.local/
/backups/ /backups/
/certbot/conf/ /certbot/conf/
/certbot/www/ /certbot/www/
@@ -16,4 +17,3 @@
.vscode/ .vscode/
.DS_Store .DS_Store
Thumbs.db Thumbs.db

View File

@@ -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. 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 ## Repository topology
```text ```text

38
docs/LOCAL-DEVELOPMENT.md Normal file
View File

@@ -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.

206
scripts/LocalDev.psm1 Normal file
View File

@@ -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

26
scripts/local-dev.ps1 Normal file
View File

@@ -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
}

73
tests/local-dev.tests.ps1 Normal file
View File

@@ -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
}