Last active
March 2, 2026 07:38
-
-
Save tranch/2d67a066e4ae1a12b89623771b28310e to your computer and use it in GitHub Desktop.
Tools to help load and export environment variables from Apple’s Keychain.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| usage() { | |
| cat <<'EOF' | |
| Usage: load2keychain [--service NAME] [--dry-run] [FILE] | |
| Imports KEY=value pairs from a dotenv-style file into macOS Keychain as | |
| generic-password items. | |
| Default behavior: | |
| - FILE defaults to .env | |
| - service defaults to the current directory name | |
| Storage layout: | |
| - keychain service: <service> | |
| - keychain account: <dotenv key> | |
| - keychain password: <dotenv value> | |
| This matches with-keychain, which loads all generic-password items whose | |
| service equals the current directory name and turns each account into an | |
| environment variable name. | |
| Supported dotenv syntax: | |
| - blank lines | |
| - comment lines starting with # | |
| - optional "export " prefix | |
| - KEY=value pairs | |
| - single-quoted or double-quoted whole values | |
| Not supported: | |
| - multiline values | |
| - inline comments after unquoted values | |
| - shell interpolation or command substitution | |
| EOF | |
| } | |
| service_name_from_pwd() { | |
| basename "$PWD" | |
| } | |
| trim_whitespace() { | |
| local value="$1" | |
| value="${value#"${value%%[![:space:]]*}"}" | |
| value="${value%"${value##*[![:space:]]}"}" | |
| printf '%s\n' "$value" | |
| } | |
| decode_double_quoted_value() { | |
| local value="$1" | |
| value="${value//\\\\/\\}" | |
| value="${value//\\n/$'\n'}" | |
| value="${value//\\r/$'\r'}" | |
| value="${value//\\t/$'\t'}" | |
| value="${value//\\\"/\"}" | |
| printf '%s' "$value" | |
| } | |
| parse_env_line() { | |
| local line="$1" | |
| local key_part | |
| local value_part | |
| line="$(trim_whitespace "$line")" | |
| [[ -n "$line" ]] || return 1 | |
| [[ "$line" =~ ^# ]] && return 1 | |
| if [[ "$line" == export\ * ]]; then | |
| line="${line#export }" | |
| line="$(trim_whitespace "$line")" | |
| fi | |
| [[ "$line" == *=* ]] || { | |
| printf 'Unsupported line (missing =): %s\n' "$line" >&2 | |
| return 2 | |
| } | |
| key_part="${line%%=*}" | |
| value_part="${line#*=}" | |
| key_part="$(trim_whitespace "$key_part")" | |
| if [[ ! "$key_part" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then | |
| printf 'Unsupported key name: %s\n' "$key_part" >&2 | |
| return 2 | |
| fi | |
| if [[ "$value_part" =~ ^\".*\"$ ]]; then | |
| value_part="${value_part:1:${#value_part}-2}" | |
| value_part="$(decode_double_quoted_value "$value_part")" | |
| elif [[ "$value_part" =~ ^\'.*\'$ ]]; then | |
| value_part="${value_part:1:${#value_part}-2}" | |
| fi | |
| printf '%s\t%s\n' "$key_part" "$value_part" | |
| } | |
| import_file() { | |
| local file="$1" | |
| local service="$2" | |
| local dry_run="$3" | |
| local line | |
| local parsed | |
| local env_name | |
| local env_value | |
| local imported_count=0 | |
| while IFS= read -r line || [[ -n "$line" ]]; do | |
| if ! parsed="$(parse_env_line "$line")"; then | |
| status=$? | |
| if [[ $status -eq 1 ]]; then | |
| continue | |
| fi | |
| exit "$status" | |
| fi | |
| env_name="${parsed%%$'\t'*}" | |
| env_value="${parsed#*$'\t'}" | |
| if [[ "$dry_run" == "1" ]]; then | |
| printf '[dry-run] security add-generic-password -a %q -s %q -w %q -U\n' \ | |
| "$env_name" "$service" "$env_value" | |
| else | |
| security add-generic-password \ | |
| -a "$env_name" \ | |
| -s "$service" \ | |
| -w "$env_value" \ | |
| -U >/dev/null | |
| printf 'Imported %s into service %s\n' "$env_name" "$service" | |
| fi | |
| imported_count=$((imported_count + 1)) | |
| done < "$file" | |
| if [[ "$dry_run" == "1" ]]; then | |
| printf 'Would import %d item(s) into service %s\n' "$imported_count" "$service" | |
| else | |
| printf 'Imported %d item(s) into service %s\n' "$imported_count" "$service" | |
| fi | |
| } | |
| main() { | |
| local file=".env" | |
| local service="" | |
| local dry_run=0 | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --service) | |
| [[ $# -ge 2 ]] || { | |
| printf 'Missing value for --service\n' >&2 | |
| exit 1 | |
| } | |
| service="$2" | |
| shift 2 | |
| ;; | |
| --dry-run) | |
| dry_run=1 | |
| shift | |
| ;; | |
| -h|--help) | |
| usage | |
| exit 0 | |
| ;; | |
| -*) | |
| printf 'Unknown option: %s\n' "$1" >&2 | |
| usage >&2 | |
| exit 1 | |
| ;; | |
| *) | |
| file="$1" | |
| shift | |
| ;; | |
| esac | |
| done | |
| [[ -n "$service" ]] || service="$(service_name_from_pwd)" | |
| if [[ ! -f "$file" ]]; then | |
| printf 'File not found: %s\n' "$file" >&2 | |
| exit 1 | |
| fi | |
| import_file "$file" "$service" "$dry_run" | |
| } | |
| main "$@" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| usage() { | |
| cat <<'EOF' | |
| Usage: with-keychain <command> [args...] | |
| Loads all generic-password items whose service matches the current directory | |
| name, exports them as environment variables for the target command, then | |
| replaces the shell with that command. | |
| Each keychain account name becomes an environment variable name after: | |
| 1. uppercasing | |
| 2. replacing non [A-Z0-9_] characters with _ | |
| 3. prefixing with _ if the name would start with a digit | |
| EOF | |
| } | |
| service_name_from_pwd() { | |
| basename "$PWD" | |
| } | |
| env_name_for_account() { | |
| local account="$1" | |
| local env_name | |
| env_name="$(printf '%s' "$account" | tr '[:lower:]' '[:upper:]' | tr -c 'A-Z0-9_' '_')" | |
| if [[ -z "$env_name" ]]; then | |
| return 1 | |
| fi | |
| if [[ "$env_name" =~ ^[0-9] ]]; then | |
| env_name="_${env_name}" | |
| fi | |
| printf '%s\n' "$env_name" | |
| } | |
| list_accounts_for_service() { | |
| local service="$1" | |
| security dump-keychain 2>/dev/null | awk -v target="$service" ' | |
| /^class:/ { | |
| if (in_genp && svce == target && acct != "") { | |
| print acct | |
| } | |
| in_genp = index($0, "\"genp\"") > 0 | |
| svce = "" | |
| acct = "" | |
| next | |
| } | |
| in_genp && /"svce"<blob>=/ { | |
| if (match($0, /"svce"<blob>="([^"]*)"/)) { | |
| svce = substr($0, RSTART + 14, RLENGTH - 15) | |
| } | |
| next | |
| } | |
| in_genp && /"acct"<blob>=/ { | |
| if (match($0, /"acct"<blob>="([^"]*)"/)) { | |
| acct = substr($0, RSTART + 14, RLENGTH - 15) | |
| } | |
| next | |
| } | |
| END { | |
| if (in_genp && svce == target && acct != "") { | |
| print acct | |
| } | |
| } | |
| ' | awk '!seen[$0]++' | |
| } | |
| load_service_into_env() { | |
| local service="$1" | |
| local account | |
| local env_name | |
| local password | |
| local -A exported_names=() | |
| while IFS= read -r account; do | |
| [[ -n "$account" ]] || continue | |
| env_name="$(env_name_for_account "$account")" || { | |
| printf 'Skipping keychain account with unsupported name: %s\n' "$account" >&2 | |
| continue | |
| } | |
| if [[ -n "${exported_names[$env_name]:-}" ]]; then | |
| printf 'Refusing to continue: multiple accounts map to %s (%s, %s)\n' \ | |
| "$env_name" "${exported_names[$env_name]}" "$account" >&2 | |
| exit 1 | |
| fi | |
| if ! password="$(security find-generic-password -s "$service" -a "$account" -w 2>/dev/null)"; then | |
| printf 'Failed to read password for service=%s account=%s\n' "$service" "$account" >&2 | |
| exit 1 | |
| fi | |
| exported_names["$env_name"]="$account" | |
| export "$env_name=$password" | |
| done < <(list_accounts_for_service "$service") | |
| } | |
| main() { | |
| local service | |
| if [[ $# -eq 0 ]]; then | |
| usage >&2 | |
| exit 1 | |
| fi | |
| service="$(service_name_from_pwd)" | |
| load_service_into_env "$service" | |
| exec "$@" | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment