diff --git a/CHANGELOG.md b/CHANGELOG.md index 3456e35..2ecfe4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and Base versions are tracked in the repo-root `VERSION` file. ### Added +- Added opt-in project Git `origin` reachability diagnostics with + `basectl check|doctor --remote-network`. - Added an explicit `ai` prerequisite profile for Codex CLI and Claude Code setup, check, doctor, onboard, and shell completion flows. diff --git a/cli/bash/commands/basectl/subcommands/check.sh b/cli/bash/commands/basectl/subcommands/check.sh index 97a61a9..e7aca0f 100644 --- a/cli/bash/commands/basectl/subcommands/check.sh +++ b/cli/bash/commands/basectl/subcommands/check.sh @@ -17,6 +17,7 @@ Options: --profile Include named prerequisite profiles. Known profiles: dev, sre, ai. --format Select output format. Defaults to text. --manifest Use a specific base_manifest.yaml path for project checks. + --remote-network Opt in to bounded project Git origin reachability checks. -v Enable DEBUG logging for this subcommand. -h, --help Show this help text. @@ -36,6 +37,7 @@ EOF base_check_subcommand_main() { local output_format="text" local project="" + local remote_network=false setup_clear_run_state @@ -86,6 +88,9 @@ base_check_subcommand_main() { BASE_SETUP_MANIFEST="$1" export BASE_SETUP_MANIFEST ;; + --remote-network) + remote_network=true + ;; -v) setup_enable_debug_logging ;; @@ -107,10 +112,12 @@ base_check_subcommand_main() { done BASE_SETUP_PROJECT_NAME="$project" + BASE_SETUP_REMOTE_NETWORK="$remote_network" export BASE_SETUP_PROJECT_NAME + export BASE_SETUP_REMOTE_NETWORK log_debug "Running 'basectl check'." if [[ "$output_format" == json ]]; then - setup_run_check_json + setup_run_check_json "$remote_network" else setup_run_check fi diff --git a/cli/bash/commands/basectl/subcommands/doctor.sh b/cli/bash/commands/basectl/subcommands/doctor.sh index 9bd3f90..12a424a 100644 --- a/cli/bash/commands/basectl/subcommands/doctor.sh +++ b/cli/bash/commands/basectl/subcommands/doctor.sh @@ -17,6 +17,7 @@ Options: --profile Include named prerequisite profiles. Known profiles: dev, sre, ai. --format Select output format. Defaults to text. --manifest Use a specific base_manifest.yaml path for project diagnostics. + --remote-network Opt in to bounded project Git origin reachability diagnostics. -v Enable DEBUG logging for this subcommand. -h, --help Show this help text. @@ -259,6 +260,7 @@ base_doctor_run_json() { local errors=0 profile_errors=0 profile_json="[]" local project="$1" local project_errors=0 project_json="[]" + local remote_network="${2:-${BASE_SETUP_REMOTE_NETWORK:-}}" local status="ok" setup_collect_base_check_results warn || true @@ -277,7 +279,7 @@ base_doctor_run_json() { fi if [[ -n "$project" ]]; then - if project_json="$(setup_run_project_artifact_doctor_json)"; then + if project_json="$(setup_run_project_artifact_doctor_json "$remote_network")"; then project_errors=0 else project_errors=$? @@ -321,6 +323,7 @@ base_doctor_run_json() { base_doctor_subcommand_main() { local errors=0 output_format="text" profile_errors=0 project="" + local remote_network=false setup_clear_run_state @@ -371,6 +374,9 @@ base_doctor_subcommand_main() { BASE_SETUP_MANIFEST="$1" export BASE_SETUP_MANIFEST ;; + --remote-network) + remote_network=true + ;; -v) setup_enable_debug_logging ;; @@ -392,11 +398,13 @@ base_doctor_subcommand_main() { done BASE_SETUP_PROJECT_NAME="$project" + BASE_SETUP_REMOTE_NETWORK="$remote_network" export BASE_SETUP_PROJECT_NAME + export BASE_SETUP_REMOTE_NETWORK log_debug "Running 'basectl doctor'." if setup_ci_runtime_only; then if [[ "$output_format" == json ]]; then - base_doctor_run_json "$project" + base_doctor_run_json "$project" "$remote_network" else base_doctor_run_ci_runtime_text "$project" fi @@ -406,7 +414,7 @@ base_doctor_subcommand_main() { setup_require_macos if [[ "$output_format" == json ]]; then - base_doctor_run_json "$project" + base_doctor_run_json "$project" "$remote_network" return $? fi diff --git a/cli/bash/commands/basectl/subcommands/setup_common.sh b/cli/bash/commands/basectl/subcommands/setup_common.sh index 0de4e40..260bb8c 100644 --- a/cli/bash/commands/basectl/subcommands/setup_common.sh +++ b/cli/bash/commands/basectl/subcommands/setup_common.sh @@ -53,7 +53,7 @@ setup_ensure_cached_paths() { setup_clear_run_state() { # Clear legacy lowercase state too so inherited environments cannot trigger # lib_std.sh dry-run behavior unless this command explicitly enables it. - unset dry_run DRY_RUN BASE_SETUP_PROFILE_ERROR BASE_SETUP_PROFILES BASE_SETUP_PROJECT_NAME BASE_SETUP_MANIFEST BASE_SETUP_RECREATE_VENV BASE_PROJECT + unset dry_run DRY_RUN BASE_SETUP_PROFILE_ERROR BASE_SETUP_PROFILES BASE_SETUP_PROJECT_NAME BASE_SETUP_MANIFEST BASE_SETUP_REMOTE_NETWORK BASE_SETUP_RECREATE_VENV BASE_PROJECT setup_refresh_cached_paths } @@ -861,6 +861,7 @@ setup_run_project_pre_venv_layer() { local output_format="$2" local manifest_path="$3" local project="$4" + local remote_network="${5:-${BASE_SETUP_REMOTE_NETWORK:-}}" local python_bin venv_dir local args=() @@ -871,6 +872,9 @@ setup_run_project_pre_venv_layer() { args+=(--manifest "$manifest_path") args+=(--action "$action") args+=(--format "$output_format") + if [[ "$remote_network" == true ]]; then + args+=(--remote-network) + fi args+=("$project") env BASE_HOME="$BASE_HOME" BASE_PROJECT="$project" PYTHONPATH="$_BASE_SETUP_PYTHONPATH_CACHE" "$python_bin" -m base_setup "${args[@]}" @@ -912,7 +916,7 @@ setup_run_project_bootstrap_layer() { setup_run_project_artifact_layer() { local action="$1" local output_format="$2" - local exit_code manifest_path precheck_json project project_venv_dir python_bin resolved_name resolved_root resolve_output venv_dir + local exit_code manifest_path precheck_json project project_venv_dir python_bin remote_network resolved_name resolved_root resolve_output venv_dir local args=() if setup_is_dry_run && ! setup_base_python_package_installed "$(setup_pyyaml_package)"; then @@ -954,6 +958,11 @@ setup_run_project_artifact_layer() { if [[ "$action" == check || "$action" == doctor ]]; then args+=(--format "$output_format") fi + remote_network=false + if [[ "${BASE_SETUP_REMOTE_NETWORK:-}" == true && ( "$action" == check || "$action" == doctor ) ]]; then + args+=(--remote-network) + remote_network=true + fi args+=("$project") if [[ "$output_format" != json ]]; then @@ -982,7 +991,7 @@ setup_run_project_artifact_layer() { fi if [[ "$output_format" == json ]]; then if [[ "$action" == doctor ]]; then - precheck_json="$(setup_run_project_pre_venv_layer predoctor json "$manifest_path" "$project")" || true + precheck_json="$(setup_run_project_pre_venv_layer predoctor json "$manifest_path" "$project" "$remote_network")" || true [[ -n "$precheck_json" ]] || precheck_json="[]" setup_print_project_venv_doctor_json \ "$precheck_json" \ @@ -990,7 +999,7 @@ setup_run_project_artifact_layer() { "$_BASE_SETUP_VENV_HEALTH_MESSAGE" \ "$(setup_recovery_project_venv "$project")" else - precheck_json="$(setup_run_project_pre_venv_layer precheck json "$manifest_path" "$project")" || true + precheck_json="$(setup_run_project_pre_venv_layer precheck json "$manifest_path" "$project" "$remote_network")" || true [[ -n "$precheck_json" ]] || precheck_json="[]" setup_print_project_check_json_with_venv \ "$precheck_json" \ @@ -1000,11 +1009,11 @@ setup_run_project_artifact_layer() { "$project" fi elif [[ "$action" == doctor ]]; then - setup_run_project_pre_venv_layer predoctor text "$manifest_path" "$project" || true + setup_run_project_pre_venv_layer predoctor text "$manifest_path" "$project" "$remote_network" || true printf 'error %-9s %-26s %s\n' "BASE-P050" "Project virtualenv" "$_BASE_SETUP_VENV_HEALTH_MESSAGE" printf ' Fix: %s\n' "$(setup_recovery_project_venv "$project")" elif [[ "$action" == check ]]; then - setup_run_project_pre_venv_layer precheck text "$manifest_path" "$project" || true + setup_run_project_pre_venv_layer precheck text "$manifest_path" "$project" "$remote_network" || true log_warn "$_BASE_SETUP_VENV_HEALTH_MESSAGE" log_warn "$(setup_recovery_project_venv "$project")" else @@ -1036,6 +1045,10 @@ setup_run_project_artifact_check() { } setup_run_project_artifact_check_json() { + if [[ -n "${1:-}" ]]; then + BASE_SETUP_REMOTE_NETWORK="$1" + export BASE_SETUP_REMOTE_NETWORK + fi setup_run_project_artifact_layer check json } @@ -1044,6 +1057,10 @@ setup_run_project_artifact_doctor() { } setup_run_project_artifact_doctor_json() { + if [[ -n "${1:-}" ]]; then + BASE_SETUP_REMOTE_NETWORK="$1" + export BASE_SETUP_REMOTE_NETWORK + fi setup_run_project_artifact_layer doctor json } @@ -1655,6 +1672,7 @@ setup_run_check_json() { local project="${BASE_SETUP_PROJECT_NAME:-}" local project_json="" local project_status="ok" + local remote_network="${1:-${BASE_SETUP_REMOTE_NETWORK:-}}" local status setup_collect_base_check_results warn || true @@ -1669,7 +1687,7 @@ setup_run_check_json() { fi if [[ -n "$project" ]]; then - if ! project_json="$(setup_run_project_artifact_check_json)"; then + if ! project_json="$(setup_run_project_artifact_check_json "$remote_network")"; then [[ -n "$project_json" ]] || project_json='{"schema_version":1,"status":"error","checks":[]}' fi project_status="$(setup_json_payload_status "$project_json")" diff --git a/cli/bash/commands/basectl/tests/check.bats b/cli/bash/commands/basectl/tests/check.bats index 534303e..965dcb2 100644 --- a/cli/bash/commands/basectl/tests/check.bats +++ b/cli/bash/commands/basectl/tests/check.bats @@ -11,6 +11,7 @@ load ./setup_helpers.bash [[ "$output" == *"basectl check [project] [options]"* ]] [[ "$output" != *"--dev"* ]] [[ "$output" == *"--profile "* ]] + [[ "$output" == *"--remote-network"* ]] [[ "$output" == *"Verify the local Base CLI environment and, when provided, project artifacts on macOS without making changes."* ]] } @@ -221,6 +222,27 @@ load ./setup_helpers.bash [ "$(cat "$TEST_STATE_DIR/project-setup-args")" = "$(printf '%s\n' --manifest "$workspace/demo/base_manifest.yaml" --action check --format text demo)" ] } +@test "basectl check project passes opt-in remote network diagnostics flag" { + local venv_dir="$TEST_HOME/.base.d/base/.venv" + local workspace="$TEST_TMPDIR/workspace" + + create_brew_stub + create_xcode_stubs + touch "$TEST_STATE_DIR/xcode-installed" + mkdir -p "$TEST_TMPDIR/CommandLineTools" "$workspace/demo" + touch "$TEST_STATE_DIR/python-installed" + touch "$TEST_STATE_DIR/pyyaml-installed" + touch "$TEST_STATE_DIR/click-installed" + printf 'project:\n name: demo\nartifacts: []\n' > "$workspace/demo/base_manifest.yaml" + BASE_SETUP_TEST_WORKSPACE="$workspace" create_project_setup_venv_stub "$venv_dir" + BASE_SETUP_TEST_WORKSPACE="$workspace" create_project_setup_venv_stub "$TEST_HOME/.base.d/demo/.venv" + + run_base_command BASE_SETUP_TEST_WORKSPACE="$workspace" check demo --remote-network + + [ "$status" -eq 0 ] + [ "$(cat "$TEST_STATE_DIR/project-setup-args")" = "$(printf '%s\n' --manifest "$workspace/demo/base_manifest.yaml" --action check --format text --remote-network demo)" ] +} + @test "basectl check --format json writes successful check results to stdout" { local click_line local pyyaml_line @@ -402,7 +424,7 @@ load ./setup_helpers.bash BASE_SETUP_TEST_PYTHON_PREFIX="$TEST_TMPDIR/python-prefix" \ BASE_SETUP_TEST_WORKSPACE="$workspace" \ BASE_SETUP_XCODE_COMMAND_LINE_TOOLS_DIR="$TEST_TMPDIR/CommandLineTools" \ - "$BASE_REPO_ROOT/bin/basectl" check demo --format json + "$BASE_REPO_ROOT/bin/basectl" check demo --remote-network --format json [ "$status" -eq 0 ] [[ "$output" == *'"schema_version": 1'* ]] @@ -443,7 +465,7 @@ load ./setup_helpers.bash BASE_SETUP_TEST_PYTHON_PREFIX="$TEST_TMPDIR/python-prefix" \ BASE_SETUP_TEST_WORKSPACE="$workspace" \ BASE_SETUP_XCODE_COMMAND_LINE_TOOLS_DIR="$TEST_TMPDIR/CommandLineTools" \ - "$BASE_REPO_ROOT/bin/basectl" check demo --format json + "$BASE_REPO_ROOT/bin/basectl" check demo --remote-network --format json [ "$status" -eq 1 ] [[ "$output" == *'"schema_version": 1'* ]] @@ -451,6 +473,7 @@ load ./setup_helpers.bash [[ "$output" == *'"project": "demo"'* ]] [[ "$output" == *'"project_checks":'* ]] [[ "$output" == *'"id":"BASE-P080","status":"ok","name":"git_repository"'* ]] + [[ "$output" == *'"id":"BASE-P083","status":"ok","name":"git_origin_reachability"'* ]] [[ "$output" == *'"id":"BASE-P050","status":"error","name":"project_virtualenv"'* ]] [[ "$output" != *'"ok":'* ]] [[ "$output" == *"Virtual environment Python is broken because home path '$missing_home' no longer provides Python."* ]] diff --git a/cli/bash/commands/basectl/tests/doctor.bats b/cli/bash/commands/basectl/tests/doctor.bats index d32cf72..3fbfbf2 100644 --- a/cli/bash/commands/basectl/tests/doctor.bats +++ b/cli/bash/commands/basectl/tests/doctor.bats @@ -10,6 +10,7 @@ load ./basectl_helpers.bash [[ "$output" == *"Usage:"* ]] [[ "$output" == *"basectl doctor [project] [options]"* ]] [[ "$output" == *"--profile "* ]] + [[ "$output" == *"--remote-network"* ]] [[ "$output" != *"--dev"* ]] [[ "$output" == *"Diagnose the local Base CLI environment"* ]] } @@ -351,6 +352,87 @@ EOF [[ "$output" == *"Base doctor found no blocking issues for project 'demo'."* ]] } +@test "basectl doctor project passes opt-in remote network diagnostics flag" { + local fake_bin="$TEST_TMPDIR/bin" + local project_python="$TEST_HOME/.base.d/demo/.venv/bin/python" + local venv_python="$TEST_HOME/.base.d/base/.venv/bin/python" + local workspace="$TEST_TMPDIR/workspace" + + mkdir -p "$fake_bin" "$(dirname "$venv_python")" "$(dirname "$project_python")" "$workspace/demo" + printf 'project:\n name: demo\nartifacts: []\n' > "$workspace/demo/base_manifest.yaml" + cat > "$fake_bin/brew" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == "list" ]]; then + case "${2:-}" in + python@3.13) exit 0 ;; + esac +fi +if [[ "${1:-}" == "--prefix" ]]; then + printf '/tmp/fake-prefix\n' + exit 0 +fi +exit 1 +EOF + cat > "$fake_bin/xcode-select" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == "-p" ]]; then + printf '%s\n' "${BASE_TEST_XCODE_TOOLS_DIR:?}" + exit 0 +fi +exit 1 +EOF + cat > "$fake_bin/xcrun" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == "-f" && "${2:-}" == "clang" ]]; then + printf '/tmp/fake-clang\n' + exit 0 +fi +exit 1 +EOF + cat > "$venv_python" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == "--version" ]]; then + printf 'Python 3.13.test\n' + exit 0 +fi +if [[ "${1:-}" == "-m" && "${2:-}" == "pip" && "${3:-}" == "show" ]]; then + case "${4:-}" in + PyYAML|click) exit 0 ;; + esac +fi +if [[ "${1:-}" == "-m" && "${2:-}" == "base_projects" && "${3:-}" == "resolve" && "${4:-}" == "demo" ]]; then + printf 'demo\t%s\t%s\n' "${BASE_TEST_PROJECT_ROOT:?}" "${BASE_TEST_PROJECT_ROOT:?}/base_manifest.yaml" + exit 0 +fi +if [[ "${1:-}" == "-m" && "${2:-}" == "base_setup" ]]; then + printf '%s\n' "$@" > "${BASE_TEST_PROJECT_ARGS:?}" + printf 'ok demo-artifact Project artifact check passed.\n' + exit 0 +fi +printf 'unexpected doctor project python args: %s\n' "$*" >&2 +exit 1 +EOF + cp "$venv_python" "$project_python" + chmod +x "$fake_bin/brew" "$fake_bin/xcode-select" "$fake_bin/xcrun" "$venv_python" "$project_python" + mkdir -p "$TEST_TMPDIR/xcode-tools/usr/bin" + touch "$TEST_TMPDIR/xcode-tools/usr/bin/clang" + touch "$TEST_HOME/.base.d/base/.venv/pyvenv.cfg" + touch "$TEST_HOME/.base.d/demo/.venv/pyvenv.cfg" + + run env \ + HOME="$TEST_HOME" \ + OSTYPE="darwin24" \ + PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + BASE_TEST_PROJECT_ARGS="$TEST_TMPDIR/project-args" \ + BASE_TEST_PROJECT_ROOT="$workspace/demo" \ + BASE_TEST_XCODE_TOOLS_DIR="$TEST_TMPDIR/xcode-tools" \ + BASE_SETUP_XCODE_COMMAND_LINE_TOOLS_DIR="$TEST_TMPDIR/xcode-tools" \ + "$BASE_REPO_ROOT/bin/basectl" doctor demo --remote-network + + [ "$status" -eq 0 ] + [ "$(cat "$TEST_TMPDIR/project-args")" = "$(printf '%s\n' -m base_setup --manifest "$workspace/demo/base_manifest.yaml" --action doctor --format text --remote-network demo)" ] +} + @test "basectl doctor project --format json includes project findings" { local fake_bin="$TEST_TMPDIR/bin" local project_python="$TEST_HOME/.base.d/demo/.venv/bin/python" @@ -483,6 +565,7 @@ if [[ "${1:-}" == "-m" && "${2:-}" == "base_projects" && "${3:-}" == "resolve" & exit 0 fi if [[ "${1:-}" == "-m" && "${2:-}" == "base_setup" ]]; then + printf '%s\n' "$@" > "${BASE_TEST_PROJECT_ARGS:?}" shift 2 action="setup" output_format="text" @@ -518,10 +601,11 @@ EOF HOME="$TEST_HOME" \ OSTYPE="darwin24" \ PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + BASE_TEST_PROJECT_ARGS="$TEST_TMPDIR/project-args" \ BASE_TEST_PROJECT_ROOT="$workspace/demo" \ BASE_TEST_XCODE_TOOLS_DIR="$TEST_TMPDIR/xcode-tools" \ BASE_SETUP_XCODE_COMMAND_LINE_TOOLS_DIR="$TEST_TMPDIR/xcode-tools" \ - "$BASE_REPO_ROOT/bin/basectl" doctor demo --format json + "$BASE_REPO_ROOT/bin/basectl" doctor demo --remote-network --format json [ "$status" -eq 1 ] [[ "$output" == *'"schema_version": 1'* ]] @@ -533,5 +617,6 @@ EOF [[ "$output" == *'"id":"BASE-P050","status":"error","name":"project_virtualenv"'* ]] [[ "$output" == *"Virtual environment Python is broken because home path '$missing_home' no longer provides Python."* ]] [[ "$output" == *"Run 'basectl setup demo --recreate-venv' to back up and recreate the project virtual environment."* ]] + [ "$(cat "$TEST_TMPDIR/project-args")" = "$(printf '%s\n' -m base_setup --manifest "$workspace/demo/base_manifest.yaml" --action predoctor --format json --remote-network demo)" ] [ "${stderr:-}" = "" ] } diff --git a/cli/bash/commands/basectl/tests/setup_helpers.bash b/cli/bash/commands/basectl/tests/setup_helpers.bash index a4ecc6a..898ab17 100644 --- a/cli/bash/commands/basectl/tests/setup_helpers.bash +++ b/cli/bash/commands/basectl/tests/setup_helpers.bash @@ -504,6 +504,7 @@ if [[ "${1:-}" == "-m" && "${2:-}" == "base_setup" ]]; then touch "$BASE_SETUP_TEST_STATE_DIR/project-setup-ran" action="setup" output_format="text" + remote_network=false while (($#)); do case "$1" in --action) @@ -514,15 +515,26 @@ if [[ "${1:-}" == "-m" && "${2:-}" == "base_setup" ]]; then shift output_format="${1:-}" ;; + --remote-network) + remote_network=true + ;; esac shift || true done if [[ "$action" == "precheck" && "$output_format" == "json" ]]; then - printf '[{"id":"BASE-P080","status":"ok","name":"git_repository","message":"Project is inside a Git repository.","fix":""}]\n' + if [[ "$remote_network" == true ]]; then + printf '[{"id":"BASE-P080","status":"ok","name":"git_repository","message":"Project is inside a Git repository.","fix":""},{"id":"BASE-P083","status":"ok","name":"git_origin_reachability","message":"Project Git origin remote is reachable.","fix":""}]\n' + else + printf '[{"id":"BASE-P080","status":"ok","name":"git_repository","message":"Project is inside a Git repository.","fix":""}]\n' + fi elif [[ "$action" == "precheck" ]]; then printf 'Project is inside a Git repository.\n' >&2 elif [[ "$action" == "predoctor" && "$output_format" == "json" ]]; then - printf '[{"id":"BASE-P080","status":"ok","name":"git_repository","message":"Project is inside a Git repository.","fix":""}]\n' + if [[ "$remote_network" == true ]]; then + printf '[{"id":"BASE-P080","status":"ok","name":"git_repository","message":"Project is inside a Git repository.","fix":""},{"id":"BASE-P083","status":"ok","name":"git_origin_reachability","message":"Project Git origin remote is reachable.","fix":""}]\n' + else + printf '[{"id":"BASE-P080","status":"ok","name":"git_repository","message":"Project is inside a Git repository.","fix":""}]\n' + fi elif [[ "$action" == "predoctor" ]]; then printf 'ok BASE-P080 git_repository Project is inside a Git repository.\n' elif [[ "$action" == "check" && "$output_format" == "json" ]]; then diff --git a/cli/python/base_setup/engine.py b/cli/python/base_setup/engine.py index 320ff56..7e4343f 100644 --- a/cli/python/base_setup/engine.py +++ b/cli/python/base_setup/engine.py @@ -49,6 +49,7 @@ class ManifestAction: action: str dry_run: bool output_format: str + remote_network: bool = False def main(argv: list[str] | None = None) -> int: @@ -67,6 +68,11 @@ def main(argv: list[str] | None = None) -> int: help="Action to run: setup, bootstrap, check, or doctor. Defaults to setup.", ) @base_cli.option("--format", "output_format", default="text", help="Output format for check/doctor: text or json.") +@base_cli.option( + "--remote-network", + is_flag=True, + help="Opt in to bounded network reachability diagnostics for project Git origin.", +) # pylint: disable=too-many-arguments,too-many-positional-arguments def run( ctx: base_cli.Context, @@ -76,6 +82,7 @@ def run( dry_run: bool, action: str, output_format: str, + remote_network: bool, ) -> int: manifest_path = Path(manifest).resolve() if manifest else discover_manifest(Path(start_dir)) if manifest_path is None: @@ -89,7 +96,12 @@ def run( base_manifest = read_manifest(manifest_path) validate_project_name(base_manifest, project) default_manifest = read_default_manifest(ctx) - return run_manifest_action(ctx, ManifestAction(action, dry_run, output_format), default_manifest, base_manifest) + return run_manifest_action( + ctx, + ManifestAction(action, dry_run, output_format, remote_network), + default_manifest, + base_manifest, + ) except ManifestError as exc: ctx.log.error(str(exc)) return 1 @@ -115,13 +127,33 @@ def run_manifest_action( reconcile_bootstrap_artifacts(ctx, default_manifest, base_manifest, dry_run=manifest_action.dry_run) status = 0 elif action == "check": - status = check_manifest(ctx, default_manifest, base_manifest, output_format=manifest_action.output_format) + status = check_manifest( + ctx, + default_manifest, + base_manifest, + output_format=manifest_action.output_format, + remote_network=manifest_action.remote_network, + ) elif action == "doctor": - status = doctor_manifest(default_manifest, base_manifest, output_format=manifest_action.output_format) + status = doctor_manifest( + default_manifest, + base_manifest, + output_format=manifest_action.output_format, + remote_network=manifest_action.remote_network, + ) elif action == "precheck": - status = check_pre_venv_manifest(ctx, base_manifest, output_format=manifest_action.output_format) + status = check_pre_venv_manifest( + ctx, + base_manifest, + output_format=manifest_action.output_format, + remote_network=manifest_action.remote_network, + ) elif action == "predoctor": - status = doctor_pre_venv_manifest(base_manifest, output_format=manifest_action.output_format) + status = doctor_pre_venv_manifest( + base_manifest, + output_format=manifest_action.output_format, + remote_network=manifest_action.remote_network, + ) else: ctx.log.error( "Unsupported base_setup action '%s'. Expected setup, bootstrap, check, doctor, precheck, or predoctor.", @@ -201,8 +233,9 @@ def check_manifest( default_manifest: BaseManifest, manifest: BaseManifest, output_format: str, + remote_network: bool = False, ) -> int: - checks = manifest_checks(default_manifest, manifest) + checks = manifest_checks(default_manifest, manifest, remote_network=remote_network) if output_format == "json": print(json.dumps(checks_payload_to_json(checks, project=manifest.project_name), indent=2)) elif output_format == "text": @@ -220,8 +253,13 @@ def check_manifest( return 0 if all(check.ok or doctor_status(check) == "warn" for check in checks) else 1 -def check_pre_venv_manifest(ctx: base_cli.Context, manifest: BaseManifest, output_format: str) -> int: - checks = pre_venv_manifest_checks(manifest) +def check_pre_venv_manifest( + ctx: base_cli.Context, + manifest: BaseManifest, + output_format: str, + remote_network: bool = False, +) -> int: + checks = pre_venv_manifest_checks(manifest, remote_network=remote_network) if output_format == "json": print(json.dumps([check_to_json(check) for check in checks], separators=(",", ":"))) elif output_format == "text": @@ -238,8 +276,13 @@ def check_pre_venv_manifest(ctx: base_cli.Context, manifest: BaseManifest, outpu return 0 if all(check.ok or doctor_status(check) == "warn" for check in checks) else 1 -def doctor_manifest(default_manifest: BaseManifest, manifest: BaseManifest, output_format: str) -> int: - checks = manifest_checks(default_manifest, manifest) +def doctor_manifest( + default_manifest: BaseManifest, + manifest: BaseManifest, + output_format: str, + remote_network: bool = False, +) -> int: + checks = manifest_checks(default_manifest, manifest, remote_network=remote_network) if output_format == "json": print(json.dumps([check_to_doctor_json(check) for check in checks], indent=2)) return min(sum(1 for check in checks if doctor_status(check) == "error"), 125) @@ -259,8 +302,12 @@ def doctor_manifest(default_manifest: BaseManifest, manifest: BaseManifest, outp return min(error_count, 125) -def doctor_pre_venv_manifest(manifest: BaseManifest, output_format: str) -> int: - checks = pre_venv_manifest_checks(manifest) +def doctor_pre_venv_manifest( + manifest: BaseManifest, + output_format: str, + remote_network: bool = False, +) -> int: + checks = pre_venv_manifest_checks(manifest, remote_network=remote_network) if output_format == "json": print(json.dumps([check_to_doctor_json(check) for check in checks], separators=(",", ":"))) return min(sum(1 for check in checks if doctor_status(check) == "error"), 125) @@ -277,11 +324,15 @@ def doctor_pre_venv_manifest(manifest: BaseManifest, output_format: str) -> int: return min(error_count, 125) -def pre_venv_manifest_checks(manifest: BaseManifest) -> tuple[ArtifactCheck, ...]: - return check_git_remote(manifest) +def pre_venv_manifest_checks(manifest: BaseManifest, remote_network: bool = False) -> tuple[ArtifactCheck, ...]: + return check_git_remote(manifest, check_network=remote_network) -def manifest_checks(default_manifest: BaseManifest, manifest: BaseManifest) -> tuple[ArtifactCheck, ...]: +def manifest_checks( + default_manifest: BaseManifest, + manifest: BaseManifest, + remote_network: bool = False, +) -> tuple[ArtifactCheck, ...]: pre_venv_checks: list[ArtifactCheck] = [] checks: list[ArtifactCheck] = [] user_config = read_user_config() @@ -289,7 +340,7 @@ def manifest_checks(default_manifest: BaseManifest, manifest: BaseManifest) -> t artifacts = merge_artifacts(default_manifest.artifacts, effective_manifest.artifacts) definitions = resolve_artifact_definitions(artifacts) - pre_venv_checks.extend(pre_venv_manifest_checks(effective_manifest)) + pre_venv_checks.extend(pre_venv_manifest_checks(effective_manifest, remote_network=remote_network)) checks.extend(ide_preference_warning_checks(manifest, user_config)) if effective_manifest.brewfile is not None: diff --git a/cli/python/base_setup/git_remote.py b/cli/python/base_setup/git_remote.py index be544da..29d94b7 100644 --- a/cli/python/base_setup/git_remote.py +++ b/cli/python/base_setup/git_remote.py @@ -14,6 +14,7 @@ GITHUB_HOST = "github.com" +REMOTE_REACHABILITY_TIMEOUT_SECONDS = 5 SCP_REMOTE_RE = re.compile(r"^(?:(?P[^@\s]+)@)?(?P[^:\s/]+):(?P.+)$") @@ -30,7 +31,7 @@ class RemoteInfo: error: str = "" -def check_git_remote(manifest: BaseManifest) -> tuple[ArtifactCheck, ...]: +def check_git_remote(manifest: BaseManifest, check_network: bool = False) -> tuple[ArtifactCheck, ...]: if not manifest.path.is_absolute() or not manifest.path.is_file(): return () @@ -47,6 +48,9 @@ def check_git_remote(manifest: BaseManifest) -> tuple[ArtifactCheck, ...]: if not origin_check.ok or remote_info is None: return tuple(checks) + if check_network: + checks.append(check_origin_reachability(project_root, remote_info)) + if remote_info.provider == "github": checks.append(check_github_cli_auth(remote_info)) return tuple(checks) @@ -204,6 +208,63 @@ def check_github_cli_auth(remote_info: RemoteInfo) -> ArtifactCheck: ) +def check_origin_reachability(project_root: Path, remote_info: RemoteInfo) -> ArtifactCheck: + details = remote_details(remote_info) | { + "network_checked": True, + "remote": "origin", + } + try: + completed = run_git( + project_root, + ["ls-remote", "--exit-code", "origin", "HEAD"], + timeout_seconds=REMOTE_REACHABILITY_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired: + return ArtifactCheck( + name="git_origin_reachability", + ok=False, + message=( + "Project Git origin remote reachability check timed out after " + f"{REMOTE_REACHABILITY_TIMEOUT_SECONDS} seconds." + ), + fix="Check network access and Git credentials, then rerun with '--remote-network'.", + finding_id="BASE-P083", + status="warn", + details=details | {"reachable": False, "failure_category": "timeout"}, + ) + + if completed.returncode == 0: + return ArtifactCheck( + name="git_origin_reachability", + ok=True, + message="Project Git origin remote is reachable.", + fix="", + finding_id="BASE-P083", + details=details | {"reachable": True}, + ) + + return ArtifactCheck( + name="git_origin_reachability", + ok=False, + message="Project Git origin remote could not be reached with 'git ls-remote'.", + fix="Check network access and Git credentials, then rerun with '--remote-network'.", + finding_id="BASE-P083", + status="warn", + details=details + | { + "reachable": False, + "failure_category": reachability_failure_category(completed.stderr), + }, + ) + + +def reachability_failure_category(stderr: str | None) -> str: + message = (stderr or "").lower() + if "auth" in message or "permission denied" in message: + return "authentication" + return "unreachable" + + def parse_origin_remote(remote_url: str, project_root: Path) -> RemoteInfo: remote_url = remote_url.strip() if not remote_url: @@ -362,11 +423,16 @@ def remote_details(remote_info: RemoteInfo) -> dict[str, object]: return details -def run_git(project_root: Path, arguments: list[str]) -> subprocess.CompletedProcess[str]: +def run_git( + project_root: Path, + arguments: list[str], + timeout_seconds: int | None = None, +) -> subprocess.CompletedProcess[str]: return subprocess.run( ["git", "-C", str(project_root), *arguments], stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, text=True, check=False, + timeout=timeout_seconds, ) diff --git a/cli/python/base_setup/tests/test_git_remote.py b/cli/python/base_setup/tests/test_git_remote.py index 99c377f..0ebebab 100644 --- a/cli/python/base_setup/tests/test_git_remote.py +++ b/cli/python/base_setup/tests/test_git_remote.py @@ -8,6 +8,7 @@ from pathlib import Path from unittest import mock +from base_setup import git_remote as git_remote_module from base_setup.checks import check_to_json from base_setup.checks import doctor_status from base_setup.git_remote import check_git_remote @@ -29,6 +30,15 @@ def git(project_root: Path, *args: str) -> None: ) +def completed_process( + args: list[str], + returncode: int, + stdout: str = "", + stderr: str = "", +) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args, returncode, stdout, stderr) + + @unittest.skipUnless(shutil.which("git"), "Git is not installed") class GitRemoteCheckTests(unittest.TestCase): def test_skips_project_directory_outside_git_repository(self) -> None: @@ -147,3 +157,139 @@ def test_sanitizes_credential_bearing_github_remote(self) -> None: self.assertNotIn("secret", payload) self.assertIn("https://github.com/codeforester/base.git", payload) self.assertEqual(checks[1].details["sanitized_url"], "https://github.com/codeforester/base.git") + + def test_default_remote_checks_do_not_probe_network_reachability(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + project_root = Path(tmpdir) + git(project_root, "init") + git(project_root, "remote", "add", "origin", "https://example.com/team/demo.git") + manifest = manifest_for(project_root) + + with mock.patch("base_setup.git_remote.run_git", wraps=git_remote_module.run_git) as run_git: + checks = check_git_remote(manifest) + + self.assertEqual([check.finding_id for check in checks], ["BASE-P080", "BASE-P081"]) + self.assertFalse(any(call.args[1][0] == "ls-remote" for call in run_git.call_args_list)) + self.assertEqual(checks[1].details["network_checked"], False) + + def test_opt_in_network_check_reports_reachable_origin_remote(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + project_root = Path(tmpdir) + git(project_root, "init") + git(project_root, "remote", "add", "origin", "https://example.com/team/demo.git") + manifest = manifest_for(project_root) + original_run_git = git_remote_module.run_git + + def run_git( + project_root_arg: Path, + arguments: list[str], + timeout_seconds: int | None = None, + ) -> subprocess.CompletedProcess[str]: + if arguments[0] == "ls-remote": + self.assertIsNotNone(timeout_seconds) + return completed_process(arguments, 0, "abc123\tHEAD\n") + return original_run_git(project_root_arg, arguments) + + with mock.patch("base_setup.git_remote.run_git", side_effect=run_git) as run_git_mock: + checks = check_git_remote(manifest, check_network=True) + + self.assertEqual([check.finding_id for check in checks], ["BASE-P080", "BASE-P081", "BASE-P083"]) + self.assertTrue(checks[2].ok) + self.assertEqual(doctor_status(checks[2]), "ok") + self.assertEqual(checks[2].details["network_checked"], True) + self.assertEqual(checks[2].details["reachable"], True) + self.assertEqual(checks[2].details["remote"], "origin") + self.assertEqual(checks[2].details["provider"], "other") + self.assertEqual(checks[2].details["transport"], "https") + self.assertEqual(checks[2].details["sanitized_url"], "https://example.com/team/demo.git") + self.assertTrue( + any( + call.args[1] == ["ls-remote", "--exit-code", "origin", "HEAD"] + for call in run_git_mock.call_args_list + ) + ) + + def test_opt_in_network_check_reports_unreachable_origin_remote_as_warning(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + project_root = Path(tmpdir) + git(project_root, "init") + git(project_root, "remote", "add", "origin", "https://example.com/team/missing.git") + manifest = manifest_for(project_root) + original_run_git = git_remote_module.run_git + + def run_git( + project_root_arg: Path, + arguments: list[str], + timeout_seconds: int | None = None, + ) -> subprocess.CompletedProcess[str]: + if arguments[0] == "ls-remote": + self.assertIsNotNone(timeout_seconds) + return completed_process(arguments, 128, stderr="fatal: repository not found\n") + return original_run_git(project_root_arg, arguments) + + with mock.patch("base_setup.git_remote.run_git", side_effect=run_git): + checks = check_git_remote(manifest, check_network=True) + + self.assertEqual([check.finding_id for check in checks], ["BASE-P080", "BASE-P081", "BASE-P083"]) + self.assertFalse(checks[2].ok) + self.assertEqual(doctor_status(checks[2]), "warn") + self.assertIn("could not be reached", checks[2].message) + self.assertEqual(checks[2].details["network_checked"], True) + self.assertEqual(checks[2].details["reachable"], False) + self.assertEqual(checks[2].details["failure_category"], "unreachable") + + def test_opt_in_network_check_reports_timeout_as_warning(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + project_root = Path(tmpdir) + git(project_root, "init") + git(project_root, "remote", "add", "origin", "git@example.com:team/demo.git") + manifest = manifest_for(project_root) + original_run_git = git_remote_module.run_git + + def run_git( + project_root_arg: Path, + arguments: list[str], + timeout_seconds: int | None = None, + ) -> subprocess.CompletedProcess[str]: + if arguments[0] == "ls-remote": + self.assertIsNotNone(timeout_seconds) + raise subprocess.TimeoutExpired(["git", *arguments], 5) + return original_run_git(project_root_arg, arguments) + + with mock.patch("base_setup.git_remote.run_git", side_effect=run_git): + checks = check_git_remote(manifest, check_network=True) + + self.assertEqual([check.finding_id for check in checks], ["BASE-P080", "BASE-P081", "BASE-P083"]) + self.assertFalse(checks[2].ok) + self.assertEqual(doctor_status(checks[2]), "warn") + self.assertIn("timed out", checks[2].message) + self.assertEqual(checks[2].details["network_checked"], True) + self.assertEqual(checks[2].details["reachable"], False) + self.assertEqual(checks[2].details["failure_category"], "timeout") + + def test_network_check_json_does_not_expose_credential_bearing_remote(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + project_root = Path(tmpdir) + git(project_root, "init") + git(project_root, "remote", "add", "origin", "https://token:secret@example.com/team/demo.git") + manifest = manifest_for(project_root) + original_run_git = git_remote_module.run_git + + def run_git( + project_root_arg: Path, + arguments: list[str], + timeout_seconds: int | None = None, + ) -> subprocess.CompletedProcess[str]: + if arguments[0] == "ls-remote": + self.assertIsNotNone(timeout_seconds) + return completed_process(arguments, 128, stderr="fatal: authentication failed\n") + return original_run_git(project_root_arg, arguments) + + with mock.patch("base_setup.git_remote.run_git", side_effect=run_git): + checks = check_git_remote(manifest, check_network=True) + + payload = json.dumps([check_to_json(check) for check in checks]) + self.assertNotIn("token", payload) + self.assertNotIn("secret", payload) + self.assertIn("https://example.com/team/demo.git", payload) + self.assertEqual(checks[2].details["sanitized_url"], "https://example.com/team/demo.git") diff --git a/docs/doctor-findings.md b/docs/doctor-findings.md index a9ef0ef..aec9787 100644 --- a/docs/doctor-findings.md +++ b/docs/doctor-findings.md @@ -111,6 +111,7 @@ Doctor commands use the same diagnostic item fields. The top-level | `BASE-P080` | Project Git repository status | | `BASE-P081` | Project Git `origin` remote status | | `BASE-P082` | GitHub CLI authentication status for a GitHub-hosted project remote | +| `BASE-P083` | Opt-in project Git `origin` remote reachability status | | `BASE-P100` | User config disables all IDE setup and checks | | `BASE-P101` | User config disables setup and checks for one IDE | | `BASE-P102` | User IDE setting conflicts with a project manifest setting | @@ -143,11 +144,16 @@ configuration source and do not cause Base to install Python dependencies. Warnings in this range should guide users toward a valid Python project file without failing the Base manifest check by themselves. -`BASE-P080` through `BASE-P082` are read-only project Git remote diagnostics. +`BASE-P080` through `BASE-P083` are read-only project Git remote diagnostics. They report whether the project directory is inside a Git repository, whether `origin` is configured and parseable, and whether GitHub CLI authentication is ready when `origin` points at GitHub. Default project check and doctor do not -probe network remote reachability; that belongs behind an explicit opt-in path. +probe network remote reachability. + +`BASE-P083` appears only when the user explicitly opts in with +`--remote-network`. It delegates reachability to Git with a bounded +`git ls-remote` call, reports sanitized provider and transport details, and +does not print credential-bearing remote URLs. ## Health Findings diff --git a/docs/superpowers/plans/2026-06-09-project-git-remote-reachability.md b/docs/superpowers/plans/2026-06-09-project-git-remote-reachability.md new file mode 100644 index 0000000..e928eee --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-project-git-remote-reachability.md @@ -0,0 +1,97 @@ +# Project Git Remote Reachability Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an explicit opt-in project Git remote reachability diagnostic without changing default local-only project checks. + +**Architecture:** Extend the existing `base_setup.git_remote` project diagnostic unit with a new `BASE-P083` reachability check that delegates to `git ls-remote`. Thread a `--remote-network` opt-in flag from `basectl check|doctor` through the Bash project wrapper and `base_setup` pre-venv/normal actions. + +**Tech Stack:** Bash `basectl` wrappers, Python `base_setup`, Git CLI, pytest, BATS. + +--- + +### Task 1: Add Opt-In Python Reachability Diagnostics + +**Files:** +- Modify: `cli/python/base_setup/git_remote.py` +- Modify: `cli/python/base_setup/engine.py` +- Test: `cli/python/base_setup/tests/test_git_remote.py` + +- [ ] **Step 1: Write failing tests** + +Add tests that call `check_git_remote(manifest, check_network=True)` and assert: +- default `check_git_remote(manifest)` does not run `git ls-remote` +- reachable remotes add `BASE-P083` with `network_checked: true`, `reachable: true`, provider, transport, remote, and sanitized URL details +- failed remotes add warning `BASE-P083` with `failure_category: unreachable` +- timed-out remotes add warning `BASE-P083` with `failure_category: timeout` +- credential-bearing URLs are not present in serialized JSON + +- [ ] **Step 2: Verify tests fail** + +Run: + +```bash +env -u BASE_HOME PYTHONPATH=$PWD/lib/python:$PWD/cli/python /Users/rameshhp/.base.d/base/.venv/bin/python -m pytest cli/python/base_setup/tests/test_git_remote.py -q +``` + +Expected: FAIL because `check_git_remote()` does not accept `check_network`. + +- [ ] **Step 3: Implement minimal Python support** + +Add a `check_network: bool = False` argument to `check_git_remote()` and pass it through from `pre_venv_manifest_checks()` and `manifest_checks()`. Implement a bounded `git ls-remote --exit-code HEAD` call through a new helper that maps success to `ok`, timeout/unreachable to `warn`, and never prints credential-bearing URLs. + +- [ ] **Step 4: Verify Python tests pass** + +Run the same pytest command and expect all tests to pass. + +### Task 2: Expose the Opt-In Flag Through User Commands + +**Files:** +- Modify: `cli/bash/commands/basectl/subcommands/check.sh` +- Modify: `cli/bash/commands/basectl/subcommands/doctor.sh` +- Modify: `cli/bash/commands/basectl/subcommands/setup_common.sh` +- Test: `cli/bash/commands/basectl/tests/check.bats` +- Test: `cli/bash/commands/basectl/tests/doctor.bats` + +- [ ] **Step 1: Write failing BATS tests** + +Add tests showing `basectl check demo --remote-network --format json` and `basectl doctor demo --remote-network --format json` pass `--remote-network` to `python -m base_setup` for both normal and pre-venv project paths. + +- [ ] **Step 2: Verify BATS tests fail** + +Run: + +```bash +env -u BASE_HOME bats cli/bash/commands/basectl/tests/check.bats cli/bash/commands/basectl/tests/doctor.bats +``` + +Expected: FAIL because the wrappers reject or omit `--remote-network`. + +- [ ] **Step 3: Implement flag plumbing** + +Parse `--remote-network` in `check.sh` and `doctor.sh`, export `BASE_SETUP_REMOTE_NETWORK=true`, append `--remote-network` when invoking `base_setup` in `setup_common.sh`, and document the flag in command help. + +- [ ] **Step 4: Verify BATS tests pass** + +Run the same BATS command and expect success. + +### Task 3: Document Finding and Validate + +**Files:** +- Modify: `docs/doctor-findings.md` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Document `BASE-P083`** + +Add `BASE-P083` as the explicit opt-in project Git remote reachability diagnostic. State that default project check/doctor remain local-only. + +- [ ] **Step 2: Run final validation** + +Run: + +```bash +env -u BASE_HOME ./bin/base-test +git diff --check +``` + +Expected: both pass.