From 3da5174bb6d6e21b6d5ade92b08a43ad04a56428 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Tue, 4 Aug 2026 18:19:37 +0100 Subject: [PATCH] Let .env.local override .env, and resolve duplicate keys last-wins setup_storefront_env took the first match for a duplicated key while run_maestro took the last. Last-wins is the predictable rule: in a file kept by hand, the last uncommented assignment is the active one. .env.local now overrides .env per key. Nothing writes to .env.local, so it survives every sync and gives a developer a place to point the sample apps at their own store. Write decisions about .env deliberately ignore the overlay. Resolved values carry overrides, so writing them back would make an override permanent. While .env.local exists, .env is left alone and the reason is reported. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/setup_storefront_env | 87 +++++++++++++++++--- scripts/test_setup_storefront_env | 127 +++++++++++++++++++++++++++++- 2 files changed, 202 insertions(+), 12 deletions(-) diff --git a/scripts/setup_storefront_env b/scripts/setup_storefront_env index ce03864e2..73e2d9962 100755 --- a/scripts/setup_storefront_env +++ b/scripts/setup_storefront_env @@ -5,6 +5,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_ENV="${ROOT_DIR}/.env" +ROOT_ENV_LOCAL="${ROOT_DIR}/.env.local" ANDROID_ENV="${ROOT_DIR}/platforms/android/samples/CheckoutKitAndroidDemo/.env" SWIFT_DEMO_XCCONFIG="${ROOT_DIR}/platforms/swift/Samples/CheckoutKitSwiftDemo/Storefront.xcconfig" SWIFT_ACCELERATED_XCCONFIG="${ROOT_DIR}/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/Storefront.xcconfig" @@ -94,15 +95,37 @@ read_env_value() { sub(/[[:space:]]*$/, "", candidate) if (candidate == key) { value = substr(line, index(line, "=") + 1) - print value - exit + found = 1 } } + END { if (found) print value } ' "$file")" strip_outer_quotes "$raw_value" } +env_keys() { + local file="$1" + + [[ -f "$file" ]] || return 0 + + awk ' + /^[[:space:]]*#/ || /^[[:space:]]*\/\// || /^[[:space:]]*$/ { next } + $0 !~ /=/ { next } + { + line = $0 + sub(/^[[:space:]]*/, "", line) + candidate = line + sub(/=.*/, "", candidate) + sub(/[[:space:]]*$/, "", candidate) + if (candidate != "" && !(candidate in seen)) { + seen[candidate] = 1 + print candidate + } + } + ' "$file" +} + env_has_key() { local key="$1" local file="$2" @@ -127,6 +150,36 @@ env_has_key() { ' "$file" } +# Value resolution reads .env.local ahead of .env, so an entry there wins. +# Decisions about writing .env deliberately do not use these: .env.local must +# never be baked into .env, or the override would become permanent. +read_root_value() { + local key="$1" + + if env_has_key "$key" "$ROOT_ENV_LOCAL"; then + read_env_value "$key" "$ROOT_ENV_LOCAL" + return 0 + fi + + read_env_value "$key" "$ROOT_ENV" +} + +root_has_key() { + local key="$1" + + env_has_key "$key" "$ROOT_ENV_LOCAL" || env_has_key "$key" "$ROOT_ENV" +} + +report_local_overrides() { + local keys + + keys="$(env_keys "$ROOT_ENV_LOCAL" | paste -sd, - | sed -e 's/,/, /g')" + [[ -n "$keys" ]] || return 0 + + echo ".env.local overrides .env for: ${keys}" + echo "Nothing writes to .env.local, so remove a line there to fall back to .env." +} + is_missing_required_value() { local value="$1" @@ -175,7 +228,7 @@ required_config_value() { local file_value local env_value - file_value="$(read_env_value "$key" "$ROOT_ENV")" + file_value="$(read_root_value "$key")" if ! is_missing_required_value "$file_value"; then printf '%s' "$file_value" return 0 @@ -192,8 +245,8 @@ root_or_source_value() { shift local root_value - if env_has_key "$key" "$ROOT_ENV"; then - root_value="$(read_env_value "$key" "$ROOT_ENV")" + if root_has_key "$key"; then + root_value="$(read_root_value "$key")" if ! is_placeholder_value "$root_value"; then printf '%s' "$root_value" return 0 @@ -208,8 +261,8 @@ root_or_source_nonempty_value() { shift local root_value - if env_has_key "$key" "$ROOT_ENV"; then - root_value="$(read_env_value "$key" "$ROOT_ENV")" + if root_has_key "$key"; then + root_value="$(read_root_value "$key")" if [[ -n "$root_value" ]] && ! is_placeholder_value "$root_value"; then printf '%s' "$root_value" return 0 @@ -408,8 +461,8 @@ load_values() { "$(read_env_value STOREFRONT_ACCESS_TOKEN "$SWIFT_ACCELERATED_XCCONFIG")")" API_VERSION_VALUE="$(first_config_value \ - "$(read_env_value API_VERSION "$ROOT_ENV")" \ - "$(read_env_value STOREFRONT_VERSION "$ROOT_ENV")" \ + "$(read_root_value API_VERSION)" \ + "$(read_root_value STOREFRONT_VERSION)" \ "$(env_fallback API_VERSION)" \ "$(env_fallback STOREFRONT_VERSION)" \ "$(read_env_value API_VERSION "$ANDROID_ENV")" \ @@ -703,8 +756,8 @@ ensure_root_env() { exit 1 fi - if is_missing_required_value "$(read_env_value STOREFRONT_DOMAIN "$ROOT_ENV")" || - is_missing_required_value "$(read_env_value STOREFRONT_ACCESS_TOKEN "$ROOT_ENV")"; then + if is_missing_required_value "$(read_root_value STOREFRONT_DOMAIN)" || + is_missing_required_value "$(read_root_value STOREFRONT_ACCESS_TOKEN)"; then echo "Root .env is missing required storefront configuration." >&2 exit 1 fi @@ -738,6 +791,17 @@ ensure_root_env() { echo "Normalizing root storefront configuration at .env." fi + # Resolved values carry .env.local overrides, so writing them back would make an + # override permanent. Leave .env alone and say why instead. + if [[ -f "$ROOT_ENV_LOCAL" ]]; then + if [[ "$root_needs_write" == "true" ]]; then + echo "Leaving .env alone because .env.local exists. Remove .env.local to let .env be rewritten." + fi + + load_values + return 0 + fi + if [[ "$root_needs_write" == "true" ]]; then collect_missing_values generate_root_env >"$ROOT_ENV" @@ -795,6 +859,7 @@ check_generated_files() { echo "Sample app storefront configuration is up to date." } +report_local_overrides ensure_root_env if [[ "$mode" == "check" ]]; then diff --git a/scripts/test_setup_storefront_env b/scripts/test_setup_storefront_env index d2fa56797..f57013af9 100755 --- a/scripts/test_setup_storefront_env +++ b/scripts/test_setup_storefront_env @@ -62,6 +62,40 @@ assert_not_contains() { fi } +write_canonical_root_env() { + local path="$1" + local domain="$2" + + cat >"$path" <>"$fixture/.env" + + "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + assert_output_is_sanitized "$output" + + assert_contains "$android_env" "STOREFRONT_DOMAIN=later-store.example.myshopify.com" + assert_not_contains "$android_env" "synthetic-store.example.myshopify.com" +} + +test_env_local_overrides_root_env() { + local fixture output android_env root_before local_before + fixture="$(make_fixture)" + output="$fixture/output.log" + android_env="$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" + root_before="$fixture/root_before" + local_before="$fixture/local_before" + + write_canonical_root_env "$fixture/.env" synthetic-store.example.myshopify.com + cat >"$fixture/.env.local" <<'EOF' +STOREFRONT_DOMAIN=overridden-store.example.myshopify.com +STOREFRONT_ACCESS_TOKEN=overridden-token +EOF + cp "$fixture/.env" "$root_before" + cp "$fixture/.env.local" "$local_before" + + "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + assert_output_is_sanitized "$output" + + assert_contains "$android_env" "STOREFRONT_DOMAIN=overridden-store.example.myshopify.com" + assert_contains "$android_env" "STOREFRONT_ACCESS_TOKEN=overridden-token" + assert_contains "$android_env" "EMAIL=checkout-kit@example.com" + assert_file_is_unchanged "$fixture/.env" "$root_before" + assert_file_is_unchanged "$fixture/.env.local" "$local_before" +} + +test_env_local_warning_names_only_keys() { + local fixture output + fixture="$(make_fixture)" + output="$fixture/output.log" + + write_canonical_root_env "$fixture/.env" synthetic-store.example.myshopify.com + cat >"$fixture/.env.local" <<'EOF' +STOREFRONT_DOMAIN=overridden-store.example.myshopify.com +STOREFRONT_ACCESS_TOKEN=overridden-token +EOF + + "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + assert_output_is_sanitized "$output" + + assert_contains "$output" ".env.local" + assert_contains "$output" "STOREFRONT_DOMAIN" + assert_contains "$output" "STOREFRONT_ACCESS_TOKEN" + assert_not_contains "$output" "EMAIL" +} + +test_env_local_is_never_baked_into_root_env() { + local fixture output root_before + fixture="$(make_fixture)" + output="$fixture/output.log" + root_before="$fixture/root_before" + + cat >"$fixture/.env" <<'EOF' +STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com +STOREFRONT_ACCESS_TOKEN=synthetic-token +EOF + printf 'STOREFRONT_DOMAIN=%s\n' overridden-store.example.myshopify.com >"$fixture/.env.local" + cp "$fixture/.env" "$root_before" + + "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + assert_output_is_sanitized "$output" + + assert_file_is_unchanged "$fixture/.env" "$root_before" + assert_contains "$output" "Leaving .env alone" + assert_contains "$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" \ + "STOREFRONT_DOMAIN=overridden-store.example.myshopify.com" +} + test_sync_and_check test_required_values_only test_buyer_address_defaults_match_the_e2e_shop_country @@ -387,5 +508,9 @@ test_blank_customer_account_api_version_defaults test_development_team_follows_env_and_clears test_development_team_is_added_to_an_existing_env test_migration_from_platform_config +test_duplicate_keys_resolve_last_wins +test_env_local_overrides_root_env +test_env_local_warning_names_only_keys +test_env_local_is_never_baked_into_root_env echo "setup_storefront_env synthetic tests passed."