fix(bootstrap): fail closed before manifest installs
This commit is contained in:
@@ -32,6 +32,10 @@ jobs:
|
||||
shell: pwsh
|
||||
run: ./skills/scripts/verify-routing-coherence.ps1
|
||||
|
||||
- name: Bootstrap supply-chain regression
|
||||
shell: pwsh
|
||||
run: ./skills/scripts/test-bootstrap-supply-chain.ps1
|
||||
|
||||
- name: Smoke (verify + parse + quick route)
|
||||
shell: pwsh
|
||||
run: ./skills/scripts/smoke.ps1
|
||||
|
||||
@@ -23,6 +23,7 @@ $ErrorActionPreference = 'Stop'
|
||||
$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)
|
||||
@@ -164,29 +165,6 @@ function Ensure-JavaRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
function Ensure-Pnpm {
|
||||
Ensure-NodeRuntime
|
||||
$dependency = Get-BootstrapDependency -Name 'pnpm'
|
||||
$pnpm = Get-NodeCommandPath -Name 'pnpm'
|
||||
$currentVersion = ''
|
||||
if ($pnpm) {
|
||||
$versionLine = & $pnpm --version 2>$null | Select-Object -First 1
|
||||
if ($LASTEXITCODE -eq 0 -and $null -ne $versionLine) {
|
||||
$currentVersion = ([string]$versionLine).Trim()
|
||||
}
|
||||
}
|
||||
if ($currentVersion -ne [string]$dependency.version) {
|
||||
$npm = Get-NodeCommandPath -Name 'npm'
|
||||
if ([string]::IsNullOrWhiteSpace($npm)) {
|
||||
throw 'npm is not available after Node.js installation.'
|
||||
}
|
||||
& $npm install -g ([string]$dependency.package)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to install pinned pnpm dependency $($dependency.package)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-AnythingAnalyzerUserDataPaths {
|
||||
$candidates = @(
|
||||
(Join-Path $env:APPDATA 'anything-analyzer'),
|
||||
@@ -801,54 +779,9 @@ if (Test-ReverseIsWindows) {
|
||||
if ([string]::IsNullOrWhiteSpace($pnpm)) {
|
||||
throw 'pnpm is not available after installation.'
|
||||
}
|
||||
|
||||
$workspacePath = Join-Path $repoDir 'pnpm-workspace.yaml'
|
||||
$workspaceExisted = Test-Path -LiteralPath $workspacePath -PathType Leaf
|
||||
$workspaceBytes = if ($workspaceExisted) { [IO.File]::ReadAllBytes($workspacePath) } else { $null }
|
||||
|
||||
Push-Location $repoDir
|
||||
try {
|
||||
Approve-AnythingAnalyzerBuildScripts -RepoDir $repoDir
|
||||
|
||||
if (-not (Test-AnythingAnalyzerElectronHealthy -RepoDir $repoDir -PnpmPath $pnpm)) {
|
||||
$nodeModules = Join-Path $repoDir 'node_modules'
|
||||
if (Test-Path -LiteralPath $nodeModules) {
|
||||
Remove-Item -LiteralPath $nodeModules -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
& $pnpm install --frozen-lockfile
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($vsBuildToolsError)) {
|
||||
throw "pnpm install failed for anything-analyzer. Visual Studio Build Tools auto-install also failed earlier: $vsBuildToolsError"
|
||||
}
|
||||
throw 'pnpm install failed for anything-analyzer.'
|
||||
}
|
||||
|
||||
& $pnpm rebuild electron esbuild better-sqlite3
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($vsBuildToolsError)) {
|
||||
throw "pnpm rebuild failed for anything-analyzer. Visual Studio Build Tools auto-install also failed earlier: $vsBuildToolsError"
|
||||
}
|
||||
throw 'pnpm rebuild failed for anything-analyzer.'
|
||||
}
|
||||
|
||||
if (-not (Test-AnythingAnalyzerElectronHealthy -RepoDir $repoDir -PnpmPath $pnpm)) {
|
||||
throw 'Electron is still not healthy after reinstall/rebuild.'
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
if ($workspaceExisted) {
|
||||
[IO.File]::WriteAllBytes($workspacePath, $workspaceBytes)
|
||||
}
|
||||
elseif (Test-Path -LiteralPath $workspacePath) {
|
||||
Remove-Item -LiteralPath $workspacePath -Force
|
||||
}
|
||||
}
|
||||
|
||||
$git = Get-FirstCommandPath -Names @('git')
|
||||
Assert-GitCheckoutState -GitPath $git -CheckoutPath $repoDir -PinnedCommit ([string]$Definition.pinnedCommit)
|
||||
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'
|
||||
@@ -889,82 +822,6 @@ function Ensure-AndroidPlatformTools {
|
||||
return (Resolve-ReverseToolSpec -Name 'adb')
|
||||
}
|
||||
|
||||
function Assert-GitCheckoutState {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$GitPath,
|
||||
[Parameter(Mandatory = $true)][string]$CheckoutPath,
|
||||
[Parameter(Mandatory = $true)][string]$PinnedCommit
|
||||
)
|
||||
|
||||
$resolvedLine = & $GitPath -C $CheckoutPath rev-parse HEAD 2>$null | Select-Object -First 1
|
||||
$resolvedCommit = if ($null -eq $resolvedLine) { '' } else { ([string]$resolvedLine).Trim() }
|
||||
if ($LASTEXITCODE -ne 0 -or $resolvedCommit -ne $PinnedCommit) {
|
||||
throw "Checkout verification failed: expected $PinnedCommit, got $resolvedCommit ($CheckoutPath)"
|
||||
}
|
||||
$status = @(& $GitPath -C $CheckoutPath status --porcelain --untracked-files=all 2>&1)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Cannot inspect checkout state: $CheckoutPath"
|
||||
}
|
||||
if ($status.Count -gt 0) {
|
||||
throw "Checkout has local changes; refusing to execute it: $CheckoutPath"
|
||||
}
|
||||
}
|
||||
|
||||
function Ensure-GitCloneInstall {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]$Definition,
|
||||
[Parameter(Mandatory = $true)][string]$TargetPath
|
||||
)
|
||||
|
||||
$pinnedCommit = if ($Definition.PSObject.Properties['pinnedCommit']) { [string]$Definition.pinnedCommit } else { '' }
|
||||
$git = Get-FirstCommandPath -Names @('git')
|
||||
if ([string]::IsNullOrWhiteSpace($git)) {
|
||||
throw "Cannot clone $($Definition.repo) because git is not available."
|
||||
}
|
||||
|
||||
if ((Test-Path -LiteralPath $TargetPath -PathType Container) -and (Test-Path -LiteralPath (Join-Path $TargetPath '.git'))) {
|
||||
if ([string]::IsNullOrWhiteSpace($pinnedCommit)) {
|
||||
throw "Git capability $($Definition.repo) must define pinnedCommit before an existing checkout can be used."
|
||||
}
|
||||
Assert-GitCheckoutState -GitPath $git -CheckoutPath $TargetPath -PinnedCommit $pinnedCommit
|
||||
return $true
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $TargetPath) {
|
||||
throw "Install path exists but is not a git checkout: $TargetPath"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($pinnedCommit)) {
|
||||
throw "Git capability $($Definition.repo) must define pinnedCommit."
|
||||
}
|
||||
|
||||
$parent = Split-Path -Path $TargetPath -Parent
|
||||
Ensure-DownloadDirectory -Path $parent
|
||||
$stagePath = Join-Path $parent ('.reverse-bootstrap-{0}' -f [Guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $stagePath | Out-Null
|
||||
try {
|
||||
& $git init --quiet $stagePath
|
||||
if ($LASTEXITCODE -ne 0) { throw 'git init failed' }
|
||||
& $git -C $stagePath remote add origin $Definition.repo
|
||||
if ($LASTEXITCODE -ne 0) { throw 'git remote add failed' }
|
||||
& $git -C $stagePath fetch --depth 1 origin $pinnedCommit
|
||||
if ($LASTEXITCODE -ne 0) { throw 'git fetch failed' }
|
||||
& $git -C $stagePath checkout --quiet --detach FETCH_HEAD
|
||||
if ($LASTEXITCODE -ne 0) { throw 'git checkout failed' }
|
||||
Assert-GitCheckoutState -GitPath $git -CheckoutPath $stagePath -PinnedCommit $pinnedCommit
|
||||
Move-Item -LiteralPath $stagePath -Destination $TargetPath
|
||||
if ((Test-Path -LiteralPath $stagePath) -or -not (Test-Path -LiteralPath (Join-Path $TargetPath '.git') -PathType Container)) {
|
||||
throw "Failed to promote staged checkout to $TargetPath"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $stagePath) {
|
||||
Remove-Item -LiteralPath $stagePath -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
function Ensure-Capability {
|
||||
param([Parameter(Mandatory = $true)][string]$Name)
|
||||
|
||||
|
||||
@@ -224,13 +224,7 @@ install_brew_cask() {
|
||||
}
|
||||
|
||||
ensure_python_runtime() {
|
||||
if ! has_cmd python3; then
|
||||
case "$PLATFORM" in
|
||||
macos) install_brew python ;;
|
||||
linux) install_apt python3 ;;
|
||||
*) log_err "Install Python 3 manually. See $(platform_doc)"; return 1 ;;
|
||||
esac
|
||||
fi
|
||||
ensure_python_interpreter || return 1
|
||||
local pipx_package pipx_version current_version
|
||||
pipx_package=$(manifest_dependency pipx package) || return 1
|
||||
pipx_version=$(manifest_dependency pipx version) || return 1
|
||||
@@ -245,6 +239,17 @@ ensure_python_runtime() {
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
}
|
||||
|
||||
ensure_python_interpreter() {
|
||||
if ! has_cmd python3; then
|
||||
case "$PLATFORM" in
|
||||
macos) install_brew python ;;
|
||||
linux) install_apt python3 ;;
|
||||
*) log_err "Install Python 3 manually. See $(platform_doc)"; return 1 ;;
|
||||
esac
|
||||
fi
|
||||
has_cmd python3 || { log_err "Python 3 installation completed without a usable python3 command."; return 1; }
|
||||
}
|
||||
|
||||
ensure_node_runtime() {
|
||||
if has_cmd node && has_cmd npm && has_cmd npx; then return 0; fi
|
||||
case "$PLATFORM" in
|
||||
@@ -567,10 +572,10 @@ ensure_jadx() {
|
||||
if has_cmd jadx; then log_ok "jadx ready: $(cmd_path jadx)"; return 0; fi
|
||||
ensure_java_runtime
|
||||
local repo re tag sha
|
||||
repo=$(manifest_field jadx repo)
|
||||
re=$(manifest_field jadx assetRegex)
|
||||
tag=$(manifest_field jadx releaseTag)
|
||||
sha=$(manifest_field jadx assetSha256)
|
||||
repo=$(manifest_field jadx repo) || return 1
|
||||
re=$(manifest_field jadx assetRegex) || return 1
|
||||
tag=$(manifest_field jadx releaseTag) || return 1
|
||||
sha=$(manifest_field jadx assetSha256) || return 1
|
||||
case "$PLATFORM" in
|
||||
macos) install_brew jadx || install_github_release "$repo" "$re" "$TOOLS_ROOT/jadx" "$tag" "$sha" ;;
|
||||
linux) install_github_release "$repo" "$re" "$TOOLS_ROOT/jadx" "$tag" "$sha" ;;
|
||||
@@ -587,10 +592,10 @@ ensure_apktool() {
|
||||
ensure_dir "$TOOLS_ROOT/apktool"
|
||||
local meta url digest jar wrapper
|
||||
local repo tag sha re
|
||||
repo=$(manifest_field apktool repo)
|
||||
tag=$(manifest_field apktool releaseTag)
|
||||
sha=$(manifest_field apktool assetSha256)
|
||||
re=$(manifest_field apktool assetRegex)
|
||||
repo=$(manifest_field apktool repo) || return 1
|
||||
tag=$(manifest_field apktool releaseTag) || return 1
|
||||
sha=$(manifest_field apktool assetSha256) || return 1
|
||||
re=$(manifest_field apktool assetRegex) || return 1
|
||||
meta=$(latest_github_asset_meta "$repo" "$re" "$tag")
|
||||
url=$(printf '%s' "$meta" | cut -f1)
|
||||
digest=$(printf '%s' "$meta" | cut -f2)
|
||||
@@ -609,7 +614,7 @@ ensure_frida_tools() {
|
||||
ensure_python_runtime || return 1
|
||||
if has_cmd frida && has_cmd frida-ps; then log_ok "frida-tools ready"; return 0; fi
|
||||
local package
|
||||
package=$(manifest_field frida pipPackage)
|
||||
package=$(manifest_field frida pipPackage) || return 1
|
||||
pipx install --force "$package" || return 1
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
}
|
||||
@@ -618,7 +623,7 @@ ensure_idalib_mcp() {
|
||||
ensure_python_runtime || return 1
|
||||
if has_cmd ida-pro-mcp; then log_ok "ida-pro-mcp ready: $(cmd_path ida-pro-mcp)"; return 0; fi
|
||||
local source
|
||||
source=$(manifest_field idalib-mcp pipSource)
|
||||
source=$(manifest_field idalib-mcp pipSource) || return 1
|
||||
pipx install --force "$source" || return 1
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
log_warn "Post-install: run 'ida-pro-mcp --install', choose Streamable HTTP + Global, then restart IDA Pro."
|
||||
@@ -627,7 +632,7 @@ ensure_idalib_mcp() {
|
||||
ensure_jshookmcp() {
|
||||
ensure_node_runtime || return 1
|
||||
local package
|
||||
package=$(manifest_field jshookmcp npmPackage)
|
||||
package=$(manifest_field jshookmcp npmPackage) || return 1
|
||||
write_mcp_server "jshook" "$(python3 - "$package" <<'PY'
|
||||
import json, sys
|
||||
print(json.dumps({'command':'npx','args':['-y',sys.argv[1]],'env':{'JSHOOK_BASE_PROFILE':'search'}}))
|
||||
@@ -638,7 +643,7 @@ PY
|
||||
ensure_reqable_mcp() {
|
||||
ensure_node_runtime || return 1
|
||||
local package
|
||||
package=$(manifest_field reqable-mcp npmPackage)
|
||||
package=$(manifest_field reqable-mcp npmPackage) || return 1
|
||||
write_mcp_server "reqable-mcp" "$(python3 - "$package" <<'PY'
|
||||
import json, sys
|
||||
print(json.dumps({'command':'npx','args':['-y',sys.argv[1]]}))
|
||||
@@ -650,8 +655,8 @@ PY
|
||||
ensure_anything_analyzer() {
|
||||
local dir="$TOOLS_ROOT/anything-analyzer"
|
||||
local repo commit
|
||||
repo=$(manifest_field anything-analyzer repoUrl)
|
||||
commit=$(manifest_field anything-analyzer pinnedCommit)
|
||||
repo=$(manifest_field anything-analyzer repoUrl) || return 1
|
||||
commit=$(manifest_field anything-analyzer pinnedCommit) || return 1
|
||||
if ! has_cmd git; then
|
||||
case "$PLATFORM" in macos) install_brew git ;; linux) install_apt git ;; esac
|
||||
fi
|
||||
@@ -719,7 +724,7 @@ ensure_agent_browser() {
|
||||
ensure_node_runtime || return 1
|
||||
if has_cmd agent-browser; then log_ok "agent-browser ready"; return 0; fi
|
||||
local package
|
||||
package=$(manifest_field agent-browser npmPackage)
|
||||
package=$(manifest_field agent-browser npmPackage) || return 1
|
||||
npm install -g "$package" || return 1
|
||||
if has_cmd npx; then npx playwright install chromium || true; fi
|
||||
local setup="$SKILL_ROOT/browser-automation/scripts/setup.sh"
|
||||
@@ -729,8 +734,8 @@ ensure_agent_browser() {
|
||||
ensure_ghidra_mcp() {
|
||||
ensure_java_runtime || return 1
|
||||
local repo regex
|
||||
repo=$(manifest_field ghidra-mcp repo)
|
||||
regex=$(manifest_field ghidra-mcp assetRegex)
|
||||
repo=$(manifest_field ghidra-mcp repo) || return 1
|
||||
regex=$(manifest_field ghidra-mcp assetRegex) || return 1
|
||||
case "$PLATFORM" in
|
||||
macos)
|
||||
if ! has_cmd ghidraRun && [[ ! -d /Applications/Ghidra.app ]]; then
|
||||
@@ -752,8 +757,8 @@ ensure_seclists() {
|
||||
if [[ -d /usr/share/seclists ]]; then log_ok "SecLists ready"; return 0; fi
|
||||
if ! has_cmd git; then case "$PLATFORM" in macos) install_brew git ;; linux) install_apt git ;; esac; fi
|
||||
local repo commit
|
||||
repo=$(manifest_field seclists repo)
|
||||
commit=$(manifest_field seclists pinnedCommit)
|
||||
repo=$(manifest_field seclists repo) || return 1
|
||||
commit=$(manifest_field seclists pinnedCommit) || return 1
|
||||
install_git_commit "$repo" "$commit" "$dir" || return 1
|
||||
}
|
||||
|
||||
@@ -761,8 +766,8 @@ ensure_proxycat() {
|
||||
ensure_python_runtime || return 1
|
||||
if has_cmd proxycat; then log_ok "proxycat ready"; return 0; fi
|
||||
local repo commit
|
||||
repo=$(manifest_field proxycat repo)
|
||||
commit=$(manifest_field proxycat pinnedCommit)
|
||||
repo=$(manifest_field proxycat repo) || return 1
|
||||
commit=$(manifest_field proxycat pinnedCommit) || return 1
|
||||
pipx install "git+${repo}@${commit}" || {
|
||||
manual_required proxycat "Clone/install ProxyCat manually; verify command 'proxycat'."
|
||||
LAST_CAPABILITY_MANUAL=true
|
||||
@@ -810,8 +815,8 @@ ensure_pentestswarm() {
|
||||
case "$PLATFORM" in macos) install_brew go ;; linux) install_apt golang-go ;; esac
|
||||
fi
|
||||
local go_package docker_image
|
||||
go_package=$(manifest_field pentestswarm goPackage)
|
||||
docker_image=$(manifest_field pentestswarm dockerImage)
|
||||
go_package=$(manifest_field pentestswarm goPackage) || return 1
|
||||
docker_image=$(manifest_field pentestswarm dockerImage) || return 1
|
||||
if go install "$go_package"; then
|
||||
local go_bin
|
||||
go_bin="$(go env GOBIN 2>/dev/null || true)"
|
||||
@@ -857,7 +862,7 @@ ensure_pwntools() {
|
||||
ensure_python_runtime || return 1
|
||||
if python3 -c "import pwn" 2>/dev/null; then log_ok "pwntools ready"; return 0; fi
|
||||
local package
|
||||
package=$(manifest_field pwntools pipPackage)
|
||||
package=$(manifest_field pwntools pipPackage) || return 1
|
||||
pipx install "$package" || python3 -m pip install --user "$package" || return 1
|
||||
}
|
||||
|
||||
@@ -937,6 +942,11 @@ done < <(expand_capabilities "${CAPABILITIES[@]}")
|
||||
|
||||
log_info "platform=$PLATFORM doc=$(platform_doc) tools_root=$TOOLS_ROOT"
|
||||
|
||||
if ! ensure_python_interpreter; then
|
||||
log_err "Python 3 is required to read bootstrap-manifest.json; no capability was executed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for cap in "${EXPANDED[@]}"; do
|
||||
log_info "ensure $cap"
|
||||
LAST_CAPABILITY_MANUAL=false
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
function Ensure-Pnpm {
|
||||
Ensure-NodeRuntime
|
||||
$dependency = Get-BootstrapDependency -Name 'pnpm'
|
||||
$pnpm = Get-NodeCommandPath -Name 'pnpm'
|
||||
$currentVersion = ''
|
||||
if ($pnpm) {
|
||||
$versionLine = & $pnpm --version 2>$null | Select-Object -First 1
|
||||
if ($LASTEXITCODE -eq 0 -and $null -ne $versionLine) {
|
||||
$currentVersion = ([string]$versionLine).Trim()
|
||||
}
|
||||
}
|
||||
if ($currentVersion -ne [string]$dependency.version) {
|
||||
$npm = Get-NodeCommandPath -Name 'npm'
|
||||
if ([string]::IsNullOrWhiteSpace($npm)) {
|
||||
throw 'npm is not available after Node.js installation.'
|
||||
}
|
||||
& $npm install -g ([string]$dependency.package)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to install pinned pnpm dependency $($dependency.package)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-GitCheckoutState {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$GitPath,
|
||||
[Parameter(Mandatory = $true)][string]$CheckoutPath,
|
||||
[Parameter(Mandatory = $true)][string]$PinnedCommit
|
||||
)
|
||||
|
||||
$resolvedLine = & $GitPath -C $CheckoutPath rev-parse HEAD 2>$null | Select-Object -First 1
|
||||
$resolvedCommit = if ($null -eq $resolvedLine) { '' } else { ([string]$resolvedLine).Trim() }
|
||||
if ($LASTEXITCODE -ne 0 -or $resolvedCommit -ne $PinnedCommit) {
|
||||
throw "Checkout verification failed: expected $PinnedCommit, got $resolvedCommit ($CheckoutPath)"
|
||||
}
|
||||
$status = @(& $GitPath -C $CheckoutPath status --porcelain --untracked-files=all 2>&1)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Cannot inspect checkout state: $CheckoutPath"
|
||||
}
|
||||
if ($status.Count -gt 0) {
|
||||
throw "Checkout has local changes; refusing to execute it: $CheckoutPath"
|
||||
}
|
||||
}
|
||||
|
||||
function Move-BootstrapDirectory {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Source,
|
||||
[Parameter(Mandatory = $true)][string]$Destination
|
||||
)
|
||||
[IO.Directory]::Move($Source, $Destination)
|
||||
}
|
||||
|
||||
function Ensure-GitCloneInstall {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]$Definition,
|
||||
[Parameter(Mandatory = $true)][string]$TargetPath
|
||||
)
|
||||
|
||||
$git = Get-FirstCommandPath -Names @('git')
|
||||
if ([string]::IsNullOrWhiteSpace($git)) {
|
||||
throw 'git is required for git-clone bootstrap definitions.'
|
||||
}
|
||||
|
||||
$pinnedCommit = if ($Definition.PSObject.Properties['pinnedCommit']) { [string]$Definition.pinnedCommit } else { '' }
|
||||
if ([string]::IsNullOrWhiteSpace($pinnedCommit)) {
|
||||
throw "Git capability $($Definition.repo) must define pinnedCommit."
|
||||
}
|
||||
|
||||
if ((Test-Path -LiteralPath $TargetPath -PathType Container) -and (Test-Path -LiteralPath (Join-Path $TargetPath '.git'))) {
|
||||
Assert-GitCheckoutState -GitPath $git -CheckoutPath $TargetPath -PinnedCommit $pinnedCommit
|
||||
return $true
|
||||
}
|
||||
if (Test-Path -LiteralPath $TargetPath) {
|
||||
throw "Install path exists but is not a git checkout: $TargetPath"
|
||||
}
|
||||
|
||||
$parent = Split-Path -Path $TargetPath -Parent
|
||||
Ensure-DownloadDirectory -Path $parent
|
||||
$stagePath = Join-Path $parent ('.reverse-bootstrap-{0}' -f [Guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $stagePath | Out-Null
|
||||
try {
|
||||
& $git init --quiet $stagePath
|
||||
if ($LASTEXITCODE -ne 0) { throw 'git init failed' }
|
||||
& $git -C $stagePath remote add origin $Definition.repo
|
||||
if ($LASTEXITCODE -ne 0) { throw 'git remote add failed' }
|
||||
& $git -C $stagePath fetch --depth 1 origin $pinnedCommit
|
||||
if ($LASTEXITCODE -ne 0) { throw 'git fetch failed' }
|
||||
& $git -C $stagePath checkout --quiet --detach FETCH_HEAD
|
||||
if ($LASTEXITCODE -ne 0) { throw 'git checkout failed' }
|
||||
Assert-GitCheckoutState -GitPath $git -CheckoutPath $stagePath -PinnedCommit $pinnedCommit
|
||||
Move-BootstrapDirectory -Source $stagePath -Destination $TargetPath
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $TargetPath '.git') -PathType Container)) {
|
||||
throw "Failed to promote staged checkout to $TargetPath"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $stagePath) {
|
||||
Remove-Item -LiteralPath $stagePath -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
function Invoke-AnythingAnalyzerPinnedInstall {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoDir,
|
||||
[Parameter(Mandatory = $true)][string]$PnpmPath,
|
||||
[Parameter(Mandatory = $true)][string]$GitPath,
|
||||
[Parameter(Mandatory = $true)][string]$PinnedCommit,
|
||||
[string]$VsBuildToolsError = ''
|
||||
)
|
||||
|
||||
$workspacePath = Join-Path $RepoDir 'pnpm-workspace.yaml'
|
||||
$workspaceExisted = Test-Path -LiteralPath $workspacePath -PathType Leaf
|
||||
$workspaceBytes = if ($workspaceExisted) { [IO.File]::ReadAllBytes($workspacePath) } else { $null }
|
||||
|
||||
Push-Location $RepoDir
|
||||
try {
|
||||
Approve-AnythingAnalyzerBuildScripts -RepoDir $RepoDir
|
||||
if (-not (Test-AnythingAnalyzerElectronHealthy -RepoDir $RepoDir -PnpmPath $PnpmPath)) {
|
||||
$nodeModules = Join-Path $RepoDir 'node_modules'
|
||||
Remove-Item -LiteralPath $nodeModules -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
& $PnpmPath install --frozen-lockfile
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if ($VsBuildToolsError) { throw "pnpm install failed for anything-analyzer. Visual Studio Build Tools auto-install also failed earlier: $VsBuildToolsError" }
|
||||
throw 'pnpm install failed for anything-analyzer.'
|
||||
}
|
||||
& $PnpmPath rebuild electron esbuild better-sqlite3
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if ($VsBuildToolsError) { throw "pnpm rebuild failed for anything-analyzer. Visual Studio Build Tools auto-install also failed earlier: $VsBuildToolsError" }
|
||||
throw 'pnpm rebuild failed for anything-analyzer.'
|
||||
}
|
||||
if (-not (Test-AnythingAnalyzerElectronHealthy -RepoDir $RepoDir -PnpmPath $PnpmPath)) {
|
||||
throw 'anything-analyzer Electron dependency is still unhealthy after pnpm rebuild.'
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
if ($workspaceExisted) {
|
||||
[IO.File]::WriteAllBytes($workspacePath, $workspaceBytes)
|
||||
}
|
||||
elseif (Test-Path -LiteralPath $workspacePath) {
|
||||
Remove-Item -LiteralPath $workspacePath -Force
|
||||
}
|
||||
}
|
||||
|
||||
Assert-GitCheckoutState -GitPath $GitPath -CheckoutPath $RepoDir -PinnedCommit $PinnedCommit
|
||||
}
|
||||
@@ -19,6 +19,11 @@ name="$(basename "$0")"
|
||||
case "$name:${1:-}" in
|
||||
pipx:--version) printf '%s\n' "${STUB_PIPX_VERSION:-0}" ;;
|
||||
pnpm:--version) printf '%s\n' "${STUB_PNPM_VERSION:-0}" ;;
|
||||
brew:install)
|
||||
if [[ "${2:-}" == python ]]; then
|
||||
ln -sf "$STUB_PYTHON_SOURCE" "$STUB_ACTIVE_BIN/python3"
|
||||
fi
|
||||
;;
|
||||
git:init)
|
||||
target="${!#}"; mkdir -p "$target/.git"; printf '%s\n' unpinned-head > "$target/.stub-head"
|
||||
;;
|
||||
@@ -43,6 +48,7 @@ esac
|
||||
STUB
|
||||
chmod +x "$STUB_BIN/command-stub"
|
||||
for name in git node npm npx pipx pnpm sleep nc; do ln -s command-stub "$STUB_BIN/$name"; done
|
||||
ln -s command-stub "$STUB_BIN/brew"
|
||||
|
||||
cat > "$STUB_BIN/python3" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
@@ -67,6 +73,7 @@ PY
|
||||
|
||||
run_generic() {
|
||||
env PATH="$STUB_BIN:/usr/bin:/bin" HOME="$SCRATCH/home" CALL_LOG="$CALL_LOG" \
|
||||
STUB_ACTIVE_BIN="$STUB_BIN" STUB_PYTHON_SOURCE="$STUB_BIN/python3" \
|
||||
STUB_PIPX_VERSION="${STUB_PIPX_VERSION:-}" STUB_PNPM_VERSION="${STUB_PNPM_VERSION:-}" \
|
||||
STUB_FAIL_PIP_INSTALL="${STUB_FAIL_PIP_INSTALL:-0}" STUB_FAIL_FETCH="${STUB_FAIL_FETCH:-0}" \
|
||||
REVERSE_SKILL_TOOLS_DIR="${TEST_TOOLS_ROOT:-$SCRATCH/tools}" \
|
||||
@@ -91,6 +98,45 @@ pnpm_package=$(json_value dependency pnpm)
|
||||
anything_repo=$(json_value anything-analyzer repoUrl)
|
||||
anything_pin=$(json_value anything-analyzer pinnedCommit)
|
||||
|
||||
# The manifest parser is bootstrapped before a Node-only sink, without installing pipx.
|
||||
NO_PYTHON_BIN="$SCRATCH/no-python-bin"
|
||||
PARSER_FIXTURE="$SCRATCH/parser-bootstrap"
|
||||
mkdir -p "$NO_PYTHON_BIN"
|
||||
for name in git node npm npx pipx pnpm sleep nc brew; do ln -s "$STUB_BIN/command-stub" "$NO_PYTHON_BIN/$name"; done
|
||||
for tool in bash uname dirname mktemp rm head tr basename mkdir cat ln; do ln -s "$(command -v "$tool")" "$NO_PYTHON_BIN/$tool"; done
|
||||
mkdir -p "$PARSER_FIXTURE"
|
||||
cp "$BOOTSTRAP" "$PARSER_FIXTURE/bootstrap-reverse.sh"
|
||||
cp "$MANIFEST" "$PARSER_FIXTURE/bootstrap-manifest.json"
|
||||
: > "$CALL_LOG"
|
||||
env PATH="$NO_PYTHON_BIN" HOME="$SCRATCH/home" CALL_LOG="$CALL_LOG" \
|
||||
STUB_ACTIVE_BIN="$NO_PYTHON_BIN" STUB_PYTHON_SOURCE="$STUB_BIN/python3" \
|
||||
REVERSE_SKILL_TOOLS_DIR="$SCRATCH/tools" CLAUDE_MCP_CONFIG="$SCRATCH/home/mcp.json" \
|
||||
bash "$PARSER_FIXTURE/bootstrap-reverse.sh" agent-browser --skip-refresh >/dev/null
|
||||
expect_line 'brew|install|python'
|
||||
expect_line "npm|install|-g|$(json_value agent-browser npmPackage)"
|
||||
! grep -Fq '|pip|install|' "$CALL_LOG"
|
||||
|
||||
# A required empty manifest field fails before any package-manager sink.
|
||||
BROKEN_DIR="$SCRATCH/broken-bootstrap"
|
||||
mkdir -p "$BROKEN_DIR"
|
||||
cp "$BOOTSTRAP" "$BROKEN_DIR/bootstrap-reverse.sh"
|
||||
"$REAL_PYTHON" - "$MANIFEST" "$BROKEN_DIR/bootstrap-manifest.json" <<'PY'
|
||||
import json, pathlib, sys
|
||||
data = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||
next(x for x in data['capabilities'] if x['name'] == 'agent-browser')['npmPackage'] = ''
|
||||
pathlib.Path(sys.argv[2]).write_text(json.dumps(data))
|
||||
PY
|
||||
: > "$CALL_LOG"
|
||||
set +e
|
||||
env PATH="$STUB_BIN:/usr/bin:/bin" HOME="$SCRATCH/home" CALL_LOG="$CALL_LOG" \
|
||||
STUB_ACTIVE_BIN="$STUB_BIN" STUB_PYTHON_SOURCE="$STUB_BIN/python3" \
|
||||
REVERSE_SKILL_TOOLS_DIR="$SCRATCH/tools" CLAUDE_MCP_CONFIG="$SCRATCH/home/mcp.json" \
|
||||
bash "$BROKEN_DIR/bootstrap-reverse.sh" agent-browser --skip-refresh >/dev/null 2>&1
|
||||
broken_rc=$?
|
||||
set -e
|
||||
[[ $broken_rc -ne 0 ]]
|
||||
! grep -Eq '^npm\|install\|-g(\||$)' "$CALL_LOG"
|
||||
|
||||
# Table: each generic package-manager sink receives its canonical manifest value.
|
||||
while IFS='|' read -r capability field expected; do
|
||||
: > "$CALL_LOG"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
$scratch = Join-Path ([IO.Path]::GetTempPath()) ('reverse-bootstrap-ps-' + [Guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $scratch | Out-Null
|
||||
|
||||
function Ensure-DownloadDirectory { param([string]$Path) New-Item -ItemType Directory -Path $Path -Force | Out-Null }
|
||||
function Get-FirstCommandPath { param([string[]]$Names) return (Get-Command $Names[0]).Source }
|
||||
function Ensure-NodeRuntime {}
|
||||
function Get-NodeCommandPath { param([string]$Name) $command = Get-Command $Name -ErrorAction SilentlyContinue; if ($command) { return $command.Source } }
|
||||
function Get-BootstrapDependency { return [pscustomobject]@{ package = 'pnpm@10.24.0'; version = '10.24.0' } }
|
||||
function Approve-AnythingAnalyzerBuildScripts { param([string]$RepoDir) Set-Content (Join-Path $RepoDir 'pnpm-workspace.yaml') 'generated' }
|
||||
function Test-AnythingAnalyzerElectronHealthy { return $true }
|
||||
|
||||
. (Join-Path $PSScriptRoot 'lib/BootstrapSupplyChain.ps1')
|
||||
|
||||
function Assert-True { param([bool]$Condition, [string]$Message) if (-not $Condition) { throw $Message } }
|
||||
function Invoke-Git { param([string[]]$Arguments) & git @Arguments; if ($LASTEXITCODE -ne 0) { throw "git failed: $Arguments" } }
|
||||
|
||||
try {
|
||||
$source = Join-Path $scratch 'source'
|
||||
New-Item -ItemType Directory -Path $source | Out-Null
|
||||
Invoke-Git -Arguments @('-C', $source, 'init', '--quiet')
|
||||
Invoke-Git -Arguments @('-C', $source, 'config', 'user.email', 'test@example.invalid')
|
||||
Invoke-Git -Arguments @('-C', $source, 'config', 'user.name', 'test')
|
||||
Set-Content (Join-Path $source 'package.json') '{}'
|
||||
Invoke-Git -Arguments @('-C', $source, 'add', 'package.json')
|
||||
Invoke-Git -Arguments @('-C', $source, 'commit', '--quiet', '-m', 'fixture')
|
||||
$pin = (& git -C $source rev-parse HEAD).Trim()
|
||||
$definition = [pscustomobject]@{ repo = $source; pinnedCommit = $pin }
|
||||
|
||||
$target = Join-Path $scratch 'installed'
|
||||
Ensure-GitCloneInstall -Definition $definition -TargetPath $target | Out-Null
|
||||
Assert-True ((& git -C $target rev-parse HEAD).Trim() -eq $pin) 'pinned checkout was not promoted'
|
||||
Set-Content (Join-Path $target 'package.json') '{"dirty":true}'
|
||||
try { Ensure-GitCloneInstall -Definition $definition -TargetPath $target | Out-Null; throw 'dirty checkout accepted' } catch { Assert-True ($_.Exception.Message -match 'local changes') 'dirty rejection reason changed' }
|
||||
|
||||
$failedTarget = Join-Path $scratch 'failed'
|
||||
$badDefinition = [pscustomobject]@{ repo = (Join-Path $scratch 'missing'); pinnedCommit = $pin }
|
||||
try { Ensure-GitCloneInstall -Definition $badDefinition -TargetPath $failedTarget | Out-Null; throw 'failed fetch accepted' } catch {}
|
||||
Assert-True (-not (Test-Path $failedTarget)) 'failed fetch poisoned final path'
|
||||
Assert-True (@(Get-ChildItem $scratch -Filter '.reverse-bootstrap-*').Count -eq 0) 'failed fetch left staging path'
|
||||
|
||||
$raceTarget = Join-Path $scratch 'race'
|
||||
$raceStage = Join-Path $scratch '.reverse-bootstrap-race'
|
||||
New-Item -ItemType Directory -Path $raceTarget, $raceStage | Out-Null
|
||||
Set-Content (Join-Path $raceTarget 'owner.txt') owner
|
||||
$raceRejected = $false
|
||||
try { Move-BootstrapDirectory -Source $raceStage -Destination $raceTarget } catch { $raceRejected = $true }
|
||||
Assert-True $raceRejected 'promotion race accepted'
|
||||
Assert-True ((Get-Content (Join-Path $raceTarget 'owner.txt')) -eq 'owner') 'promotion race modified concurrent target'
|
||||
Remove-Item -LiteralPath $raceStage -Recurse -Force
|
||||
|
||||
$bin = Join-Path $scratch 'bin'
|
||||
New-Item -ItemType Directory -Path $bin | Out-Null
|
||||
$env:PATH = "$bin$([IO.Path]::PathSeparator)$env:PATH"
|
||||
$env:BOOTSTRAP_PS_LOG = Join-Path $scratch 'commands.log'
|
||||
$isWindowsHost = $env:OS -eq 'Windows_NT'
|
||||
$stub = Join-Path $bin ($(if ($isWindowsHost) { 'npm.cmd' } else { 'npm' }))
|
||||
if ($isWindowsHost) {
|
||||
Set-Content $stub @'
|
||||
@echo off
|
||||
echo npm^|%*>>"%BOOTSTRAP_PS_LOG%"
|
||||
'@
|
||||
}
|
||||
else {
|
||||
Set-Content $stub @'
|
||||
#!/bin/sh
|
||||
printf "npm|%s\n" "$*" >> "$BOOTSTRAP_PS_LOG"
|
||||
'@
|
||||
& chmod +x $stub
|
||||
}
|
||||
$pnpm = Join-Path $bin ($(if ($isWindowsHost) { 'pnpm.cmd' } else { 'pnpm' }))
|
||||
if ($isWindowsHost) { Set-Content $pnpm "@echo off`r`necho 0" }
|
||||
else { Set-Content $pnpm "#!/bin/sh`necho 0"; & chmod +x $pnpm }
|
||||
Ensure-Pnpm
|
||||
Assert-True ((Get-Content $env:BOOTSTRAP_PS_LOG) -match 'npm\|install -g pnpm@10.24.0') 'pnpm install was not pinned'
|
||||
|
||||
Invoke-Git -Arguments @('-C', $target, 'checkout', '--quiet', '--', 'package.json')
|
||||
if ($isWindowsHost) {
|
||||
Set-Content $pnpm @'
|
||||
@echo off
|
||||
if "%1"=="--version" (echo 10.24.0) else (echo pnpm^|%*>>"%BOOTSTRAP_PS_LOG%")
|
||||
'@
|
||||
}
|
||||
else {
|
||||
Set-Content $pnpm @'
|
||||
#!/bin/sh
|
||||
[ "$1" = --version ] && { echo 10.24.0; exit; }
|
||||
printf "pnpm|%s\n" "$*" >> "$BOOTSTRAP_PS_LOG"
|
||||
'@
|
||||
& chmod +x $pnpm
|
||||
}
|
||||
function Approve-AnythingAnalyzerBuildScripts { param([string]$RepoDir) Set-Content (Join-Path $RepoDir 'pnpm-workspace.yaml') 'generated'; Set-Content (Join-Path $RepoDir 'package.json') '{"mutated":true}' }
|
||||
$dirtyRejected = $false
|
||||
try { Invoke-AnythingAnalyzerPinnedInstall -RepoDir $target -PnpmPath $pnpm -GitPath (Get-Command git).Source -PinnedCommit $pin } catch { $dirtyRejected = $_.Exception.Message -match 'local changes' }
|
||||
Assert-True $dirtyRejected 'post-install dirty checkout accepted or rejection reason changed'
|
||||
Assert-True (-not (Test-Path (Join-Path $target 'pnpm-workspace.yaml'))) 'generated workspace file was not removed'
|
||||
|
||||
Write-Host 'PowerShell bootstrap supply-chain regression passed'
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Reference in New Issue
Block a user