From fedace95d706fc15c4d7443810ec629c78463d87 Mon Sep 17 00:00:00 2001 From: Anant Shrivastava Date: Wed, 24 Jun 2026 14:02:39 +0530 Subject: [PATCH 1/3] compatibility fix for older bash --- lib/aidc.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/aidc.sh b/lib/aidc.sh index 2ea80cc..b3a3b2e 100644 --- a/lib/aidc.sh +++ b/lib/aidc.sh @@ -628,7 +628,7 @@ aidc::cmd_exec() { AIDC_EXEC_ENV_ARGS=() aidc::append_passthrough_env_args - aidc::compose "$workspace" exec "${AIDC_EXEC_ENV_ARGS[@]}" workspace "$@" + aidc::compose "$workspace" exec ${AIDC_EXEC_ENV_ARGS[@]+"${AIDC_EXEC_ENV_ARGS[@]}"} workspace "$@" } aidc::cmd_claude() { @@ -669,7 +669,7 @@ aidc::cmd_claude() { return fi - aidc::run_tool "claude" "$profile" "${args[@]}" + aidc::run_tool "claude" "$profile" ${args[@]+"${args[@]}"} } aidc::cmd_codex() { @@ -807,7 +807,7 @@ aidc::run_tool() { command+=("$@") fi - aidc::compose "$workspace" exec "${AIDC_EXEC_ENV_ARGS[@]}" workspace "${command[@]}" + aidc::compose "$workspace" exec ${AIDC_EXEC_ENV_ARGS[@]+"${AIDC_EXEC_ENV_ARGS[@]}"} workspace "${command[@]}" } aidc::need_cmd() { @@ -1661,7 +1661,7 @@ aidc::detect_toolchains() { fi # Join with commas without touching the global IFS. local out="" item - for item in "${detected[@]}"; do + for item in ${detected[@]+"${detected[@]}"}; do out+="${out:+,}$item" done printf '%s' "$out" @@ -1834,7 +1834,7 @@ aidc::remove_stale_claude_aliases() { fi alias_name="$(basename "$path")" - if ! aidc::array_contains "$alias_name" "${desired_aliases[@]}"; then + if ! aidc::array_contains "$alias_name" ${desired_aliases[@]+"${desired_aliases[@]}"}; then rm -f "$path" aidc::log "removed stale Claude alias $alias_name" fi @@ -1862,7 +1862,7 @@ aidc::sync_claude_aliases() { aidc::log "synced Claude alias $alias_name" done < <(aidc::find_claude_profiles) - aidc::remove_stale_claude_aliases "${desired_aliases[@]}" + aidc::remove_stale_claude_aliases ${desired_aliases[@]+"${desired_aliases[@]}"} } aidc::load_claude_profile_env() { From e0ba96e25d6e1a606ac93342e666ad06da0fc8a1 Mon Sep 17 00:00:00 2001 From: Anant Shrivastava Date: Wed, 24 Jun 2026 16:51:21 +0530 Subject: [PATCH 2/3] compatibility checker for older bash versions --- .github/scripts/bash-compat-check.sh | 46 +++++++++++++++++++ .github/workflows/bash-compat.yml | 36 +++++++++++++++ .../scripts/init-firewall.sh.tmpl | 2 +- 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100755 .github/scripts/bash-compat-check.sh create mode 100644 .github/workflows/bash-compat.yml diff --git a/.github/scripts/bash-compat-check.sh b/.github/scripts/bash-compat-check.sh new file mode 100755 index 0000000..988cb13 --- /dev/null +++ b/.github/scripts/bash-compat-check.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# +# Runs INSIDE an official `bash:` container (see bash-compat.yml) to +# catch shell-version incompatibilities that the shellcheck job can't: +# +# * empty-array expansion under `set -u` — "${arr[@]}" aborts with +# "unbound variable" on bash < 4.4 (e.g. macOS's system bash 3.2), +# but is silently fine on the newer bash most Linux CI boxes run. +# +# This is a RUNTIME check: `bash -n` only parses, so it would miss the above. +# We therefore also execute the standalone install path that actually trips it. +set -euo pipefail + +echo "::group::bash ${BASH_VERSION}" + +# --- 1. Parse every shell script under this bash version -------------------- +# `*.sh.tmpl` files hold mustache-style placeholders that won't parse, so the +# `*.sh` glob excludes them by design (mirrors shellcheck.yml). `!` (not `-not`) +# keeps this compatible with the container's busybox find. +targets=(bin/aidc) +while IFS= read -r f; do + targets+=("$f") +done < <(find . -type f -name '*.sh' ! -path './.git/*' | sort) + +for f in "${targets[@]}"; do + echo " parse: $f" + bash -n "$f" +done + +# --- 2. Execute the standalone install path --------------------------------- +# A fresh HOME has no Claude `*.env` profiles, so `sync-claude-aliases` builds +# an EMPTY `desired_aliases` array and then expands it — the exact path that +# crashed on bash 3.2. No Docker daemon or network is required for this path. +export HOME=/tmp/aidc-compat-home +export AIDC_BIN_DIR=/tmp/aidc-compat-bin +rm -rf "$HOME" "$AIDC_BIN_DIR" +mkdir -p "$HOME" "$AIDC_BIN_DIR" + +echo " smoke: aidc help" +bash bin/aidc help >/dev/null + +echo " smoke: aidc sync-claude-aliases (empty profile set)" +bash bin/aidc sync-claude-aliases + +echo "OK: bash ${BASH_VERSION}" +echo "::endgroup::" diff --git a/.github/workflows/bash-compat.yml b/.github/workflows/bash-compat.yml new file mode 100644 index 0000000..5af3a6b --- /dev/null +++ b/.github/workflows/bash-compat.yml @@ -0,0 +1,36 @@ +name: bash-compat + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + bash-compat: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 3.2 = macOS's system bash (the floor we must support). + # 4.2 = still common on older RHEL/CentOS. + # 4.4 = first release that tolerates empty-array expansion under `set -u`. + # 5.2 = current. + bash: ["3.2", "4.2", "4.4", "5.2"] + name: bash ${{ matrix.bash }} + steps: + - uses: actions/checkout@v6 + + # Run the checks inside the official bash image for this version. The + # checkout lives on the host and is bind-mounted in, so the container + # only needs bash + busybox (no git/node) — keeping even the 3.2 image + # usable. + - name: Check under bash ${{ matrix.bash }} + run: | + docker run --rm \ + -v "$PWD:/work" -w /work \ + "bash:${{ matrix.bash }}" \ + bash /work/.github/scripts/bash-compat-check.sh diff --git a/templates/devcontainer/scripts/init-firewall.sh.tmpl b/templates/devcontainer/scripts/init-firewall.sh.tmpl index 7d73368..8dbc332 100644 --- a/templates/devcontainer/scripts/init-firewall.sh.tmpl +++ b/templates/devcontainer/scripts/init-firewall.sh.tmpl @@ -49,7 +49,7 @@ if [[ -f "$allowlist_file" ]]; then done <"$allowlist_file" fi -hosts=("${DEFAULT_HOSTS[@]}" "${extra_hosts[@]}") +hosts=("${DEFAULT_HOSTS[@]}" ${extra_hosts[@]+"${extra_hosts[@]}"}) ipset destroy aidc-allow 2>/dev/null || true ipset create aidc-allow hash:ip family inet hashsize 1024 maxelem 65536 From 64c5468a070a05ffcc583ab9d4f27a2e7730dcdd Mon Sep 17 00:00:00 2001 From: Anant Shrivastava Date: Thu, 25 Jun 2026 13:47:47 +0530 Subject: [PATCH 3/3] merging additional stuff from pull/2 by @abhisek --- .github/workflows/aidc-e2e.yml | 203 +++++++++++++++++++++++++++++++++ lib/aidc.sh | 13 ++- 2 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/aidc-e2e.yml diff --git a/.github/workflows/aidc-e2e.yml b/.github/workflows/aidc-e2e.yml new file mode 100644 index 0000000..4131bc7 --- /dev/null +++ b/.github/workflows/aidc-e2e.yml @@ -0,0 +1,203 @@ +name: aidc-e2e + +# End-to-end smoke tests for the host-side aidc CLI across both supported +# host platforms. These cover the Docker-free code paths (install, Claude +# profile alias syncing, profile listing, toolchain detection) plus the full +# `aidc init` scaffold where a Docker CLI is available. +# +# The macOS leg deliberately runs under the system bash 3.2 (see the PATH +# shim below). macOS ships bash 3.2.57, where "${arr[@]}" on an *empty* array +# is an "unbound variable" error under `set -u` — the exact failure mode this +# suite regression-tests. GitHub's macOS image otherwise resolves a newer +# Homebrew bash first on PATH, which would silently skip that path. + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: aidc-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + name: e2e (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + + steps: + - uses: actions/checkout@v6 + + # Make `#!/usr/bin/env bash` resolve to the system bash 3.2 on macOS so + # the install/sync paths are exercised under the interpreter real macOS + # users have. Prepending a shim dir that contains only `bash` avoids + # shadowing any other tool. No-op on Linux (already bash 5.x). + - name: Pin macOS to system bash 3.2 + if: runner.os == 'macOS' + run: | + set -euo pipefail + shim="$RUNNER_TEMP/bash32" + mkdir -p "$shim" + ln -sf /bin/bash "$shim/bash" + echo "$shim" >> "$GITHUB_PATH" + + - name: Show + verify bash version + run: | + set -euo pipefail + echo "bash on PATH: $(command -v bash)" + bash --version | head -1 + echo "env bash: $(env bash -c 'echo $BASH_VERSION')" + if [ "${{ runner.os }}" = "macOS" ]; then + ver="$(env bash -c 'echo $BASH_VERSION')" + case "$ver" in + 3.2*) echo "macOS is running the system bash 3.2 as intended ($ver)";; + *) echo "::error::expected system bash 3.2 on macOS but got $ver — the shim did not take effect"; exit 1;; + esac + fi + + # Core regression: on a clean runner there are no *.env profiles (only + # the generated *.env.example files), so `desired_aliases` is empty. + # Before the fix this aborted here with "unbound variable" on bash 3.2. + - name: install.sh (empty-profile path) + run: | + set -euo pipefail + fail() { echo "::error::$1"; exit 1; } + + ./install.sh + + aidc_bin="$HOME/.local/bin/aidc" + [ -L "$aidc_bin" ] || fail "expected symlink at $aidc_bin" + target="$(readlink "$aidc_bin")" + case "$target" in + *"/bin/aidc") echo "symlink -> $target";; + *) fail "symlink points at unexpected target: $target";; + esac + + prof_dir="$HOME/.config/aidc/providers/claude" + [ -f "$prof_dir/zai.env.example" ] || fail "profile examples were not seeded in $prof_dir" + + # Make the freshly installed CLI available to later steps. + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + echo "install.sh OK" + + - name: Docker-free command smoke test + run: | + set -euo pipefail + fail() { echo "::error::$1"; exit 1; } + + aidc help >/dev/null || fail "aidc help failed" + + # No profiles yet -> must exit 0 (empty-array path again). + out="$(aidc claude --list-profiles)" || fail "--list-profiles exited non-zero" + echo "$out" | grep -q "no Claude profiles found" \ + || fail "unexpected --list-profiles output: $out" + + # Idempotent re-sync with no profiles must also stay clean. + aidc sync-claude-aliases >/dev/null || fail "sync-claude-aliases (empty) failed" + + # status outside any project should report cleanly, not crash. + ( cd "$RUNNER_TEMP" && aidc status >/dev/null ) || fail "aidc status failed outside a project" + echo "smoke OK" + + - name: Claude profile alias lifecycle (non-empty + stale-removal paths) + run: | + set -euo pipefail + fail() { echo "::error::$1"; exit 1; } + + prof_dir="$HOME/.config/aidc/providers/claude" + alias_bin="$HOME/.local/bin/claude-ci" + mkdir -p "$prof_dir" + + cat > "$prof_dir/ci.env" <<'EOF' + AIDC_CLAUDE_DESCRIPTION="CI test profile" + ANTHROPIC_AUTH_TOKEN="dummy-token" + ANTHROPIC_BASE_URL="https://example.invalid" + EOF + chmod 600 "$prof_dir/ci.env" + + # Sync should materialize a managed wrapper for the new profile. + aidc sync-claude-aliases >/dev/null || fail "sync with a profile failed" + [ -x "$alias_bin" ] || fail "expected executable alias at $alias_bin" + grep -q 'exec aidc claude --profile ci' "$alias_bin" \ + || fail "alias wrapper does not invoke the ci profile" + + aidc claude --list-profiles | grep -q 'claude-ci' \ + || fail "ci profile not shown in --list-profiles" + + # Remove the profile and re-sync: the managed alias must be pruned. + # This drives the empty-desired-aliases branch *with* an existing + # managed alias present (the array_contains guard). + rm -f "$prof_dir/ci.env" + aidc sync-claude-aliases >/dev/null || fail "stale-removal sync failed" + [ ! -e "$alias_bin" ] || fail "stale alias $alias_bin was not removed" + echo "alias lifecycle OK" + + - name: Toolchain detection under set -u + run: | + set -euo pipefail + fail() { echo "::error::$1"; exit 1; } + lib="$GITHUB_WORKSPACE/lib/aidc.sh" + + empty="$RUNNER_TEMP/proj-empty" + mkdir -p "$empty" + # No marker files -> empty result. Run under `bash -u` so the empty + # array expansion in detect_toolchains is actually exercised. + out="$(bash -u -c 'source "$1"; aidc::detect_toolchains "$2"' _ "$lib" "$empty")" \ + || fail "detect_toolchains crashed on a project with no toolchains" + [ -z "$out" ] || fail "expected empty toolchain list, got: '$out'" + + go="$RUNNER_TEMP/proj-go" + mkdir -p "$go" + printf 'module example.com/x\n' > "$go/go.mod" + out="$(bash -u -c 'source "$1"; aidc::detect_toolchains "$2"' _ "$lib" "$go")" \ + || fail "detect_toolchains crashed on a go project" + [ "$out" = "go" ] || fail "expected 'go', got: '$out'" + echo "toolchain detection OK" + + # Full scaffold. `aidc init` only needs the Docker *CLI* present (a + # binary presence check), not a running daemon. ubuntu-latest ships it; + # macOS runners do not, so this skips cleanly there. + - name: aidc init scaffold (requires docker CLI) + run: | + set -euo pipefail + fail() { echo "::error::$1"; exit 1; } + + if ! command -v docker >/dev/null 2>&1; then + echo "docker CLI not present on ${{ runner.os }} — skipping init scaffold test" + exit 0 + fi + + work="$RUNNER_TEMP/scaffold-proj" + rm -rf "$work" + mkdir -p "$work" + git -C "$work" init -q + + ( cd "$work" && aidc init ) || fail "aidc init failed" + + for f in \ + .devcontainer/Dockerfile \ + .devcontainer/compose.yaml \ + .devcontainer/devcontainer.json \ + .devcontainer/scripts/bootstrap-state.sh \ + .devcontainer/scripts/init-firewall.sh \ + .ai-container/project.env \ + CLAUDE.md \ + AGENTS.md \ + .cursor/rules/00-core-logics.mdc; do + [ -f "$work/$f" ] || fail "init did not create $f" + done + + grep -q '^AIDC_REPO_SLUG=' "$work/.ai-container/project.env" \ + || fail "project.env missing AIDC_REPO_SLUG" + + # Re-running init over an initialized repo must be a clean no-op. + ( cd "$work" && aidc init ) || fail "second aidc init was not idempotent" + echo "init scaffold OK" diff --git a/lib/aidc.sh b/lib/aidc.sh index b3a3b2e..c0dc646 100644 --- a/lib/aidc.sh +++ b/lib/aidc.sh @@ -616,6 +616,15 @@ aidc::cmd_shell() { aidc::compose "$workspace" exec workspace zsh -l } +# Run a command in the workspace container with the collected passthrough env +# args. Centralizes the `${AIDC_EXEC_ENV_ARGS[@]+...}` guard so empty-array +# expansion under `set -u` (bash 3.2) can't be reintroduced at a call site. +aidc::compose_exec() { + local workspace="$1" + shift + aidc::compose "$workspace" exec ${AIDC_EXEC_ENV_ARGS[@]+"${AIDC_EXEC_ENV_ARGS[@]}"} workspace "$@" +} + aidc::cmd_exec() { local workspace workspace="$(aidc::default_workspace)" @@ -628,7 +637,7 @@ aidc::cmd_exec() { AIDC_EXEC_ENV_ARGS=() aidc::append_passthrough_env_args - aidc::compose "$workspace" exec ${AIDC_EXEC_ENV_ARGS[@]+"${AIDC_EXEC_ENV_ARGS[@]}"} workspace "$@" + aidc::compose_exec "$workspace" "$@" } aidc::cmd_claude() { @@ -807,7 +816,7 @@ aidc::run_tool() { command+=("$@") fi - aidc::compose "$workspace" exec ${AIDC_EXEC_ENV_ARGS[@]+"${AIDC_EXEC_ENV_ARGS[@]}"} workspace "${command[@]}" + aidc::compose_exec "$workspace" "${command[@]}" } aidc::need_cmd() {