+242
@@ -0,0 +1,242 @@
|
||||
# tickflow-stock-panel - one-shot launcher for backend + frontend (Windows / PowerShell)
|
||||
#
|
||||
# Usage:
|
||||
# .\dev.ps1
|
||||
# .\dev.ps1 -BackendPort 8000 -FrontendPort 5173
|
||||
# $env:BACKEND_PORT='8000'; .\dev.ps1
|
||||
#
|
||||
# Ctrl-C closes both processes.
|
||||
#
|
||||
# If you see "running scripts is disabled":
|
||||
# Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$BackendPort = 0,
|
||||
[int]$FrontendPort = 0
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Port precedence: CLI arg > env var > default
|
||||
if ($BackendPort -le 0) { $BackendPort = if ($env:BACKEND_PORT) { [int]$env:BACKEND_PORT } else { 3018 } }
|
||||
if ($FrontendPort -le 0) { $FrontendPort = if ($env:FRONTEND_PORT) { [int]$env:FRONTEND_PORT } else { 3011 } }
|
||||
|
||||
# Force UTF-8 console output so child process logs aren't garbled
|
||||
try {
|
||||
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
|
||||
$OutputEncoding = New-Object System.Text.UTF8Encoding $false
|
||||
} catch {}
|
||||
|
||||
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$BackendDir = Join-Path $Root 'backend'
|
||||
$FrontendDir = Join-Path $Root 'frontend'
|
||||
|
||||
function Log-Info($m) { Write-Host "[dev] $m" -ForegroundColor DarkGray }
|
||||
function Log-Ok ($m) { Write-Host "[dev] $m" -ForegroundColor Green }
|
||||
function Log-Warn($m) { Write-Host "[dev] $m" -ForegroundColor Yellow }
|
||||
function Log-Err ($m) { Write-Host "[dev] $m" -ForegroundColor Red }
|
||||
|
||||
# ===== 1. Dependency check =====
|
||||
function Require-Cmd($cmd, $hint) {
|
||||
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
|
||||
Log-Err "$cmd not found"
|
||||
Write-Host " install via: $hint"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Require-Cmd 'uv' 'powershell -c "irm https://astral.sh/uv/install.ps1 | iex" OR winget install --id=astral-sh.uv'
|
||||
Require-Cmd 'pnpm' 'npm i -g pnpm OR corepack enable; corepack prepare pnpm@9 --activate'
|
||||
|
||||
# ===== 2. Port check - kill anything listening on the target ports =====
|
||||
function Free-Port($name, $port) {
|
||||
$conns = Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue
|
||||
if (-not $conns) { return }
|
||||
$pids = @($conns.OwningProcess | Where-Object { $_ -gt 0 } | Sort-Object -Unique)
|
||||
if ($pids.Count -eq 0) { return }
|
||||
|
||||
# Filter to PIDs that still exist as running processes.
|
||||
# A zombie TCP endpoint can linger after the process is already dead.
|
||||
$alive = @($pids | Where-Object {
|
||||
try { [System.Diagnostics.Process]::GetProcessById($_) | Out-Null; $true }
|
||||
catch { $false }
|
||||
})
|
||||
|
||||
if ($alive.Count -eq 0) {
|
||||
# All processes are dead but kernel still holds the socket (zombie endpoint).
|
||||
# On Windows this can linger for minutes, but uvicorn/vite can still bind
|
||||
# via SO_REUSEADDR — no point waiting, just proceed.
|
||||
Log-Warn "port ${port} (${name}) - zombie socket (processes gone), starting anyway"
|
||||
return
|
||||
}
|
||||
|
||||
Log-Warn "port $port ($name) is in use, killing PID: $($alive -join ', ')"
|
||||
# Use taskkill /F /T to kill the entire process tree (parent + children),
|
||||
# not just the parent. Stop-Process only kills one process, leaving child
|
||||
# processes (e.g. uvicorn spawned by uv) as orphans holding the socket.
|
||||
foreach ($p in $alive) {
|
||||
# Suppress stderr properly for Windows PowerShell (5.x)
|
||||
$null = & cmd /c "taskkill /F /T /PID $p 2>nul"
|
||||
# Fallback: if taskkill failed, try Stop-Process
|
||||
try { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue } catch {}
|
||||
}
|
||||
|
||||
# Wait up to 5 seconds for the kernel to release the TCP endpoint
|
||||
for ($i = 0; $i -lt 10; $i++) {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$still = Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue
|
||||
if (-not $still) {
|
||||
Log-Ok "port $port freed"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
# Port still stuck — process might be dead with zombie socket
|
||||
$anyAlive = $still | Where-Object {
|
||||
try { [System.Diagnostics.Process]::GetProcessById($_.OwningProcess) | Out-Null; $true }
|
||||
catch { $false }
|
||||
}
|
||||
if ($anyAlive.Count -eq 0) {
|
||||
Log-Warn "port ${port} - processes gone but socket lingers, starting anyway"
|
||||
} else {
|
||||
Log-Err "port ${port} still in use by live process(es). Inspect: Get-NetTCPConnection -LocalPort ${port}"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Free-Port 'backend' $BackendPort
|
||||
Free-Port 'frontend' $FrontendPort
|
||||
|
||||
# ===== 3. First-time dependency install =====
|
||||
if (-not (Test-Path (Join-Path $BackendDir '.venv'))) {
|
||||
Log-Info 'first run - installing Python deps (1-2 min)...'
|
||||
Push-Location $BackendDir
|
||||
try { & uv sync } finally { Pop-Location }
|
||||
if ($LASTEXITCODE -ne 0) { Log-Err 'uv sync failed'; exit 1 }
|
||||
Log-Ok 'backend deps installed'
|
||||
}
|
||||
|
||||
if (-not (Test-Path (Join-Path $FrontendDir 'node_modules'))) {
|
||||
Log-Info 'first run - installing Node deps...'
|
||||
Push-Location $FrontendDir
|
||||
try { & pnpm install } finally { Pop-Location }
|
||||
if ($LASTEXITCODE -ne 0) { Log-Err 'pnpm install failed'; exit 1 }
|
||||
Log-Ok 'frontend deps installed'
|
||||
}
|
||||
|
||||
# ===== 4. Banner (ASCII so it renders on any codepage) =====
|
||||
Write-Host ''
|
||||
Write-Host '+----------------------------------------------+' -ForegroundColor Blue
|
||||
Write-Host '| tickflow-stock-panel |' -ForegroundColor Blue
|
||||
Write-Host '| |' -ForegroundColor Blue
|
||||
Write-Host "| backend http://localhost:$BackendPort" -ForegroundColor Blue
|
||||
Write-Host "| frontend http://localhost:$FrontendPort" -ForegroundColor Blue
|
||||
Write-Host '| |' -ForegroundColor Blue
|
||||
Write-Host '| Ctrl-C closes both |' -ForegroundColor Blue
|
||||
Write-Host '+----------------------------------------------+' -ForegroundColor Blue
|
||||
Write-Host ''
|
||||
|
||||
# ===== 5. Launch jobs =====
|
||||
# Each job writes its $PID to a temp file so the main thread can find the
|
||||
# child powershell.exe and taskkill /T the whole process tree on exit.
|
||||
$backendPidFile = [System.IO.Path]::GetTempFileName()
|
||||
$frontendPidFile = [System.IO.Path]::GetTempFileName()
|
||||
|
||||
$backendJob = Start-Job -Name 'backend' -ScriptBlock {
|
||||
param($pidFile, $dir, $port)
|
||||
$PID | Out-File -FilePath $pidFile -Encoding ascii -Force
|
||||
$env:PYTHONUNBUFFERED = '1'
|
||||
Set-Location $dir
|
||||
& .\.venv\Scripts\python.exe -m uvicorn app.main:app --reload --host 0.0.0.0 --port $port 2>&1
|
||||
} -ArgumentList $backendPidFile, $BackendDir, $BackendPort
|
||||
|
||||
$frontendJob = Start-Job -Name 'frontend' -ScriptBlock {
|
||||
param($pidFile, $dir, $port)
|
||||
$PID | Out-File -FilePath $pidFile -Encoding ascii -Force
|
||||
Set-Location $dir
|
||||
& pnpm dev --host 0.0.0.0 --port $port 2>&1
|
||||
} -ArgumentList $frontendPidFile, $FrontendDir, $FrontendPort
|
||||
|
||||
# Wait up to 5 seconds for the PID files to materialise
|
||||
function Read-JobPid($file) {
|
||||
for ($i = 0; $i -lt 50; $i++) {
|
||||
try {
|
||||
$c = (Get-Content $file -ErrorAction SilentlyContinue) -as [string]
|
||||
if ($c -and $c.Trim()) { return [int]$c.Trim() }
|
||||
} catch {}
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
return $null
|
||||
}
|
||||
$backendChildPid = Read-JobPid $backendPidFile
|
||||
$frontendChildPid = Read-JobPid $frontendPidFile
|
||||
|
||||
# ===== 6. Cleanup =====
|
||||
$script:cleaning = $false
|
||||
function Cleanup-All {
|
||||
if ($script:cleaning) { return }
|
||||
$script:cleaning = $true
|
||||
Write-Host ''
|
||||
Log-Info 'shutting down...'
|
||||
|
||||
foreach ($p in @($backendChildPid, $frontendChildPid)) {
|
||||
if ($p) {
|
||||
# /T kills the whole process tree (the job's powershell + uvicorn/vite)
|
||||
$null = & cmd /c "taskkill /F /T /PID $p 2>nul"
|
||||
}
|
||||
}
|
||||
foreach ($j in @($backendJob, $frontendJob)) {
|
||||
if ($j) {
|
||||
Stop-Job $j -ErrorAction SilentlyContinue
|
||||
Remove-Job $j -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
foreach ($f in @($backendPidFile, $frontendPidFile)) {
|
||||
Remove-Item $f -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Log-Ok 'bye'
|
||||
}
|
||||
|
||||
# ===== 7. Main loop - pump output, handle Ctrl-C =====
|
||||
# Treat Ctrl-C as input so try/finally is guaranteed to run.
|
||||
$prevCtrlC = [Console]::TreatControlCAsInput
|
||||
try {
|
||||
[Console]::TreatControlCAsInput = $true
|
||||
|
||||
while ($true) {
|
||||
if ([Console]::KeyAvailable) {
|
||||
$key = [Console]::ReadKey($true)
|
||||
if (($key.Modifiers -band [ConsoleModifiers]::Control) -and $key.Key -eq 'C') {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
$bOut = Receive-Job $backendJob -ErrorAction SilentlyContinue
|
||||
if ($bOut) {
|
||||
foreach ($line in $bOut) {
|
||||
Write-Host '[backend ] ' -NoNewline -ForegroundColor Blue
|
||||
Write-Host $line
|
||||
}
|
||||
}
|
||||
|
||||
$fOut = Receive-Job $frontendJob -ErrorAction SilentlyContinue
|
||||
if ($fOut) {
|
||||
foreach ($line in $fOut) {
|
||||
Write-Host '[frontend] ' -NoNewline -ForegroundColor Green
|
||||
Write-Host $line
|
||||
}
|
||||
}
|
||||
|
||||
if ($backendJob.State -ne 'Running' -or $frontendJob.State -ne 'Running') {
|
||||
Log-Warn 'one of the processes exited; closing the other...'
|
||||
break
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds 150
|
||||
}
|
||||
}
|
||||
finally {
|
||||
[Console]::TreatControlCAsInput = $prevCtrlC
|
||||
Cleanup-All
|
||||
}
|
||||
Reference in New Issue
Block a user