From abe673cbdd1d87b5e95de0b6fdff8882ed8b72c2 Mon Sep 17 00:00:00 2001 From: Mihir Nagda Date: Wed, 17 Jun 2026 11:09:30 +0530 Subject: [PATCH] fix: harden Windows bootstrap installer --- install.ps1 | 169 ++++++++++++++++++++++------ install.sh | 66 +++++++---- scripts/bootstrap_environment.py | 12 +- scripts/verify_environment.py | 10 +- tests/test_bootstrap_environment.py | 10 ++ tests/test_codex_port_contracts.py | 28 +++++ tests/test_verify_environment.py | 12 ++ 7 files changed, 242 insertions(+), 65 deletions(-) diff --git a/install.ps1 b/install.ps1 index 918f7f8..646ca71 100644 --- a/install.ps1 +++ b/install.ps1 @@ -2,6 +2,10 @@ # PowerShell installation script $ErrorActionPreference = "Stop" +$MinPythonMajor = 3 +$MinPythonMinor = 10 +$MaxPythonMajor = 3 +$MaxPythonMinor = 14 Write-Host "========================================" -ForegroundColor Cyan Write-Host " Codex SEO - Installer" -ForegroundColor Cyan @@ -10,14 +14,47 @@ Write-Host "========================================" -ForegroundColor Cyan Write-Host "" function Resolve-Python { - $pythonCmd = Get-Command -Name python -ErrorAction SilentlyContinue - if ($null -ne $pythonCmd) { - return @{ Exe = "python"; Args = @() } - } + $candidates = @() $pyCmd = Get-Command -Name py -ErrorAction SilentlyContinue if ($null -ne $pyCmd) { - return @{ Exe = "py"; Args = @("-3") } + foreach ($minor in @(13, 12, 11, 10)) { + $candidates += @{ Exe = "py"; Args = @("-3.$minor") } + } + $candidates += @{ Exe = "py"; Args = @("-3") } + } + + foreach ($name in @("python", "python3")) { + $cmd = Get-Command -Name $name -ErrorAction SilentlyContinue + if ($null -ne $cmd) { + $candidates += @{ Exe = $name; Args = @() } + } + } + + foreach ($candidate in $candidates) { + try { + $versionOutput = (& $candidate.Exe @($candidate.Args + @("--version")) 2>&1 | Select-Object -Last 1).ToString().Trim() + } catch { + continue + } + + if ($versionOutput -notmatch "Python\s+(\d+)\.(\d+)") { + continue + } + + $major = [int]$Matches[1] + $minor = [int]$Matches[2] + $meetsMinimum = $major -gt $MinPythonMajor -or ($major -eq $MinPythonMajor -and $minor -ge $MinPythonMinor) + $belowMaximum = $major -lt $MaxPythonMajor -or ($major -eq $MaxPythonMajor -and $minor -lt $MaxPythonMinor) + if ($meetsMinimum -and $belowMaximum) { + return @{ + Exe = $candidate.Exe + Args = $candidate.Args + VersionOutput = $versionOutput + Major = $major + Minor = $minor + } + } } return $null @@ -102,12 +139,24 @@ import sys payload = json.loads(sys.stdin.read()) verification = payload.get("verification") or {} +failed_step = None +for step in payload.get("steps") or []: + if step.get("required") and not step.get("ok"): + failed_step = { + "group": step.get("group") or "unknown", + "stdout": step.get("stdout") or "", + "stderr": step.get("stderr") or "", + "ok": False, + "required": True, + } + break summary = { "ok": bool(payload.get("ok")), "full_ready": bool(payload.get("full_ready")), "python": payload.get("python") or "", "optional_failed_groups": payload.get("optional_failed_groups") or [], "verification": {"notes": verification.get("notes") or []}, + "steps": [failed_step] if failed_step else [], "error": payload.get("error") or "", } print(json.dumps(summary)) @@ -136,6 +185,63 @@ print(json.dumps(summary)) } } +function Write-BootstrapDiagnostics { + param([object]$Payload) + + if ($null -eq $Payload) { + return + } + + if (-not [string]::IsNullOrWhiteSpace($Payload.error)) { + Write-Host "[ERROR] $($Payload.error)" -ForegroundColor Red + } + + $verificationNotes = @() + if ($null -ne $Payload.verification -and $null -ne $Payload.verification.notes) { + $verificationNotes = @($Payload.verification.notes) + } + $verificationNotes | Select-Object -First 5 | ForEach-Object { + Write-Host "[ERROR] $_" -ForegroundColor Red + } + + $failedStep = $null + if ($null -ne $Payload.steps) { + $failedStep = @($Payload.steps) | Where-Object { $_.required -and -not $_.ok } | Select-Object -First 1 + } + if ($null -eq $failedStep) { + return + } + + $group = if ($failedStep.group) { $failedStep.group } else { "unknown" } + Write-Host "[ERROR] Failed bootstrap step: $group." -ForegroundColor Red + $diagnostic = if ($failedStep.stderr) { $failedStep.stderr } else { $failedStep.stdout } + if (-not [string]::IsNullOrWhiteSpace($diagnostic)) { + if ($group -eq "verification") { + try { + $verificationPayload = $diagnostic | ConvertFrom-Json + if ($null -ne $verificationPayload.missing_required -and @($verificationPayload.missing_required).Count -gt 0) { + Write-Host "[ERROR] Missing required packages: $(@($verificationPayload.missing_required) -join ', ')." -ForegroundColor Red + } + if ($null -ne $verificationPayload.paths) { + foreach ($pathName in @("cache", "output")) { + $pathCheck = $verificationPayload.paths.$pathName + if ($null -ne $pathCheck -and -not $pathCheck.ok) { + Write-Host "[ERROR] Cannot write $pathName path $($pathCheck.path): $($pathCheck.error)" -ForegroundColor Red + } + } + } + return + } catch { + # Fall back to a compact stderr/stdout tail below. + } + } + $lines = $diagnostic.Trim() -split "`r?`n" + if ($lines.Count -gt 0) { + Write-Host "[ERROR] $($lines[-1])" -ForegroundColor Red + } + } +} + function Remove-PathIfExists { param([string]$Path) @@ -146,29 +252,14 @@ function Remove-PathIfExists { $python = Resolve-Python if ($null -eq $python) { - Write-Host "[ERROR] Python 3 is required but was not found in PATH." -ForegroundColor Red - exit 1 -} - -try { - $pythonVersionOutput = (& $python.Exe @($python.Args + @("--version")) 2>&1 | Select-Object -Last 1).ToString().Trim() -} catch { - Write-Host "[ERROR] Failed to determine Python version." -ForegroundColor Red - exit 1 -} - -if ($pythonVersionOutput -notmatch "Python\s+(\d+)\.(\d+)") { - Write-Host "[ERROR] Failed to parse Python version from: $pythonVersionOutput" -ForegroundColor Red + Write-Host "[ERROR] Python 3.10-3.13 is required but was not found in PATH." -ForegroundColor Red + Write-Host "[ERROR] Python 3.14 is not supported yet by all Codex SEO dependencies." -ForegroundColor Red exit 1 } -$pythonMajor = [int]$Matches[1] -$pythonMinor = [int]$Matches[2] +$pythonMajor = $python.Major +$pythonMinor = $python.Minor $pythonVersion = "$pythonMajor.$pythonMinor" -if ($pythonMajor -lt 3 -or ($pythonMajor -eq 3 -and $pythonMinor -lt 10)) { - Write-Host "[ERROR] Python 3.10+ is required but $pythonVersion was found." -ForegroundColor Red - exit 1 -} Write-Host "[OK] Python $pythonVersion detected" -ForegroundColor Green $gitCmd = Get-Command -Name git -ErrorAction SilentlyContinue @@ -183,6 +274,11 @@ $agentDir = Join-Path $codexRoot "agents" $skillDir = Join-Path $skillsRoot "seo" $repoUrl = if ($env:CODEX_SEO_REPO) { $env:CODEX_SEO_REPO } else { "https://github.com/AgriciDaniel/codex-seo" } $repoRef = if ($env:CODEX_SEO_REF) { $env:CODEX_SEO_REF } else { "v1.9.6-codex.5" } +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$localSource = if ( + (Test-Path (Join-Path $scriptDir ".codex-plugin\plugin.json")) -and + (Test-Path (Join-Path $scriptDir "skills\seo\SKILL.md")) +) { $scriptDir } else { "" } $skipPlaywrightBrowser = Test-Truthy $env:CODEX_SEO_SKIP_PLAYWRIGHT_BROWSER $playwrightWithDeps = Test-Truthy $env:CODEX_SEO_PLAYWRIGHT_WITH_DEPS $suiteSkillDirs = @( @@ -221,12 +317,17 @@ New-Item -ItemType Directory -Force -Path $agentDir | Out-Null $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString()) New-Item -ItemType Directory -Force -Path $tempDir | Out-Null try { - $checkoutDir = Join-Path $tempDir "codex-seo" - - Write-Host "[INFO] Downloading Codex SEO ($repoRef)..." -ForegroundColor Yellow - $cloneResult = Invoke-External -Exe "git" -Args @("clone", "--depth", "1", "--branch", $repoRef, $repoUrl, $checkoutDir) - if ($cloneResult.ExitCode -ne 0) { - throw "Unable to download ref $repoRef. Confirm the branch/tag exists and your Git credentials can access $repoUrl." + if (-not [string]::IsNullOrWhiteSpace($localSource)) { + $checkoutDir = $localSource + Write-Host "[INFO] Installing Codex SEO from local checkout..." -ForegroundColor Yellow + } else { + $checkoutDir = Join-Path $tempDir "codex-seo" + + Write-Host "[INFO] Downloading Codex SEO ($repoRef)..." -ForegroundColor Yellow + $cloneResult = Invoke-External -Exe "git" -Args @("clone", "--depth", "1", "--branch", $repoRef, $repoUrl, $checkoutDir) + if ($cloneResult.ExitCode -ne 0) { + throw "Unable to download ref $repoRef. Confirm the branch/tag exists and your Git credentials can access $repoUrl." + } } $commitResult = Invoke-External -Exe "git" -Args @("-C", $checkoutDir, "rev-parse", "HEAD") -Quiet @@ -318,13 +419,7 @@ try { $bootstrapPayload = ConvertFrom-JsonCompat -Json $bootstrapJson -ErrorMessage "Bootstrap script produced invalid JSON output." if ($bootstrapResult.ExitCode -ne 0 -or -not $bootstrapPayload.ok) { - $verificationNotes = @() - if ($null -ne $bootstrapPayload.verification -and $null -ne $bootstrapPayload.verification.notes) { - $verificationNotes = @($bootstrapPayload.verification.notes) - } - if ($verificationNotes.Count -gt 0) { - $verificationNotes | ForEach-Object { Write-Host "[ERROR] $_" -ForegroundColor Red } - } + Write-BootstrapDiagnostics -Payload $bootstrapPayload throw "Codex SEO runtime bootstrap failed." } diff --git a/install.sh b/install.sh index fe1a4e0..d1bf9d9 100755 --- a/install.sh +++ b/install.sh @@ -2,14 +2,15 @@ set -euo pipefail resolve_python() { - if command -v python3 >/dev/null 2>&1; then - printf '%s\n' "python3" - return - fi - if command -v python >/dev/null 2>&1; then - printf '%s\n' "python" - return - fi + for candidate in python3.13 python3.12 python3.11 python3.10 python3 python; do + if ! command -v "${candidate}" >/dev/null 2>&1; then + continue + fi + if "${candidate}" -c 'import sys; raise SystemExit(0 if (3, 10) <= sys.version_info[:2] < (3, 14) else 1)' >/dev/null 2>&1; then + printf '%s\n' "${candidate}" + return + fi + done return 1 } @@ -78,7 +79,16 @@ main() { SKILL_DIR="${SKILLS_ROOT}/seo" REPO_URL="${CODEX_SEO_REPO:-https://github.com/AgriciDaniel/codex-seo}" REPO_REF="${CODEX_SEO_REF:-v1.9.6-codex.5}" - PYTHON_BIN="$(resolve_python)" || { echo "[ERROR] Python 3 is required but not installed."; exit 1; } + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + LOCAL_SOURCE="" + if [ -f "${SCRIPT_DIR}/.codex-plugin/plugin.json" ] && [ -f "${SCRIPT_DIR}/skills/seo/SKILL.md" ]; then + LOCAL_SOURCE="${SCRIPT_DIR}" + fi + PYTHON_BIN="$(resolve_python)" || { + echo "[ERROR] Python 3.10-3.13 is required but not installed." + echo "[ERROR] Python 3.14 is not supported yet by all Codex SEO dependencies." + exit 1 + } SUITE_SKILL_DIRS=( seo seo-audit @@ -118,9 +128,9 @@ main() { command -v git >/dev/null 2>&1 || { echo "[ERROR] Git is required but not installed."; exit 1; } PYTHON_VERSION="$("${PYTHON_BIN}" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" - PYTHON_OK="$("${PYTHON_BIN}" -c 'import sys; print(1 if sys.version_info >= (3, 10) else 0)')" + PYTHON_OK="$("${PYTHON_BIN}" -c 'import sys; print(1 if (3, 10) <= sys.version_info[:2] < (3, 14) else 0)')" if [ "${PYTHON_OK}" = "0" ]; then - echo "[ERROR] Python 3.10+ is required but ${PYTHON_VERSION} was found." + echo "[ERROR] Python 3.10-3.13 is required but ${PYTHON_VERSION} was found." exit 1 fi echo "[OK] Python ${PYTHON_VERSION} detected" @@ -130,13 +140,19 @@ main() { TEMP_DIR="$(make_temp_dir)" || { echo "[ERROR] Unable to create a temporary directory."; exit 1; } trap 'rm -rf "${TEMP_DIR}"' EXIT - echo "[INFO] Downloading Codex SEO (${REPO_REF})..." - if ! git clone --depth 1 --branch "${REPO_REF}" "${REPO_URL}" "${TEMP_DIR}/codex-seo" 2>/dev/null; then - echo "[ERROR] Unable to download ref ${REPO_REF}. Confirm the branch/tag exists and your Git credentials can access ${REPO_URL}." - exit 1 + if [ -n "${LOCAL_SOURCE}" ]; then + echo "[INFO] Installing Codex SEO from local checkout..." + CHECKOUT_DIR="${LOCAL_SOURCE}" + else + echo "[INFO] Downloading Codex SEO (${REPO_REF})..." + CHECKOUT_DIR="${TEMP_DIR}/codex-seo" + if ! git clone --depth 1 --branch "${REPO_REF}" "${REPO_URL}" "${CHECKOUT_DIR}" 2>/dev/null; then + echo "[ERROR] Unable to download ref ${REPO_REF}. Confirm the branch/tag exists and your Git credentials can access ${REPO_URL}." + exit 1 + fi fi - INSTALLED_COMMIT="$(git -C "${TEMP_DIR}/codex-seo" rev-parse HEAD)" + INSTALLED_COMMIT="$(git -C "${CHECKOUT_DIR}" rev-parse HEAD)" echo "[INFO] Resetting existing Codex SEO install..." for skill_name in "${SUITE_SKILL_DIRS[@]}"; do @@ -145,8 +161,8 @@ main() { rm -f "${AGENT_DIR}/seo-"*.md "${AGENT_DIR}/seo-"*.toml 2>/dev/null || true echo "[INFO] Installing skill files..." - if [ -d "${TEMP_DIR}/codex-seo/skills" ]; then - for skill_dir in "${TEMP_DIR}/codex-seo/skills"/*/; do + if [ -d "${CHECKOUT_DIR}/skills" ]; then + for skill_dir in "${CHECKOUT_DIR}/skills"/*/; do [ -d "${skill_dir}" ] || continue skill_name="$(basename "${skill_dir}")" target="${SKILLS_ROOT}/${skill_name}" @@ -156,26 +172,26 @@ main() { fi for dir_name in scripts schema pdf hooks extensions; do - if [ -d "${TEMP_DIR}/codex-seo/${dir_name}" ]; then + if [ -d "${CHECKOUT_DIR}/${dir_name}" ]; then mkdir -p "${SKILL_DIR}/${dir_name}" - cp -r "${TEMP_DIR}/codex-seo/${dir_name}/." "${SKILL_DIR}/${dir_name}/" + cp -r "${CHECKOUT_DIR}/${dir_name}/." "${SKILL_DIR}/${dir_name}/" fi done - for requirements_file in "${TEMP_DIR}/codex-seo"/requirements*.txt; do + for requirements_file in "${CHECKOUT_DIR}"/requirements*.txt; do [ -f "${requirements_file}" ] || continue cp "${requirements_file}" "${SKILL_DIR}/$(basename "${requirements_file}")" done for doc_name in CHANGELOG.md README.md; do - if [ -f "${TEMP_DIR}/codex-seo/${doc_name}" ]; then - cp "${TEMP_DIR}/codex-seo/${doc_name}" "${SKILL_DIR}/${doc_name}" + if [ -f "${CHECKOUT_DIR}/${doc_name}" ]; then + cp "${CHECKOUT_DIR}/${doc_name}" "${SKILL_DIR}/${doc_name}" fi done echo "[INFO] Installing agent profiles..." - if [ -d "${TEMP_DIR}/codex-seo/agents" ]; then - cp "${TEMP_DIR}/codex-seo/agents/"*.toml "${AGENT_DIR}/" + if [ -d "${CHECKOUT_DIR}/agents" ]; then + cp "${CHECKOUT_DIR}/agents/"*.toml "${AGENT_DIR}/" fi BOOTSTRAP_SCRIPT="${SKILL_DIR}/scripts/bootstrap_environment.py" diff --git a/scripts/bootstrap_environment.py b/scripts/bootstrap_environment.py index 0116ad3..33acfce 100644 --- a/scripts/bootstrap_environment.py +++ b/scripts/bootstrap_environment.py @@ -92,8 +92,13 @@ def parse_json_stdout(step: dict[str, Any]) -> dict[str, Any] | None: """Parse a subprocess stdout payload as JSON when possible.""" if not step["ok"] or not step["stdout"].strip(): return None + text = step["stdout"].strip() + start = text.find("{") + end = text.rfind("}") + if start >= 0 and end > start: + text = text[start : end + 1] try: - return json.loads(step["stdout"]) + return json.loads(text) except json.JSONDecodeError: return None @@ -233,7 +238,10 @@ def main() -> int: result = exception_payload(exc) if args.json: - write_json_output(args.json_output, result) + try: + write_json_output(args.json_output, result) + except Exception as exc: # pragma: no cover - depends on filesystem permissions + result = exception_payload(exc) print(json.dumps(result, indent=2)) return 0 if result["ok"] else 1 diff --git a/scripts/verify_environment.py b/scripts/verify_environment.py index 108f0f2..097b484 100644 --- a/scripts/verify_environment.py +++ b/scripts/verify_environment.py @@ -10,6 +10,8 @@ from __future__ import annotations import argparse +import contextlib +import io import importlib import json import sys @@ -85,7 +87,8 @@ def normalize_url(target: str) -> str: def check_dependency(module_name: str, package_name: str) -> dict[str, Any]: """Check whether a dependency can be imported.""" try: - module = importlib.import_module(module_name) + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + module = importlib.import_module(module_name) version = getattr(module, "__version__", None) return {"package": package_name, "module": module_name, "ok": True, "version": version} except Exception as exc: # noqa: BLE001 @@ -201,6 +204,11 @@ def verify_environment(target: str | None = None) -> dict[str, Any]: checks["notes"].append(f"Missing Google API packages: {', '.join(missing_google)}.") if missing_optional: checks["notes"].append(f"Missing optional packages: {', '.join(missing_optional)}.") + for path_name, path_check in writable_checks.items(): + if not path_check["ok"]: + checks["notes"].append( + f"Cannot write {path_name} path {path_check['path']}: {path_check.get('error', 'unknown error')}." + ) if not missing_visual and not playwright_browser["ok"]: checks["notes"].append("Playwright Chromium is unavailable, so visual analysis and PDF generation are not fully ready until `playwright install chromium` succeeds.") if checks["capabilities"]["core_ready"] and not checks["capabilities"]["full_ready"]: diff --git a/tests/test_bootstrap_environment.py b/tests/test_bootstrap_environment.py index af13021..0673e0f 100644 --- a/tests/test_bootstrap_environment.py +++ b/tests/test_bootstrap_environment.py @@ -130,6 +130,16 @@ def fake_run_command(cmd: list[str], cwd: Path | None = None): assert len(failed_required_steps) == 2 +def test_parse_json_stdout_extracts_json_after_import_banner(): + payload = {"ready": True, "capabilities": {"core_ready": True}} + step = { + "ok": True, + "stdout": "WeasyPrint could not import some external libraries\n" + json.dumps(payload), + } + + assert bootstrap_module.parse_json_stdout(step) == payload + + def test_run_command_truncates_large_output(monkeypatch): class Completed: returncode = 0 diff --git a/tests/test_codex_port_contracts.py b/tests/test_codex_port_contracts.py index 8ccaf65..9c38e3b 100644 --- a/tests/test_codex_port_contracts.py +++ b/tests/test_codex_port_contracts.py @@ -196,6 +196,34 @@ def test_installers_use_bootstrap_json_output_file(): assert "--json-output" in install_ps1 +def test_installers_use_local_checkout_when_run_from_repo_root(): + install_sh = (ROOT / "install.sh").read_text(encoding="utf-8") + install_ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8") + assert "Installing Codex SEO from local checkout" in install_sh + assert 'CHECKOUT_DIR="${LOCAL_SOURCE}"' in install_sh + assert "Installing Codex SEO from local checkout" in install_ps1 + assert "$checkoutDir = $localSource" in install_ps1 + + +def test_installers_reject_python_314_until_dependencies_support_it(): + install_sh = (ROOT / "install.sh").read_text(encoding="utf-8") + install_ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8") + assert "Python 3.10-3.13 is required" in install_sh + assert "sys.version_info[:2] < (3, 14)" in install_sh + assert "Python 3.10-3.13 is required" in install_ps1 + assert "$MaxPythonMinor = 14" in install_ps1 + + +def test_windows_installer_prints_failed_bootstrap_step_diagnostics(): + install_ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8") + assert "Write-BootstrapDiagnostics" in install_ps1 + assert "Failed bootstrap step" in install_ps1 + assert "$failedStep.stderr" in install_ps1 + assert "failed_step = {" in install_ps1 + assert '"steps": [failed_step] if failed_step else []' in install_ps1 + assert "Cannot write $pathName path" in install_ps1 + + def test_api_readiness_matrix_covers_smoke_suite(): smoke_text = (ROOT / "scripts" / "run_api_smoke_suite.py").read_text(encoding="utf-8") match = re.search(r"DEFAULT_SKILLS = (\[[\s\S]*?\])", smoke_text) diff --git a/tests/test_verify_environment.py b/tests/test_verify_environment.py index 80d1fa0..95285c5 100644 --- a/tests/test_verify_environment.py +++ b/tests/test_verify_environment.py @@ -27,6 +27,18 @@ def test_verify_environment_returns_expected_shape(): assert "missing_google_api" in result +def test_verify_environment_reports_unwritable_paths(monkeypatch): + def fake_check_writable(path): + return {"ok": False, "path": str(path), "error": "permission denied"} + + monkeypatch.setattr("verify_environment.check_writable", fake_check_writable) + monkeypatch.setattr("verify_environment.check_playwright_browser", lambda: {"ok": False, "error": "missing"}) + result = verify_environment() + assert any("Cannot write cache path" in note for note in result["notes"]) + assert any("Cannot write output path" in note for note in result["notes"]) + assert not result["capabilities"]["core_ready"] + + def test_verify_environment_cli_survives_without_site_packages(): completed = subprocess.run( [sys.executable, "-S", str(SCRIPTS_DIR / "verify_environment.py"), "--json"],