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) try { $recordedProcessId = [int]$Record.Pid if ($recordedProcessId -le 0) { return $false } $recordedStartTime = if ($Record.StartTimeUtc -is [datetime]) { $Record.StartTimeUtc.ToUniversalTime() } else { [datetime]::Parse( [string]$Record.StartTimeUtc, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind ).ToUniversalTime() } } catch { return $false } $process = Get-Process -Id $recordedProcessId -ErrorAction SilentlyContinue if (-not $process) { return $false } try { return $process.StartTime.ToUniversalTime().Ticks -eq $recordedStartTime.Ticks } 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