From 139e8f8781cf401c5abc58b844172d50c412a015 Mon Sep 17 00:00:00 2001 From: GHStaK Date: Tue, 17 Mar 2026 15:47:01 +0900 Subject: [PATCH] Add Windows PowerShell dev workflow --- .gitignore | 4 + TODO.md | 52 +++++++ scripts/dev-tools.ps1 | 280 ++++++++++++++++++++++++++++++++++++ scripts/enter-dev-shell.ps1 | 10 ++ scripts/push.ps1 | 31 ++++ scripts/run-dev.ps1 | 28 ++++ scripts/selftest.ps1 | 158 ++++++++++++++++++++ scripts/setup-dev.ps1 | 43 ++++++ 8 files changed, 606 insertions(+) create mode 100644 scripts/dev-tools.ps1 create mode 100644 scripts/enter-dev-shell.ps1 create mode 100644 scripts/push.ps1 create mode 100644 scripts/run-dev.ps1 create mode 100644 scripts/selftest.ps1 create mode 100644 scripts/setup-dev.ps1 diff --git a/.gitignore b/.gitignore index 325181c..ad64c2c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ worker/__pycache__/ *.pyc node_modules/ dist/ +.venv/ +.tools/ +.local/ +*.log diff --git a/TODO.md b/TODO.md index e1a60e5..ec8ea0a 100644 --- a/TODO.md +++ b/TODO.md @@ -8,6 +8,11 @@ - how it was verified - what is still risky or incomplete - If a push fails or a change remains local-only, that must be written here explicitly. +- Active planning and handoff notes should be written in Korean. +- After each meaningful completed task: + - update this file in context + - push the latest git state when operationally possible +- Local git credentials used for push automation must stay in an ignored local-only file and must never be committed. ## Current State At A Glance - Project: `ai-media-hub` @@ -35,6 +40,7 @@ - Search preview delivery is now moving away from persistent on-disk preview caching toward live proxy / live transcode behavior, with Google Video preview reuse added to result cards and modal playback. - Search breadth and Gemini review budget were widened again because the latest user feedback still reported a thin visible result count even after smarter filtering. - The codebase is now back on the broader-search / modal-fitting direction associated with `5ca7aef`, with an added Gemini deadline guard to reduce reverse-proxy `504` risk. +- Windows 11 local development now has a repo-local PowerShell bootstrap / run / self-test / push workflow built around `.venv`, `.tools`, and `.local` so the machine does not need global Go / ffmpeg / yt-dlp changes for this repo. ## Current Architecture - `backend/main.go` @@ -217,6 +223,7 @@ - [x] Local self-test workflow - [x] Source-specific search collectors - [x] Shared ranker service layer +- [x] Windows 11 PowerShell local bootstrap / self-test / push workflow ## Important Current Constraints / Known Problems - Search backend quality is still the most fragile subsystem. @@ -426,6 +433,24 @@ - `node` is still not installed on this machine. - This is acceptable for the current repo because there is still no Node-based frontend build or lint workflow in-tree. - If future frontend work adds a Node toolchain, document the exact version and setup steps here before pushing. +- Windows 11 repo-local workflow: + - `scripts/setup-dev.ps1` + - creates `.venv` + - installs `worker/requirements.txt` into the repo-local venv + - downloads repo-local Go into `.tools/go` + - downloads repo-local ffmpeg into `.tools/ffmpeg` + - creates local command shims under `.tools/bin` such as `python3.cmd` + - `scripts/enter-dev-shell.ps1` + - prepends `.venv`, `.tools/bin`, local Go, and local ffmpeg to `PATH` + - pins `GOPATH`, `GOMODCACHE`, and `GOCACHE` into `.local` so Go does not need user-profile write access + - `scripts/run-dev.ps1` + - launches the backend with Windows-friendly local environment wiring + - `scripts/selftest.ps1` + - PowerShell-native replacement for the Linux shell smoke test + - verifies `gofmt`, Python syntax, `go test ./...`, frontend syntax via `node --check`, backend build, mock SearXNG boot, `/healthz`, `/api/search`, and `/api/upload` + - `scripts/push.ps1` + - reads ignored local credentials from `.local/git-credentials.psd1` + - pushes without storing credentials in tracked files ## Local Self-Test Workflow - Primary command: @@ -630,6 +655,33 @@ - If behavior in the browser does not match the latest backend/frontend code, the first assumption should be stale frontend assets until proven otherwise ## Recent Change Log +- Date: `2026-03-17` +- What changed: + - Added repo-local Windows 11 PowerShell workflows: + - `scripts/dev-tools.ps1` + - `scripts/setup-dev.ps1` + - `scripts/enter-dev-shell.ps1` + - `scripts/run-dev.ps1` + - `scripts/selftest.ps1` + - `scripts/push.ps1` + - Added ignored local directories for `.venv`, `.tools`, and `.local`. + - Created a local-only git credential file for automated push flow and kept it excluded from git. + - Pinned PowerShell tooling to repo-local Go caches under `.local` so `go test` no longer depends on writable user-profile Go paths. +- Why it changed: + - The active development machine is now Windows 11, and the user requested that setup, verification, and push flows work through PowerShell while minimizing machine-wide side effects. + - The previous local workflow was Linux-shell-oriented and did not directly cover Windows PowerShell usage. + - Initial Windows self-test attempts failed because Go wanted to write into the default user Go cache path, which was not reliably writable in this environment. +- How it was verified: + - `pwsh -NoProfile -File scripts/setup-dev.ps1 -SkipPythonDeps -SkipGoDownload -SkipFFmpegDownload` + - elevated `pwsh -NoProfile -File scripts/setup-dev.ps1` + - `pwsh -NoProfile -Command ". .\scripts\enter-dev-shell.ps1; python3 --version; yt-dlp --version; go version; ffmpeg -version | Select-Object -First 1"` + - `pwsh -NoProfile -File scripts/selftest.ps1` + - `git check-ignore -v .local\git-credentials.psd1 .venv .tools` +- What is still risky or incomplete: + - `scripts/setup-dev.ps1` still requires network access for Python package install and local Go / ffmpeg downloads on a truly fresh machine. + - The backend runtime still invokes `python3` by name, so Windows usage depends on entering the repo-local dev shell or otherwise exposing the generated shim on `PATH`. + - Push automation now uses an ignored local credential file, but credential rotation remains a manual step. + - Date: `2026-03-17` - What changed: - Restored hard/ignorable Gemini batch error filtering so low-value thumbnail skips and no-visual candidate skips do not count as user-facing partial batch failures when useful recommendations were still recovered. diff --git a/scripts/dev-tools.ps1 b/scripts/dev-tools.ps1 new file mode 100644 index 0000000..884ff19 --- /dev/null +++ b/scripts/dev-tools.ps1 @@ -0,0 +1,280 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$script:RepoRoot = Split-Path -Parent $PSScriptRoot +$script:ToolsRoot = Join-Path $script:RepoRoot ".tools" +$script:BinRoot = Join-Path $script:ToolsRoot "bin" +$script:GoRoot = Join-Path $script:ToolsRoot "go" +$script:GoBin = Join-Path $script:GoRoot "bin" +$script:FFmpegRoot = Join-Path $script:ToolsRoot "ffmpeg" +$script:FFmpegBin = Join-Path $script:FFmpegRoot "bin" +$script:VenvRoot = Join-Path $script:RepoRoot ".venv" +$script:VenvScripts = Join-Path $script:VenvRoot "Scripts" +$script:LocalRoot = Join-Path $script:RepoRoot ".local" +$script:GoPathRoot = Join-Path $script:LocalRoot "go" +$script:GoModCacheRoot = Join-Path $script:GoPathRoot "pkg\mod" +$script:GoBuildCacheRoot = Join-Path $script:LocalRoot "go-build-cache" +$script:CredentialFile = Join-Path $script:LocalRoot "git-credentials.psd1" + +function Write-Step { + param([string]$Message) + Write-Host "[ai-media-hub] $Message" +} + +function Invoke-CheckedCommand { + param( + [Parameter(Mandatory = $true)][string]$FilePath, + [string[]]$Arguments = @() + ) + + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + $joined = if ($Arguments.Count -gt 0) { " " + ($Arguments -join " ") } else { "" } + throw "명령 실행 실패: $FilePath$joined" + } +} + +function Ensure-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -ItemType Directory -Path $Path | Out-Null + } +} + +function Get-RepoRoot { return $script:RepoRoot } +function Get-ToolsRoot { return $script:ToolsRoot } +function Get-BinRoot { return $script:BinRoot } +function Get-GoRoot { return $script:GoRoot } +function Get-GoBin { return $script:GoBin } +function Get-FFmpegBin { return $script:FFmpegBin } +function Get-VenvRoot { return $script:VenvRoot } +function Get-VenvScripts { return $script:VenvScripts } +function Get-LocalRoot { return $script:LocalRoot } +function Get-GoPathRoot { return $script:GoPathRoot } +function Get-GoModCacheRoot { return $script:GoModCacheRoot } +function Get-GoBuildCacheRoot { return $script:GoBuildCacheRoot } +function Get-CredentialFile { return $script:CredentialFile } + +function Resolve-SystemCommand { + param([Parameter(Mandatory = $true)][string]$Name) + try { + return (Get-Command $Name -ErrorAction Stop).Source + } catch { + return $null + } +} + +function Resolve-PythonExe { + $candidates = @( + (Join-Path $script:VenvScripts "python.exe"), + (Resolve-SystemCommand -Name "python") + ) + foreach ($candidate in $candidates) { + if ($candidate -and (Test-Path -LiteralPath $candidate)) { + return $candidate + } + } + throw "python.exe를 찾을 수 없습니다. Python 3.12+가 필요합니다." +} + +function Resolve-BasePythonExe { + $all = @(Get-Command python -All -ErrorAction SilentlyContinue) + foreach ($item in $all) { + if ($item.Source -and $item.Source -notlike "*\.venv\*") { + return $item.Source + } + } + $fallback = Resolve-SystemCommand -Name "python" + if ($fallback -and $fallback -notlike "*\.venv\*") { + return $fallback + } + throw "시스템 python.exe를 찾지 못했습니다." +} + +function Resolve-VenvPythonExe { + $pythonExe = Join-Path $script:VenvScripts "python.exe" + if (-not (Test-Path -LiteralPath $pythonExe)) { + throw ".venv가 아직 준비되지 않았습니다. scripts/setup-dev.ps1를 먼저 실행하세요." + } + return $pythonExe +} + +function Use-LocalTooling { + Ensure-Directory -Path $script:ToolsRoot + Ensure-Directory -Path $script:BinRoot + Ensure-Directory -Path $script:LocalRoot + Ensure-Directory -Path $script:GoPathRoot + Ensure-Directory -Path $script:GoModCacheRoot + Ensure-Directory -Path $script:GoBuildCacheRoot + + $segments = @( + $script:BinRoot, + $script:GoBin, + $script:FFmpegBin, + $script:VenvScripts, + $env:PATH + ) | Where-Object { $_ -and $_.Trim() -ne "" } + + $env:PATH = ($segments | Select-Object -Unique) -join ";" + $env:GOPATH = $script:GoPathRoot + $env:GOMODCACHE = $script:GoModCacheRoot + $env:GOCACHE = $script:GoBuildCacheRoot +} + +function Ensure-Venv { + $venvPython = Join-Path $script:VenvScripts "python.exe" + if (Test-Path -LiteralPath $venvPython) { + return $venvPython + } + + Ensure-Directory -Path $script:LocalRoot + $pythonExe = Resolve-SystemCommand -Name "python" + if (-not $pythonExe) { + throw "시스템 python이 없어 .venv를 만들 수 없습니다." + } + + Write-Step ".venv 생성" + & $pythonExe -m venv $script:VenvRoot + return $venvPython +} + +function Install-PythonRequirements { + param([switch]$UpgradePip) + + $venvPython = Ensure-Venv + $systemPython = Resolve-BasePythonExe + + if ($UpgradePip) { + Write-Step "pip 업그레이드" + Invoke-CheckedCommand -FilePath $systemPython -Arguments @("-m", "pip", "--python", $venvPython, "install", "--upgrade", "pip") + } + + Write-Step "worker requirements 설치" + Invoke-CheckedCommand -FilePath $systemPython -Arguments @("-m", "pip", "--python", $venvPython, "install", "-r", (Join-Path $script:RepoRoot "worker\requirements.txt")) +} + +function New-CmdShim { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Target + ) + + $content = @( + "@echo off", + "set SCRIPT_DIR=%~dp0", + "`"%SCRIPT_DIR%$Target`" %*" + ) -join "`r`n" + Set-Content -LiteralPath $Path -Value $content -Encoding ASCII +} + +function Ensure-CommandShims { + Ensure-Directory -Path $script:BinRoot + + $pythonShimTarget = "..\..\ .venv\Scripts\python.exe".Replace(" ", "") + New-CmdShim -Path (Join-Path $script:BinRoot "python3.cmd") -Target $pythonShimTarget + + $ytDlpExe = Join-Path $script:VenvScripts "yt-dlp.exe" + if (Test-Path -LiteralPath $ytDlpExe) { + New-CmdShim -Path (Join-Path $script:BinRoot "yt-dlp.cmd") -Target "..\..\ .venv\Scripts\yt-dlp.exe".Replace(" ", "") + } +} + +function Test-GoReady { + return [bool](Resolve-SystemCommand -Name "go") +} + +function Test-FFmpegReady { + return [bool](Resolve-SystemCommand -Name "ffmpeg") +} + +function Test-YtDlpReady { + return [bool](Resolve-SystemCommand -Name "yt-dlp") +} + +function Install-LocalGo { + param( + [string]$Version = "1.24.0", + [string]$ArchiveUrl = "" + ) + + if (Test-Path -LiteralPath (Join-Path $script:GoBin "go.exe")) { + Write-Step "로컬 Go 이미 준비됨" + return + } + + if (-not $ArchiveUrl) { + $ArchiveUrl = "https://go.dev/dl/go$Version.windows-amd64.zip" + } + + Ensure-Directory -Path $script:ToolsRoot + $archivePath = Join-Path $script:LocalRoot "go-$Version.windows-amd64.zip" + Write-Step "로컬 Go 다운로드: $ArchiveUrl" + Invoke-WebRequest -Uri $ArchiveUrl -OutFile $archivePath + + if (Test-Path -LiteralPath $script:GoRoot) { + Remove-Item -Recurse -Force $script:GoRoot + } + Expand-Archive -LiteralPath $archivePath -DestinationPath $script:ToolsRoot -Force +} + +function Install-LocalFFmpeg { + param([string]$ArchiveUrl = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip") + + if (Test-Path -LiteralPath (Join-Path $script:FFmpegBin "ffmpeg.exe")) { + Write-Step "로컬 ffmpeg 이미 준비됨" + return + } + + Ensure-Directory -Path $script:ToolsRoot + $archivePath = Join-Path $script:LocalRoot "ffmpeg-release-essentials.zip" + Write-Step "로컬 ffmpeg 다운로드: $ArchiveUrl" + Invoke-WebRequest -Uri $ArchiveUrl -OutFile $archivePath + + $extractRoot = Join-Path $script:LocalRoot "ffmpeg-extract" + if (Test-Path -LiteralPath $extractRoot) { + Remove-Item -Recurse -Force $extractRoot + } + Expand-Archive -LiteralPath $archivePath -DestinationPath $extractRoot -Force + + $binCandidate = Get-ChildItem -Path $extractRoot -Recurse -Filter "ffmpeg.exe" | Select-Object -First 1 + if (-not $binCandidate) { + throw "ffmpeg.exe를 압축 파일에서 찾지 못했습니다." + } + + $sourceBin = Split-Path -Parent $binCandidate.FullName + if (Test-Path -LiteralPath $script:FFmpegRoot) { + Remove-Item -Recurse -Force $script:FFmpegRoot + } + Ensure-Directory -Path $script:FFmpegBin + Copy-Item -Path (Join-Path $sourceBin "*") -Destination $script:FFmpegBin -Recurse -Force +} + +function Get-AppEnvironment { + param( + [Parameter(Mandatory = $true)][string]$WorkspaceRoot, + [Parameter(Mandatory = $true)][string]$DownloadsDir, + [Parameter(Mandatory = $true)][string]$SqlitePath, + [Parameter(Mandatory = $true)][string]$AppAddr, + [string]$SearxUrl = "http://127.0.0.1:18080" + ) + + return @{ + APP_ROOT = $WorkspaceRoot + APP_ADDR = $AppAddr + SQLITE_PATH = $SqlitePath + DOWNLOADS_DIR = $DownloadsDir + FRONTEND_DIR = (Join-Path $WorkspaceRoot "frontend") + WORKER_SCRIPT = (Join-Path $WorkspaceRoot "worker\downloader.py") + SEARXNG_BASE_URL = $SearxUrl + SEARXNG_GOOGLE_VIDEO_ENGINE = "google videos" + SEARXNG_WEB_ENGINE = "google" + } +} + +function Import-GitCredential { + $credentialFile = Get-CredentialFile + if (-not (Test-Path -LiteralPath $credentialFile)) { + throw "git 자격정보 파일이 없습니다: $credentialFile" + } + return Import-PowerShellDataFile -Path $credentialFile +} diff --git a/scripts/enter-dev-shell.ps1 b/scripts/enter-dev-shell.ps1 new file mode 100644 index 0000000..69245a5 --- /dev/null +++ b/scripts/enter-dev-shell.ps1 @@ -0,0 +1,10 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +. (Join-Path $PSScriptRoot "dev-tools.ps1") + +Use-LocalTooling +Write-Step "로컬 개발 PATH 적용 완료" +Write-Host (" Repo : " + (Get-RepoRoot)) +Write-Host (" .venv : " + (Get-VenvRoot)) +Write-Host (" .tools : " + (Get-ToolsRoot)) diff --git a/scripts/push.ps1 b/scripts/push.ps1 new file mode 100644 index 0000000..6503406 --- /dev/null +++ b/scripts/push.ps1 @@ -0,0 +1,31 @@ +param( + [string]$Remote = "origin", + [string]$Branch = "", + [switch]$SetUpstream +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +. (Join-Path $PSScriptRoot "dev-tools.ps1") + +Use-LocalTooling +$credential = Import-GitCredential +if (-not $Branch) { + $Branch = (& git branch --show-current).Trim() +} +if (-not $Branch) { + throw "현재 브랜치를 찾지 못했습니다." +} + +$pair = "{0}:{1}" -f $credential.Username, $credential.Password +$token = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($pair)) +$gitArgs = @("-c", "http.extraheader=AUTHORIZATION: Basic $token", "push") +if ($SetUpstream) { + $gitArgs += @("--set-upstream", $Remote, $Branch) +} else { + $gitArgs += @($Remote, $Branch) +} + +Write-Step "git push $Remote $Branch" +& git @gitArgs diff --git a/scripts/run-dev.ps1 b/scripts/run-dev.ps1 new file mode 100644 index 0000000..cc21806 --- /dev/null +++ b/scripts/run-dev.ps1 @@ -0,0 +1,28 @@ +param( + [string]$AppAddr = "127.0.0.1:8080", + [string]$SqlitePath = "", + [string]$DownloadsDir = "", + [string]$SearxUrl = "http://127.0.0.1:18080" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +. (Join-Path $PSScriptRoot "dev-tools.ps1") + +Use-LocalTooling +$repoRoot = Get-RepoRoot +if (-not $SqlitePath) { + $SqlitePath = Join-Path $repoRoot "db\media.dev.db" +} +if (-not $DownloadsDir) { + $DownloadsDir = Join-Path $repoRoot "downloads" +} + +$envMap = Get-AppEnvironment -WorkspaceRoot $repoRoot -DownloadsDir $DownloadsDir -SqlitePath $SqlitePath -AppAddr $AppAddr -SearxUrl $SearxUrl +foreach ($entry in $envMap.GetEnumerator()) { + Set-Item -Path ("Env:" + $entry.Key) -Value $entry.Value +} + +Write-Step "백엔드 실행" +Invoke-CheckedCommand -FilePath "go" -Arguments @("run", "./backend") diff --git a/scripts/selftest.ps1 b/scripts/selftest.ps1 new file mode 100644 index 0000000..0fd1e24 --- /dev/null +++ b/scripts/selftest.ps1 @@ -0,0 +1,158 @@ +param( + [int]$MockPort = 18080, + [int]$AppPort = 18081, + [switch]$SkipGoFmt +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +. (Join-Path $PSScriptRoot "dev-tools.ps1") + +Use-LocalTooling +$repoRoot = Get-RepoRoot +$tmpRoot = Join-Path (Get-LocalRoot) ("selftest-" + [guid]::NewGuid().ToString("N")) +Ensure-Directory -Path $tmpRoot +$mockProcess = $null +$appProcess = $null +$appStdoutTask = $null +$appStderrTask = $null + +function Stop-TrackedProcess { + param($Process) + if ($null -ne $Process -and -not $Process.HasExited) { + Stop-Process -Id $Process.Id -Force + $Process.WaitForExit() + } +} + +try { + if (-not $SkipGoFmt) { + Write-Step "gofmt" + Invoke-CheckedCommand -FilePath "gofmt" -Arguments @( + "-w", + (Join-Path $repoRoot "backend\main.go"), + (Join-Path $repoRoot "backend\handlers\api.go"), + (Join-Path $repoRoot "backend\models\db.go"), + (Join-Path $repoRoot "backend\services\cse.go"), + (Join-Path $repoRoot "backend\services\cse_test.go"), + (Join-Path $repoRoot "backend\services\search_collectors.go"), + (Join-Path $repoRoot "backend\services\ranker.go"), + (Join-Path $repoRoot "backend\services\gemini.go"), + (Join-Path $repoRoot "backend\services\gemini_test.go") + ) + } + + Write-Step "python syntax" + Invoke-CheckedCommand -FilePath (Resolve-VenvPythonExe) -Arguments @( + "-m", + "py_compile", + (Join-Path $repoRoot "worker\downloader.py"), + (Join-Path $repoRoot "scripts\mock_searxng.py") + ) + + Write-Step "go test" + Invoke-CheckedCommand -FilePath "go" -Arguments @("test", "./...") + + Write-Step "frontend syntax" + Invoke-CheckedCommand -FilePath "node" -Arguments @("--check", (Join-Path $repoRoot "frontend\app.js")) + + Write-Step "go build" + $binaryPath = Join-Path $tmpRoot "ai-media-hub.exe" + Invoke-CheckedCommand -FilePath "go" -Arguments @("build", "-o", $binaryPath, "./backend") + + Write-Step "start mock searxng" + $mockLog = Join-Path $tmpRoot "mock-searxng.stdout.log" + $mockErrLog = Join-Path $tmpRoot "mock-searxng.stderr.log" + $mockProcess = Start-Process -FilePath (Resolve-VenvPythonExe) ` + -ArgumentList @((Join-Path $repoRoot "scripts\mock_searxng.py"), "--port", $MockPort) ` + -RedirectStandardOutput $mockLog ` + -RedirectStandardError $mockErrLog ` + -PassThru ` + -WindowStyle Hidden + + Write-Step "start app" + $downloadsDir = Join-Path $tmpRoot "downloads" + Ensure-Directory -Path $downloadsDir + $appLog = Join-Path $tmpRoot "app.log" + $envMap = Get-AppEnvironment ` + -WorkspaceRoot $repoRoot ` + -DownloadsDir $downloadsDir ` + -SqlitePath (Join-Path $tmpRoot "media.db") ` + -AppAddr ("127.0.0.1:{0}" -f $AppPort) ` + -SearxUrl ("http://127.0.0.1:{0}" -f $MockPort) + + $appStartInfo = New-Object System.Diagnostics.ProcessStartInfo + $appStartInfo.FileName = $binaryPath + $appStartInfo.WorkingDirectory = $repoRoot + $appStartInfo.UseShellExecute = $false + $appStartInfo.RedirectStandardOutput = $true + $appStartInfo.RedirectStandardError = $true + foreach ($entry in $envMap.GetEnumerator()) { + $appStartInfo.Environment[$entry.Key] = [string]$entry.Value + } + $appProcess = New-Object System.Diagnostics.Process + $appProcess.StartInfo = $appStartInfo + $null = $appProcess.Start() + $appStdoutTask = $appProcess.StandardOutput.ReadToEndAsync() + $appStderrTask = $appProcess.StandardError.ReadToEndAsync() + + $healthUrl = "http://127.0.0.1:$AppPort/healthz" + $healthy = $false + for ($i = 0; $i -lt 30; $i++) { + try { + $health = Invoke-RestMethod -Uri $healthUrl -TimeoutSec 2 + if ($health.status -eq "ok") { + $healthy = $true + break + } + } catch { + Start-Sleep -Seconds 1 + } + } + if (-not $healthy) { + throw "/healthz 확인 실패" + } + + Write-Step "verify search" + $searchPayload = @{ + query = "city rain" + platforms = @("envato", "artgrid", "google video") + } | ConvertTo-Json + $search = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:$AppPort/api/search" -ContentType "application/json" -Body $searchPayload + $searchResults = @($search.results) + if ($null -eq $search.results -or $searchResults.Count -lt 2) { + throw "검색 결과가 너무 적습니다." + } + if (@($searchResults | Where-Object { -not $_.link }).Count -gt 0) { + throw "검색 결과에 link가 비어 있습니다." + } + + Write-Step "verify upload" + $sampleFile = Join-Path $tmpRoot "sample.txt" + Set-Content -LiteralPath $sampleFile -Value "selftest upload" -Encoding UTF8 + $form = @{ + file = Get-Item -LiteralPath $sampleFile + } + $upload = Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:$AppPort/api/upload" -Form $form + if (-not $upload.filename) { + throw "업로드 응답에 filename이 없습니다." + } + $uploadedPath = Join-Path $downloadsDir $upload.filename + if (-not (Test-Path -LiteralPath $uploadedPath)) { + throw "업로드된 파일이 downloads에 존재하지 않습니다." + } + + Write-Step "selftest ok" +} finally { + Stop-TrackedProcess -Process $appProcess + Stop-TrackedProcess -Process $mockProcess + if ($appStdoutTask) { + $appStdoutTask.Wait() + $appStdoutTask.Result | Set-Content -LiteralPath (Join-Path $tmpRoot "app.log") -Encoding UTF8 + } + if ($appStderrTask) { + $appStderrTask.Wait() + $appStderrTask.Result | Add-Content -LiteralPath (Join-Path $tmpRoot "app.log") -Encoding UTF8 + } +} diff --git a/scripts/setup-dev.ps1 b/scripts/setup-dev.ps1 new file mode 100644 index 0000000..bdd2bf9 --- /dev/null +++ b/scripts/setup-dev.ps1 @@ -0,0 +1,43 @@ +param( + [switch]$SkipPythonDeps, + [switch]$SkipGoDownload, + [switch]$SkipFFmpegDownload, + [switch]$UpgradePip +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +. (Join-Path $PSScriptRoot "dev-tools.ps1") + +Use-LocalTooling +Ensure-Directory -Path (Get-ToolsRoot) +Ensure-Directory -Path (Get-LocalRoot) + +$pythonExe = Ensure-Venv +Write-Step "Python 사용 경로: $pythonExe" + +if (-not $SkipPythonDeps) { + Install-PythonRequirements -UpgradePip:$UpgradePip +} + +Ensure-CommandShims + +if (-not $SkipGoDownload -and -not (Test-GoReady)) { + Install-LocalGo +} + +if (-not $SkipFFmpegDownload -and -not (Test-FFmpegReady)) { + Install-LocalFFmpeg +} + +Use-LocalTooling + +Write-Step "준비 상태" +Write-Host (" python3: " + ((Resolve-SystemCommand -Name "python3") ?? "미설치")) +Write-Host (" python : " + ((Resolve-SystemCommand -Name "python") ?? "미설치")) +Write-Host (" yt-dlp : " + ((Resolve-SystemCommand -Name "yt-dlp") ?? "미설치")) +Write-Host (" go : " + ((Resolve-SystemCommand -Name "go") ?? "미설치")) +Write-Host (" ffmpeg : " + ((Resolve-SystemCommand -Name "ffmpeg") ?? "미설치")) +Write-Step "현재 셸에 PATH를 적용하려면 다음처럼 dot-source 하세요." +Write-Host " . .\scripts\enter-dev-shell.ps1"