# rfdev: `irm dev.nortico.pe | iex` turns this PC into the workstation for the # VPS. Safe to run again any time: it only installs what is missing, pulls the # latest code and Claude sessions from the VPS and opens http://localhost:8220. # While Claude works, hooks keep the VPS updated after every answer. # Everything lives in C:\rfdev; `irm dev.nortico.pe/stop | iex` removes it. $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $RfUrl = 'https://dev.nortico.pe' $Root = 'C:\rfdev' $Home2 = "$Root\home" $Tools = "$Root\tools" $Work = "$Root\work" $Cache = "$Root\cache" $Port = 8220 $Tar = "$env:SystemRoot\System32\tar.exe" function Say($msg) { Write-Host "==> $msg" -ForegroundColor Cyan } function Warn($msg) { Write-Host "!! $msg" -ForegroundColor Yellow } function Fail($msg) { Write-Host "XX $msg" -ForegroundColor Red; throw $msg } # curl.exe ships with Windows 10+ and is far faster than Invoke-WebRequest on PS 5.1. function Download($url, $file) { & "$env:SystemRoot\System32\curl.exe" -L --fail --retry 3 --progress-bar -o $file $url if ($LASTEXITCODE -ne 0) { Fail "Descarga fallida: $url" } } function Run($exe, [string[]]$argv) { & $exe @argv if ($LASTEXITCODE -ne 0) { Fail "$exe $($argv -join ' ') (exit $LASTEXITCODE)" } } if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitOperatingSystem) { Fail 'Se necesita Windows de 64 bits.' } if (-not (Test-Path $Tar)) { Fail 'Falta tar.exe (Windows 10 1803 o superior).' } if ([math]::Round((Get-PSDrive C).Free / 1GB) -lt 10) { Fail 'Se necesitan al menos 10 GB libres en C:.' } # ---------------------------------------------------------------- key $sec = Read-Host 'Clave' -AsSecureString $plain = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)) $pcName = ($env:COMPUTERNAME -replace '[^A-Za-z0-9_-]', '') try { $body = @{ password = $plain; pc = $pcName } | ConvertTo-Json $s = Invoke-RestMethod -Method Post -Uri "$RfUrl/api/start" -ContentType 'application/json' -Body $body } catch { Fail "No se pudo entrar: $($_.ErrorDetails.Message)" } finally { $plain = $null } foreach ($d in @($Root, $Home2, "$Home2\.ssh", "$Home2\.claude", $Tools, $Work, $Cache, "$Root\bin")) { New-Item -ItemType Directory -Force -Path $d | Out-Null } $keyFile = "$Home2\.ssh\id_rfdev" if (Test-Path $keyFile) { & icacls.exe $keyFile /reset | Out-Null; Remove-Item $keyFile -Force } [IO.File]::WriteAllText($keyFile, ($s.privateKey -replace "`r", '')) & icacls.exe $keyFile /inheritance:r /grant:r "$($env:USERNAME):(R)" | Out-Null [IO.File]::WriteAllText("$Home2\.ssh\known_hosts", "$($s.sshHost) $($s.hostKey)`n") [IO.File]::WriteAllText("$Home2\.ssh\config", @" Host rfvps HostName $($s.sshHost) User root IdentityFile ~/.ssh/id_rfdev IdentitiesOnly yes UserKnownHostsFile ~/.ssh/known_hosts StrictHostKeyChecking yes ServerAliveInterval 30 "@ -replace "`r", '') @{ keyId = $s.keyId; expires = $s.expires; pc = $pcName } | ConvertTo-Json | Set-Content -Encoding utf8 "$Root\session.json" # ---------------------------------------------------------------- tools (first time only) $nodeDir = "$Tools\node-v$($s.nodeVersion)-win-x64" if (-not (Test-Path "$nodeDir\node.exe")) { Say "Descargando Node $($s.nodeVersion)" Download "https://nodejs.org/dist/v$($s.nodeVersion)/node-v$($s.nodeVersion)-win-x64.zip" "$Cache\node.zip" Run $Tar @('-xf', "$Cache\node.zip", '-C', $Tools) Remove-Item "$Cache\node.zip" } # Git for Windows also provides the bash Claude Code and the sync hook use. $gitDir = "$Tools\git" if (-not (Test-Path "$gitDir\bin\bash.exe")) { Say 'Descargando Git portable' $rel = Invoke-RestMethod 'https://api.github.com/repos/git-for-windows/git/releases/latest' -Headers @{ 'User-Agent' = 'rfdev' } $asset = $rel.assets | Where-Object { $_.name -match '^PortableGit-.*-64-bit\.7z\.exe$' } | Select-Object -First 1 if (-not $asset) { Fail 'No encontre PortableGit en GitHub.' } $sfx = "$Cache\$($asset.name)" Download $asset.browser_download_url $sfx Say 'Extrayendo Git portable (1-2 min)' $p = Start-Process $sfx -ArgumentList @("-o`"$gitDir`"", '-y') -PassThru if (-not $p.WaitForExit(600000)) { $p.Kill(); Fail 'La extraccion de Git se colgo.' } if (-not (Test-Path "$gitDir\bin\bash.exe")) { Fail 'No se pudo extraer Git portable.' } Remove-Item $sfx } # Process-scoped environment only: nothing goes to the profile, registry or global PATH. Set-Content -Encoding utf8 "$Root\env.ps1" @" `$env:HOME = '$Home2' `$env:USERPROFILE = '$Home2' `$env:WORKSPACES_ROOT = '$Work' `$env:CLAUDE_CONFIG_DIR = '$Home2\.claude' `$env:CLAUDE_CODE_GIT_BASH_PATH = '$gitDir\bin\bash.exe' `$env:npm_config_prefix = '$Tools\npm' `$env:npm_config_cache = '$Cache\npm' `$env:npm_config_store_dir = '$Cache\pnpm-store' `$env:GIT_CONFIG_GLOBAL = '$Home2\.gitconfig' `$env:GIT_SSH_COMMAND = 'ssh -F ~/.ssh/config' `$env:Path = '$Tools\npm;$nodeDir;$gitDir\cmd;$gitDir\usr\bin;' + `$env:Path "@ . "$Root\env.ps1" if (-not (Test-Path "$Home2\.gitconfig")) { git config --global user.name 'Ricardo Fuentes' git config --global user.email 'ricardoenzofuentesgomez@gmail.com' git config --global core.autocrlf false } Say 'Conectando con el VPS' Run ssh @('rfvps', 'true') if (-not (Test-Path "$Tools\npm\claude.cmd") -or -not (Test-Path "$Tools\npm\node_modules\@cloudcli-ai\cloudcli")) { Say 'Instalando Claude Code y CloudCLI (primera vez, varios minutos)' Run npm.cmd @('install', '-g', '--no-fund', '--no-audit', "pnpm@$($s.pnpmVersion)", '@anthropic-ai/claude-code', '@cloudcli-ai/cloudcli') } Download "$RfUrl/sync.sh" "$Root\bin\rfdev-sync.sh" # ---------------------------------------------------------------- code foreach ($r in $s.repos) { $dest = "$Work\$($r.name)" Run ssh @('rfvps', '/opt/rfdev/bin/rfdev', 'snapshot', $r.name) if (-not (Test-Path "$dest\.git")) { Say "Descargando codigo: $($r.name)" Run git @('clone', '--quiet', "rfvps:$($r.path)", $dest) } else { Say "Actualizando codigo: $($r.name)" & git -C $dest add -A & git -C $dest diff --cached --quiet if ($LASTEXITCODE -ne 0) { Run git @('-C', $dest, 'commit', '-q', '-m', "WIP $pcName $(Get-Date -Format s)") } Run git @('-C', $dest, 'fetch', '--quiet', 'origin') # History on the VPS was rebuilt: keep the old folder aside and clone again. & git -C $dest merge-base HEAD origin/main 2>$null | Out-Null if ($LASTEXITCODE -ne 0) { $aside = "$dest.old-$(Get-Date -Format yyyyMMddHHmmss)" Warn " el historial del VPS cambio; guardo la copia anterior en $aside" Move-Item $dest $aside Run git @('clone', '--quiet', "rfvps:$($r.path)", $dest) } & git -C $dest merge -q --no-edit origin/main if ($LASTEXITCODE -ne 0) { & git -C $dest merge --abort; Warn " conflicto con cambios del VPS en $($r.name); pidele a Claude que lo resuelva" } } # Work pushed from a PC that could not be put live yet (conflict) is on wip. & git -C $dest rev-parse --verify -q origin/wip 2>$null | Out-Null if ($LASTEXITCODE -eq 0) { & git -C $dest merge-base --is-ancestor origin/wip HEAD if ($LASTEXITCODE -ne 0) { & git -C $dest merge -q --no-edit origin/wip if ($LASTEXITCODE -ne 0) { & git -C $dest merge --abort; Warn " hay trabajo pendiente en la rama wip de $($r.name) que choca; pidele a Claude que lo mezcle" } } } # Dependencies only when the lockfile changed since the last install here. $lock = "$dest\pnpm-lock.yaml" if (Test-Path $lock) { $hash = (Get-FileHash $lock).Hash $stamp = "$dest\node_modules\.rfdev-lock" if (-not (Test-Path $stamp) -or (Get-Content $stamp -Raw).Trim() -ne $hash) { Say ' instalando dependencias (la primera vez tarda varios minutos)' Push-Location $dest & pnpm.cmd install --frozen-lockfile $ok = ($LASTEXITCODE -eq 0) Pop-Location if ($ok) { Set-Content $stamp $hash } else { Warn " pnpm install fallo en $($r.name); Claude igual puede leer y editar el codigo" } } } } Set-Content -Encoding utf8 "$Work\CLAUDE.md" @" # rfdev workspace This PC is a workstation for the VPS. Production and all secrets live on the VPS. - VPS shell: ``ssh rfvps ''`` (root). Vault: ``/root/.claude/secrets/`` on the VPS; read values over ssh when needed, never copy them into this folder or the repos. - Each folder here is a clone of a live tree on the VPS (``git remote -v``). After every answer a hook commits, merges VPS changes and puts this work into the live tree on the VPS (C:/rfdev/sync.log). If the log shows CONFLICT, resolve it here and commit. - Builds and deploys that ship to production run on the VPS with the project's usual deploy scripts, over ssh. - The same sessions continue on the VPS at http://2.24.80.68:8220 (project /root/rfdev-work). "@ # Hooks: push after every answer and at session end, pull before a resume. $hook = 'bash /c/rfdev/bin/rfdev-sync.sh' @{ hooks = @{ Stop = @(@{ hooks = @(@{ type = 'command'; command = "$hook push"; async = $true }) }) SessionEnd = @(@{ hooks = @(@{ type = 'command'; command = "$hook push"; timeout = 60 }) }) SessionStart = @(@{ matcher = 'resume'; hooks = @(@{ type = 'command'; command = "$hook pull"; timeout = 60 }) }) } } | ConvertTo-Json -Depth 8 | Set-Content -Encoding ascii "$Home2\.claude\settings.json" # ---------------------------------------------------------------- sessions Say 'Trayendo sesiones de Claude' & "$gitDir\bin\bash.exe" -c '/c/rfdev/bin/rfdev-sync.sh pull /dev/null') -join '').Trim() if (-not $tok) { Say 'Configuracion unica: autoriza Claude en el navegador que se abre' & claude.cmd setup-token $tsec = Read-Host 'Copia el token que aparecio arriba (empieza con sk-ant-oat) y pegalo aqui' -AsSecureString $tok = ([Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($tsec))).Trim() if (-not $tok.StartsWith('sk-ant-')) { Fail 'Ese no parece un token de Claude; vuelve a correr el comando.' } $tok | & ssh rfvps 'umask 077; cat > /root/rfdev-state/claude-oauth-token' if ($LASTEXITCODE -ne 0) { Fail 'No se pudo guardar el token en el VPS.' } Say 'Listo: ninguna PC volvera a pedir inicio de sesion' } $env:CLAUDE_CODE_OAUTH_TOKEN = $tok Add-Content -Encoding utf8 "$Root\env.ps1" "`$env:CLAUDE_CODE_OAUTH_TOKEN = '$tok'" # ---------------------------------------------------------------- run $running = $false if (Test-Path "$Root\cloudcli.pid") { $old = (Get-Content "$Root\cloudcli.pid" -Raw).Trim() if ($old -and (Get-Process -Id $old -ErrorAction SilentlyContinue)) { $running = $true } } if (-not $running) { $env:SERVER_PORT = "$Port" $env:HOST = '127.0.0.1' $env:DATABASE_PATH = "$Home2\cloudcli\auth.db" $env:CLAUDE_CLI_PATH = "$Tools\npm\claude.cmd" New-Item -ItemType Directory -Force -Path "$Home2\cloudcli" | Out-Null $cli = "$Tools\npm\node_modules\@cloudcli-ai\cloudcli\dist-server\server\index.js" $proc = Start-Process "$nodeDir\node.exe" -ArgumentList "`"$cli`"" -WorkingDirectory $Work -WindowStyle Hidden -PassThru ` -RedirectStandardOutput "$Root\cloudcli.log" -RedirectStandardError "$Root\cloudcli.err.log" Set-Content "$Root\cloudcli.pid" $proc.Id for ($i = 0; $i -lt 60; $i++) { Start-Sleep -Seconds 1 try { Invoke-WebRequest "http://127.0.0.1:$Port" -UseBasicParsing -TimeoutSec 2 | Out-Null; $running = $true; break } catch {} } } if ($running) { Start-Process "http://localhost:$Port" Write-Host '' Write-Host "Listo: http://localhost:$Port (proyecto $Work)" -ForegroundColor Green } else { Warn "CloudCLI no arranco; revisa $Root\cloudcli.err.log" }