diff --git a/cli/src/__tests__/shared-common-env-inject.test.ts b/cli/src/__tests__/shared-common-env-inject.test.ts index 040f6ae7..38005242 100644 --- a/cli/src/__tests__/shared-common-env-inject.test.ts +++ b/cli/src/__tests__/shared-common-env-inject.test.ts @@ -289,14 +289,14 @@ _extract_json_field '{"items":["first","second"]}' "d['items'][0]" _extract_json_field '{"active":true}' "d['active']" `); expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("True"); + expect(result.stdout).toBe("true"); }); it("should handle null values with default", () => { const result = runBash(` -_extract_json_field '{"ip":null}' "d['ip'] if d['ip'] else ''" "none" +_extract_json_field '{"ip":null}' "d['ip']" "none" `); - expect(result.stdout).toBe(""); + expect(result.stdout).toBe("none"); }); it("should extract from deeply nested response structures", () => { diff --git a/cli/src/__tests__/shared-common-error-polling.test.ts b/cli/src/__tests__/shared-common-error-polling.test.ts index a04c52aa..15358ae3 100644 --- a/cli/src/__tests__/shared-common-error-polling.test.ts +++ b/cli/src/__tests__/shared-common-error-polling.test.ts @@ -325,7 +325,7 @@ describe("_extract_json_field edge cases", () => { `_extract_json_field '{"enabled":false}' "d['enabled']" "default"` ); expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("False"); + expect(result.stdout).toBe("false"); }); }); diff --git a/cli/src/__tests__/shared-common-helpers.test.ts b/cli/src/__tests__/shared-common-helpers.test.ts index 48744104..e247c777 100644 --- a/cli/src/__tests__/shared-common-helpers.test.ts +++ b/cli/src/__tests__/shared-common-helpers.test.ts @@ -160,7 +160,7 @@ describe("_load_json_config_fields", () => { expect(result.exitCode).toBe(0); const lines = result.stdout.split("\n"); expect(lines[0]).toBe("8080"); - expect(lines[1]).toBe("True"); + expect(lines[1]).toBe("true"); rmSync(dir, { recursive: true, force: true }); }); diff --git a/cli/src/__tests__/shared-common-json-extraction.test.ts b/cli/src/__tests__/shared-common-json-extraction.test.ts index e3476348..20d2b802 100644 --- a/cli/src/__tests__/shared-common-json-extraction.test.ts +++ b/cli/src/__tests__/shared-common-json-extraction.test.ts @@ -93,15 +93,15 @@ describe("_extract_json_field", () => { _extract_json_field '{"ready": true}' "d['ready']" `); expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("True"); + expect(result.stdout).toBe("true"); }); - it("should extract a null field", () => { + it("should extract a null field and return default", () => { const result = runBash(` - _extract_json_field '{"value": null}' "d['value']" + _extract_json_field '{"value": null}' "d['value']" "fallback" `); expect(result.exitCode).toBe(0); - expect(result.stdout).toBe("None"); + expect(result.stdout).toBe("fallback"); }); }); @@ -147,18 +147,18 @@ describe("_extract_json_field", () => { }); }); - describe("complex Python expressions", () => { - it("should support .get() with default", () => { + describe("complex JS expressions", () => { + it("should support bracket access for existing key", () => { const result = runBash(` - _extract_json_field '{"status": "active"}' "d.get('status', 'unknown')" + _extract_json_field '{"status": "active"}' "d['status']" `); expect(result.exitCode).toBe(0); expect(result.stdout).toBe("active"); }); - it("should support .get() default when key missing", () => { + it("should return default when key missing", () => { const result = runBash(` - _extract_json_field '{"other": 1}' "d.get('status', 'unknown')" + _extract_json_field '{"other": 1}' "d['status']" "unknown" `); expect(result.exitCode).toBe(0); expect(result.stdout).toBe("unknown"); @@ -258,7 +258,7 @@ describe("_extract_json_field", () => { it("should handle empty JSON object", () => { const result = runBash(` - _extract_json_field '{}' "d.get('key', 'empty')" + _extract_json_field '{}' "d['key']" "empty" `); expect(result.exitCode).toBe(0); expect(result.stdout).toBe("empty"); diff --git a/cli/src/__tests__/shared-common-logging-utils.test.ts b/cli/src/__tests__/shared-common-logging-utils.test.ts index e2380e77..828d2adb 100644 --- a/cli/src/__tests__/shared-common-logging-utils.test.ts +++ b/cli/src/__tests__/shared-common-logging-utils.test.ts @@ -11,7 +11,7 @@ import { tmpdir } from "os"; * pervasively across all cloud provider scripts: * - log_step: progress messages (cyan), added in PR #757 * - _log_diagnostic: structured error output (header + causes + fixes) - * - check_python_available: Python 3 dependency check + * - check_json_processor_available: JSON processor (jq/bun) dependency check * - find_node_runtime: bun/node detection * - track_temp_file + cleanup_temp_files: secure credential temp file cleanup * - get_cloud_init_userdata: cloud-init YAML generation for all providers @@ -179,34 +179,34 @@ describe("_log_diagnostic", () => { }); }); -// ── check_python_available ────────────────────────────────────────────────── +// ── check_json_processor_available ────────────────────────────────────────────────── -describe("check_python_available", () => { +describe("check_json_processor_available", () => { it("should return 0 when python3 is available", () => { - const result = runBash("check_python_available"); + const result = runBash("check_json_processor_available"); expect(result.exitCode).toBe(0); }); it("should return 1 when python3 is not in PATH", () => { - const result = runBash("check_python_available", { PATH: "/nonexistent" }); + const result = runBash("check_json_processor_available", { PATH: "/nonexistent" }); expect(result.exitCode).toBe(1); }); - it("should show install instructions when python3 is missing", () => { - // Override command to simulate python3 not found (can't restrict PATH since sourcing needs it) + it("should show install instructions when jq and bun are missing", () => { + // Override command to simulate jq and bun not found (can't restrict PATH since sourcing needs it) const result = runBash(` - command() { if [[ "$2" == "python3" ]]; then return 1; fi; builtin command "$@"; } - check_python_available + command() { if [[ "$2" == "jq" || "$2" == "bun" ]]; then return 1; fi; builtin command "$@"; } + check_json_processor_available `); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Python 3 is required"); - expect(result.stderr).toContain("Install Python 3:"); + expect(result.stderr).toContain("jq or bun is required"); + expect(result.stderr).toContain("Install jq:"); }); it("should mention Ubuntu, Fedora, macOS, and Arch install options", () => { const result = runBash(` - command() { if [[ "$2" == "python3" ]]; then return 1; fi; builtin command "$@"; } - check_python_available + command() { if [[ "$2" == "jq" || "$2" == "bun" ]]; then return 1; fi; builtin command "$@"; } + check_json_processor_available `); expect(result.exitCode).toBe(1); expect(result.stderr).toContain("Ubuntu/Debian"); diff --git a/cli/src/__tests__/shared-common-ssh-helpers.test.ts b/cli/src/__tests__/shared-common-ssh-helpers.test.ts index a666b108..780549fc 100644 --- a/cli/src/__tests__/shared-common-ssh-helpers.test.ts +++ b/cli/src/__tests__/shared-common-ssh-helpers.test.ts @@ -579,7 +579,7 @@ mock_api() { } INSTANCE_STATUS_POLL_DELAY=0 generic_wait_for_instance mock_api "/x/1" "ready" \\ - "d['x']['status']" "d['x'].get('ip','')" \\ + "d['x']['status']" "d['x']['ip']" \\ X_IP "Droplet" 2 `); expect(stderr).toContain("Check your cloud dashboard"); @@ -604,7 +604,7 @@ mock_api() { } INSTANCE_STATUS_POLL_DELAY=0 generic_wait_for_instance mock_api "/s/1" "running" \\ - "d['s']" "d.get('ip','')" \\ + "d['s']" "d['ip']" \\ S_IP "Server" 5 `); expect(exitCode).toBe(0); diff --git a/cli/src/__tests__/shared-common-untested-helpers.test.ts b/cli/src/__tests__/shared-common-untested-helpers.test.ts index 4720c871..ebd5443d 100644 --- a/cli/src/__tests__/shared-common-untested-helpers.test.ts +++ b/cli/src/__tests__/shared-common-untested-helpers.test.ts @@ -357,12 +357,12 @@ describe("_multi_creds_validate", () => { }); // ============================================================================ -// check_python_available +// check_json_processor_available // ============================================================================ -describe("check_python_available", () => { +describe("check_json_processor_available", () => { it("should return 0 when python3 is available", () => { - const result = runBash("check_python_available"); + const result = runBash("check_json_processor_available"); // python3 should be available in CI/test environment expect(result.exitCode).toBe(0); }); @@ -371,20 +371,20 @@ describe("check_python_available", () => { const result = runBash(` PATH=/nonexistent hash -r - check_python_available 2>/dev/null + check_json_processor_available 2>/dev/null `); expect(result.exitCode).toBe(1); }); - it("should show installation instructions when python3 is missing", () => { + it("should show installation instructions when jq and bun are missing", () => { const result = runBash(` PATH=/nonexistent hash -r - check_python_available 2>&1 + check_json_processor_available 2>&1 `); - expect(result.stdout).toContain("Python 3 is required"); + expect(result.stdout).toContain("jq or bun is required"); expect(result.stdout).toContain("sudo apt-get"); - expect(result.stdout).toContain("brew install python3"); + expect(result.stdout).toContain("brew install jq"); }); }); diff --git a/shared/common.sh b/shared/common.sh index ffa9cc60..a355cc0d 100644 --- a/shared/common.sh +++ b/shared/common.sh @@ -113,29 +113,29 @@ POLL_INTERVAL="${SPAWN_POLL_INTERVAL:-1}" # Dependency checks # ============================================================ -# Check if Python 3 is available (required for JSON parsing throughout Spawn) -check_python_available() { - if ! command -v python3 &> /dev/null; then - log_error "Python 3 is required but not installed" - log_error "" - log_error "Spawn uses Python 3 for JSON parsing and API interactions." - log_error "" - printf '%b\n' "${YELLOW}Install Python 3:${NC}" >&2 - log_error " ${CYAN}# Ubuntu/Debian${NC}" - log_error " sudo apt-get update && sudo apt-get install -y python3" - log_error "" - log_error " ${CYAN}# Fedora/RHEL${NC}" - log_error " sudo dnf install -y python3" - log_error "" - log_error " ${CYAN}# macOS${NC}" - log_error " brew install python3" - log_error "" - log_error " ${CYAN}# Arch Linux${NC}" - log_error " sudo pacman -S python" - log_error "" - return 1 +# Check if a JSON processor is available (jq or bun, required for JSON parsing throughout Spawn) +check_json_processor_available() { + if command -v jq &>/dev/null || command -v bun &>/dev/null; then + return 0 fi - return 0 + log_error "jq or bun is required but neither is installed" + log_error "" + log_error "Spawn uses jq (or bun) for JSON parsing and API interactions." + log_error "" + printf '%b\n' "${YELLOW}Install jq:${NC}" >&2 + log_error " ${CYAN}# Ubuntu/Debian${NC}" + log_error " sudo apt-get update && sudo apt-get install -y jq" + log_error "" + log_error " ${CYAN}# Fedora/RHEL${NC}" + log_error " sudo dnf install -y jq" + log_error "" + log_error " ${CYAN}# macOS${NC}" + log_error " brew install jq" + log_error "" + log_error " ${CYAN}# Arch Linux${NC}" + log_error " sudo pacman -S jq" + log_error "" + return 1 } # Install jq if not already present (required by some cloud providers) @@ -344,7 +344,6 @@ verify_openrouter_model() { if [[ "${model_id}" == "openrouter/auto" || "${model_id}" == "openrouter/free" ]]; then return 0; fi if [[ -n "${SPAWN_SKIP_API_VALIDATION:-}" || "${BUN_ENV:-}" == "test" || "${NODE_ENV:-}" == "test" ]]; then return 0; fi if ! command -v curl &>/dev/null; then return 0; fi - if ! command -v python3 &>/dev/null; then return 0; fi local models_json models_json=$(curl -s --connect-timeout 5 --max-time 15 \ @@ -352,14 +351,23 @@ verify_openrouter_model() { # Extract model IDs and check for exact match local found - found=$(printf '%s' "${models_json}" | python3 -c " -import sys, json -try: - data = json.load(sys.stdin) - ids = [m['id'] for m in data.get('data', [])] - print('yes' if sys.argv[1] in ids else 'no') -except: print('skip') -" "${model_id}" 2>/dev/null) + if command -v jq &>/dev/null; then + if printf '%s' "${models_json}" | jq -e '.data[].id' 2>/dev/null | grep -qFx "\"${model_id}\""; then + found="yes" + else + found="no" + fi + elif command -v bun &>/dev/null; then + found=$(_INPUT="${models_json}" _MODEL="${model_id}" bun -e " +try { + const d = JSON.parse(process.env._INPUT); + const ids = (d.data || []).map(m => m.id); + process.stdout.write(ids.includes(process.env._MODEL) ? 'yes' : 'no'); +} catch { process.stdout.write('skip'); } +" 2>/dev/null) + else + found="skip" + fi if [[ "${found}" == "no" ]]; then log_warn "Model '${model_id}' not found on OpenRouter" @@ -877,12 +885,12 @@ wait_for_oauth_code() { log_step "Waiting for authentication in browser (this usually takes 10-30 seconds, timeout: ${timeout}s)..." while [[ ! -f "${code_file}" ]] && [[ ${elapsed} -lt ${timeout} ]]; do sleep "${POLL_INTERVAL}" - # Use python3 for float addition since bash arithmetic only handles integers + # Use bun for float addition since bash arithmetic only handles integers # If POLL_INTERVAL is 0.5, bash $(( )) would fail. Fallback keeps timeout working. - if command -v python3 &>/dev/null; then - elapsed=$(python3 -c "print(int(${elapsed} + ${POLL_INTERVAL}))" 2>/dev/null || echo "$((elapsed + 1))") + if command -v bun &>/dev/null; then + elapsed=$(_E="${elapsed}" _P="${POLL_INTERVAL}" bun -e "process.stdout.write(String(Math.floor(Number(process.env._E) + Number(process.env._P))))" 2>/dev/null || echo "$((elapsed + 1))") else - # No python3 available - fall back to integer seconds (may timeout early with fractional POLL_INTERVAL) + # No bun available - fall back to integer seconds (may timeout early with fractional POLL_INTERVAL) elapsed=$((elapsed + 1)) fi done @@ -1926,7 +1934,7 @@ get_ssh_fingerprint() { # Usage: json_escape STRING json_escape() { local string="${1}" - python3 -c "import json, sys; print(json.dumps(sys.stdin.read().rstrip('\n')))" <<< "${string}" 2>/dev/null || { + _INPUT="${string}" bun -e "process.stdout.write(JSON.stringify(process.env._INPUT) + '\n')" 2>/dev/null || { # Fallback: manually escape backslashes, quotes, and JSON control characters local escaped="${string//\\/\\\\}" escaped="${escaped//\"/\\\"}" @@ -1943,18 +1951,23 @@ json_escape() { extract_ssh_key_ids() { local api_response="${1}" local key_field="${2:-ssh_keys}" - # SECURITY: Pass key_field via sys.argv to prevent Python code injection. - # Previously interpolated directly into Python source as '${key_field}'. - python3 -c " -import json, sys -data = json.loads(sys.stdin.read()) -ids = [k['id'] for k in data.get(sys.argv[1], [])] -print(json.dumps(ids)) -" "${key_field}" <<< "${api_response}" 2>/dev/null || { - log_error "Failed to parse SSH key IDs from API response" - log_error "The API response may be malformed or python3 is unavailable" - return 1 - } + # Use jq with --arg to safely pass key_field (prevents code injection). + if command -v jq &>/dev/null; then + printf '%s' "${api_response}" | jq --arg field "${key_field}" '[.[$field][]?.id]' 2>/dev/null || { + log_error "Failed to parse SSH key IDs from API response" + return 1 + } + else + _DATA="${api_response}" _FIELD="${key_field}" bun -e " +const d = JSON.parse(process.env._DATA); +const ids = (d[process.env._FIELD] || []).map(k => k.id); +process.stdout.write(JSON.stringify(ids) + '\n'); +" 2>/dev/null || { + log_error "Failed to parse SSH key IDs from API response" + log_error "The API response may be malformed or bun is unavailable" + return 1 + } + fi } # ============================================================ @@ -2018,8 +2031,8 @@ calculate_retry_backoff() { fi # Add jitter: ±20% randomization to prevent thundering herd - # Fallback to no-jitter interval if python3 is unavailable - python3 -c "import random; print(int(${interval} * (0.8 + random.random() * 0.4)))" 2>/dev/null || printf '%s' "${interval}" + # Fallback to no-jitter interval if bun is unavailable + _INTERVAL="${interval}" bun -e "process.stdout.write(String(Math.floor(Number(process.env._INTERVAL) * (0.8 + Math.random() * 0.4))) + '\n')" 2>/dev/null || printf '%s\n' "${interval}" } # Handle API retry decision with backoff - extracted to reduce duplication across API wrappers @@ -2598,16 +2611,42 @@ ssh_verify_connectivity() { generic_ssh_wait "${SSH_USER:-root}" "${ip}" "$SSH_OPTS -o ConnectTimeout=5" "echo ok" "SSH connectivity" "${max_attempts}" "${initial_interval}" } -# Extract a value from a JSON response using a Python expression -# Usage: _extract_json_field JSON_STRING PYTHON_EXPR [DEFAULT] -# The Python expression receives 'd' as the parsed JSON dict. +# Extract a value from a JSON response using bracket-notation path +# Usage: _extract_json_field JSON_STRING JS_EXPR [DEFAULT] +# The JS expression uses bracket access syntax: d['key1']['key2'][0] # Returns DEFAULT (or empty string) on parse failure. _extract_json_field() { local json="${1}" - local py_expr="${2}" + local js_expr="${2}" local default="${3:-}" - printf '%s' "${json}" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(${py_expr})" 2>/dev/null || echo "${default}" + _DATA="${json}" _EXPR="${js_expr}" bun -e " +try { + const d = JSON.parse(process.env._DATA); + const expr = process.env._EXPR || ''; + // Parse bracket-notation path: d['key1']['key2'][0] + // Extract segments from ['...'] or [N] patterns + const segments = []; + const re = /\[(\d+|'[^']*'|\"[^\"]*\")\]/g; + let m; + while ((m = re.exec(expr)) !== null) { + let key = m[1]; + if ((key.startsWith(\"'\") && key.endsWith(\"'\")) || (key.startsWith('\"') && key.endsWith('\"'))) { + key = key.slice(1, -1); + } else { + key = Number(key); + } + segments.push(key); + } + let result = d; + for (const seg of segments) { + if (result === null || result === undefined) { process.exit(1); } + result = result[seg]; + } + if (result !== undefined && result !== null) process.stdout.write(String(result) + '\n'); + else process.exit(1); +} catch { process.exit(1); } +" 2>/dev/null || echo "${default}" } # Extract an error message from a JSON API response. @@ -2619,24 +2658,19 @@ extract_api_error_message() { local json="${1}" local fallback="${2:-Unknown error}" - printf '%s' "${json}" | python3 -c " -import json, sys -try: - d = json.loads(sys.stdin.read()) - e = d.get('error', '') - msg = ( - (isinstance(e, dict) and (e.get('message') or e.get('error_message'))) - or d.get('message') - or d.get('reason') - or (isinstance(e, str) and e) - or '' - ) - if msg: - print(msg) - else: - sys.exit(1) -except: - sys.exit(1) + _DATA="${json}" bun -e " +try { + const d = JSON.parse(process.env._DATA); + const e = d.error || ''; + const msg = + (typeof e === 'object' && e !== null && (e.message || e.error_message)) || + d.message || + d.reason || + (typeof e === 'string' && e) || + ''; + if (msg) process.stdout.write(String(msg) + '\n'); + else process.exit(1); +} catch { process.exit(1); } " 2>/dev/null || echo "${fallback}" } @@ -2778,7 +2812,15 @@ _load_token_from_config() { fi local saved_token - saved_token=$(python3 -c "import json, sys; data=json.load(open(sys.argv[1])); print(data.get('api_key','') or data.get('token',''))" "${config_file}" 2>/dev/null) + if command -v jq &>/dev/null; then + saved_token=$(jq -r '(if (.api_key // "" | length) > 0 then .api_key else (.token // "") end)' "${config_file}" 2>/dev/null) + else + saved_token=$(_FILE="${config_file}" bun -e " +import fs from 'fs'; +const d = JSON.parse(fs.readFileSync(process.env._FILE, 'utf8')); +process.stdout.write(d.api_key || d.token || ''); +" 2>/dev/null) + fi if [[ -z "${saved_token}" ]]; then return 1 fi @@ -2882,7 +2924,7 @@ ensure_api_token_with_provider() { local help_url="${4}" local test_func="${5:-}" - check_python_available || return 1 + check_json_processor_available || return 1 # Try environment variable (validate if test function provided) if _load_token_from_env "${env_var_name}" "${provider_name}"; then @@ -2925,7 +2967,7 @@ ensure_api_token_with_provider() { # Multi-credential configuration helpers # ============================================================ -# Load multiple fields from a JSON config file in a single python3 call. +# Load multiple fields from a JSON config file in a single call. # Outputs each field value on a separate line. Returns 1 if file missing or parse fails. # Usage: local creds; creds=$(_load_json_config_fields CONFIG_FILE field1 field2 ...) # Then: { read -r var1; read -r var2; ... } <<< "${creds}" @@ -2933,14 +2975,22 @@ _load_json_config_fields() { local config_file="${1}"; shift [[ -f "${config_file}" ]] || return 1 - # SECURITY: Pass field names via sys.argv to prevent Python code injection. - # Previously built Python source by interpolating field names as '${field}'. - python3 -c " -import json, sys -d = json.load(open(sys.argv[1])) -for field in sys.argv[2:]: - print(d.get(field, '')) -" "${config_file}" "$@" 2>/dev/null || return 1 + if command -v jq &>/dev/null; then + # Use jq to extract each field; output one value per line + local field + for field in "$@"; do + jq -r --arg f "${field}" '.[$f] // ""' "${config_file}" 2>/dev/null || return 1 + done + else + # SECURITY: Pass field names via env var to prevent code injection. + _FILE="${config_file}" _FIELDS="$(printf '%s\n' "$@")" bun -e " +import fs from 'fs'; +const d = JSON.parse(fs.readFileSync(process.env._FILE, 'utf8')); +for (const field of process.env._FIELDS.split('\n')) { + if (field) process.stdout.write((d[field] || '') + '\n'); +} +" 2>/dev/null || return 1 + fi } # Save key-value pairs to a JSON config file using json_escape for safe encoding. @@ -3100,7 +3150,7 @@ ensure_multi_credentials() { local test_func="${4:-}" shift 4 - check_python_available || return 1 + check_json_processor_available || return 1 # Parse credential specs into parallel arrays local env_vars=() config_keys=() labels=() diff --git a/shared/key-request.sh b/shared/key-request.sh index db3c2017..859b92a9 100644 --- a/shared/key-request.sh +++ b/shared/key-request.sh @@ -2,7 +2,7 @@ # Shell helpers for API key provisioning # Sourced by qa-cycle.sh for Phase 0 key loading and Phase 1 stale key handling # -# Requires: python3, curl, REPO_ROOT set, log() function defined by caller +# Requires: jq or bun, curl, REPO_ROOT set, log() function defined by caller # # Functions: # load_cloud_keys_from_config — Load keys from ~/.config/spawn/{cloud}.json into env @@ -23,17 +23,30 @@ fi # Outputs one env var name per line, empty if CLI-based auth get_cloud_env_vars() { local cloud="${1}" - python3 -c " -import json, re, sys -m = json.load(open(sys.argv[1])) -auth = m.get('clouds', {}).get(sys.argv[2], {}).get('auth', '') -if re.search(r'\b(login|configure|setup)\b', auth, re.I): - sys.exit(0) -for var in re.split(r'\s*[+,]\s*', auth): - v = var.strip() - if v: - print(v) -" "${REPO_ROOT}/manifest.json" "${cloud}" 2>/dev/null + if command -v jq &>/dev/null; then + local auth + auth=$(jq -r --arg c "${cloud}" '.clouds[$c].auth // ""' "${REPO_ROOT}/manifest.json" 2>/dev/null) || return 0 + # Skip CLI-based auth (login, configure, setup) + if printf '%s' "${auth}" | grep -qiE '\b(login|configure|setup)\b'; then + return 0 + fi + # Empty auth means no env vars needed + if [[ -z "${auth}" ]]; then + return 0 + fi + # Split on + or , and output each var name + printf '%s' "${auth}" | tr '+,' '\n' | sed 's/^ *//;s/ *$//' | grep -v '^$' || true + else + _MANIFEST="${REPO_ROOT}/manifest.json" _CLOUD="${cloud}" bun -e " +import fs from 'fs'; +const m = JSON.parse(fs.readFileSync(process.env._MANIFEST, 'utf8')); +const auth = (m.clouds?.[process.env._CLOUD]?.auth) || ''; +if (/\b(login|configure|setup)\b/i.test(auth)) process.exit(0); +for (const v of auth.split(/\s*[+,]\s*/)) { + if (v.trim()) process.stdout.write(v.trim() + '\n'); +} +" 2>/dev/null + fi } # Parse manifest.json to extract cloud_key|auth_string lines for API-token clouds. @@ -41,17 +54,20 @@ for var in re.split(r'\s*[+,]\s*', auth): # Outputs one "cloud_key|auth_string" per line to stdout. _parse_cloud_auths() { local manifest_path="${1}" - python3 -c " -import json, re, sys -manifest = json.load(open(sys.argv[1])) -for key, cloud in manifest.get('clouds', {}).items(): - auth = cloud.get('auth', '') - if re.search(r'\b(login|configure|setup)\b', auth, re.I): - continue - if not auth.strip(): - continue - print(key + '|' + auth) -" "${manifest_path}" 2>/dev/null + if command -v jq &>/dev/null; then + jq -r '.clouds | to_entries[] | select(.value.auth != null and .value.auth != "") | select(.value.auth | test("\\b(login|configure|setup)\\b"; "i") | not) | "\(.key)|\(.value.auth)"' "${manifest_path}" 2>/dev/null + else + _MANIFEST="${manifest_path}" bun -e " +import fs from 'fs'; +const m = JSON.parse(fs.readFileSync(process.env._MANIFEST, 'utf8')); +for (const [key, cloud] of Object.entries(m.clouds || {})) { + const auth = cloud.auth || ''; + if (/\b(login|configure|setup)\b/i.test(auth)) continue; + if (!auth.trim()) continue; + process.stdout.write(key + '|' + auth + '\n'); +} +" 2>/dev/null + fi } # Try to load a single env var from config file if not already set in environment. @@ -76,12 +92,15 @@ _try_load_env_var() { # Try loading from config file if [[ -f "${config_file}" ]]; then local val - val=$(python3 -c " -import json, sys -data = json.load(open(sys.argv[1])) -v = data.get(sys.argv[2], '') or data.get('api_key', '') or data.get('token', '') -print(v) -" "${config_file}" "${var_name}" 2>/dev/null) + if command -v jq &>/dev/null; then + val=$(jq -r --arg v "${var_name}" '(.[$v] // .api_key // .token) // "" | select(. != null)' "${config_file}" 2>/dev/null) + else + val=$(_FILE="${config_file}" _VAR="${var_name}" bun -e " +import fs from 'fs'; +const d = JSON.parse(fs.readFileSync(process.env._FILE, 'utf8')); +process.stdout.write(d[process.env._VAR] || d.api_key || d.token || ''); +" 2>/dev/null) + fi if [[ -n "${val}" ]]; then # SECURITY: Defense-in-depth — prevent malicious values from being misused # downstream in unquoted expansions, eval contexts, or logging @@ -138,8 +157,8 @@ load_cloud_keys_from_config() { return 1 fi - if ! command -v python3 &>/dev/null; then - log "Key preflight: python3 not found, skipping" + if ! command -v jq &>/dev/null && ! command -v bun &>/dev/null; then + log "Key preflight: neither jq nor bun found, skipping" return 1 fi @@ -188,17 +207,18 @@ request_missing_cloud_keys() { return 0 # Nothing to request fi - if ! command -v python3 &>/dev/null; then - return 0 - fi - # Build JSON array of provider names local providers_json - providers_json=$(printf '%s\n' ${MISSING_KEY_PROVIDERS} | python3 -c " -import json, sys -providers = [line.strip() for line in sys.stdin if line.strip()] -print(json.dumps(providers)) + if command -v jq &>/dev/null; then + providers_json=$(printf '%s\n' ${MISSING_KEY_PROVIDERS} | jq -Rn '[inputs | select(. != "")]' 2>/dev/null) || return 0 + elif command -v bun &>/dev/null; then + providers_json=$(_PROVIDERS="${MISSING_KEY_PROVIDERS}" bun -e " +const providers = process.env._PROVIDERS.trim().split(/\s+/).filter(Boolean); +process.stdout.write(JSON.stringify(providers)); " 2>/dev/null) || return 0 + else + return 0 + fi log "Key preflight: Requesting keys for: ${MISSING_KEY_PROVIDERS}"