diff --git a/internal/cli/client.go b/internal/cli/client.go index 1a207d4..c662351 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -17,6 +17,7 @@ import ( "github.com/tracebloc/cli/internal/geo" "github.com/tracebloc/cli/internal/slug" "github.com/tracebloc/cli/internal/ui" + "github.com/tracebloc/cli/internal/zones" ) // newClientCmd wires the `tracebloc client` subtree — provisioning + selecting @@ -123,21 +124,27 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, name, loca return mapClientErr(err) } } - if location == "" { + if location != "" { + // --location given: validate up front so an invalid zone fails here with + // a suggestion, not as an opaque backend 400 at create time. + if verr := validateZone(location); verr != nil { + return &exitError{code: 1, err: verr} + } + } else { if pr == nil { return errMissingFlag("--location") } // Auto-detect a suggested zone (cloud metadata → IP geolocation) and // pre-fill it as the prompt default; the user confirms with Enter or - // overrides. Never silent (it's a prompt), never empty (validateNonEmpty). + // overrides. validateZone keeps the input a real zone; never empty/silent. suggested := "" help := "electricityMaps zone for the carbon footprint (e.g. DE)" - if z := detectZone(ctx); z != nil { + if z := detectZone(ctx); z != nil && zones.Valid(z.Code) { suggested = z.Code help = fmt.Sprintf("detected %s via %s (%s confidence) — Enter to accept, or type your zone", z.Code, z.Source, z.Confidence) } - if location, err = pr.Input("Location zone (e.g. DE)", help, suggested, validateNonEmpty); err != nil { + if location, err = pr.Input("Location zone (e.g. DE)", help, suggested, validateZone); err != nil { return mapClientErr(err) } } @@ -289,6 +296,21 @@ func validateNonEmpty(s string) error { return nil } +// validateZone rejects a location that isn't a known zone (what the backend's +// ChoiceField accepts), with close-match suggestions — so an invalid zone fails +// at the prompt/flag with guidance instead of as an opaque backend 400. +func validateZone(s string) error { + s = strings.TrimSpace(s) + if zones.Valid(s) { + return nil + } + msg := fmt.Sprintf("%q is not a valid location zone", s) + if sugg := zones.Suggest(s, 3); len(sugg) > 0 { + msg += " — did you mean: " + strings.Join(sugg, ", ") + "?" + } + return errors.New(msg) +} + // mapClientErr turns a cancelled interactive prompt into a clean exit. func mapClientErr(err error) error { if errors.Is(err, errInteractiveCancelled) { diff --git a/internal/cli/client_test.go b/internal/cli/client_test.go index 176728c..400b459 100644 --- a/internal/cli/client_test.go +++ b/internal/cli/client_test.go @@ -271,3 +271,18 @@ func TestClientCreate_AcceptsDetectedZone(t *testing.T) { t.Errorf("location = %q, want FR (detected zone accepted as the default)", body.Location) } } + +func TestClientCreate_RejectsInvalidZone(t *testing.T) { + withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("no API call expected for an invalid zone; got %s %s", r.Method, r.URL.Path) + }) + // --location given but not a real zone → fails up front with a suggestion, + // before any API call. + err := runClientCreate(context.Background(), ui.New(&bytes.Buffer{}), nil, "My Client", "Germany", true) + if err == nil || !strings.Contains(err.Error(), "not a valid location zone") { + t.Fatalf("want invalid-zone error, got %v", err) + } + if !strings.Contains(err.Error(), "DE") { + t.Errorf("expected a DE suggestion, got: %v", err) + } +} diff --git a/internal/zones/zones.go b/internal/zones/zones.go new file mode 100644 index 0000000..a826ed3 --- /dev/null +++ b/internal/zones/zones.go @@ -0,0 +1,95 @@ +// Package zones is the CLI's vendored copy of the backend's location zone list +// (ZONE_CHOICES — electricityMaps zones), embedded from zones.json so `client +// create` can validate a location against exactly what the backend's ChoiceField +// accepts, instead of letting an invalid zone fail as a 400 at create time. +// Regenerate with scripts/sync-zones.sh when the backend list changes. +package zones + +import ( + _ "embed" + "encoding/json" + "sort" + "strings" +) + +//go:embed zones.json +var zonesJSON []byte + +// names maps a zone code (e.g. "DE", "US-CAL-CISO") to its display name. +var names map[string]string + +func init() { + if err := json.Unmarshal(zonesJSON, &names); err != nil { + panic("zones: invalid embedded zones.json: " + err.Error()) + } +} + +// Valid reports whether code is a known zone. Case-sensitive: zone codes are +// upper-case, matching the backend ZONE_CHOICES the API validates against. +func Valid(code string) bool { + _, ok := names[strings.TrimSpace(code)] + return ok +} + +// Name returns the display name for a zone code, or "" if unknown. +func Name(code string) string { return names[strings.TrimSpace(code)] } + +// Count returns how many zones are known. +func Count() int { return len(names) } + +// Suggest returns up to n plausible zone codes for a (likely invalid) input, to +// help a user who typed "germany", "de", or "Germany" find "DE". Match priority: +// the input as an exact code in the wrong case, then a code prefix, then a +// case-insensitive name substring. De-duplicated; sorted within each tier. +func Suggest(input string, n int) []string { + q := strings.TrimSpace(input) + if q == "" || n <= 0 { + return nil + } + upper := strings.ToUpper(q) + lower := strings.ToLower(q) + + var out []string + seen := map[string]bool{} + add := func(code string) bool { + if !seen[code] { + seen[code] = true + out = append(out, code) + } + return len(out) >= n + } + + // 1. exact code, wrong case ("de" → "DE") + for code := range names { + if strings.EqualFold(code, q) && add(code) { + return out + } + } + // 2. code prefix ("US" → US, US-CAL-CISO, …) + var prefixed []string + for code := range names { + if !seen[code] && strings.HasPrefix(code, upper) { + prefixed = append(prefixed, code) + } + } + sort.Strings(prefixed) + for _, code := range prefixed { + if add(code) { + return out + } + } + // 3. name substring ("german" → DE) + var named []string + for code, name := range names { + if !seen[code] && strings.Contains(strings.ToLower(name), lower) { + named = append(named, code) + } + } + sort.Strings(named) + for _, code := range named { + if add(code) { + return out + } + } + return out +} diff --git a/internal/zones/zones.json b/internal/zones/zones.json new file mode 100644 index 0000000..3afee26 --- /dev/null +++ b/internal/zones/zones.json @@ -0,0 +1,354 @@ +{ + "AE": "United Arab Emirates", + "AF": "Afghanistan", + "AG": "Antigua and Barbuda", + "AL": "Albania", + "AM": "Armenia", + "AO": "Angola", + "AR": "Argentina", + "AT": "Austria", + "AU": "Australia", + "AU-NSW": "New South Wales", + "AU-NT": "Northern Territory", + "AU-QLD": "Queensland", + "AU-SA": "South Australia", + "AU-TAS": "Tasmania", + "AU-TAS-FI": "Flinders Island", + "AU-TAS-KI": "King Island", + "AU-VIC": "Victoria", + "AU-WA": "Western Australia", + "AU-WA-RI": "Rottnest Island", + "AW": "Aruba", + "AX": "Åland Islands", + "AZ": "Azerbaijan", + "BA": "Bosnia and Herzegovina", + "BB": "Barbados", + "BD": "Bangladesh", + "BE": "Belgium", + "BF": "Burkina Faso", + "BG": "Bulgaria", + "BH": "Bahrain", + "BI": "Burundi", + "BJ": "Benin", + "BN": "Brunei", + "BO": "Bolivia", + "BR": "Brazil", + "BR-CS": "Central Brazil", + "BR-N": "North Brazil", + "BR-NE": "North-East Brazil", + "BR-S": "South Brazil", + "BS": "Bahamas", + "BT": "Bhutan", + "BW": "Botswana", + "BY": "Belarus", + "BZ": "Belize", + "CA": "Canada", + "CA-AB": "Alberta", + "CA-BC": "British Columbia", + "CA-MB": "Manitoba", + "CA-NB": "New Brunswick", + "CA-NL": "Newfoundland and Labrador", + "CA-NS": "Nova Scotia", + "CA-NT": "Northwest Territories", + "CA-NU": "Nunavut", + "CA-ON": "Ontario", + "CA-PE": "Prince Edward Island", + "CA-QC": "Québec", + "CA-SK": "Saskatchewan", + "CA-YT": "Yukon", + "CD": "Democratic Republic of the Congo", + "CF": "Central African Republic", + "CG": "Congo", + "CH": "Switzerland", + "CI": "Ivory Coast", + "CL-SEN": "Sistema Eléctrico Nacional", + "CM": "Cameroon", + "CN": "China", + "CO": "Colombia", + "CR": "Costa Rica", + "CU": "Cuba", + "CV": "Cabo Verde", + "CW": "Curaçao", + "CY": "Cyprus", + "CZ": "Czechia", + "DE": "Germany", + "DJ": "Djibouti", + "DK": "Denmark", + "DK-BHM": "Bornholm", + "DK-DK1": "West Denmark", + "DK-DK2": "East Denmark", + "DM": "Dominica", + "DO": "Dominican Republic", + "DZ": "Algeria", + "EC": "Ecuador", + "EE": "Estonia", + "EG": "Egypt", + "ER": "Eritrea", + "ES": "Spain", + "ES-CE": "Ceuta", + "ES-CN-FV": "Fuerteventura", + "ES-CN-GC": "Gran Canaria", + "ES-CN-HI": "El Hierro", + "ES-CN-IG": "Isla de la Gomera", + "ES-CN-LP": "La Palma", + "ES-CN-LZ": "Lanzarote", + "ES-CN-TE": "Tenerife", + "ES-IB-FO": "Formentera", + "ES-IB-IZ": "Ibiza", + "ES-IB-MA": "Mallorca", + "ES-IB-ME": "Menorca", + "ES-ML": "Melilla", + "ET": "Ethiopia", + "FI": "Finland", + "FJ": "Fiji", + "FK": "Falkland Islands", + "FM": "Micronesia", + "FO": "Faroe Islands", + "FO-MI": "Main Islands", + "FO-SI": "South Island", + "FR": "France", + "FR-COR": "Corsica", + "GA": "Gabon", + "GB": "Great Britain", + "GB-NIR": "Northern Ireland", + "GB-ORK": "Orkney Islands", + "GE": "Georgia", + "GF": "French Guiana", + "GH": "Ghana", + "GI": "Gibraltar", + "GL": "Greenland", + "GM": "Gambia", + "GN": "Guinea", + "GP": "Guadeloupe", + "GQ": "Equatorial Guinea", + "GR": "Greece", + "GS": "South Georgia and the South Sandwich Islands", + "GT": "Guatemala", + "GU": "Guam", + "GW": "Guinea-Bissau", + "GY": "Guyana", + "HK": "Hong Kong", + "HN": "Honduras", + "HR": "Croatia", + "HT": "Haiti", + "HU": "Hungary", + "ID": "Indonesia", + "IE": "Ireland", + "IL": "Israel", + "IN": "Mainland India", + "IN-EA": "Eastern India", + "IN-NE": "North Eastern India", + "IN-NO": "Northern India", + "IN-SO": "Southern India", + "IN-WE": "Western India", + "IQ": "Iraq", + "IR": "Iran", + "IS": "Iceland", + "IT": "Italy", + "IT-CNO": "Central North Italy", + "IT-CSO": "Central South Italy", + "IT-NO": "North Italy", + "IT-SAR": "Sardinia", + "IT-SIC": "Sicily", + "IT-SO": "South Italy", + "JM": "Jamaica", + "JO": "Jordan", + "JP": "Japan", + "JP-CB": "Chūbu", + "JP-CG": "Chūgoku", + "JP-HKD": "Hokkaidō", + "JP-HR": "Hokuriku", + "JP-KN": "Kansai", + "JP-KY": "Kyūshū", + "JP-ON": "Okinawa", + "JP-SK": "Shikoku", + "JP-TH": "Tōhoku", + "JP-TK": "Tōkyō", + "KE": "Kenya", + "KG": "Kyrgyzstan", + "KH": "Cambodia", + "KM": "Comoros", + "KP": "North Korea", + "KR": "South Korea", + "KW": "Kuwait", + "KY": "Cayman Islands", + "KZ": "Kazakhstan", + "LA": "Laos", + "LB": "Lebanon", + "LC": "Saint Lucia", + "LK": "Sri Lanka", + "LR": "Liberia", + "LS": "Lesotho", + "LT": "Lithuania", + "LU": "Luxembourg", + "LV": "Latvia", + "LY": "Libya", + "MA": "Morocco", + "MD": "Moldova", + "ME": "Montenegro", + "MG": "Madagascar", + "MK": "North Macedonia", + "ML": "Mali", + "MM": "Myanmar", + "MN": "Mongolia", + "MO": "Macao", + "MQ": "Martinique", + "MR": "Mauritania", + "MT": "Malta", + "MU": "Mauritius", + "MV": "Maldives", + "MW": "Malawi", + "MX": "Mexico", + "MY": "Malaysia", + "MY-EM": "Borneo", + "MY-WM": "Peninsula", + "MZ": "Mozambique", + "NA": "Namibia", + "NC": "New Caledonia", + "NE": "Niger", + "NG": "Nigeria", + "NI": "Nicaragua", + "NL": "Netherlands", + "NO": "Norway", + "NO-NO1": "Eastern Norway", + "NO-NO2": "Southern Norway", + "NO-NO3": "Central Norway", + "NO-NO4": "Northern Norway", + "NO-NO5": "Western Norway", + "NP": "Nepal", + "NZ": "New Zealand", + "OM": "Oman", + "PA": "Panama", + "PE": "Peru", + "PF": "French Polynesia", + "PG": "Papua New Guinea", + "PH": "Philippines", + "PH-LU": "Luzon", + "PH-MI": "Mindanao", + "PH-VI": "Visayas", + "PK": "Pakistan", + "PL": "Poland", + "PM": "Saint Pierre and Miquelon", + "PR": "Puerto Rico", + "PS": "State of Palestine", + "PT": "Portugal", + "PT-MA": "Madeira", + "PW": "Palau", + "PY": "Paraguay", + "QA": "Qatar", + "RE": "Réunion", + "RO": "Romania", + "RS": "Serbia", + "RU-1": "Europe-Ural", + "RU-2": "Siberia", + "RU-AS": "East", + "RW": "Rwanda", + "SA": "Saudi Arabia", + "SB": "Solomon Islands", + "SC": "Seychelles", + "SD": "Sudan", + "SE": "Sweden", + "SE-SE1": "North Sweden", + "SE-SE2": "North Central Sweden", + "SE-SE3": "South Central Sweden", + "SE-SE4": "South Sweden", + "SG": "Singapore", + "SI": "Slovenia", + "SK": "Slovakia", + "SL": "Sierra Leone", + "SN": "Senegal", + "SO": "Somalia", + "SR": "Suriname", + "SS": "South Sudan", + "ST": "São Tomé and Príncipe", + "SV": "El Salvador", + "SY": "Syria", + "SZ": "Eswatini", + "TD": "Chad", + "TG": "Togo", + "TH": "Thailand", + "TJ": "Tajikistan", + "TL": "Timor-Leste", + "TM": "Turkmenistan", + "TN": "Tunisia", + "TO": "Tonga", + "TR": "Turkey", + "TT": "Trinidad and Tobago", + "TW": "Taiwan", + "TZ": "Tanzania", + "UG": "Uganda", + "US": "United States", + "US-AK": "Alaska", + "US-AK-SEAPA": "Southeast Alaska Power Agency", + "US-CAL-BANC": "Balancing Authority of Northern California", + "US-CAL-CISO": "CAISO", + "US-CAL-IID": "Imperial Irrigation District", + "US-CAL-LDWP": "Los Angeles Department of Water and Power", + "US-CAL-TIDC": "Turlock Irrigation District", + "US-CAR-CPLE": "Duke Energy Progress East", + "US-CAR-CPLW": "Duke Energy Progress West", + "US-CAR-DUK": "Duke Energy Carolinas", + "US-CAR-SC": "South Carolina Public Service Authority", + "US-CAR-SCEG": "South Carolina Electric & Gas Company", + "US-CAR-YAD": "Alcoa Power Generating, Inc. Yadkin Division", + "US-CENT-SPA": "Southwestern Power Administration", + "US-CENT-SWPP": "Southwest Power Pool", + "US-FLA-FMPP": "Florida Municipal Power Pool", + "US-FLA-FPC": "Duke Energy Florida", + "US-FLA-FPL": "Florida Power and Light Company", + "US-FLA-GVL": "Gainesville Regional Utilities", + "US-FLA-HST": "City of Homestead", + "US-FLA-JEA": "Jacksonville Electric Authority", + "US-FLA-SEC": "Seminole Electric Cooperative", + "US-FLA-TAL": "City of Tallahassee", + "US-FLA-TEC": "Tampa Electric Company", + "US-HI": "Hawaii", + "US-MIDA-PJM": "PJM Interconnection", + "US-MIDW-AECI": "Associated Electric Cooperative", + "US-MIDW-LGEE": "Louisville Gas and Electric Company and Kentucky Utilities", + "US-MIDW-MISO": "Midcontinent ISO", + "US-NE-ISNE": "ISO New England", + "US-NW-AVA": "Avista Corporation", + "US-NW-BPAT": "Bonneville Power Administration", + "US-NW-CHPD": "Chelan County", + "US-NW-DOPD": "Douglas County", + "US-NW-GCPD": "Grant County", + "US-NW-GRID": "Gridforce Energy Management", + "US-NW-IPCO": "Idaho Power Company", + "US-NW-NEVP": "Nevada Power Company", + "US-NW-NWMT": "Northwestern Energy", + "US-NW-PACE": "Pacificorp East", + "US-NW-PACW": "Pacificorp West", + "US-NW-PGE": "Portland General Electric Company", + "US-NW-PSCO": "Public Service Company of Colorado", + "US-NW-PSEI": "Puget Sound Energy", + "US-NW-SCL": "Seattle City Light", + "US-NW-TPWR": "City of Tacoma", + "US-NW-WACM": "Western Area Power Administration - Rocky Mountain Region", + "US-NW-WAUW": "Western Area Power Administration - Upper Great Plains West", + "US-NY-NYIS": "New York ISO", + "US-SE-SEPA": "Southeastern Power Administration", + "US-SE-SOCO": "Southern Company Services", + "US-SW-AZPS": "Arizona Public Service Company", + "US-SW-EPE": "El Paso Electric Company", + "US-SW-PNM": "Public Service Company of New Mexico", + "US-SW-SRP": "Salt River Project", + "US-SW-TEPC": "Tucson Electric Power Company", + "US-SW-WALC": "Western Area Power Administration - Desert Southwest Region", + "US-TEN-TVA": "Tennessee Valley Authority", + "US-TEX-ERCO": "Electric Reliability Council of Texas", + "UY": "Uruguay", + "UZ": "Uzbekistan", + "VC": "Saint Vincent and the Grenadines", + "VE": "Venezuela", + "VI": "Virgin Islands", + "VN": "Vietnam", + "VU": "Vanuatu", + "WS": "Samoa", + "XK": "Kosovo", + "YE": "Yemen", + "YT": "Mayotte", + "ZA": "South Africa", + "ZM": "Zambia", + "ZW": "Zimbabwe" +} diff --git a/internal/zones/zones_test.go b/internal/zones/zones_test.go new file mode 100644 index 0000000..9edeedd --- /dev/null +++ b/internal/zones/zones_test.go @@ -0,0 +1,47 @@ +package zones + +import "testing" + +func TestValidAndName(t *testing.T) { + if !Valid("DE") || Name("DE") != "Germany" { + t.Errorf("DE: valid=%v name=%q", Valid("DE"), Name("DE")) + } + if !Valid("US-CAL-CISO") { + t.Error("US-CAL-CISO should be valid (a sub-zone)") + } + if Valid("de") { + t.Error("lower-case 'de' must be invalid — codes are upper-case") + } + if Valid("Germany") || Valid("") || Valid("eu-central-1") { + t.Error("a name / empty / cloud-region must be invalid") + } + if Count() < 100 { + t.Errorf("expected the full zone list, got %d", Count()) + } +} + +func TestSuggest(t *testing.T) { + has := func(s []string, want string) bool { + for _, v := range s { + if v == want { + return true + } + } + return false + } + if got := Suggest("germany", 3); !has(got, "DE") { + t.Errorf("Suggest(germany) = %v, want it to include DE (name match)", got) + } + if got := Suggest("de", 3); !has(got, "DE") { + t.Errorf("Suggest(de) = %v, want DE (wrong case)", got) + } + if got := Suggest("US", 5); !has(got, "US") { + t.Errorf("Suggest(US) = %v, want US (prefix)", got) + } + if got := Suggest("zzzznotazone", 3); len(got) != 0 { + t.Errorf("Suggest(garbage) = %v, want none", got) + } + if got := Suggest("DE", 0); got != nil { + t.Errorf("Suggest with n=0 = %v, want nil", got) + } +} diff --git a/scripts/sync-zones.sh b/scripts/sync-zones.sh new file mode 100755 index 0000000..6a13db8 --- /dev/null +++ b/scripts/sync-zones.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Sync the location zone list (ZONE_CHOICES) from tracebloc/backend into the +# CLI's vendored internal/zones/zones.json, so `client create` validates a +# location against exactly what the backend's ChoiceField accepts. +# +# The backend (metaApi/models/zone_choices.py) is the source of truth — it is +# what the API validates against. It lives in a PRIVATE repo, so unlike +# sync-schema.sh (which curls a public URL) this reads a local sibling checkout; +# point ZONES_SOURCE at zone_choices.py if your layout differs. +# +# Usage: +# scripts/sync-zones.sh regenerate zones.json +# scripts/sync-zones.sh --check fail if zones.json has drifted (CI) +# +# Env: +# ZONES_SOURCE path to zone_choices.py +# (default: ../backend/metaApi/models/zone_choices.py) +# ZONES_OUT destination (default: internal/zones/zones.json) +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ZONES_SOURCE="${ZONES_SOURCE:-$repo_root/../backend/metaApi/models/zone_choices.py}" +ZONES_OUT="${ZONES_OUT:-$repo_root/internal/zones/zones.json}" + +if [[ ! -f "$ZONES_SOURCE" ]]; then + echo "error: zone source not found: $ZONES_SOURCE" >&2 + echo " set ZONES_SOURCE to the backend's metaApi/models/zone_choices.py" >&2 + exit 1 +fi + +tmp="$(mktemp)" +trap 'rm -f "$tmp"' EXIT + +# zone_choices.py holds ZONE_CHOICES = [("CODE", "Name"), ...]. Extract the +# (code, name) pairs by regex (no eval of the source) and emit a sorted +# {code: name} JSON object. Zone names contain no embedded double quotes. +python3 - "$ZONES_SOURCE" >"$tmp" <<'PY' +import json, re, sys + +with open(sys.argv[1], encoding="utf-8") as f: + content = f.read() +pairs = re.findall(r'\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\)', content) +if not pairs: + sys.exit("no zone tuples found in " + sys.argv[1]) +json.dump(dict(pairs), sys.stdout, indent=2, sort_keys=True, ensure_ascii=False) +sys.stdout.write("\n") +PY + +if [[ "${1:-}" == "--check" ]]; then + if [[ ! -f "$ZONES_OUT" ]] || ! diff -q "$tmp" "$ZONES_OUT" >/dev/null; then + echo "error: $ZONES_OUT has drifted from the backend — run scripts/sync-zones.sh" >&2 + diff -u "$ZONES_OUT" "$tmp" 2>/dev/null | head -40 >&2 || true + exit 1 + fi + echo "==> zones.json matches the backend — no drift" + exit 0 +fi + +mkdir -p "$(dirname "$ZONES_OUT")" +cp "$tmp" "$ZONES_OUT" +echo "==> wrote $ZONES_OUT ($(grep -c '": "' "$ZONES_OUT") zones)"