#!/usr/bin/env nu # provisioning registrar — Domain registrar management via the configured middleware. # Reads infra/lib/registrar_domains.ncl for account and domain declarations. # Credentials are decrypted from infra/*/secrets/.sops.yaml via SOPS/Age. use ../../../catalog/providers/prov_lib/middleware.nu [ mw_registrar_list_domains mw_registrar_domain_info mw_registrar_get_nameservers mw_registrar_set_nameservers mw_dns_record_list ] def load_dns_zones [ws_path: string]: nothing -> record { let config_file = ($ws_path | path join "infra/lib/dns_zones.ncl") if not ($config_file | path exists) { return {} } let r = (do { ^nickel export --format json $config_file } | complete) if $r.exit_code != 0 { return {} } $r.stdout | from json | get -o zones | default {} } # Maps each zone to its DNS provider name string (e.g. "cloudflare", "internal"). def build_dns_provider_map [zones: record]: nothing -> record { $zones | transpose name zone | each {|z| { ($z.name): ($z.zone.provider | into string) } } | into record } def registrar_cache_path [ws: string]: nothing -> string { let abs = ($ws | path expand) let slug = ($abs | hash md5 | str substring 0..12) $env.HOME | path join ".cache" "provisioning" "registrar" $"($slug).json" } def read_domain_cache [cache_path: string]: nothing -> list { if not ($cache_path | path exists) { return [] } open $cache_path | get -o domains | default [] } def write_domain_cache [cache_path: string, domains: list]: nothing -> nothing { let dir = ($cache_path | path dirname) mkdir $dir { saved_at: (date now | into string), domains: $domains } | to json --indent 2 | save --force $cache_path } def cache_saved_at [ws: string]: nothing -> string { let p = (registrar_cache_path $ws) if not ($p | path exists) { return "" } open $p | get -o saved_at | default "" } # Returns declared domains from cache (or fetches live if cache empty or --live). # --all bypasses cache entirely (superset, not cacheable with declared-domain scope). def fetch_cached_domains [config: record, ws: string, live: bool]: nothing -> list { let cache = (registrar_cache_path $ws) if $live { let fresh = (fetch_and_enrich_domains $config false $ws) write_domain_cache $cache $fresh $fresh } else { let cached = (read_domain_cache $cache) if ($cached | is-not-empty) { $cached } else { let fresh = (fetch_and_enrich_domains $config false $ws) write_domain_cache $cache $fresh $fresh } } } def fetch_and_enrich_domains [config: record, all: bool, ws: string]: nothing -> list { let accounts = ($config.accounts? | default {}) let groups = (build_account_groups $config $all) let dns_prov = (build_dns_provider_map (load_dns_zones $ws)) let results = ($groups | each {|grp| let acc = ($accounts | get -o $grp.account_ref | default {}) if ($acc | is-empty) { print $"(ansi yellow)Warning(ansi reset): account '($grp.account_ref)' not found in registrar_domains.ncl" [] } else { let provider = ($acc.provider | into string) set_registrar_creds $acc $ws let domains = (mw_registrar_list_domains $provider { domains: $grp.domains }) $domains | each {|d| let ns = (mw_registrar_get_nameservers $provider $d.name) $d | upsert nameservers $ns } } } | flatten) if ($dns_prov | columns | is-not-empty) { $results | each {|d| $d | upsert external_dns ($dns_prov | get -o $d.name | default "") } } else { $results } } def load_registrar_config [workspace?: string]: nothing -> record { let ws_path = ($workspace | default ($env.PROVISIONING_WORKSPACE? | default ".")) let config_file = ($ws_path | path join "infra/lib/registrar_domains.ncl") if not ($config_file | path exists) { error make --unspanned { msg: $"registrar_domains.ncl not found at ($config_file)" } } let r = (do { ^nickel export --format json $config_file } | complete) if $r.exit_code != 0 { error make --unspanned { msg: $"Failed to evaluate registrar_domains.ncl: ($r.stderr)" } } $r.stdout | from json } def --env ensure_age_key [ws_path: string]: nothing -> nothing { if ($env | get -o SOPS_AGE_KEY_FILE | default "" | is-not-empty) { return } let kage_hits = (glob ($"($ws_path)/infra/*/.kage") | append (glob ($"($ws_path)/.p/.kage"))) if ($kage_hits | is-not-empty) { load-env { SOPS_AGE_KEY_FILE: ($kage_hits | first) } } } def decrypt_sops [sops_file: string, ws_path: string]: nothing -> record { ensure_age_key $ws_path let r = (do { ^sops --decrypt $sops_file } | complete) if $r.exit_code != 0 { error make --unspanned { msg: $"sops --decrypt failed for ($sops_file): ($r.stderr)" } } $r.stdout | from yaml } def --env set_registrar_creds [account: record, ws_path: string]: nothing -> nothing { let provider = ($account.provider | into string) let secret_ref = ($account.shared_secret_ref? | default "") if ($secret_ref | is-empty) { return } match $provider { "porkbun" => { if ($env | get -o PORKBUN_API_KEY | default "" | is-not-empty) { return } let matches = (glob ($"($ws_path)/infra/*/secrets/($secret_ref).sops.yaml")) if ($matches | is-empty) { error make --unspanned { msg: $"No SOPS file found for ($secret_ref) in ($ws_path)/infra/*/secrets/" } } let secrets = (decrypt_sops ($matches | first) $ws_path) let key = ($secrets | get -o api_key | default "") let secret = ($secrets | get -o secret_key | default "") if ($key | is-empty) { error make --unspanned { msg: $"Key 'api_key' missing in ($secret_ref).sops.yaml" } } if ($secret | is-empty) { error make --unspanned { msg: $"Key 'secret_key' missing in ($secret_ref).sops.yaml" } } load-env { PORKBUN_API_KEY: $key, PORKBUN_SECRET_KEY: $secret } } "cloudflare" => { let has_token = ($env | get -o CF_API_TOKEN | default "" | is-not-empty) let has_acct_id = ($env | get -o CF_ACCOUNT_ID | default "" | is-not-empty) if $has_token and $has_acct_id { return } let matches = (glob ($"($ws_path)/infra/*/secrets/($secret_ref).sops.yaml")) if ($matches | is-empty) { error make --unspanned { msg: $"No SOPS file found for ($secret_ref) in ($ws_path)/infra/*/secrets/" } } let secrets = (decrypt_sops ($matches | first) $ws_path) let token = ($secrets | get -o api_token | default "") let account_id = ($secrets | get -o account_id | default "") if ($token | is-empty) { error make --unspanned { msg: $"Key 'api_token' missing in ($secret_ref).sops.yaml" } } load-env { CF_API_TOKEN: $token, CF_ACCOUNT_ID: $account_id } } _ => { error make --unspanned { msg: $"Unknown registrar provider: ($provider)" } } } } # Load per-zone Cloudflare token from dns-.sops.yaml. # Always reloads — each zone has its own scoped token, never shared. def --env set_dns_creds [domain: string, ws_path: string]: nothing -> nothing { ensure_age_key $ws_path let slug = ($domain | str replace --all '.' '-') let matches = (glob ($"($ws_path)/infra/*/secrets/dns-($slug).sops.yaml")) if ($matches | is-empty) { error make --unspanned { msg: $"DNS credentials not found for ($domain) — expected dns-($slug).sops.yaml in infra/*/secrets/" } } let token = (decrypt_sops ($matches | first) $ws_path | get -o token | default "") if ($token | is-empty) { error make --unspanned { msg: $"Key 'token' missing in dns-($slug).sops.yaml" } } load-env { CF_API_TOKEN: $token } } def resolve_account [config: record, domain: string]: nothing -> record { let domains = ($config.domains? | default {}) let accounts = ($config.accounts? | default {}) let d_info = ($domains | get -o $domain | default {}) if ($d_info | is-empty) { error make --unspanned { msg: $"Domain '($domain)' not declared in registrar_domains.ncl" } } let acc_ref = ($d_info.account | into string) let acc = ($accounts | get -o $acc_ref | default {}) if ($acc | is-empty) { error make --unspanned { msg: $"Account '($acc_ref)' not found in registrar_domains.ncl" } } $acc } def build_account_groups [config: record, all: bool]: nothing -> list { let accounts = ($config.accounts? | default {}) let domains = ($config.domains? | default {}) if $all { $accounts | columns | each {|acc_ref| { account_ref: $acc_ref, domains: [] } } } else { let rows = ($domains | transpose name info | each {|d| { account_ref: ($d.info.account | into string), domain: $d.name } }) let acc_refs = ($rows | get account_ref | uniq) $acc_refs | each {|acc_ref| let acc_domains = ($rows | where account_ref == $acc_ref | get domain) { account_ref: $acc_ref, domains: $acc_domains } } } } # List domains for this workspace's declared registrars. # Without --all: only domains declared in registrar_domains.ncl. # With --all: every domain in each registrar account (admin view). export def "registrar list" [ --all (-a) --expiring-before (-e): string --expiring-in (-x): int # expiring within N months from today --workspace (-w): string --raw (-r) --live (-l) --sort-by (-s): string = "expires" # expires | name ]: nothing -> any { let ws = ($workspace | default ($env.PROVISIONING_WORKSPACE? | default ".")) let config = (load_registrar_config $ws) mut results = if $all { fetch_and_enrich_domains $config true $ws } else { fetch_cached_domains $config $ws $live } mut active_filter: string = "" if ($expiring_before | is-not-empty) { $results = ($results | where {|d| ($d.expire_date | str substring 0..9) <= $expiring_before }) $active_filter = $"before ($expiring_before)" } if ($expiring_in | is-not-empty) { let cutoff = (date now) + ($expiring_in * 30 * 24 * 60 * 60 * 1_000_000_000 | into int | into duration --unit ns) let cutoff_str = ($cutoff | format date "%Y-%m-%d") $results = ($results | where {|d| ($d.expire_date | str substring 0..9) <= $cutoff_str }) $active_filter = $"next ($expiring_in)mo — before ($cutoff_str)" } if $raw { return $results } let ordered = match $sort_by { "name" => ($results | sort-by name), _ => ($results | sort-by expire_date), } let rows = ($ordered | each {|d| { domain: $d.name, registrar: $d.registrar, status: $d.status, expires: ($d.expire_date | str substring 0..9), auto_renew: (if ($d.auto_renew? | default false) { "✓" } else { "✗" }), locked: (if ($d.locked? | default false) { "✓" } else { "✗" }), privacy: (if ($d.privacy? | default false) { "✓" } else { "✗" }), ns: ($d.nameservers? | default [] | str join ", "), } }) $rows | table | print let registrars = ($results | get -o registrar | compact | uniq | str join ", ") if not $all { let ts = (cache_saved_at $ws) let filter_part = if ($active_filter | is-not-empty) { $" · filter: ($active_filter)" } else { "" } let ts_part = if ($ts | is-not-empty) { $" · cache: ($ts)" } else { "" } if ($registrars | is-not-empty) or ($ts | is-not-empty) { print $"(ansi dark_gray) source: ($registrars) · registrar($filter_part)($ts_part)(ansi reset)" } } else { if ($registrars | is-not-empty) { let filter_part = if ($active_filter | is-not-empty) { $" · filter: ($active_filter)" } else { "" } print $"(ansi dark_gray) source: ($registrars) · registrar · live($filter_part)(ansi reset)" } } } # Show full domain record including current nameservers. export def "registrar show" [ domain: string --workspace (-w): string ]: nothing -> record { let ws = ($workspace | default ($env.PROVISIONING_WORKSPACE? | default ".")) let config = (load_registrar_config $ws) let acc = (resolve_account $config $domain) set_registrar_creds $acc $ws mw_registrar_domain_info ($acc.provider | into string) $domain } # List DNS records declared in Cloudflare for a domain zone. # Credentials loaded from infra/*/secrets/dns-.sops.yaml (per-zone token). export def "registrar dns" [ domain: string --type (-t): string --workspace (-w): string ]: nothing -> nothing { let ws = ($workspace | default ($env.PROVISIONING_WORKSPACE? | default ".")) let dns_prov = (build_dns_provider_map (load_dns_zones $ws)) let provider = ($dns_prov | get -o $domain | default "cloudflare") set_dns_creds $domain $ws let records = (mw_dns_record_list $provider $domain) let filtered = if ($type | is-not-empty) { $records | where {|r| $r.type == ($type | str upcase)} } else { $records } let rows = ($filtered | sort-by type name | each {|r| let short_name = if $r.name == $domain { "@" } else { $r.name | str replace $".($domain)" "" } { type: $r.type, name: $short_name, value: $r.value, ttl: $r.ttl, proxied: (if ($r.proxied? | default false) { "✓" } else { "-" }), priority: ($r.priority? | default ""), } }) $rows | table | print print $"(ansi dark_gray) source: ($provider) · dns-provider — ($domain)(ansi reset)" } # Get nameservers currently delegated for domain. export def "registrar ns get" [ domain: string --workspace (-w): string ]: nothing -> list { let ws = ($workspace | default ($env.PROVISIONING_WORKSPACE? | default ".")) let config = (load_registrar_config $ws) let acc = (resolve_account $config $domain) set_registrar_creds $acc $ws mw_registrar_get_nameservers ($acc.provider | into string) $domain } # Replace nameserver delegation for domain. Requires --confirm. export def "registrar ns set" [ domain: string ...ns: string --workspace (-w): string --confirm ]: nothing -> bool { if not $confirm { error make --unspanned { msg: "Pass --confirm to change nameservers" } } if ($ns | is-empty) { error make --unspanned { msg: "Provide at least one nameserver" } } let ws = ($workspace | default ($env.PROVISIONING_WORKSPACE? | default ".")) let config = (load_registrar_config $ws) let acc = (resolve_account $config $domain) set_registrar_creds $acc $ws mw_registrar_set_nameservers ($acc.provider | into string) $domain $ns } # Security and expiry audit for declared domains. # Reports per-domain risks: transfer-unlocked, whois-exposed, no-auto-renew, expires-soon. # Prints "All N domains clean." when no risks are found. export def "registrar audit" [ --days (-d): int = 60 --workspace (-w): string --live (-l) ]: nothing -> nothing { let ws = ($workspace | default ($env.PROVISIONING_WORKSPACE? | default ".")) let config = (load_registrar_config $ws) let domains = (fetch_cached_domains $config $ws $live) let risks = ($domains | each {|d| let exp_str = ($d.expire_date | str substring 0..10) let days_left = ((($exp_str | into datetime) - (date now)) / 1day | into int) mut r: list = [] if not ($d.locked? | default false) { $r = ($r | append "transfer-unlocked") } if not ($d.privacy? | default false) { $r = ($r | append "whois-exposed") } if not ($d.auto_renew? | default false) { $r = ($r | append "no-auto-renew") } if $days_left <= $days { $r = ($r | append $"expires-in-($days_left)d") } if ($r | is-not-empty) { [{ domain: $d.name, expires: $exp_str, days_left: $days_left, risks: ($r | str join ", ") }] } else { [] } } | flatten) if ($risks | is-empty) { print $"(ansi green)All ($domains | length) domains clean.(ansi reset)" } else { $risks | table | print } let registrars = ($domains | get -o registrar | compact | uniq | str join ", ") let ts = (cache_saved_at $ws) let ts_part = if ($ts | is-not-empty) { $" · cache: ($ts)" } else { "" } if ($registrars | is-not-empty) { print $"(ansi dark_gray) source: ($registrars) · registrar($ts_part)(ansi reset)" } } def print_help []: nothing -> nothing { let h = (ansi green_bold) let c = (ansi cyan) let y = (ansi yellow) let d = (ansi dark_gray) let r = (ansi reset) let ex = (ansi white) print $"($h)prvng registrar($r) — Domain registrar management" print $"($d)Reads infra/lib/registrar_domains.ncl · credentials from infra/*/secrets/.sops.yaml($r)" print "" print $"($h)LIST($r)" print $" ($c)ls($r) / ($c)l($r) / ($c)list($r) declared domains — reads cache" print $" ($c)ll($r) fetch live from API + refresh cache" print $" ($c)la($r) all domains in registrar account — admin" print $" ($c)lal($r) all domains, live refresh" print $" ($d)Columns: domain · registrar · status · expires · auto_renew · locked · privacy · ns($r)" print "" print $" ($ex)$ prvng registrar ls($r)" print $" ($ex)$ prvng registrar ls --expiring-in 2($r) ($d)expiring in next 2 months($r)" print $" ($ex)$ prvng registrar ls --expiring-before 2026-08-01($r) ($d)before a specific date($r)" print $" ($ex)$ prvng registrar ls --sort-by name($r) ($d)alphabetical order($r)" print $" ($ex)$ prvng registrar ll($r) ($d)refresh cache then show($r)" print $" ($ex)$ prvng registrar la($r) ($d)all porkbun account domains($r)" print "" print $"($h)DNS RECORDS($r)" print $" ($c)dns($r) / ($c)d($r) [--type TYPE] Cloudflare records for that zone" print $" ($d)Columns: type · name · value · ttl · proxied · priority($r)" print $" ($d)Token loaded from infra/*/secrets/dns-.sops.yaml — scoped per zone($r)" print "" print $" ($ex)$ prvng registrar dns rust-laspalmas.dev($r) ($d)all records($r)" print $" ($ex)$ prvng registrar d rust-laspalmas.dev --type A($r) ($d)A records only($r)" print $" ($ex)$ prvng registrar d jesusperez.pro --type MX($r) ($d)mail records($r)" print $" ($ex)$ prvng registrar d rustelo.com --type TXT($r) ($d)SPF / DKIM / DMARC($r)" print "" print $"($h)NAMESERVERS($r)" print $" ($c)ns get($r) / ($c)ng($r) current NS from registrar API" print $" ($c)ns set($r) / ($c)ns($r) --confirm replace NS delegation" print "" print $" ($ex)$ prvng registrar ng rust-laspalmas.dev($r)" print $" ($ex)$ prvng registrar ns rust-laspalmas.dev ns1.example.com ns2.example.com --confirm($r)" print "" print $"($h)SECURITY AUDIT($r)" print $" ($c)audit($r) / ($c)a($r) [--days N] risks per domain: transfer-unlocked, whois-exposed, no-auto-renew, expiry" print $" ($d)Default threshold: 60 days. --live fetches fresh data.($r)" print "" print $" ($ex)$ prvng registrar audit($r) ($d)domains with any risk (default 60d)($r)" print $" ($ex)$ prvng registrar audit --days 30($r) ($d)expiry threshold 30 days($r)" print $" ($ex)$ prvng registrar a --live($r) ($d)audit from fresh API data($r)" print "" print $"($h)DOMAIN DETAIL($r)" print $" ($c)show($r) / ($c)s($r) full record from registrar — includes NS" print "" print $" ($ex)$ prvng registrar show rustelo.dev($r)" print "" print $"($h)CACHE($r)" let cache_dir = ($env.HOME | path join ".cache" "provisioning" "registrar") print $" ($d)($cache_dir)/.json($r)" print $" ($d)Written on first call or --live. Stores domain list + NS. No TTL — use ll to refresh.($r)" print "" print $"($h)CONFIG($r)" print $" ($d)Declared domains :($r) infra/lib/registrar_domains.ncl" print $" ($d)DNS zones :($r) infra/lib/dns_zones.ncl" print $" ($d)Registrar secret :($r) infra/*/secrets/.sops.yaml" print $" ($d)porkbun → api_key + secret_key($r)" print $" ($d)cloudflare → api_token + account_id($r)" print $" ($d)DNS secret :($r) infra/*/secrets/dns-.sops.yaml ($d)field: token($r)" print $" ($y)auto-renew toggle not in Porkbun API v3 — manage at porkbun.com($r)" } # Short aliases — main dispatch entry points def "main list" [--all (-a) --expiring-before (-e): string --expiring-in (-x): int --workspace (-w): string --raw (-r) --live (-l) --sort-by (-s): string = "expires"]: nothing -> any { registrar list --all=$all --expiring-before $expiring_before --expiring-in $expiring_in --workspace $workspace --raw=$raw --live=$live --sort-by $sort_by } def "main l" [--workspace (-w): string --raw (-r) --live (-l) --sort-by (-s): string = "expires" --expiring-in (-x): int]: nothing -> any { registrar list --workspace $workspace --raw=$raw --live=$live --sort-by $sort_by --expiring-in $expiring_in } def "main ls" [--workspace (-w): string --raw (-r) --live (-l) --sort-by (-s): string = "expires" --expiring-in (-x): int --expiring-before (-e): string]: nothing -> any { registrar list --workspace $workspace --raw=$raw --live=$live --sort-by $sort_by --expiring-in $expiring_in --expiring-before $expiring_before } def "main ll" [--workspace (-w): string --raw (-r) --sort-by (-s): string = "expires"]: nothing -> any { registrar list --live --workspace $workspace --raw=$raw --sort-by $sort_by } def "main la" [--expiring-before (-e): string --expiring-in (-x): int --workspace (-w): string --raw (-r) --sort-by (-s): string = "expires"]: nothing -> any { registrar list --all --expiring-before $expiring_before --expiring-in $expiring_in --workspace $workspace --raw=$raw --sort-by $sort_by } def "main lal" [--expiring-before (-e): string --expiring-in (-x): int --workspace (-w): string --raw (-r) --sort-by (-s): string = "expires"]: nothing -> any { registrar list --all --live --expiring-before $expiring_before --expiring-in $expiring_in --workspace $workspace --raw=$raw --sort-by $sort_by } def "main show" [domain: string --workspace (-w): string]: nothing -> record { registrar show $domain --workspace $workspace } def "main s" [domain: string --workspace (-w): string]: nothing -> record { registrar show $domain --workspace $workspace } def "main dns" [domain: string --type (-t): string --workspace (-w): string]: nothing -> nothing { registrar dns $domain --type $type --workspace $workspace } def "main d" [domain: string --type (-t): string --workspace (-w): string]: nothing -> nothing { registrar dns $domain --type $type --workspace $workspace } def "main ns get" [domain: string --workspace (-w): string]: nothing -> list { registrar ns get $domain --workspace $workspace } def "main ng" [domain: string --workspace (-w): string]: nothing -> list { registrar ns get $domain --workspace $workspace } def "main ns set" [domain: string ...ns: string --workspace (-w): string --confirm]: nothing -> bool { registrar ns set $domain ...$ns --workspace $workspace --confirm=$confirm } def "main ns" [domain: string ...ns: string --workspace (-w): string --confirm]: nothing -> bool { registrar ns set $domain ...$ns --workspace $workspace --confirm=$confirm } def "main audit" [--days (-d): int = 60 --workspace (-w): string --live (-l)]: nothing -> nothing { registrar audit --days $days --workspace $workspace --live=$live } def "main a" [--days (-d): int = 60 --workspace (-w): string --live (-l)]: nothing -> nothing { registrar audit --days $days --workspace $workspace --live=$live } def "main help" []: nothing -> nothing { print_help } def main [--help (-h) ...args: string]: nothing -> nothing { if $help { print_help; return } let first = ($args | get -o 0 | default "") let rest = ($args | skip 1) match $first { "" | "help" | "h" => { print_help } "list" | "l" | "ls" => { let a = ($rest | any {|x| $x == "--all" or $x == "-a"}) let lv = ($rest | any {|x| $x == "--live" or $x == "-l"}) registrar list --all=$a --live=$lv } "ll" => { registrar list --live } "la" => { registrar list --all } "lal" => { registrar list --all --live } "audit" | "a" => { let d = ($rest | where {|x| $x != "--days" and not ($x | str starts-with "-")} | get -o 0 | default "60" | into int) let lv = ($rest | any {|x| $x == "--live" or $x == "-l"}) registrar audit --days $d --live=$lv } "show" | "s" => { let domain = ($rest | get -o 0 | default "") if ($domain | is-empty) { error make --unspanned { msg: "show requires " } } registrar show $domain } "dns" | "d" => { let domain = ($rest | get -o 0 | default "") if ($domain | is-empty) { error make --unspanned { msg: "dns requires " } } let t = ($rest | skip 1 | where {|x| not ($x | str starts-with "-")} | get -o 0 | default "") if ($t | is-not-empty) { registrar dns $domain --type $t } else { registrar dns $domain } } "ns" => { let sub = ($rest | get -o 0 | default "") match $sub { "get" | "ng" => { let domain = ($rest | get -o 1 | default "") if ($domain | is-empty) { error make --unspanned { msg: "ns get requires " } } registrar ns get $domain } "set" | "ns" => { let domain = ($rest | get -o 1 | default "") if ($domain | is-empty) { error make --unspanned { msg: "ns set requires " } } let ns_list = ($rest | skip 2 | where {|x| not ($x | str starts-with "--") }) let confirm = ($rest | any {|x| $x == "--confirm"}) if not $confirm { error make --unspanned { msg: "Pass --confirm to change nameservers" } } registrar ns set $domain ...$ns_list --confirm } _ => { print $"Unknown ns subcommand: ($sub)"; print_help } } } "ng" => { let domain = ($rest | get -o 0 | default "") if ($domain | is-empty) { error make --unspanned { msg: "ng requires " } } registrar ns get $domain } _ => { print $"Unknown subcommand: ($first)"; print_help } } }