Files
reverse-skill/skills/scripts/bootstrap-reverse.ps1
T

1124 lines
39 KiB
PowerShell

#requires -Version 5
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string[]]$Capability,
[switch]$SkipRefresh,
[switch]$StartServices,
[ValidateSet('Claude', 'Codex', 'Both')]
[string]$McpHostTarget = 'Both'
)
# 临时目录统一入口($env:TEMP 在 Linux/macOS 上可能未设置)
$tmpBase = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() }
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
$OutputEncoding = [System.Text.UTF8Encoding]::new($false)
. (Join-Path $PSScriptRoot 'lib\ToolDiscovery.ps1')
. (Join-Path $PSScriptRoot 'lib\BootstrapSupplyChain.ps1')
function Get-BootstrapDependency {
param([Parameter(Mandatory = $true)][string]$Name)
$manifest = Get-Content -LiteralPath (Get-ReverseBootstrapManifestPath) -Raw -Encoding UTF8 | ConvertFrom-Json
$dependency = $manifest.bootstrapDependencies.PSObject.Properties[$Name].Value
if ($null -eq $dependency -or [string]::IsNullOrWhiteSpace([string]$dependency.package) -or [string]::IsNullOrWhiteSpace([string]$dependency.version)) {
throw "bootstrapDependencies.$Name must define package and version."
}
return $dependency
}
$Capability = @(
foreach ($item in @($Capability)) {
if ([string]::IsNullOrWhiteSpace($item)) {
continue
}
foreach ($name in ($item -split ',')) {
$trimmed = $name.Trim()
if (-not [string]::IsNullOrWhiteSpace($trimmed)) {
$trimmed
}
}
}
) | Select-Object -Unique
function Get-FirstCommandPath {
param(
[string[]]$Names,
[switch]$PreferApplication
)
foreach ($name in $Names) {
$commands = @(Get-Command $name -All -ErrorAction SilentlyContinue)
if ($PreferApplication) {
$preferred = $commands | Where-Object { $_.CommandType -eq 'Application' } | Select-Object -First 1
if ($preferred) {
return $preferred.Source
}
}
$cmd = $commands | Select-Object -First 1
if ($cmd) {
return $cmd.Source
}
}
return ''
}
function Get-PreferredPowerShellPath {
$pwsh = Get-FirstCommandPath -Names @('pwsh.exe', 'pwsh') -PreferApplication
if (-not [string]::IsNullOrWhiteSpace($pwsh)) {
return $pwsh
}
return Get-FirstCommandPath -Names @('powershell.exe', 'powershell') -PreferApplication
}
function Get-NodeCommandPath {
param([Parameter(Mandatory = $true)][string]$Name)
return Get-FirstCommandPath -Names @("$Name.cmd", "$Name.exe", $Name) -PreferApplication
}
function Test-SuccessExitCode {
param([int]$ExitCode)
return $ExitCode -in @(0, 3010, 1641)
}
function Get-McpHostTargets {
switch ($McpHostTarget) {
'Claude' { return @('Claude') }
'Codex' { return @('Codex') }
default { return @('Claude', 'Codex') }
}
}
function Test-ReverseIsWindows {
return $env:OS -eq 'Windows_NT'
}
function Test-ReverseIsElevated {
if (-not (Test-ReverseIsWindows)) {
return $true
}
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Ensure-WingetPackage {
param(
[Parameter(Mandatory = $true)][string]$Id,
[Parameter(Mandatory = $true)][string]$Label,
[string]$Override
)
$winget = Get-FirstCommandPath -Names @('winget')
if ([string]::IsNullOrWhiteSpace($winget)) {
throw "Cannot auto-install $Label because winget is not available."
}
$arguments = @(
'install',
'--id', $Id,
'--source', 'winget',
'--accept-package-agreements',
'--accept-source-agreements',
'--disable-interactivity'
)
if (-not [string]::IsNullOrWhiteSpace($Override)) {
$arguments += @('--override', $Override)
}
& $winget @arguments
if (-not (Test-SuccessExitCode -ExitCode $LASTEXITCODE)) {
throw "winget install failed for $Label ($Id)."
}
}
function Ensure-NodeRuntime {
if (-not (Get-FirstCommandPath -Names @('node'))) {
Ensure-WingetPackage -Id 'OpenJS.NodeJS.22' -Label 'Node.js 22'
}
if (-not (Get-NodeCommandPath -Name 'npx')) {
Ensure-WingetPackage -Id 'OpenJS.NodeJS.22' -Label 'Node.js 22'
}
}
function Ensure-PythonRuntime {
if (-not (Get-FirstCommandPath -Names @('python', 'python3'))) {
Ensure-WingetPackage -Id 'Python.Python.3.13' -Label 'Python 3.13'
}
}
function Ensure-JavaRuntime {
if (-not (Get-FirstCommandPath -Names @('java'))) {
Ensure-WingetPackage -Id 'Microsoft.OpenJDK.21' -Label 'OpenJDK 21'
}
}
function Get-AnythingAnalyzerUserDataPaths {
$candidates = @(
(Join-Path $env:APPDATA 'anything-analyzer'),
(Join-Path $env:APPDATA 'Anything Analyzer')
)
$existing = @($candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Container })
if ($existing.Count -gt 0) {
return @($existing | Sort-Object -Unique)
}
return @($candidates | Sort-Object -Unique)
}
function Ensure-AnythingAnalyzerMcpConfig {
param([int]$Port = 23816)
$token = ''
foreach ($userDataPath in Get-AnythingAnalyzerUserDataPaths) {
$configPath = Join-Path $userDataPath 'mcp-server-config.json'
if (-not (Test-Path -LiteralPath $configPath)) {
continue
}
try {
$existing = Get-Content -LiteralPath $configPath -Raw -Encoding utf8 | ConvertFrom-Json
if (-not [string]::IsNullOrWhiteSpace([string]$existing.authToken)) {
$token = [string]$existing.authToken
break
}
}
catch {
$token = ''
}
}
if ([string]::IsNullOrWhiteSpace($token)) {
$tokenBytes = New-Object byte[] 32
$rng = [Security.Cryptography.RandomNumberGenerator]::Create()
try {
$rng.GetBytes($tokenBytes)
}
finally {
$rng.Dispose()
}
$token = [Convert]::ToBase64String($tokenBytes)
}
$payload = [ordered]@{
enabled = $true
port = $Port
authEnabled = $true
authToken = $token
}
foreach ($userDataPath in Get-AnythingAnalyzerUserDataPaths) {
if (-not (Test-Path -LiteralPath $userDataPath)) {
New-Item -ItemType Directory -Path $userDataPath -Force | Out-Null
}
$configPath = Join-Path $userDataPath 'mcp-server-config.json'
$payload | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $configPath -Encoding utf8
}
return $token
}
function Test-VsBuildToolsInstalled {
$roots = @(
(Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\2022\BuildTools'),
(Join-Path $env:ProgramFiles 'Microsoft Visual Studio\2022\BuildTools')
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
foreach ($root in $roots) {
$vcToolsPath = Join-Path $root 'VC\Tools\MSVC'
if (Test-Path -LiteralPath $vcToolsPath -PathType Container) {
return $true
}
}
return $false
}
function Ensure-VsBuildTools {
if (Test-VsBuildToolsInstalled) {
return
}
$override = '--wait --quiet --norestart --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended'
try {
Ensure-WingetPackage -Id 'Microsoft.VisualStudio.2022.BuildTools' -Label 'Visual Studio Build Tools 2022' -Override $override
}
catch {
if (-not (Test-ReverseIsElevated)) {
throw "$($_.Exception.Message) Current PowerShell process is not elevated; Visual Studio Build Tools may require an administrator/UAC install."
}
throw
}
if (-not (Test-VsBuildToolsInstalled)) {
throw 'Visual Studio Build Tools installed command completed, but the C++ workload was not detected.'
}
}
function Test-AnythingAnalyzerElectronHealthy {
param(
[Parameter(Mandatory = $true)][string]$RepoDir,
[Parameter(Mandatory = $true)][string]$PnpmPath
)
Push-Location $RepoDir
try {
& $PnpmPath exec electron --version *> $null
return $LASTEXITCODE -eq 0
}
catch {
return $false
}
finally {
Pop-Location
}
}
function Set-AnythingAnalyzerPnpmBuildApprovals {
param(
[Parameter(Mandatory = $true)][string]$RepoDir,
[Parameter(Mandatory = $true)][string[]]$Packages
)
$workspacePath = Join-Path $RepoDir 'pnpm-workspace.yaml'
$lines = @()
if (Test-Path -LiteralPath $workspacePath) {
$lines = @(Get-Content -LiteralPath $workspacePath)
}
$existingPackages = New-Object System.Collections.Generic.List[string]
$preserved = New-Object System.Collections.Generic.List[string]
$skipOnlyBuilt = $false
foreach ($line in $lines) {
if ($line -match '^\s*onlyBuiltDependencies\s*:') {
$skipOnlyBuilt = $true
continue
}
if ($skipOnlyBuilt) {
if ($line -match '^\s*-\s*(.+?)\s*$') {
$existingPackages.Add($matches[1].Trim("'`""))
continue
}
if ($line -match '^\s+' -or [string]::IsNullOrWhiteSpace($line)) {
continue
}
$skipOnlyBuilt = $false
}
$preserved.Add($line)
}
while ($preserved.Count -gt 0 -and [string]::IsNullOrWhiteSpace($preserved[$preserved.Count - 1])) {
$preserved.RemoveAt($preserved.Count - 1)
}
$approvedPackages = @($existingPackages + @($Packages)) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique
if ($preserved.Count -gt 0) {
$preserved.Add('')
}
$preserved.Add('onlyBuiltDependencies:')
foreach ($package in $approvedPackages) {
$preserved.Add(" - $package")
}
Set-Content -LiteralPath $workspacePath -Value @($preserved) -Encoding utf8
}
function Approve-AnythingAnalyzerBuildScripts {
param(
[Parameter(Mandatory = $true)][string]$RepoDir
)
$buildPackages = @('electron', 'esbuild', 'better-sqlite3')
Set-AnythingAnalyzerPnpmBuildApprovals -RepoDir $RepoDir -Packages $buildPackages
}
function Get-GitHubLatestReleaseAsset {
param(
[Parameter(Mandatory = $true)][string]$Repo,
[Parameter(Mandatory = $true)][string]$AssetRegex,
[string]$ReleaseTag = ''
)
if (-not [string]::IsNullOrWhiteSpace($ReleaseTag)) {
$uri = "https://api.github.com/repos/$Repo/releases/tags/$ReleaseTag"
}
else {
$uri = "https://api.github.com/repos/$Repo/releases/latest"
}
$release = Invoke-RestMethod -Uri $uri -Headers @{ 'User-Agent' = 'reverse-skill-bootstrap' }
$asset = @($release.assets) | Where-Object { $_.name -match $AssetRegex } | Select-Object -First 1
if ($null -eq $asset) {
throw "No release asset matched $AssetRegex for $Repo (tag=$ReleaseTag)"
}
return $asset
}
function Get-FileSha256Hex {
param([Parameter(Mandatory = $true)][string]$Path)
return (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant()
}
function Assert-DownloadedFileIntegrity {
param(
[Parameter(Mandatory = $true)][string]$Path,
$Definition = $null,
$Asset = $null
)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Integrity check failed: file missing $Path"
}
$actual = Get-FileSha256Hex -Path $Path
$expected = $null
$source = $null
if ($null -ne $Definition -and $Definition.PSObject.Properties['assetSha256'] -and -not [string]::IsNullOrWhiteSpace([string]$Definition.assetSha256)) {
$expected = ([string]$Definition.assetSha256 -replace '^(?i)sha256:', '').Trim().ToLowerInvariant()
$source = 'manifest.assetSha256'
}
elseif ($null -ne $Asset -and $Asset.PSObject.Properties['digest'] -and -not [string]::IsNullOrWhiteSpace([string]$Asset.digest)) {
$expected = ([string]$Asset.digest -replace '^(?i)sha256:', '').Trim().ToLowerInvariant()
$source = 'github.api.digest'
}
if (-not [string]::IsNullOrWhiteSpace($expected)) {
if ($actual -ne $expected) {
Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
throw "SHA256 mismatch for $(Split-Path -Leaf $Path) (via $source): expected $expected got $actual — file deleted"
}
Write-Host ("[integrity] SHA256 OK ({0}): {1}" -f $source, $actual) -ForegroundColor Green
return $actual
}
Write-Warning ("[integrity] No pinned digest for {0}; recorded sha256={1} (supply-chain residual — prefer assetSha256 in manifest)" -f (Split-Path -Leaf $Path), $actual)
return $actual
}
function Expand-ArchiveIntoDirectory {
param(
[Parameter(Mandatory = $true)][string]$ZipPath,
[Parameter(Mandatory = $true)][string]$Destination
)
$tempExtract = Join-Path $tmpBase ("reverse-bootstrap-" + [System.Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $tempExtract -Force | Out-Null
Expand-Archive -LiteralPath $ZipPath -DestinationPath $tempExtract -Force
if (Test-Path -LiteralPath $Destination) {
Remove-Item -LiteralPath $Destination -Recurse -Force
}
New-Item -ItemType Directory -Path $Destination -Force | Out-Null
$children = Get-ChildItem -LiteralPath $tempExtract
if ($children.Count -eq 1 -and $children[0].PSIsContainer) {
$sourceDir = $children[0].FullName
}
else {
$sourceDir = $tempExtract
}
Get-ChildItem -LiteralPath $sourceDir -Force | ForEach-Object {
Move-Item -LiteralPath $_.FullName -Destination $Destination -Force
}
Remove-Item -LiteralPath $tempExtract -Recurse -Force
}
function Ensure-DownloadDirectory {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
}
}
function Ensure-GitHubZipInstall {
param(
[Parameter(Mandatory = $true)]$Definition,
[Parameter(Mandatory = $true)][string]$TargetPath,
[Parameter(Mandatory = $true)][string]$VerifyName
)
$existing = Resolve-ReverseToolSpec -Name $VerifyName
if ($existing.Available) {
return $existing
}
$releaseTag = if ($Definition.PSObject.Properties['releaseTag']) { [string]$Definition.releaseTag } else { '' }
$asset = Get-GitHubLatestReleaseAsset -Repo $Definition.repo -AssetRegex $Definition.assetRegex -ReleaseTag $releaseTag
$downloadUrl = if ($asset.PSObject.Properties['browser_download_url']) { $asset.browser_download_url } else { $asset.url }
$downloadPath = Join-Path $tmpBase $asset.name
Invoke-WebRequest -Uri $downloadUrl -OutFile $downloadPath -Headers @{ 'Accept' = 'application/octet-stream' }
Assert-DownloadedFileIntegrity -Path $downloadPath -Definition $Definition -Asset $asset | Out-Null
Ensure-DownloadDirectory -Path (Split-Path -Path $TargetPath -Parent)
Expand-ArchiveIntoDirectory -ZipPath $downloadPath -Destination $TargetPath
Remove-Item -LiteralPath $downloadPath -Force
# Refresh PATH so newly installed tools are discoverable
$binCandidates = @(
(Join-Path $TargetPath 'bin'),
$TargetPath
)
foreach ($binDir in $binCandidates) {
if ((Test-Path -LiteralPath $binDir) -and ($env:PATH -notlike "*$binDir*")) {
$env:PATH = "$binDir;$env:PATH"
}
}
return (Resolve-ReverseToolSpec -Name $VerifyName)
}
function Ensure-ApktoolInstall {
param([Parameter(Mandatory = $true)]$Definition)
$existing = Resolve-ReverseToolSpec -Name 'apktool'
if ($existing.Available) {
return $existing
}
Ensure-JavaRuntime
$releaseTag = if ($Definition.PSObject.Properties['releaseTag']) { [string]$Definition.releaseTag } else { '' }
$asset = Get-GitHubLatestReleaseAsset -Repo $Definition.repo -AssetRegex $Definition.assetRegex -ReleaseTag $releaseTag
$installDir = $Definition.installDir
Ensure-DownloadDirectory -Path $installDir
$jarName = [System.IO.Path]::GetFileName($asset.name)
$jarPath = Join-Path $installDir 'apktool.jar'
$downloadUrl = if ($asset.PSObject.Properties['browser_download_url']) { $asset.browser_download_url } else { $asset.url }
Invoke-WebRequest -Uri $downloadUrl -OutFile $jarPath -Headers @{ 'Accept' = 'application/octet-stream' }
Assert-DownloadedFileIntegrity -Path $jarPath -Definition $Definition -Asset $asset | Out-Null
$wrapperPath = Join-Path $installDir $Definition.wrapperName
@(
'@echo off',
'setlocal',
'java -jar "%~dp0apktool.jar" %*'
) | Set-Content -LiteralPath $wrapperPath -Encoding ascii
# Add to PATH so discovery can find it immediately
if ($env:PATH -notlike "*$installDir*") {
$env:PATH = "$installDir;$env:PATH"
}
return (Resolve-ReverseToolSpec -Name 'apktool')
}
function Ensure-PipPackageInstall {
param([Parameter(Mandatory = $true)]$Definition)
Ensure-PythonRuntime
$python = Get-FirstCommandPath -Names @('python', 'python3')
# Use pipSource (git URL) if available, otherwise use pipPackage name
$installTarget = if ($Definition.PSObject.Properties['pipSource'] -and -not [string]::IsNullOrWhiteSpace($Definition.pipSource)) {
$Definition.pipSource
} else {
$Definition.pipPackage
}
& $python -m pip install --upgrade $installTarget
if ($LASTEXITCODE -ne 0) {
throw "pip install failed for $installTarget"
}
}
function Get-ClaudeMcpConfig {
$path = Get-ClaudeMcpConfigPath
if (-not (Test-Path -LiteralPath $path)) {
return @{ path = $path; json = @{ mcpServers = @{} } }
}
$json = Read-ReverseJsonAsHashtable -Path $path
if ($null -eq $json) {
$json = @{ mcpServers = @{} }
}
if (-not $json.Contains('mcpServers')) {
$json['mcpServers'] = @{}
}
return @{ path = $path; json = $json }
}
function Save-ClaudeMcpConfig {
param([Parameter(Mandatory = $true)]$Config)
$parent = Split-Path -Path $Config.path -Parent
if (-not (Test-Path -LiteralPath $parent)) {
New-Item -ItemType Directory -Path $parent -Force | Out-Null
}
$Config.json | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $Config.path -Encoding utf8
}
function ConvertTo-TomlLiteral {
param([Parameter(Mandatory = $true)]$Value)
if ($Value -is [bool]) {
return $Value.ToString().ToLowerInvariant()
}
if ($Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal]) {
return [string]$Value
}
if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) {
$items = foreach ($item in $Value) {
ConvertTo-TomlLiteral -Value $item
}
return "[{0}]" -f ($items -join ', ')
}
$escaped = ([string]$Value).Replace('\', '\\').Replace('"', '\"')
return '"' + $escaped + '"'
}
function Remove-CodexMcpServerBlocks {
param(
[AllowEmptyCollection()]
[string[]]$Lines,
[Parameter(Mandatory = $true)][string]$ServerName
)
$targetPattern = '^\[mcp_servers\.{0}(?:\.env)?\]\s*$' -f [regex]::Escape($ServerName)
$result = New-Object System.Collections.Generic.List[string]
$skipBlock = $false
if ($null -eq $Lines) {
return @()
}
foreach ($line in $Lines) {
if ($line -match '^\[') {
if ($line -match $targetPattern) {
$skipBlock = $true
continue
}
if ($skipBlock) {
$skipBlock = $false
}
}
if (-not $skipBlock) {
$result.Add($line)
}
}
return @($result)
}
function Set-CodexMcpServer {
param(
[Parameter(Mandatory = $true)][string]$ServerName,
[Parameter(Mandatory = $true)][hashtable]$ServerDefinition
)
$path = Get-CodexConfigPath
$parent = Split-Path -Path $path -Parent
if (-not (Test-Path -LiteralPath $parent)) {
New-Item -ItemType Directory -Path $parent -Force | Out-Null
}
$lines = @()
if (Test-Path -LiteralPath $path) {
$rawLines = @(Get-Content -LiteralPath $path)
if ($rawLines.Count -eq 1 -and [string]::IsNullOrEmpty($rawLines[0])) {
$lines = @()
}
else {
$lines = $rawLines
}
}
$lines = @(Remove-CodexMcpServerBlocks -Lines $lines -ServerName $ServerName)
while ($lines.Count -gt 0 -and [string]::IsNullOrWhiteSpace($lines[-1])) {
$lines = if ($lines.Count -gt 1) { $lines[0..($lines.Count - 2)] } else { @() }
}
if ($lines.Count -gt 0) {
$lines += ''
}
$lines += "[mcp_servers.$ServerName]"
foreach ($key in @('type', 'url', 'command', 'args', 'bearer_token_env_var')) {
if ($ServerDefinition.Contains($key)) {
$lines += "$key = $(ConvertTo-TomlLiteral -Value $ServerDefinition[$key])"
}
}
foreach ($key in ($ServerDefinition.Keys | Where-Object { $_ -notin @('type', 'url', 'command', 'args', 'bearer_token_env_var', 'env') } | Sort-Object)) {
$lines += "$key = $(ConvertTo-TomlLiteral -Value $ServerDefinition[$key])"
}
if ($ServerDefinition.Contains('env') -and $ServerDefinition['env'] -is [System.Collections.IDictionary] -and $ServerDefinition['env'].Count -gt 0) {
$lines += ''
$lines += "[mcp_servers.$ServerName.env]"
foreach ($envKey in ($ServerDefinition['env'].Keys | Sort-Object)) {
$lines += "$envKey = $(ConvertTo-TomlLiteral -Value $ServerDefinition['env'][$envKey])"
}
}
Set-Content -LiteralPath $path -Value $lines -Encoding utf8
}
function Ensure-McpServer {
param(
[Parameter(Mandatory = $true)][string]$ServerName,
[Parameter(Mandatory = $true)][hashtable]$ServerDefinition
)
foreach ($target in Get-McpHostTargets) {
switch ($target) {
'Claude' {
$config = Get-ClaudeMcpConfig
$claudeDefinition = @{}
foreach ($key in $ServerDefinition.Keys) {
if ($key -ne 'bearer_token_env_var') {
$claudeDefinition[$key] = $ServerDefinition[$key]
}
}
$config.json.mcpServers[$ServerName] = $claudeDefinition
Save-ClaudeMcpConfig -Config $config
}
'Codex' {
$codexDefinition = @{}
foreach ($key in $ServerDefinition.Keys) {
if ($key -ne 'headers') {
$codexDefinition[$key] = $ServerDefinition[$key]
}
}
Set-CodexMcpServer -ServerName $ServerName -ServerDefinition $codexDefinition
}
}
}
}
function Get-McpCommandServerDefinition {
param(
[Parameter(Mandatory = $true)][string]$Command,
[Parameter(Mandatory = $true)][string[]]$Arguments,
[hashtable]$Env = @{}
)
if ((Test-ReverseIsWindows) -and $Command -in @('npx', 'pnpm', 'npm')) {
return @{
type = 'stdio'
command = 'cmd'
args = @('/c', $Command) + @($Arguments)
env = $Env
}
}
return @{
type = 'stdio'
command = $Command
args = @($Arguments)
env = $Env
}
}
function Wait-ForPort {
param(
[Parameter(Mandatory = $true)][int]$Port,
[int]$TimeoutSeconds = 90
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
if (Test-ReverseTcpPort -Port $Port) {
return $true
}
Start-Sleep -Seconds 2
}
return $false
}
function Start-AnythingAnalyzerService {
param(
[Parameter(Mandatory = $true)]$Definition,
[string]$AuthToken = ''
)
if ([string]::IsNullOrWhiteSpace($AuthToken)) {
$AuthToken = Ensure-AnythingAnalyzerMcpConfig -Port ([int]$Definition.servicePort)
}
$repoDir = [string]$Definition.installDir
$checkoutDefinition = [pscustomobject]@{
repo = [string]$Definition.repoUrl
pinnedCommit = [string]$Definition.pinnedCommit
}
Ensure-GitCloneInstall -Definition $checkoutDefinition -TargetPath $repoDir | Out-Null
if (Test-ReverseTcpPort -Port ([int]$Definition.servicePort)) {
return
}
Ensure-Pnpm
$vsBuildToolsError = ''
if (Test-ReverseIsWindows) {
try {
Ensure-VsBuildTools
}
catch {
$vsBuildToolsError = $_.Exception.Message
Write-Warning "Visual Studio Build Tools auto-install failed; continuing with pnpm prebuilt/native rebuild path. $vsBuildToolsError"
}
}
$pnpm = Get-NodeCommandPath -Name 'pnpm'
if ([string]::IsNullOrWhiteSpace($pnpm)) {
throw 'pnpm is not available after installation.'
}
$git = Get-FirstCommandPath -Names @('git')
Invoke-AnythingAnalyzerPinnedInstall -RepoDir $repoDir -PnpmPath $pnpm -GitPath $git `
-PinnedCommit ([string]$Definition.pinnedCommit) -VsBuildToolsError $vsBuildToolsError
$stdoutLog = Join-Path $repoDir 'anything-analyzer-dev.log'
$stderrLog = Join-Path $repoDir 'anything-analyzer-dev.err.log'
Remove-Item -LiteralPath $stdoutLog, $stderrLog -Force -ErrorAction SilentlyContinue
Start-Process -FilePath $pnpm -ArgumentList @('dev') -WorkingDirectory $repoDir -WindowStyle Hidden -RedirectStandardOutput $stdoutLog -RedirectStandardError $stderrLog | Out-Null
if (-not (Wait-ForPort -Port ([int]$Definition.servicePort) -TimeoutSeconds 120)) {
throw "anything-analyzer did not open port 23816 in time. Logs: $stdoutLog ; $stderrLog"
}
}
function Start-IdaProService {
param([Parameter(Mandatory = $true)]$Definition)
if (Test-ReverseTcpPort -Port ([int]$Definition.servicePort)) {
return
}
$startScript = $Definition.startScript
$shell = Get-PreferredPowerShellPath
& $shell -NoProfile -ExecutionPolicy Bypass -File $startScript
if ($LASTEXITCODE -ne 0 -and -not (Test-ReverseTcpPort -Port ([int]$Definition.servicePort))) {
throw 'Failed to start idapro service.'
}
if (-not (Wait-ForPort -Port ([int]$Definition.servicePort) -TimeoutSeconds 45)) {
throw 'idapro service did not open port 13337 in time.'
}
}
function Ensure-AndroidPlatformTools {
$adb = Resolve-ReverseToolSpec -Name 'adb'
if ($adb.Available) {
return $adb
}
Ensure-WingetPackage -Id 'Google.PlatformTools' -Label 'Android SDK Platform-Tools'
return (Resolve-ReverseToolSpec -Name 'adb')
}
function Ensure-Capability {
param([Parameter(Mandatory = $true)][string]$Name)
$definition = Get-ReverseBootstrapDefinition -Name $Name
if ($null -eq $definition) {
throw "No bootstrap definition for capability: $Name"
}
# If capability is marked as not auto-installable, output guidance and skip
if ($definition.PSObject.Properties['canAutoInstall'] -and $definition.canAutoInstall -eq $false) {
$hint = if ($definition.PSObject.Properties['manualInstallHint']) { $definition.manualInstallHint } else { "Please install $Name manually. Docs: $($definition.docsUrl)" }
Write-Warning "MANUAL_INSTALL_REQUIRED: $Name — $hint"
# Still try to register MCP URL if applicable
if ($definition.PSObject.Properties['mcpNames'] -and $definition.PSObject.Properties['mcpUrl']) {
Ensure-McpServer -ServerName $definition.mcpNames[0] -ServerDefinition @{ url = $definition.mcpUrl }
}
return $false
}
$existingState = Get-ReverseCapabilityState -Name $Name
if ($existingState -and -not $definition.PSObject.Properties['mcpNames'] -and $definition.bootstrapKind -ne 'git-clone') {
$toolSpec = $null
try {
$toolSpec = Resolve-ReverseToolSpec -Name $Name
}
catch {
$toolSpec = $null
}
if ($toolSpec -and $toolSpec.Available) {
return $toolSpec
}
}
switch ($definition.bootstrapKind) {
'github-release-zip' {
# Generic handler for all github-release-zip capabilities
$verifyName = if ($definition.PSObject.Properties['verifyCommand'] -and -not [string]::IsNullOrWhiteSpace($definition.verifyCommand)) {
$definition.verifyCommand
} else { $Name }
return Ensure-GitHubZipInstall -Definition $definition -TargetPath $definition.installDir -VerifyName $verifyName
}
'git-clone' {
return Ensure-GitCloneInstall -Definition $definition -TargetPath $definition.installDir
}
'github-release-jar-wrapper' {
return Ensure-ApktoolInstall -Definition $definition
}
'pip-package' {
Ensure-PipPackageInstall -Definition $definition
return $true
}
'winget-package' {
$wingetId = $definition.wingetId
Ensure-WingetPackage -Id $wingetId -Label $Name
$toolSpec = Resolve-ReverseToolSpec -Name $Name
if (-not $toolSpec.Available) {
throw "$Name was installed, but bootstrap could not resolve the executable in this process."
}
return $toolSpec
}
'npm-mcp' {
Ensure-NodeRuntime
$envMap = @{}
if ($definition.PSObject.Properties['mcpEnv'] -and $null -ne $definition.mcpEnv) {
foreach ($property in $definition.mcpEnv.PSObject.Properties) {
$envMap[$property.Name] = $property.Value
}
}
$serverDefinition = Get-McpCommandServerDefinition -Command $definition.mcpCommand -Arguments @($definition.mcpArgs) -Env $envMap
Ensure-McpServer -ServerName $definition.mcpNames[0] -ServerDefinition $serverDefinition
return $true
}
'npm-global' {
Ensure-NodeRuntime
$npm = Get-NodeCommandPath -Name 'npm'
if ([string]::IsNullOrWhiteSpace($npm)) {
throw 'npm is not available after Node.js installation.'
}
& $npm install -g $definition.npmPackage
if ($LASTEXITCODE -ne 0) {
throw "npm install -g $($definition.npmPackage) failed."
}
# Run post-install command if specified (e.g. playwright install)
if ($definition.PSObject.Properties['postInstall'] -and -not [string]::IsNullOrWhiteSpace($definition.postInstall)) {
$postParts = $definition.postInstall -split ' ', 2
$postCmd = Get-FirstCommandPath -Names @($postParts[0])
if (-not [string]::IsNullOrWhiteSpace($postCmd)) {
if ($postParts.Count -gt 1) {
& $postCmd $postParts[1].Split(' ')
}
else {
& $postCmd
}
}
else {
# Try via npx
$npx = Get-NodeCommandPath -Name 'npx'
if ($npx) {
& $npx $definition.postInstall.Split(' ')
}
}
}
# Run setup script if specified
if ($definition.PSObject.Properties['setupScript'] -and -not [string]::IsNullOrWhiteSpace($definition.setupScript)) {
$setupPath = $definition.setupScript
if (Test-Path -LiteralPath $setupPath) {
$shell = Get-PreferredPowerShellPath
& $shell -NoProfile -ExecutionPolicy Bypass -File $setupPath -SkipBrowserInstall
}
}
return $true
}
'local-http-mcp' {
if ($Name -eq 'anything-analyzer') {
$authToken = Ensure-AnythingAnalyzerMcpConfig -Port ([int]$definition.servicePort)
$env:ANYTHING_ANALYZER_MCP_TOKEN = $authToken
try {
[Environment]::SetEnvironmentVariable('ANYTHING_ANALYZER_MCP_TOKEN', $authToken, 'User')
}
catch {
Write-Warning "Could not persist ANYTHING_ANALYZER_MCP_TOKEN for future MCP clients: $($_.Exception.Message)"
}
$serverDefinition = @{
url = $definition.mcpUrl
headers = @{ Authorization = "Bearer $authToken" }
bearer_token_env_var = 'ANYTHING_ANALYZER_MCP_TOKEN'
}
Ensure-McpServer -ServerName 'anything-analyzer' -ServerDefinition $serverDefinition
if ($StartServices) {
Start-AnythingAnalyzerService -Definition $definition -AuthToken $authToken
}
return $true
}
if ($Name -eq 'idapro') {
Ensure-Capability -Name 'idalib-mcp'
Ensure-McpServer -ServerName 'idapro' -ServerDefinition @{ url = $definition.mcpUrl }
if ($StartServices) {
Start-IdaProService -Definition $definition
}
return $true
}
}
'go-install' {
if (-not $definition.PSObject.Properties['goPackage'] -or [string]::IsNullOrWhiteSpace($definition.goPackage)) {
throw "go-install capability $Name is missing goPackage in bootstrap-manifest.json."
}
$goCmd = Get-FirstCommandPath -Names @('go')
if ([string]::IsNullOrWhiteSpace($goCmd)) {
try {
Ensure-WingetPackage -Id 'GoLang.Go' -Label 'Go'
}
catch {
Write-Warning "Go runtime unavailable: $($_.Exception.Message)"
}
$goCmd = Get-FirstCommandPath -Names @('go')
if ([string]::IsNullOrWhiteSpace($goCmd)) {
$goCandidates = @(
(Join-Path $env:LOCALAPPDATA 'Programs\Go\bin\go.exe'),
'C:\Program Files\Go\bin\go.exe',
(Join-Path $env:USERPROFILE 'go\bin\go.exe')
) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
if ($goCandidates) { $goCmd = $goCandidates }
}
}
if (-not [string]::IsNullOrWhiteSpace($goCmd)) {
& $goCmd install $definition.goPackage
if ($LASTEXITCODE -eq 0) {
if ($definition.PSObject.Properties['mcpNames'] -and -not [string]::IsNullOrWhiteSpace($definition.mcpCommand)) {
$serverDefinition = Get-McpCommandServerDefinition -Command $definition.mcpCommand -Arguments @($definition.mcpArgs)
Ensure-McpServer -ServerName $definition.mcpNames[0] -ServerDefinition $serverDefinition
}
return $true
}
Write-Warning "go install failed for $Name (exit code $LASTEXITCODE)."
}
# Docker fallback (parity with bootstrap-reverse.sh ensure_pentestswarm)
if ($definition.PSObject.Properties['fallbackKind'] -and $definition.fallbackKind -eq 'docker-image' -and $definition.PSObject.Properties['dockerImage']) {
$docker = Get-FirstCommandPath -Names @('docker')
if (-not [string]::IsNullOrWhiteSpace($docker)) {
$serverDefinition = @{
type = 'stdio'
command = 'docker'
args = @('run', '--rm', '-i', $definition.dockerImage) + @($definition.mcpArgs)
}
Ensure-McpServer -ServerName $definition.mcpNames[0] -ServerDefinition $serverDefinition
Write-Warning "$Name Go install failed; registered Docker fallback $($definition.dockerImage)"
return $true
}
}
throw "Capability $Name requires Go 1.24+ or Docker to install. Docs: $($definition.docsUrl)"
}
default {
throw "Unsupported bootstrap kind: $($definition.bootstrapKind)"
}
}
throw "Capability bootstrap fell through without action: $Name"
}
function Expand-CapabilityDependencies {
param([Parameter(Mandatory = $true)][string[]]$Names)
$ordered = New-Object System.Collections.Generic.List[string]
$seen = @{}
function Add-Capability {
param([string]$CapabilityName)
if ($seen.ContainsKey($CapabilityName)) {
return
}
$seen[$CapabilityName] = $true
$definition = Get-ReverseBootstrapDefinition -Name $CapabilityName
if ($null -ne $definition -and $definition.PSObject.Properties['dependsOn']) {
foreach ($dependency in @($definition.dependsOn)) {
Add-Capability -CapabilityName $dependency
}
}
$ordered.Add($CapabilityName)
}
foreach ($name in $Names) {
Add-Capability -CapabilityName $name
}
return $ordered
}
function Test-BootstrapResultsSucceeded {
param([Parameter(Mandatory = $true)][object[]]$Results)
return (@($Results | Where-Object { $_.status -eq 'failed' }).Count -eq 0)
}
$expandedCapabilities = Expand-CapabilityDependencies -Names $Capability
$results = @()
foreach ($name in $expandedCapabilities) {
$definition = Get-ReverseBootstrapDefinition -Name $name
if ($null -eq $definition) {
$results += [pscustomobject]@{ name = $name; status = 'missing-definition' }
continue
}
try {
$manualRequired = $false
switch ($name) {
'adb' {
Ensure-AndroidPlatformTools | Out-Null
}
default {
$ensureResult = Ensure-Capability -Name $name
if ($ensureResult -eq $false) {
$def = Get-ReverseBootstrapDefinition -Name $name
$hint = if ($def -and $def.PSObject.Properties['manualInstallHint']) { $def.manualInstallHint } else { "Install manually. Docs: $($def.docsUrl)" }
$results += [pscustomobject]@{
name = $name
status = 'manual-required'
hint = $hint
docs_url = [string]$def.docsUrl
}
$manualRequired = $true
}
}
}
if (-not $manualRequired) {
$state = Get-ReverseCapabilityState -Name $name
$status = if ($state -and -not $state.Ready) { 'configured-not-ready' } else { 'ready' }
$results += [pscustomobject]@{
name = $name
status = $status
ready = if ($state) { $state.Ready } else { $null }
registered = if ($state) { $state.Registered } else { $null }
service_online = if ($state) { $state.ServiceOnline } else { $null }
}
}
}
catch {
$results += [pscustomobject]@{
name = $name
status = 'failed'
error = $_.Exception.Message
}
}
}
if (-not $SkipRefresh) {
$shell = Get-PreferredPowerShellPath
& $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'refresh-tool-index.ps1') | Out-Null
}
$results | ConvertTo-Json -Depth 5
if (-not (Test-BootstrapResultsSucceeded -Results $results)) {
exit 1
}