[CmdletBinding(DefaultParameterSetName = "Install")] param( [ValidateSet("stable", "beta", "canary")][string]$Channel = "stable", [Parameter(ParameterSetName = "Rollback", Mandatory = $true)][switch]$Rollback, [Parameter(ParameterSetName = "Uninstall", Mandatory = $true)][switch]$Uninstall, [Parameter(ParameterSetName = "Diagnose", Mandatory = $true)][switch]$Diagnose, [Parameter(ParameterSetName = "CheckUpdates", Mandatory = $true)][switch]$CheckForUpdates, [string]$InstallRoot = (Join-Path $env:USERPROFILE ".iabit-dev\client"), [string]$WorkspacePath, [string]$PackageBaseUrl = "https://dev.iabit.net/client-package", [switch]$InstallExtensions, [switch]$InstallContinue ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $bootstrapVersion = [version]"1.1.0" $clineExtension = "saoudrizwan.claude-dev" $continueExtension = "Continue.continue" $localPackageCandidate = Join-Path $PSScriptRoot "..\client" $packageRoot = if (Test-Path -LiteralPath $localPackageCandidate) { (Resolve-Path -LiteralPath $localPackageCandidate).Path } else { $null } $currentPath = Join-Path $InstallRoot "current" $backupRoot = Join-Path $InstallRoot "backups" $statePath = Join-Path $InstallRoot "state.json" # --------------------------------------------------------------------------- # Autenticidade do manifest # # O SHA-256 de cada arquivo prova que o arquivo nao foi trocado *depois* que o # manifest foi escrito; nao prova que o manifest veio de nos. Sem assinatura, # quem controlasse a origem estatica poderia servir um manifest apontando para # conteudo arbitrario com os hashes correspondentes, e este bootstrap gravaria # esse conteudo como regra no workspace do desenvolvedor. # # RSA-2048/SHA-256 e nao Ed25519: o verificador aqui e o Windows PowerShell 5.1 # sobre .NET Framework, que nao tem Ed25519. Assinar com algoritmo que este # script nao consegue conferir seria teatro. # --------------------------------------------------------------------------- $manifestPublicKey = @{ keyId = "de1340c415e90271" modulus = "1cTpqGtxnvPqcbrB6KspxFijLqKNDQgRHtui2UBrrPpjro33jX/BbxQXqXwFM4Kxl1RiaCE55qRwFzKztyX5Tqtsq52JDE6ff3s/FXB9JRZE+2elOgPAGL6o9NjaIkT++KelU5QsyB5yLAEa0OUqFzGjWhGQoYV8J7OoQgDNbP7Diiey73jp5dFkKoBVoN2vcTudB1tTjUlCbbPymLYiFZKiltjomFpsX+ZiQzEYsQCxEmXyO4kxL9CFN+KoofMFcd3AWI2Ab/QoIE+RQSwC27DVNRNfLqxL0AZLBpld/TeD5ma1Ki2amULC3DQEcLCteXo5QvAb6ykVnd43c9zdjw==" exponent = "AQAB" } function Get-ManifestPayload($Manifest) { # Texto deterministico com o que importa: canal, versao e a lista ordenada # de hash -> destino. Reproduzir JSON canonico em PowerShell seria fragil, e # uma divergencia silenciosa transformaria a verificacao em ruido. $linhas = New-Object System.Collections.Generic.List[string] $linhas.Add("iabit-manifest-v1") $linhas.Add("channel=$($Manifest.channel)") $linhas.Add("version=$($Manifest.version)") $linhas.Add("minimumBootstrapVersion=$($Manifest.minimumBootstrapVersion)") $linhas.Add("schemaVersion=$($Manifest.schemaVersion)") foreach ($arquivo in ($Manifest.files | Sort-Object -Property target)) { $linhas.Add("$($arquivo.sha256.ToLowerInvariant()) $($arquivo.target)") } return ($linhas -join "`n") + "`n" } function Assert-ManifestSignature($Manifest) { if (-not $Manifest.PSObject.Properties.Name.Contains("signature")) { throw "Manifest sem assinatura; distribuicao recusada." } $assinatura = $Manifest.signature if ($assinatura.algorithm -ne "rsa-pkcs1-sha256") { throw "Algoritmo de assinatura nao suportado: $($assinatura.algorithm)." } if ($assinatura.keyId -ne $manifestPublicKey.keyId) { throw "Manifest assinado por outra chave ($($assinatura.keyId)); distribuicao recusada." } $rsa = New-Object System.Security.Cryptography.RSACryptoServiceProvider try { $parametros = New-Object System.Security.Cryptography.RSAParameters $parametros.Modulus = [Convert]::FromBase64String($manifestPublicKey.modulus) $parametros.Exponent = [Convert]::FromBase64String($manifestPublicKey.exponent) $rsa.ImportParameters($parametros) $bytes = [Text.Encoding]::UTF8.GetBytes((Get-ManifestPayload $Manifest)) $sha = [System.Security.Cryptography.SHA256]::Create() try { if (-not $rsa.VerifyData($bytes, $sha, [Convert]::FromBase64String($assinatura.value))) { throw "Assinatura do manifest invalida; distribuicao recusada." } } finally { $sha.Dispose() } } finally { $rsa.Dispose() } } function Get-CodeCli { $candidate = Get-Command code -ErrorAction SilentlyContinue if ($candidate) { return $candidate.Source } return $null } function Get-ExtensionIds([string]$CodeCli) { if (-not $CodeCli) { return @() } return @(& $CodeCli --list-extensions 2>$null | ForEach-Object { $_.ToLowerInvariant() }) } function Write-State([hashtable]$State) { $State.updatedAtUtc = [DateTime]::UtcNow.ToString("o") $State | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $statePath -Encoding UTF8 } function Install-WorkspaceConfig([string]$Root, $PreviousWorkspaceState) { if (-not $Root) { return $null } $resolvedRoot = (Resolve-Path -LiteralPath $Root).Path $rulesPath = Join-Path $resolvedRoot ".clinerules" New-Item -ItemType Directory -Path $rulesPath -Force | Out-Null $managedFiles = @() $previousFiles = @{} if ($PreviousWorkspaceState -and ($PreviousWorkspaceState.PSObject.Properties.Name -contains "files")) { foreach ($previous in $PreviousWorkspaceState.files) { $previousFiles[[string]$previous.path] = $previous } } $desiredPaths = @{} foreach ($source in Get-ChildItem -LiteralPath (Join-Path $currentPath "cline") -File) { $destination = Join-Path $rulesPath $source.Name $desiredPaths[$destination] = $true if (Test-Path -LiteralPath $destination) { $sourceHash = (Get-FileHash $source.FullName -Algorithm SHA256).Hash.ToLowerInvariant() $destinationHash = (Get-FileHash $destination -Algorithm SHA256).Hash.ToLowerInvariant() if ($sourceHash -eq $destinationHash) { $action = if ($previousFiles.ContainsKey($destination) -and $previousFiles[$destination].action -eq "created") { "created" } else { "preserved" } $managedFiles += @{ path = $destination; action = $action; sha256 = $destinationHash } } elseif ($previousFiles.ContainsKey($destination) -and $previousFiles[$destination].action -eq "created" -and $previousFiles[$destination].sha256 -eq $destinationHash) { Copy-Item -LiteralPath $source.FullName -Destination $destination -Force $managedFiles += @{ path = $destination; action = "created"; sha256 = $sourceHash } } else { Write-Warning "$destination ja existe e foi preservado." $managedFiles += @{ path = $destination; action = "preserved"; sha256 = $destinationHash } } continue } Copy-Item -LiteralPath $source.FullName -Destination $destination $managedFiles += @{ path = $destination; action = "created"; sha256 = (Get-FileHash $destination -Algorithm SHA256).Hash.ToLowerInvariant() } } foreach ($previous in $previousFiles.Values) { if ($previous.action -ne "created" -or $desiredPaths.ContainsKey([string]$previous.path) -or -not (Test-Path -LiteralPath $previous.path)) { continue } $actual = (Get-FileHash -LiteralPath $previous.path -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -eq $previous.sha256) { Remove-Item -LiteralPath $previous.path } else { Write-Warning "$($previous.path) nao faz mais parte do pacote, mas foi alterado e foi preservado." } } return @{ root = $resolvedRoot; files = $managedFiles } } function Remove-ManagedWorkspaceConfig($WorkspaceState) { if (-not $WorkspaceState -or -not ($WorkspaceState.PSObject.Properties.Name -contains "files")) { return } foreach ($file in $WorkspaceState.files) { if ($file.action -ne "created" -or -not (Test-Path -LiteralPath $file.path)) { continue } $actual = (Get-FileHash -LiteralPath $file.path -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -eq $file.sha256) { Remove-Item -LiteralPath $file.path } else { Write-Warning "$($file.path) foi alterado e foi preservado." } } } $existingState = $null if (Test-Path -LiteralPath $statePath) { $existingState = Get-Content -Raw -LiteralPath $statePath | ConvertFrom-Json } if ($CheckForUpdates) { if ($packageRoot) { $availableManifest = Get-Content -Raw (Join-Path $packageRoot "channels\$Channel.json") | ConvertFrom-Json } else { $packageUri = [Uri]$PackageBaseUrl if ($packageUri.Scheme -ne "https" -and -not ($packageUri.IsLoopback -and $packageUri.Scheme -eq "http")) { throw "PackageBaseUrl deve usar HTTPS (HTTP e aceito apenas em loopback para testes)." } $availableManifest = Invoke-RestMethod -Uri "$($PackageBaseUrl.TrimEnd('/'))/channels/$Channel.json" } Assert-ManifestSignature $availableManifest if ($availableManifest.schemaVersion -ne 1) { throw "Schema de manifest nao suportado." } $installedVersion = if ($existingState -and ($existingState.PSObject.Properties.Name -contains "version")) { [string]$existingState.version } else { $null } [pscustomobject]@{ Channel = $Channel InstalledVersion = if ($installedVersion) { $installedVersion } else { "nao instalado" } AvailableVersion = [string]$availableManifest.version UpdateAvailable = if (-not $installedVersion) { $true } else { [version]$availableManifest.version -gt [version]$installedVersion } Action = "execute novamente sem -CheckForUpdates para atualizar" } | Format-List exit 0 } if ($Diagnose) { $codeCli = Get-CodeCli $extensions = Get-ExtensionIds $codeCli $resolvedWorkspace = if ($WorkspacePath) { (Resolve-Path $WorkspacePath).Path } else { $null } [pscustomobject]@{ CodeCli = if ($codeCli) { $codeCli } else { "NAO ENCONTRADO" } Cline = if ($extensions -contains $clineExtension.ToLowerInvariant()) { "instalado" } else { "ausente" } Continue = if ($extensions -contains $continueExtension.ToLowerInvariant()) { "instalado" } else { "ausente/opcional" } ClientPackage = if (Test-Path $currentPath) { "instalado" } else { "ausente" } Workspace = if ($resolvedWorkspace) { $resolvedWorkspace } else { "nao informado" } ClineRules = if ($resolvedWorkspace -and (Test-Path (Join-Path $resolvedWorkspace ".clinerules"))) { "presente" } else { "ausente" } ApiProfile = "verifique na UI do Cline; segredos nao sao inspecionados" } | Format-List exit 0 } if ($Uninstall) { if ($existingState -and ($existingState.PSObject.Properties.Name -contains "workspace")) { Remove-ManagedWorkspaceConfig $existingState.workspace } if (Test-Path -LiteralPath $InstallRoot) { Remove-Item -LiteralPath $InstallRoot -Recurse -Force } Write-Host "Arquivos gerenciados removidos. Extensoes e dados do usuario foram preservados." -ForegroundColor Green exit 0 } New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null if ($Rollback) { $backup = Get-ChildItem -LiteralPath $backupRoot -Directory | Where-Object { $_.Name -match '^\d{17}$' } | Sort-Object Name -Descending | Select-Object -First 1 if (-not $backup) { throw "Nao ha backup disponivel para rollback." } $rollbackWorkspace = $null if ($existingState -and ($existingState.PSObject.Properties.Name -contains "workspace")) { $rollbackWorkspace = $existingState.workspace.root Remove-ManagedWorkspaceConfig $existingState.workspace } $displaced = $null if (Test-Path $currentPath) { $displaced = Join-Path $backupRoot ("rollback-displaced-" + (Get-Date -Format "yyyyMMddHHmmssfff")) Move-Item $currentPath $displaced } try { Move-Item $backup.FullName $currentPath } catch { if ($displaced -and (Test-Path $displaced)) { Move-Item $displaced $currentPath } throw } $restoredWorkspaceState = Install-WorkspaceConfig $rollbackWorkspace $null Write-State @{ action = "rollback"; restoredBackup = $backup.Name; workspace = $restoredWorkspaceState } Write-Host "Rollback concluido: $($backup.Name)" -ForegroundColor Green exit 0 } $downloadRoot = $null if ($packageRoot) { $manifest = Get-Content -Raw (Join-Path $packageRoot "channels\$Channel.json") | ConvertFrom-Json Assert-ManifestSignature $manifest } else { $packageUri = [Uri]$PackageBaseUrl if ($packageUri.Scheme -ne "https" -and -not ($packageUri.IsLoopback -and $packageUri.Scheme -eq "http")) { throw "PackageBaseUrl deve usar HTTPS (HTTP e aceito apenas em loopback para testes)." } $downloadRoot = Join-Path ([IO.Path]::GetTempPath()) ("iabit-client-package-" + [guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path (Join-Path $downloadRoot "channels") -Force | Out-Null $manifestUrl = "$($PackageBaseUrl.TrimEnd('/'))/channels/$Channel.json" Invoke-WebRequest -UseBasicParsing -Uri $manifestUrl -OutFile (Join-Path $downloadRoot "channels\$Channel.json") $manifest = Get-Content -Raw (Join-Path $downloadRoot "channels\$Channel.json") | ConvertFrom-Json # Antes de baixar qualquer arquivo: e o manifest que dita as URLs. Assert-ManifestSignature $manifest foreach ($file in $manifest.files) { if ($file.source -notmatch '^[A-Za-z0-9_./-]+$' -or $file.source.Contains("..")) { throw "Caminho remoto invalido." } if ($file.target -notmatch '^[A-Za-z0-9_./-]+$' -or $file.target.Contains("..")) { throw "Destino remoto invalido." } $destination = Join-Path $downloadRoot $file.source New-Item -ItemType Directory -Path (Split-Path $destination -Parent) -Force | Out-Null Invoke-WebRequest -UseBasicParsing -Uri "$($PackageBaseUrl.TrimEnd('/'))/$($file.source)" -OutFile $destination } $packageRoot = $downloadRoot } if ($manifest.schemaVersion -ne 1) { throw "Schema de manifest nao suportado." } if ([version]$manifest.minimumBootstrapVersion -gt $bootstrapVersion) { throw "Bootstrap antigo para este pacote." } $sameChannel = $existingState -and ($existingState.PSObject.Properties.Name -contains "channel") -and $existingState.channel -eq $Channel $sameVersion = $existingState -and ($existingState.PSObject.Properties.Name -contains "version") -and $existingState.version -eq $manifest.version if (-not ($sameChannel -and $sameVersion -and (Test-Path $currentPath))) { $stagePath = Join-Path $InstallRoot ("stage-" + [guid]::NewGuid().ToString("N")) New-Item -ItemType Directory $stagePath | Out-Null try { foreach ($file in $manifest.files) { if ($file.source -notmatch '^[A-Za-z0-9_./-]+$' -or $file.source.Contains("..") -or $file.target -notmatch '^[A-Za-z0-9_./-]+$' -or $file.target.Contains("..")) { throw "Manifest contem caminho invalido." } $source = (Resolve-Path (Join-Path $packageRoot $file.source)).Path $packagePrefix = $packageRoot.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar if (-not $source.StartsWith($packagePrefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Origem fora do pacote." } $actual = (Get-FileHash $source -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -ne $file.sha256.ToLowerInvariant()) { throw "Hash invalido para $($file.source)." } $destination = Join-Path $stagePath $file.target New-Item -ItemType Directory (Split-Path $destination -Parent) -Force | Out-Null Copy-Item $source $destination } if (Test-Path $currentPath) { Move-Item $currentPath (Join-Path $backupRoot (Get-Date -Format "yyyyMMddHHmmssfff")) } Move-Item $stagePath $currentPath } finally { if (Test-Path $stagePath) { Remove-Item $stagePath -Recurse -Force } } } $codeCli = Get-CodeCli if ($InstallExtensions) { if (-not $codeCli) { throw "CLI 'code' nao encontrado no PATH." } $installed = Get-ExtensionIds $codeCli if ($installed -notcontains $clineExtension.ToLowerInvariant()) { & $codeCli --install-extension $clineExtension if ($LASTEXITCODE -ne 0) { throw "Falha ao instalar a extensao Cline." } } if ($InstallContinue -and $installed -notcontains $continueExtension.ToLowerInvariant()) { & $codeCli --install-extension $continueExtension if ($LASTEXITCODE -ne 0) { throw "Falha ao instalar a extensao Continue." } } } $previousWorkspace = if ($existingState -and ($existingState.PSObject.Properties.Name -contains "workspace")) { $existingState.workspace } else { $null } $effectiveWorkspacePath = if ($WorkspacePath) { $WorkspacePath } elseif ($previousWorkspace) { $previousWorkspace.root } else { $null } $workspaceState = Install-WorkspaceConfig $effectiveWorkspacePath $previousWorkspace Write-State @{ action = "install"; channel = $Channel; version = $manifest.version; workspace = $workspaceState } Write-Host "IABit Client $($manifest.version) ($Channel) pronto em $currentPath" -ForegroundColor Green Write-Host "Crie o perfil OpenAI-compatible IABit na UI do Cline; o bootstrap nunca grava o token." -ForegroundColor Yellow if ($downloadRoot -and (Test-Path -LiteralPath $downloadRoot)) { Remove-Item -LiteralPath $downloadRoot -Recurse -Force }