deploy.ps1 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883
  1. # Claude Code Hub - One-Click Deployment Script for Windows
  2. # PowerShell 5.1+ required
  3. #Requires -Version 5.1
  4. [CmdletBinding()]
  5. param(
  6. [Alias("b")]
  7. [ValidateSet("main", "dev", "")]
  8. [string]$Branch = "",
  9. [Alias("p")]
  10. [ValidateRange(1, 65535)]
  11. [int]$Port = 0,
  12. [Alias("t")]
  13. [string]$AdminToken = "",
  14. [Alias("d")]
  15. [string]$DeployDir = "",
  16. [string]$Domain = "",
  17. [switch]$EnableCaddy,
  18. [switch]$ForceNew,
  19. [Alias("y")]
  20. [switch]$Yes,
  21. [Alias("h")]
  22. [switch]$Help
  23. )
  24. # Script version
  25. $VERSION = "1.1.0"
  26. # Global variables
  27. $script:SUFFIX = ""
  28. $script:ADMIN_TOKEN = ""
  29. $script:DB_PASSWORD = ""
  30. $script:DEPLOY_DIR = "C:\ProgramData\claude-code-hub"
  31. $script:IMAGE_TAG = "latest"
  32. $script:BRANCH_NAME = "main"
  33. $script:APP_PORT = "23000"
  34. $script:ENABLE_CADDY = $false
  35. $script:DOMAIN_ARG = ""
  36. $script:UPDATE_MODE = $false
  37. $script:FORCE_NEW = $false
  38. function Show-Help {
  39. $helpText = @"
  40. Claude Code Hub - One-Click Deployment Script v$VERSION
  41. Usage: .\deploy.ps1 [OPTIONS]
  42. Options:
  43. -Branch, -b <name> Branch to deploy: main (default) or dev
  44. -Port, -p <port> App external port (default: 23000)
  45. -AdminToken, -t <token> Custom admin token (default: auto-generated)
  46. -DeployDir, -d <path> Custom deployment directory
  47. -Domain <domain> Domain for Caddy HTTPS (enables Caddy automatically)
  48. -EnableCaddy Enable Caddy reverse proxy without HTTPS (HTTP only)
  49. -ForceNew Force fresh installation (ignore existing deployment)
  50. -Yes, -y Non-interactive mode (skip prompts, use defaults)
  51. -Help, -h Show this help message
  52. Examples:
  53. .\deploy.ps1 # Interactive deployment
  54. .\deploy.ps1 -Yes # Non-interactive with defaults
  55. .\deploy.ps1 -Branch dev -Port 8080 -Yes # Deploy dev branch on port 8080
  56. .\deploy.ps1 -AdminToken "my-secure-token" -Yes # Use custom admin token
  57. .\deploy.ps1 -Domain hub.example.com -Yes # Deploy with Caddy HTTPS
  58. .\deploy.ps1 -EnableCaddy -Yes # Deploy with Caddy HTTP-only
  59. .\deploy.ps1 -Yes # Update existing deployment (auto-detected)
  60. .\deploy.ps1 -ForceNew -Yes # Force fresh install even if deployment exists
  61. For more information, visit: https://github.com/ding113/claude-code-hub
  62. "@
  63. Write-Host $helpText
  64. }
  65. function Initialize-Parameters {
  66. # Apply CLI parameters
  67. if ($Branch) {
  68. if ($Branch -eq "main") {
  69. $script:IMAGE_TAG = "latest"
  70. $script:BRANCH_NAME = "main"
  71. } elseif ($Branch -eq "dev") {
  72. $script:IMAGE_TAG = "dev"
  73. $script:BRANCH_NAME = "dev"
  74. }
  75. }
  76. if ($Port -gt 0) {
  77. $script:APP_PORT = $Port.ToString()
  78. }
  79. if ($AdminToken) {
  80. if ($AdminToken.Length -lt 16) {
  81. Write-ColorOutput "Admin token too short: minimum 16 characters required" -Type Error
  82. exit 1
  83. }
  84. $script:ADMIN_TOKEN = $AdminToken
  85. }
  86. if ($DeployDir) {
  87. $script:DEPLOY_DIR = $DeployDir
  88. }
  89. if ($Domain) {
  90. # Validate domain format
  91. if ($Domain -notmatch '^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$') {
  92. Write-ColorOutput "Invalid domain format: $Domain" -Type Error
  93. exit 1
  94. }
  95. $script:DOMAIN_ARG = $Domain
  96. $script:ENABLE_CADDY = $true
  97. }
  98. if ($EnableCaddy) {
  99. $script:ENABLE_CADDY = $true
  100. }
  101. if ($ForceNew) {
  102. $script:FORCE_NEW = $true
  103. }
  104. }
  105. function Write-ColorOutput {
  106. param(
  107. [string]$Message,
  108. [string]$Type = "Info"
  109. )
  110. switch ($Type) {
  111. "Header" { Write-Host $Message -ForegroundColor Cyan }
  112. "Info" { Write-Host "[INFO] $Message" -ForegroundColor Blue }
  113. "Success" { Write-Host "[SUCCESS] $Message" -ForegroundColor Green }
  114. "Warning" { Write-Host "[WARNING] $Message" -ForegroundColor Yellow }
  115. "Error" { Write-Host "[ERROR] $Message" -ForegroundColor Red }
  116. default { Write-Host $Message }
  117. }
  118. }
  119. function Show-Header {
  120. Write-ColorOutput "+=================================================================+" -Type Header
  121. Write-ColorOutput "| |" -Type Header
  122. Write-ColorOutput "| Claude Code Hub - One-Click Deployment |" -Type Header
  123. Write-ColorOutput "| Version $VERSION |" -Type Header
  124. Write-ColorOutput "| |" -Type Header
  125. Write-ColorOutput "+=================================================================+" -Type Header
  126. Write-Host ""
  127. }
  128. function Test-DockerInstalled {
  129. Write-ColorOutput "Checking Docker installation..." -Type Info
  130. try {
  131. $dockerVersion = docker --version 2>$null
  132. if ($LASTEXITCODE -ne 0) {
  133. Write-ColorOutput "Docker is not installed" -Type Warning
  134. return $false
  135. }
  136. $composeVersion = docker compose version 2>$null
  137. if ($LASTEXITCODE -ne 0) {
  138. Write-ColorOutput "Docker Compose is not installed" -Type Warning
  139. return $false
  140. }
  141. Write-ColorOutput "Docker and Docker Compose are installed" -Type Success
  142. Write-Host $dockerVersion
  143. Write-Host $composeVersion
  144. return $true
  145. }
  146. catch {
  147. Write-ColorOutput "Docker is not installed" -Type Warning
  148. return $false
  149. }
  150. }
  151. function Show-DockerInstallInstructions {
  152. Write-ColorOutput "Docker is not installed on this system" -Type Error
  153. Write-Host ""
  154. Write-ColorOutput "Please install Docker Desktop for Windows:" -Type Info
  155. Write-Host " 1. Download from: https://www.docker.com/products/docker-desktop/" -ForegroundColor Cyan
  156. Write-Host " 2. Run the installer and follow the instructions"
  157. Write-Host " 3. Restart your computer after installation"
  158. Write-Host " 4. Run this script again"
  159. Write-Host ""
  160. Write-ColorOutput "Press any key to open Docker Desktop download page..." -Type Info
  161. $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
  162. Start-Process "https://www.docker.com/products/docker-desktop/"
  163. exit 1
  164. }
  165. function Select-Branch {
  166. # Skip if branch already set via CLI or non-interactive mode
  167. if ($Branch) {
  168. Write-ColorOutput "Using branch from CLI argument: $script:BRANCH_NAME" -Type Info
  169. return
  170. }
  171. if ($Yes) {
  172. Write-ColorOutput "Non-interactive mode: using default branch (main)" -Type Info
  173. return
  174. }
  175. Write-Host ""
  176. Write-ColorOutput "Please select the branch to deploy:" -Type Info
  177. Write-Host " 1) main (Stable release - recommended for production)" -ForegroundColor Green
  178. Write-Host " 2) dev (Latest features - for testing)" -ForegroundColor Yellow
  179. Write-Host ""
  180. while ($true) {
  181. $choice = Read-Host "Enter your choice [1]"
  182. if ([string]::IsNullOrWhiteSpace($choice)) {
  183. $choice = "1"
  184. }
  185. switch ($choice) {
  186. "1" {
  187. $script:IMAGE_TAG = "latest"
  188. $script:BRANCH_NAME = "main"
  189. Write-ColorOutput "Selected branch: main (image tag: latest)" -Type Success
  190. break
  191. }
  192. "2" {
  193. $script:IMAGE_TAG = "dev"
  194. $script:BRANCH_NAME = "dev"
  195. Write-ColorOutput "Selected branch: dev (image tag: dev)" -Type Success
  196. break
  197. }
  198. default {
  199. Write-ColorOutput "Invalid choice. Please enter 1 or 2." -Type Error
  200. continue
  201. }
  202. }
  203. break
  204. }
  205. }
  206. function New-RandomSuffix {
  207. $chars = "abcdefghijklmnopqrstuvwxyz0123456789"
  208. $script:SUFFIX = -join ((1..4) | ForEach-Object { $chars[(Get-Random -Maximum $chars.Length)] })
  209. Write-ColorOutput "Generated random suffix: $SUFFIX" -Type Info
  210. }
  211. function New-AdminToken {
  212. # Skip if token already set via CLI
  213. if ($script:ADMIN_TOKEN) {
  214. Write-ColorOutput "Using admin token from CLI argument" -Type Info
  215. return
  216. }
  217. # Generate more bytes to ensure we have enough after removing special chars
  218. $bytes = New-Object byte[] 48
  219. $rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::Create()
  220. $rng.GetBytes($bytes)
  221. $rng.Dispose()
  222. $token = [Convert]::ToBase64String($bytes) -replace '[/+=]', ''
  223. $script:ADMIN_TOKEN = $token.Substring(0, [Math]::Min(32, $token.Length))
  224. Write-ColorOutput "Generated secure admin token" -Type Info
  225. }
  226. function New-DbPassword {
  227. # Generate more bytes to ensure we have enough after removing special chars
  228. $bytes = New-Object byte[] 36
  229. $rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::Create()
  230. $rng.GetBytes($bytes)
  231. $rng.Dispose()
  232. $password = [Convert]::ToBase64String($bytes) -replace '[/+=]', ''
  233. $script:DB_PASSWORD = $password.Substring(0, [Math]::Min(24, $password.Length))
  234. Write-ColorOutput "Generated secure database password" -Type Info
  235. }
  236. function Test-ExistingDeployment {
  237. if ($script:FORCE_NEW) {
  238. Write-ColorOutput "Force-new flag set, skipping existing deployment detection" -Type Info
  239. return $false
  240. }
  241. if ((Test-Path "$($script:DEPLOY_DIR)\.env") -and (Test-Path "$($script:DEPLOY_DIR)\docker-compose.yaml")) {
  242. Write-ColorOutput "Detected existing deployment in $($script:DEPLOY_DIR)" -Type Info
  243. $script:UPDATE_MODE = $true
  244. return $true
  245. }
  246. return $false
  247. }
  248. function Get-SuffixFromCompose {
  249. $composeFile = "$($script:DEPLOY_DIR)\docker-compose.yaml"
  250. $content = Get-Content $composeFile -Raw
  251. if ($content -match 'container_name: claude-code-hub-db-([a-z0-9]+)') {
  252. $script:SUFFIX = $Matches[1]
  253. Write-ColorOutput "Using existing suffix: $($script:SUFFIX)" -Type Info
  254. }
  255. else {
  256. Write-ColorOutput "Could not extract suffix from docker-compose.yaml, generating new one" -Type Warning
  257. New-RandomSuffix
  258. }
  259. }
  260. function Import-ExistingEnv {
  261. $envFile = "$($script:DEPLOY_DIR)\.env"
  262. # Load DB_PASSWORD
  263. $dbPwLine = Select-String -Path $envFile -Pattern '^DB_PASSWORD=' | Select-Object -First 1
  264. if ($dbPwLine) {
  265. $script:DB_PASSWORD = ($dbPwLine.Line -split '=', 2)[1]
  266. Write-ColorOutput "Preserved existing database password" -Type Info
  267. }
  268. else {
  269. Write-ColorOutput "DB_PASSWORD not found in existing .env, generating new one" -Type Warning
  270. New-DbPassword
  271. }
  272. # Load ADMIN_TOKEN (CLI argument takes priority)
  273. if (-not $script:ADMIN_TOKEN) {
  274. $tokenLine = Select-String -Path $envFile -Pattern '^ADMIN_TOKEN=' | Select-Object -First 1
  275. if ($tokenLine) {
  276. $script:ADMIN_TOKEN = ($tokenLine.Line -split '=', 2)[1]
  277. Write-ColorOutput "Preserved existing admin token" -Type Info
  278. }
  279. else {
  280. Write-ColorOutput "ADMIN_TOKEN not found in existing .env, generating new one" -Type Warning
  281. New-AdminToken
  282. }
  283. }
  284. # Load APP_PORT (CLI argument takes priority)
  285. if ($Port -eq 0) {
  286. $portLine = Select-String -Path $envFile -Pattern '^APP_PORT=' | Select-Object -First 1
  287. if ($portLine) {
  288. $script:APP_PORT = ($portLine.Line -split '=', 2)[1]
  289. }
  290. }
  291. }
  292. function New-DeploymentDirectory {
  293. Write-ColorOutput "Creating deployment directory: $DEPLOY_DIR" -Type Info
  294. try {
  295. if (-not (Test-Path $DEPLOY_DIR)) {
  296. New-Item -ItemType Directory -Path $DEPLOY_DIR -Force | Out-Null
  297. }
  298. New-Item -ItemType Directory -Path "$DEPLOY_DIR\data\postgres" -Force | Out-Null
  299. New-Item -ItemType Directory -Path "$DEPLOY_DIR\data\redis" -Force | Out-Null
  300. Write-ColorOutput "Deployment directory created" -Type Success
  301. }
  302. catch {
  303. Write-ColorOutput "Failed to create deployment directory: $_" -Type Error
  304. exit 1
  305. }
  306. }
  307. function Write-ComposeFile {
  308. Write-ColorOutput "Writing docker-compose.yaml..." -Type Info
  309. # Build ports section for app (only if Caddy is not enabled)
  310. $appPortsSection = ""
  311. if (-not $script:ENABLE_CADDY) {
  312. $appPortsSection = @"
  313. ports:
  314. - "`${APP_PORT:-$($script:APP_PORT)}:`${APP_PORT:-$($script:APP_PORT)}"
  315. "@
  316. }
  317. $composeContent = @"
  318. services:
  319. postgres:
  320. image: postgres:18
  321. container_name: claude-code-hub-db-$SUFFIX
  322. restart: unless-stopped
  323. ports:
  324. - "127.0.0.1:35432:5432"
  325. env_file:
  326. - ./.env
  327. environment:
  328. POSTGRES_USER: `${DB_USER:-postgres}
  329. POSTGRES_PASSWORD: `${DB_PASSWORD:-postgres}
  330. POSTGRES_DB: `${DB_NAME:-claude_code_hub}
  331. PGDATA: /data/pgdata
  332. TZ: Asia/Shanghai
  333. PGTZ: Asia/Shanghai
  334. volumes:
  335. - ./data/postgres:/data
  336. networks:
  337. - claude-code-hub-net-$SUFFIX
  338. healthcheck:
  339. test: ["CMD-SHELL", "pg_isready -U `${DB_USER:-postgres} -d `${DB_NAME:-claude_code_hub}"]
  340. interval: 5s
  341. timeout: 5s
  342. retries: 10
  343. start_period: 10s
  344. redis:
  345. image: redis:7-alpine
  346. container_name: claude-code-hub-redis-$SUFFIX
  347. restart: unless-stopped
  348. volumes:
  349. - ./data/redis:/data
  350. command: redis-server --appendonly yes
  351. networks:
  352. - claude-code-hub-net-$SUFFIX
  353. healthcheck:
  354. test: ["CMD", "redis-cli", "ping"]
  355. interval: 5s
  356. timeout: 3s
  357. retries: 5
  358. start_period: 5s
  359. app:
  360. image: ghcr.io/ding113/claude-code-hub:$IMAGE_TAG
  361. container_name: claude-code-hub-app-$SUFFIX
  362. depends_on:
  363. postgres:
  364. condition: service_healthy
  365. redis:
  366. condition: service_healthy
  367. env_file:
  368. - ./.env
  369. environment:
  370. NODE_ENV: production
  371. PORT: `${APP_PORT:-$($script:APP_PORT)}
  372. DSN: postgresql://`${DB_USER:-postgres}:`${DB_PASSWORD:-postgres}@claude-code-hub-db-${SUFFIX}:5432/`${DB_NAME:-claude_code_hub}
  373. REDIS_URL: redis://claude-code-hub-redis-${SUFFIX}:6379
  374. AUTO_MIGRATE: `${AUTO_MIGRATE:-true}
  375. ENABLE_RATE_LIMIT: `${ENABLE_RATE_LIMIT:-true}
  376. SESSION_TTL: `${SESSION_TTL:-300}
  377. TZ: Asia/Shanghai
  378. $appPortsSection
  379. restart: unless-stopped
  380. networks:
  381. - claude-code-hub-net-$SUFFIX
  382. healthcheck:
  383. test: ["CMD-SHELL", "curl -f http://localhost:`${APP_PORT:-$($script:APP_PORT)}/api/actions/health || exit 1"]
  384. interval: 30s
  385. timeout: 5s
  386. retries: 3
  387. start_period: 30s
  388. "@
  389. # Add Caddy service if enabled
  390. if ($script:ENABLE_CADDY) {
  391. $composeContent += @"
  392. caddy:
  393. image: caddy:2-alpine
  394. container_name: claude-code-hub-caddy-$SUFFIX
  395. restart: unless-stopped
  396. ports:
  397. - "80:80"
  398. - "443:443"
  399. volumes:
  400. - ./Caddyfile:/etc/caddy/Caddyfile:ro
  401. - caddy_data:/data
  402. - caddy_config:/config
  403. depends_on:
  404. app:
  405. condition: service_healthy
  406. networks:
  407. - claude-code-hub-net-$SUFFIX
  408. "@
  409. }
  410. $composeContent += @"
  411. networks:
  412. claude-code-hub-net-${SUFFIX}:
  413. driver: bridge
  414. name: claude-code-hub-net-$SUFFIX
  415. "@
  416. # Add Caddy volumes if enabled
  417. if ($script:ENABLE_CADDY) {
  418. $composeContent += @"
  419. volumes:
  420. caddy_data:
  421. caddy_config:
  422. "@
  423. }
  424. try {
  425. Set-Content -Path "$DEPLOY_DIR\docker-compose.yaml" -Value $composeContent -Encoding UTF8
  426. Write-ColorOutput "docker-compose.yaml created" -Type Success
  427. }
  428. catch {
  429. Write-ColorOutput "Failed to write docker-compose.yaml: $_" -Type Error
  430. exit 1
  431. }
  432. }
  433. function Write-Caddyfile {
  434. if (-not $script:ENABLE_CADDY) {
  435. return
  436. }
  437. Write-ColorOutput "Writing Caddyfile..." -Type Info
  438. if ($script:DOMAIN_ARG) {
  439. # HTTPS mode with domain (Let's Encrypt automatic)
  440. $caddyContent = @"
  441. $($script:DOMAIN_ARG) {
  442. reverse_proxy app:$($script:APP_PORT)
  443. encode gzip
  444. }
  445. "@
  446. Write-ColorOutput "Caddyfile created (HTTPS mode with domain: $($script:DOMAIN_ARG))" -Type Success
  447. }
  448. else {
  449. # HTTP-only mode
  450. $caddyContent = @"
  451. :80 {
  452. reverse_proxy app:$($script:APP_PORT)
  453. encode gzip
  454. }
  455. "@
  456. Write-ColorOutput "Caddyfile created (HTTP-only mode)" -Type Success
  457. }
  458. try {
  459. Set-Content -Path "$DEPLOY_DIR\Caddyfile" -Value $caddyContent -Encoding UTF8
  460. }
  461. catch {
  462. Write-ColorOutput "Failed to write Caddyfile: $_" -Type Error
  463. exit 1
  464. }
  465. }
  466. function Write-EnvFile {
  467. Write-ColorOutput "Writing .env file..." -Type Info
  468. # Update mode: backup existing .env, then restore custom variables after writing
  469. $backupFile = $null
  470. if ($script:UPDATE_MODE -and (Test-Path "$($script:DEPLOY_DIR)\.env")) {
  471. $backupFile = "$($script:DEPLOY_DIR)\.env.bak"
  472. Copy-Item "$($script:DEPLOY_DIR)\.env" $backupFile
  473. Write-ColorOutput "Backed up existing .env to .env.bak" -Type Info
  474. }
  475. # Determine secure cookies setting based on Caddy and domain
  476. $secureCookies = "true"
  477. if ($script:ENABLE_CADDY -and -not $script:DOMAIN_ARG) {
  478. # HTTP-only Caddy mode - disable secure cookies
  479. $secureCookies = "false"
  480. }
  481. # If domain is set, APP_URL should use https
  482. $appUrl = ""
  483. if ($script:DOMAIN_ARG) {
  484. $appUrl = "https://$($script:DOMAIN_ARG)"
  485. }
  486. $envContent = @"
  487. # Admin Token (KEEP THIS SECRET!)
  488. ADMIN_TOKEN=$ADMIN_TOKEN
  489. # Database Configuration
  490. DB_USER=postgres
  491. DB_PASSWORD=$DB_PASSWORD
  492. DB_NAME=claude_code_hub
  493. # Application Configuration
  494. APP_PORT=$($script:APP_PORT)
  495. APP_URL=$appUrl
  496. # Auto Migration (enabled for first-time setup)
  497. AUTO_MIGRATE=true
  498. # Redis Configuration
  499. ENABLE_RATE_LIMIT=true
  500. # Session Configuration
  501. SESSION_TTL=300
  502. STORE_SESSION_MESSAGES=false
  503. STORE_SESSION_RESPONSE_BODY=true
  504. # Cookie Security
  505. ENABLE_SECURE_COOKIES=$secureCookies
  506. # Circuit Breaker Configuration
  507. ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS=false
  508. ENABLE_ENDPOINT_CIRCUIT_BREAKER=false
  509. # Environment
  510. NODE_ENV=production
  511. TZ=Asia/Shanghai
  512. LOG_LEVEL=info
  513. "@
  514. try {
  515. Set-Content -Path "$DEPLOY_DIR\.env" -Value $envContent -Encoding UTF8
  516. # Restore user custom variables from backup (variables not managed by this script)
  517. if ($backupFile -and (Test-Path $backupFile)) {
  518. $managedKeys = @(
  519. "ADMIN_TOKEN", "DB_USER", "DB_PASSWORD", "DB_NAME",
  520. "APP_PORT", "APP_URL", "AUTO_MIGRATE", "ENABLE_RATE_LIMIT",
  521. "SESSION_TTL", "STORE_SESSION_MESSAGES", "STORE_SESSION_RESPONSE_BODY",
  522. "ENABLE_SECURE_COOKIES", "ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS",
  523. "ENABLE_ENDPOINT_CIRCUIT_BREAKER", "NODE_ENV", "TZ", "LOG_LEVEL"
  524. )
  525. $customVars = Get-Content $backupFile | Where-Object {
  526. if (-not $_ -or -not $_.Trim() -or $_.TrimStart().StartsWith('#')) { return $false }
  527. $key = ($_ -split '=', 2)[0].Trim()
  528. return ($managedKeys -notcontains $key)
  529. }
  530. if ($customVars -and $customVars.Count -gt 0) {
  531. $customBlock = "`n# User Custom Configuration (preserved from previous deployment)`n" + ($customVars -join "`n")
  532. Add-Content -Path "$DEPLOY_DIR\.env" -Value $customBlock -Encoding UTF8
  533. Write-ColorOutput "Preserved $($customVars.Count) custom environment variables" -Type Info
  534. }
  535. }
  536. # W-015: Restrict .env file permissions (equivalent to chmod 600)
  537. # Remove inheritance and set owner-only access
  538. $envFile = "$DEPLOY_DIR\.env"
  539. $acl = Get-Acl $envFile
  540. $acl.SetAccessRuleProtection($true, $false) # Disable inheritance, don't copy existing rules
  541. $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
  542. $accessRule = New-Object System.Security.AccessControl.FileSystemAccessRule(
  543. $currentUser,
  544. "FullControl",
  545. "Allow"
  546. )
  547. $acl.SetAccessRule($accessRule)
  548. Set-Acl -Path $envFile -AclObject $acl
  549. Write-ColorOutput ".env file created" -Type Success
  550. }
  551. catch {
  552. Write-ColorOutput "Failed to write .env file: $_" -Type Error
  553. exit 1
  554. }
  555. }
  556. function Start-Services {
  557. Write-ColorOutput "Starting Docker services..." -Type Info
  558. try {
  559. Push-Location $DEPLOY_DIR
  560. docker compose pull
  561. if ($LASTEXITCODE -ne 0) {
  562. throw "Failed to pull Docker images"
  563. }
  564. docker compose up -d
  565. if ($LASTEXITCODE -ne 0) {
  566. throw "Failed to start services"
  567. }
  568. Pop-Location
  569. Write-ColorOutput "Docker services started" -Type Success
  570. }
  571. catch {
  572. Pop-Location
  573. Write-ColorOutput "Failed to start services: $_" -Type Error
  574. exit 1
  575. }
  576. }
  577. function Wait-ForHealth {
  578. Write-ColorOutput "Waiting for services to become healthy (max 60 seconds)..." -Type Info
  579. $maxAttempts = 12
  580. $attempt = 0
  581. Push-Location $DEPLOY_DIR
  582. while ($attempt -lt $maxAttempts) {
  583. $attempt++
  584. try {
  585. $postgresHealth = (docker inspect --format='{{.State.Health.Status}}' "claude-code-hub-db-$SUFFIX" 2>$null)
  586. $redisHealth = (docker inspect --format='{{.State.Health.Status}}' "claude-code-hub-redis-$SUFFIX" 2>$null)
  587. $appHealth = (docker inspect --format='{{.State.Health.Status}}' "claude-code-hub-app-$SUFFIX" 2>$null)
  588. if (-not $postgresHealth) { $postgresHealth = "unknown" }
  589. if (-not $redisHealth) { $redisHealth = "unknown" }
  590. if (-not $appHealth) { $appHealth = "unknown" }
  591. Write-ColorOutput "Health status - Postgres: $postgresHealth, Redis: $redisHealth, App: $appHealth" -Type Info
  592. if ($postgresHealth -eq "healthy" -and $redisHealth -eq "healthy" -and $appHealth -eq "healthy") {
  593. Pop-Location
  594. Write-ColorOutput "All services are healthy!" -Type Success
  595. return $true
  596. }
  597. }
  598. catch {
  599. # Continue waiting
  600. }
  601. if ($attempt -lt $maxAttempts) {
  602. Start-Sleep -Seconds 5
  603. }
  604. }
  605. Pop-Location
  606. Write-ColorOutput "Services did not become healthy within 60 seconds" -Type Warning
  607. Write-ColorOutput "You can check the logs with: cd $DEPLOY_DIR; docker compose logs -f" -Type Info
  608. return $false
  609. }
  610. function Get-NetworkAddresses {
  611. $addresses = @()
  612. try {
  613. $adapters = Get-NetIPAddress -AddressFamily IPv4 |
  614. Where-Object {
  615. $_.InterfaceAlias -notlike '*Loopback*' -and
  616. $_.InterfaceAlias -notlike '*Docker*' -and
  617. $_.IPAddress -notlike '169.254.*'
  618. }
  619. foreach ($adapter in $adapters) {
  620. $addresses += $adapter.IPAddress
  621. }
  622. }
  623. catch {
  624. # Silently continue
  625. }
  626. $addresses += "localhost"
  627. return $addresses
  628. }
  629. function Show-SuccessMessage {
  630. $addresses = Get-NetworkAddresses
  631. Write-Host ""
  632. Write-Host "+================================================================+" -ForegroundColor Green
  633. Write-Host "| |" -ForegroundColor Green
  634. if ($script:UPDATE_MODE) {
  635. Write-Host "| Claude Code Hub Updated Successfully! |" -ForegroundColor Green
  636. }
  637. else {
  638. Write-Host "| Claude Code Hub Deployed Successfully! |" -ForegroundColor Green
  639. }
  640. Write-Host "| |" -ForegroundColor Green
  641. Write-Host "+================================================================+" -ForegroundColor Green
  642. Write-Host ""
  643. Write-Host "Deployment Directory:" -ForegroundColor Blue
  644. Write-Host " $DEPLOY_DIR"
  645. Write-Host ""
  646. Write-Host "Access URLs:" -ForegroundColor Blue
  647. if ($script:ENABLE_CADDY) {
  648. if ($script:DOMAIN_ARG) {
  649. # HTTPS mode with domain
  650. Write-Host " https://$($script:DOMAIN_ARG)" -ForegroundColor Green
  651. }
  652. else {
  653. # HTTP-only Caddy mode
  654. foreach ($addr in $addresses) {
  655. Write-Host " http://${addr}" -ForegroundColor Green
  656. }
  657. }
  658. }
  659. else {
  660. # Direct app access
  661. foreach ($addr in $addresses) {
  662. Write-Host " http://${addr}:$($script:APP_PORT)" -ForegroundColor Green
  663. }
  664. }
  665. Write-Host ""
  666. # In update mode, skip printing the admin token (user already knows it)
  667. if (-not $script:UPDATE_MODE) {
  668. Write-Host "Admin Token (KEEP THIS SECRET!):" -ForegroundColor Blue
  669. Write-Host " $ADMIN_TOKEN" -ForegroundColor Yellow
  670. Write-Host ""
  671. }
  672. Write-Host "Usage Documentation:" -ForegroundColor Blue
  673. if ($script:ENABLE_CADDY -and $script:DOMAIN_ARG) {
  674. Write-Host " Chinese: https://$($script:DOMAIN_ARG)/zh-CN/usage-doc" -ForegroundColor Green
  675. Write-Host " English: https://$($script:DOMAIN_ARG)/en-US/usage-doc" -ForegroundColor Green
  676. }
  677. else {
  678. $firstAddr = $addresses[0]
  679. $portSuffix = ""
  680. if (-not $script:ENABLE_CADDY) {
  681. $portSuffix = ":$($script:APP_PORT)"
  682. }
  683. Write-Host " Chinese: http://${firstAddr}${portSuffix}/zh-CN/usage-doc" -ForegroundColor Green
  684. Write-Host " English: http://${firstAddr}${portSuffix}/en-US/usage-doc" -ForegroundColor Green
  685. }
  686. Write-Host ""
  687. Write-Host "Useful Commands:" -ForegroundColor Blue
  688. Write-Host " View logs: cd $DEPLOY_DIR; docker compose logs -f" -ForegroundColor Yellow
  689. Write-Host " Stop services: cd $DEPLOY_DIR; docker compose down" -ForegroundColor Yellow
  690. Write-Host " Restart: cd $DEPLOY_DIR; docker compose restart" -ForegroundColor Yellow
  691. if ($script:ENABLE_CADDY) {
  692. Write-Host ""
  693. Write-Host "Caddy Configuration:" -ForegroundColor Blue
  694. if ($script:DOMAIN_ARG) {
  695. Write-Host " Mode: HTTPS with Let's Encrypt (domain: $($script:DOMAIN_ARG))"
  696. Write-Host " Ports: 80 (HTTP redirect), 443 (HTTPS)"
  697. }
  698. else {
  699. Write-Host " Mode: HTTP-only reverse proxy"
  700. Write-Host " Port: 80"
  701. }
  702. }
  703. Write-Host ""
  704. if (-not $script:UPDATE_MODE) {
  705. Write-Host "IMPORTANT: Please save the admin token in a secure location!" -ForegroundColor Red
  706. }
  707. else {
  708. Write-Host "NOTE: Your existing secrets and custom configuration have been preserved." -ForegroundColor Blue
  709. }
  710. Write-Host ""
  711. }
  712. function Main {
  713. # Handle help flag first
  714. if ($Help) {
  715. Show-Help
  716. exit 0
  717. }
  718. # Initialize parameters from CLI args
  719. Initialize-Parameters
  720. Show-Header
  721. if (-not (Test-DockerInstalled)) {
  722. Show-DockerInstallInstructions
  723. exit 1
  724. }
  725. Select-Branch
  726. # Key branch: detect existing deployment
  727. if (Test-ExistingDeployment) {
  728. Write-ColorOutput "=== UPDATE MODE ===" -Type Info
  729. Write-ColorOutput "Updating existing deployment (secrets and custom config will be preserved)" -Type Info
  730. Get-SuffixFromCompose
  731. Import-ExistingEnv
  732. }
  733. else {
  734. Write-ColorOutput "=== FRESH INSTALL MODE ===" -Type Info
  735. New-RandomSuffix
  736. New-AdminToken
  737. New-DbPassword
  738. }
  739. New-DeploymentDirectory
  740. Write-ComposeFile
  741. Write-Caddyfile
  742. Write-EnvFile
  743. Start-Services
  744. $isHealthy = Wait-ForHealth
  745. if ($isHealthy) {
  746. Show-SuccessMessage
  747. }
  748. else {
  749. if ($script:UPDATE_MODE) {
  750. Write-ColorOutput "Update completed but some services may not be fully healthy yet" -Type Warning
  751. }
  752. else {
  753. Write-ColorOutput "Deployment completed but some services may not be fully healthy yet" -Type Warning
  754. }
  755. Write-ColorOutput "Please check the logs: cd $DEPLOY_DIR; docker compose logs -f" -Type Info
  756. Show-SuccessMessage
  757. }
  758. }
  759. # Run main function
  760. Main