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

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
}