# 창진 에이전트(cjagent) 설치 스크립트 — Windows PowerShell 5.1+ / PowerShell 7+ # irm https://cjsoft.pe.kr/download/install.ps1 | iex # 다른 서버에서 호스팅 시: $env:CJAGENT_DOWNLOAD_URL 로 오버라이드. # ※ CJAGENT_BASE_URL 은 쓰지 않는다 — cjagent 본체가 그 변수를 '모델 서버' 주소로 읽는다 # (cjagent/config.py). 모델 서버를 가리키도록 설정해 둔 PC 에서 설치를 돌리면 # 엉뚱한 호스트에서 wheel 을 받으려다 실패한다. # # ── 설계 원칙(v2) — 아래 3가지가 기존 설치 실패의 실제 원인이었다 ── # 1) `iex` 파이프 안에서는 절대 `exit` 하지 않는다. # `irm … | iex` 는 사용자의 PowerShell 세션 자체에서 실행되므로 스크립트의 `exit 1` 이 # 창을 통째로 닫아버려, 정작 원인이 적힌 오류 메시지를 아무도 읽지 못했다. # → 모든 실패는 함수 반환값으로 전달하고, 파일(-File)로 실행됐을 때만 종료코드를 넘긴다. # 2) `$ErrorActionPreference = 'Stop'` 를 쓰지 않는다. # Stop 상태에서 네이티브 명령(python/pip/pipx)의 stderr 한 줄이 `2>&1` 과 만나면 # 종료 예외가 되어 try/catch 로 삼켜졌고, Python이 멀쩡히 깔려 있는데도 # "Python 3.10+ 가 필요합니다" 로 오진하고 위 1)번 경로로 창이 닫혔다. # 3) TLS 1.2/1.3 을 명시한다. # 구형 Windows PowerShell 의 기본 SecurityProtocol 은 TLS1.0 이라 TLS1.2+ 만 받는 # 서버와 핸드셰이크 자체가 실패한다(= latest.json 조회 실패). $ErrorActionPreference = 'Continue' $CJ_Base = 'https://cjsoft.pe.kr' if ($env:CJAGENT_DOWNLOAD_URL) { $CJ_Base = $env:CJAGENT_DOWNLOAD_URL.TrimEnd('/') } elseif ($env:CJAGENT_UPDATE_URL) { $CJ_Base = $env:CJAGENT_UPDATE_URL.TrimEnd('/') } function Write-CJ($msg, $color) { if ($color) { Write-Host $msg -ForegroundColor $color } else { Write-Host $msg } } function Enable-CJTls { # TLS1.3(12288) 을 모르는 런타임에서는 대입 자체가 예외 → TLS1.2(3072) 만으로 재시도. try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072 -bor 12288 } catch { try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072 } catch {} } } function Get-CJPyVersion($exe, $pre) { # `--version` 대신 파이썬 코드를 직접 실행해 판별한다: # · Microsoft Store 의 가짜 python.exe(앱 실행 별칭)는 -c 를 실행하지 못해 자동 배제된다. # · stderr 를 $null 로 버려 ErrorRecord 가 만들어지지 않게 한다(설계 원칙 2). $a = @() if ($pre) { $a += $pre } $a += @('-c', 'import sys;print("CJPY:%d.%d" % sys.version_info[:2])') $out = $null try { $out = (& $exe @a 2>$null | Out-String) } catch { $out = $null } if ($out -and ($out -match 'CJPY:(\d+)\.(\d+)')) { return New-Object psobject -Property @{ Major = [int]$Matches[1]; Minor = [int]$Matches[2] } } return $null } function Find-CJPython { $cands = @() # py 런처: 버전을 명시해 훑는다(-3 만 쓰면 기본 버전이 3.9 이하일 때 그대로 탈락했다). if (Get-Command py -ErrorAction SilentlyContinue) { foreach ($v in @('-3.13', '-3.12', '-3.11', '-3.10', '-3')) { $cands += , @('py', @($v)) } } foreach ($n in @('python', 'python3')) { if (Get-Command $n -ErrorAction SilentlyContinue) { $cands += , @($n, @()) } } # PATH 에 없는 표준 설치 위치까지 훑는다 — 설치할 때 'Add python.exe to PATH' 를 # 체크하지 않은 경우가 실사용에서 가장 흔한 실패 원인이다. $globs = @( (Join-Path $env:LOCALAPPDATA 'Programs\Python\Python3*\python.exe'), (Join-Path $env:ProgramFiles 'Python3*\python.exe'), 'C:\Python3*\python.exe' ) foreach ($g in $globs) { foreach ($f in @(Get-ChildItem $g -ErrorAction SilentlyContinue | Sort-Object FullName -Descending)) { $cands += , @($f.FullName, @()) } } foreach ($c in $cands) { $v = Get-CJPyVersion $c[0] $c[1] if ($v -and ($v.Major -gt 3 -or ($v.Major -eq 3 -and $v.Minor -ge 10))) { return New-Object psobject -Property @{ Exe = $c[0]; Pre = $c[1]; Ver = ('{0}.{1}' -f $v.Major, $v.Minor) } } } return $null } function Get-CJText($url) { # 성공(200)인데 본문이 비어 있는 경우(캡티브 포털·일부 프록시)가 있어, 내용을 확인한 뒤에만 # 반환한다. 무조건 return 하면 아래 WebClient 폴백까지 건너뛰게 된다. try { $c = (Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 15).Content if ($c -is [byte[]]) { $c = [System.Text.Encoding]::UTF8.GetString($c) } if ($c -is [string] -and $c.Trim()) { return $c } } catch {} try { # Invoke-WebRequest 가 프록시/보안 정책에 막히는 환경 대비 폴백. $wc = New-Object System.Net.WebClient $wc.Encoding = [System.Text.Encoding]::UTF8 $c = $wc.DownloadString($url) if ($c -and $c.Trim()) { return $c } } catch {} return $null } function Add-CJPath($dir) { # 사용자 PATH 는 보통 REG_EXPAND_SZ 이고 기본값에 %USERPROFILE% 같은 변수가 들어 있다. # [Environment]::GetEnvironmentVariable(...,'User') 는 값을 '전개해서' 돌려주고, # SetEnvironmentVariable 는 REG_SZ 로 되써버린다 → 사용자 PATH 의 변수 참조가 영구히 # 리터럴로 굳어버린다. 레지스트리를 직접 다뤄 원형(ExpandString)을 보존한다. $key = $null $raw = $null $kind = 'ExpandString' try { $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) if ($key) { $raw = $key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) if ($key.GetValueKind('Path') -eq [Microsoft.Win32.RegistryValueKind]::String) { $kind = 'String' } } } catch { $key = $null } if ($null -eq $raw) { $raw = [Environment]::GetEnvironmentVariable('Path', 'User') } if (-not $raw) { $raw = '' } $parts = @($raw.Split(';') | Where-Object { $_ -ne '' }) # 부분문자열(-like "*$dir*") 비교는 오탐이 나므로 경로 단위로 정확히 비교한다. $has = $false foreach ($p in $parts) { if ($p.TrimEnd('\') -ieq $dir.TrimEnd('\')) { $has = $true } } if (-not $has) { $new = (($parts + $dir) -join ';') $done = $false if ($key) { try { $k = [Microsoft.Win32.RegistryValueKind]::ExpandString if ($kind -eq 'String') { $k = [Microsoft.Win32.RegistryValueKind]::String } $key.SetValue('Path', $new, $k) $done = $true } catch {} } if (-not $done) { [Environment]::SetEnvironmentVariable('Path', $new, 'User') } Write-CJ " PATH 등록: $dir (새 터미널부터 적용)" 'Gray' } if ($key) { try { $key.Close() } catch {} } # 지금 열려 있는 세션에서도 바로 쓸 수 있게 반영. # $env:Path 가 $null/빈 문자열일 수 있다 — .Split() 은 null 에서 터지고, # 빈 값에 ';' 를 앞세우면 '현재 디렉터리'가 PATH 최우선이 되는 위험한 항목이 생긴다. $cur = [string]$env:Path $hasCur = $false foreach ($p in ($cur -split ';')) { if ($p -and $p.TrimEnd('\') -ieq $dir.TrimEnd('\')) { $hasCur = $true } } if (-not $hasCur) { if ($cur) { $env:Path = $cur.TrimEnd(';') + ';' + $dir } else { $env:Path = $dir } } } function Add-CJShortcut($exe) { # 시작 메뉴 바로가기 — `cjagent web`(웹 UI)을 바로 띄운다. 실패해도 설치는 성공으로 본다. try { $dir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs' if (-not (Test-Path $dir)) { return } $lnk = Join-Path $dir '창진 에이전트 (웹 UI).lnk' $sh = New-Object -ComObject WScript.Shell $s = $sh.CreateShortcut($lnk) $s.TargetPath = $exe $s.Arguments = 'web' $s.WorkingDirectory = $env:USERPROFILE $s.Description = '창진 에이전트 — 로컬 오픈 웨이트 모델 코딩 에이전트' $s.Save() Write-CJ " 시작 메뉴: 창진 에이전트 (웹 UI)" 'Gray' } catch {} } function Test-CJVenv($vpy) { # 이 가상환경의 python 이 실제로 돌아가고 3.10+ 인지 확인. if (-not (Test-Path $vpy)) { return $false } try { $o = (& $vpy -c 'import sys;print(sys.version_info[0]*100+sys.version_info[1])' 2>$null | Out-String).Trim() return ([int]$o -ge 310) } catch { return $false } } function Install-CJVenv($py, $url) { $venv = Join-Path (Join-Path $env:LOCALAPPDATA 'cjagent') 'venv' $vpy = Join-Path $venv 'Scripts\python.exe' Write-CJ " 방법 : 전용 가상환경 ($venv)" 'Gray' Write-Host '' # 재설치 안전: 쓸 수 있는 환경이면 그대로 재사용한다. # `venv --clear` 로 폴더를 비우려다 실패하는 일이 잦았다 — 이전 설치본으로 `cjagent web` # 을 띄워 둔 채 다시 설치하면 Windows 가 실행 중인 exe 를 잠가 삭제가 막힌다. # 멀쩡한 환경을 지울 이유가 없다. pip 가 패키지만 새 버전으로 갈아끼우면 된다. if ((Test-Path (Join-Path $venv 'pyvenv.cfg')) -and (Test-CJVenv $vpy)) { Write-CJ " 기존 환경을 재사용합니다(폴더를 비우지 않습니다)." 'Gray' } else { $a = @() if ($py.Pre) { $a += $py.Pre } $a += @('-m', 'venv') # 못 쓰는 잔해가 있을 때만 비운다. if ((Test-Path $venv) -and (Get-ChildItem $venv -Force -ErrorAction SilentlyContinue)) { $a += '--clear' } $a += $venv $made = $false foreach ($try in 1, 2, 3) { $global:LASTEXITCODE = 0 & $py.Exe @a 2>&1 | Out-Host if ($LASTEXITCODE -eq 0 -and (Test-CJVenv $vpy)) { $made = $true; break } if ($try -lt 3) { Write-CJ " 재시도 $try/3 … (백신 검사·파일 잠금이 풀리기를 기다립니다)" 'Yellow' Start-Sleep -Seconds 2 } } if (-not $made) { Write-CJ " [오류] 가상환경 생성에 실패했습니다: $venv" 'Red' $procs = @() try { $procs = @(Get-Process | Where-Object { try { $_.Path -and $_.Path.StartsWith($venv, 'OrdinalIgnoreCase') } catch { $false } }) } catch {} if ($procs.Count -gt 0) { Write-CJ " 이 폴더의 프로그램이 아직 실행 중입니다:" 'Yellow' foreach ($p in $procs) { Write-CJ (" · " + $p.ProcessName + " (PID " + $p.Id + ")") 'Yellow' } Write-CJ " 해당 창(예: 웹 UI)을 닫고 다시 실행해 주세요." 'Yellow' } else { Write-CJ " 디스크 여유 공간·쓰기 권한, 백신의 실시간 차단 여부를 확인하세요." 'Yellow' } return $false } } # pip 자체가 낡으면 최신 wheel 메타데이터를 못 읽는 경우가 있다(실패해도 진행). & $vpy -m pip install --upgrade --disable-pip-version-check -q pip 2>$null | Out-Null # 설치 → import 확인 → 깨졌으면 의존성까지 강제 재설치. # pip 가 "성공"으로 끝나도 못 쓰는 환경이 나올 수 있다. 실제 사례: 앞선 venv --clear 가 # site-packages 를 지우다 실패해 certifi 가 __init__.py 없는 빈 폴더로 남았는데 # (ImportError: cannot import name 'where' from 'certifi'), dist-info 는 멀쩡해서 # pip 는 "이미 설치됨"으로 건너뛰었다. 메타데이터가 아니라 import 로 확인해야 잡힌다. $extra = @('--upgrade') foreach ($round in 1, 2) { $global:LASTEXITCODE = 0 # pip 는 진행 상황·경고를 stderr 로 쓴다. 2>&1 로 합치지 않으면 정상 설치인데도 # 화면이 빨간 오류 레코드로 뒤덮여 실패한 것처럼 보인다. & $vpy -m pip install --disable-pip-version-check @extra $url 2>&1 | Out-Host if ($LASTEXITCODE -ne 0) { Write-CJ " [오류] 패키지 설치에 실패했습니다 — 위 pip 메시지를 확인하세요." 'Red' return $false } $chk = 'import importlib,sys bad=[] for m in ("certifi","requests","yaml","rich","cjagent"): try: importlib.import_module(m) except Exception as e: bad.append(m+": "+type(e).__name__+": "+str(e)[:120]) sys.stdout.write("\n".join(bad))' $bad = (& $vpy -c $chk 2>&1 | Out-String).Trim() if (-not $bad) { return (Complete-CJVenv $venv) } Write-Host '' Write-CJ " 설치 후 확인에서 문제가 발견됐습니다:" 'Yellow' foreach ($line in ($bad -split "`n")) { Write-CJ (" " + $line.Trim()) 'DarkGray' } if ($round -eq 1) { Write-CJ " 의존성까지 다시 설치해 복구를 시도합니다…" 'Yellow' Write-Host '' $extra = @('--upgrade', '--force-reinstall') } else { Write-CJ " [오류] 복구하지 못했습니다. 아래로 환경을 새로 만든 뒤 다시 시도해 주세요:" 'Red' Write-CJ (" Remove-Item -Recurse -Force '" + $venv + "'") 'Yellow' return $false } } return $false } function Complete-CJVenv($venv) { # 설치 마무리 — 실행 파일 확인 + PATH 등록. $scripts = Join-Path $venv 'Scripts' $exe = Join-Path $scripts 'cjagent.exe' if (-not (Test-Path $exe)) { Write-CJ " [오류] 설치는 끝났지만 실행 파일이 없습니다: $exe" 'Red' return $false } Add-CJPath $scripts Write-CJ " 실행 파일: $exe" 'Gray' return $true } function Install-CJAgent { Write-Host '' Write-CJ " 창진 에이전트(cjagent) 설치" 'Cyan' Write-CJ " ---------------------------" 'DarkGray' Enable-CJTls # ── 1) Python 3.10+ 탐지 ── $py = Find-CJPython if (-not $py) { Write-CJ " [오류] Python 3.10 이상을 찾지 못했습니다." 'Red' Write-CJ " https://www.python.org/downloads/ 에서 설치하세요." 'Yellow' Write-CJ " 설치 화면 아래 'Add python.exe to PATH' 를 반드시 체크하고," 'Yellow' Write-CJ " 설치가 끝나면 새 PowerShell 창에서 다시 실행하세요." 'Yellow' Write-CJ " 참고: Microsoft Store 의 python 별칭은 설치에 쓸 수 없습니다 —" 'DarkGray' Write-CJ " 설정 > 앱 > 고급 앱 설정 > 앱 실행 별칭 에서 python/python3 를 끄세요." 'DarkGray' return $false } $desc = $py.Exe if ($py.Pre) { $desc = $desc + ' ' + ($py.Pre -join ' ') } Write-CJ (" Python : " + $py.Ver + " ($desc)") 'Gray' # ── 2) 최신 버전 메타 ── $wheel = '' $raw = Get-CJText "$CJ_Base/download/latest.json" if ($raw) { try { $meta = ($raw.TrimStart([char]0xFEFF) | ConvertFrom-Json) if ($meta.wheel) { $wheel = [string]$meta.wheel } } catch {} } if (-not $wheel) { Write-CJ " [오류] 최신 버전 정보를 읽지 못했습니다: $CJ_Base/download/latest.json" 'Red' Write-CJ " 네트워크·방화벽·프록시를 확인한 뒤 다시 시도하세요." 'Yellow' Write-CJ " 사내 프록시 환경이면 다음처럼 지정할 수 있습니다:" 'DarkGray' Write-CJ ' $env:HTTPS_PROXY = "http://proxy:8080"' 'DarkGray' return $false } $url = "$CJ_Base/download/$wheel" Write-CJ " 패키지 : $wheel" 'Gray' # ── 빠른 업데이트 경로 ── # 이미 설치돼 있고 버전만 낡았다면 전 과정을 다시 돌 이유가 없다. 그 환경의 pip 로 # 패키지만 갈아끼운다(수 초). 이미 최신이면 아무것도 하지 않고 끝낸다. $venvPath = Join-Path (Join-Path $env:LOCALAPPDATA 'cjagent') 'venv' $venvPy = Join-Path $venvPath 'Scripts\python.exe' if ((Test-Path (Join-Path $venvPath 'pyvenv.cfg')) -and (Test-CJVenv $venvPy)) { $cur = '' try { $cur = (& $venvPy -c "import cjagent;print(cjagent.__version__)" 2>$null | Out-String).Trim() } catch {} $want = '' if ($wheel -match 'cjagent-([0-9][^-]*)-py3') { $want = $Matches[1] } if ($cur) { Write-CJ " 설치됨 : v$cur" 'Gray' if ($want -and $cur -eq $want) { Write-Host '' Write-CJ " 이미 최신입니다 (v$cur) — 할 일이 없습니다." 'Green' return $true } Write-Host '' Write-CJ " 빠른 업데이트: v$cur → v$want (패키지만 교체)" 'Cyan' Write-Host '' $global:LASTEXITCODE = 0 & $venvPy -m pip install --upgrade --disable-pip-version-check $url 2>&1 | Out-Host if ($LASTEXITCODE -eq 0) { $bad = (& $venvPy -c 'import importlib,sys bad=[] for m in ("certifi","requests","yaml","rich","cjagent"): try: importlib.import_module(m) except Exception as e: bad.append(m+": "+type(e).__name__) sys.stdout.write("\n".join(bad))' 2>&1 | Out-String).Trim() if (-not $bad) { Write-Host '' Write-CJ " 업데이트 완료!" 'Green' return (Complete-CJVenv $venvPath) } Write-CJ " 업데이트 후 확인에서 문제가 발견돼 전체 설치로 진행합니다." 'Yellow' } else { Write-CJ " 빠른 업데이트 실패 — 전체 설치로 진행합니다." 'Yellow' } } } Write-Host '' # ── 3) 설치: pipx(격리) 우선 → 실패하거나 없으면 전용 가상환경 ── $ok = $false if (Get-Command pipx -ErrorAction SilentlyContinue) { Write-CJ " 방법 : pipx (격리 설치)" 'Gray' Write-Host '' $global:LASTEXITCODE = 0 pipx install --force $url 2>&1 | Out-Host if ($LASTEXITCODE -eq 0) { # pipx 의 bin 디렉터리는 `pipx ensurepath` 를 돌린 적이 없으면 PATH 에 없다. # 그대로 두면 "설치 완료"라고 해놓고 어느 터미널에서도 cjagent 를 못 찾는다. foreach ($d in @((Join-Path $env:USERPROFILE '.local\bin'), (Join-Path $env:LOCALAPPDATA 'pipx\pipx\venvs'))) { if ((Test-Path (Join-Path $d 'cjagent.exe'))) { Add-CJPath $d } } if (-not (Get-Command cjagent -ErrorAction SilentlyContinue)) { $global:LASTEXITCODE = 0 pipx ensurepath 2>&1 | Out-Host } # 설치 성공은 '실행 파일이 실제로 잡히는가'로 확인한다(종료코드만 믿지 않는다). if (Get-Command cjagent -ErrorAction SilentlyContinue) { $ok = $true } else { Write-Host '' Write-CJ " pipx 로 설치했지만 cjagent 명령을 찾을 수 없습니다 — 전용 가상환경으로 다시 설치합니다." 'Yellow' } } else { Write-Host '' Write-CJ " pipx 설치가 실패했습니다 — 전용 가상환경 방식으로 다시 시도합니다." 'Yellow' } } if (-not $ok) { $ok = Install-CJVenv $py $url } if (-not $ok) { return $false } # ── 4) 설치 검증 ── $ver = '' $cmd = Get-Command cjagent -ErrorAction SilentlyContinue if ($cmd) { try { $ver = (& $cmd.Source --version 2>$null | Out-String).Trim() } catch {} Add-CJShortcut $cmd.Source } $done = ' 설치 완료!' if ($ver) { $done = $done + ' (' + $ver + ')' } Write-Host '' Write-CJ $done 'Green' Write-CJ " 다음을 실행해 보세요:" 'White' Write-CJ " cjagent doctor # 환경 진단(모델 서버·PATH 확인)" 'Cyan' Write-CJ " cjagent web # 웹 UI 실행" 'Cyan' Write-Host '' Write-CJ " * 'cjagent' 명령을 못 찾으면 새 터미널을 열어 PATH를 반영하세요." 'DarkGray' return $true } $CJ_Ok = Install-CJAgent if (-not $CJ_Ok) { Write-Host '' Write-CJ " 설치가 완료되지 않았습니다. 위 메시지를 확인해 주세요." 'Red' Write-CJ " 해결이 어려우면 이 화면을 캡처해 chyang@cjsoft.pe.kr 로 보내주시면 도와드립니다." 'DarkGray' } # 설치 EXE 처럼 창이 곧바로 닫히는 환경에서는 결과를 읽을 시간을 준다. if ($env:CJAGENT_INSTALL_PAUSE -eq '1') { Write-Host '' Write-Host ' 계속하려면 Enter 키를 누르세요...' -NoNewline try { [void](Read-Host) } catch {} } # `iex` 파이프에서는 exit 하지 않는다 — 사용자 창(또는 사용자 스크립트)이 통째로 종료된다. # 파일(-File)로 실행된 경우에만 종료코드를 넘겨 설치 EXE 가 성공/실패를 판별하게 한다. # ExternalScript 검사를 함께 두는 이유: 사용자가 '자기 .ps1 안에서' irm|iex 를 하면 # $PSCommandPath 가 그 스크립트 경로로 채워져 있어, 그것만으로는 구분되지 않는다. if ($PSCommandPath -and $MyInvocation.MyCommand.CommandType -eq 'ExternalScript') { if ($CJ_Ok) { exit 0 } else { exit 1 } }