@viraatdas/rudder 3.9.2 → 3.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: rudder-google-cloud
3
- description: Use for any Google Cloud or Google API setup — creating or naming a GCP project, enabling APIs (Gmail, Calendar, Drive, Gemini, …), "Sign in with Google" / Google OAuth clients, the consent screen (app name, logo, domains, scopes, verification, test users), redirect_uri_mismatch and other OAuth errors, gcloud auth, service accounts and keys, and storing the resulting secrets. Makes sure users see the product's own name on Google's screens, never a stray project like "My First Project".
3
+ description: Use for any Google Cloud or Google API setup — creating or naming a GCP project, enabling APIs (Gmail, Calendar, Drive, Gemini, …), "Sign in with Google" / Google OAuth clients, the consent screen (app name, logo, domains, scopes, brand verification, test users), moving sign-in to another domain, redirect_uri_mismatch and other OAuth errors, gcloud auth, service accounts and keys, and storing the resulting secrets. Makes sure users see the product's own name on Google's screens, never a stray project like "My First Project".
4
4
  ---
5
5
 
6
6
  # Google Cloud, done properly
@@ -14,6 +14,15 @@ Helper: `node <this skill dir>/scripts/gcp.mjs <command>` (Node 18+, no deps).
14
14
  It finds credentials from `gcloud`, gcloud's stored login, or ADC; run
15
15
  `whoami` first.
16
16
 
17
+ **Google's official skills cover the rest.** This skill is the product layer
18
+ (project naming, consent screen, OAuth client, what a user sees). For general
19
+ `gcloud` work use Google's `gcloud` skill (check leaf syntax with `gcloud help
20
+ <command>` before running it, `--limit`/`--filter`/`--format` on every list,
21
+ `--quiet`, dry-run where supported) and `google-cloud-recipe-auth` for IAM,
22
+ service accounts and ADC. Both are in the `google-cloud-developer` plugin; if
23
+ they are not loaded: `claude plugin marketplace add google/skills && claude
24
+ plugin install google-cloud-developer@google-plugins`.
25
+
17
26
  ## The rules
18
27
 
19
28
  1. **One project per product, named after the product.** Display name = the
@@ -25,7 +34,11 @@ It finds credentials from `gcloud`, gcloud's stored login, or ADC; run
25
34
  2. **The consent screen's App name is what users read**, not the project name
26
35
  and not the client name. Set it on the Branding page to the product name,
27
36
  with the product's own support email, home page, privacy and terms URLs, and
28
- the product's domain as an authorized domain.
37
+ the product's domain as an authorized domain. **It only shows once the
38
+ branding is verified.** Until then Google shows the redirect URI's domain:
39
+ the registrable domain for your own (`app.rudder.exla.ai` → "exla.ai"), the
40
+ whole host for a platform one ("rudder-cloud-control.fly.dev"). See "Brand
41
+ verification" below.
29
42
  3. **Ask for the narrowest scopes that work.** Basic (openid/email/profile) can go
30
43
  straight to production. Sensitive (Calendar, gmail.send) need verification.
31
44
  Restricted (Gmail read/modify/compose, Drive full) need verification **and**
@@ -94,8 +107,8 @@ real sign-in in a browser and say in your report what the screen showed.
94
107
  - **Testing vs production**: in Testing only listed test users can sign in and
95
108
  refresh tokens expire after 7 days — fine for a demo, wrong for real users.
96
109
  Publish as soon as the scopes allow it.
97
- - **Logo**: uploading one triggers brand verification; leave it off until the
98
- user is ready to verify. The name, links and domain still show.
110
+ - **Logo**: uploading one adds a logo review to verification; leave it off
111
+ unless the user wants it, since basic scopes verify faster without one.
99
112
  - **Internal user type** restricts sign-in to the org's Workspace accounts — only
100
113
  right for internal tools.
101
114
  - **Verification** (sensitive/restricted scopes): needs the home page on the
@@ -113,9 +126,60 @@ real sign-in in a browser and say in your report what the screen showed.
113
126
  again) and the consent App name (Branding page). The project ID can never
114
127
  change — choose it once, deliberately.
115
128
 
129
+ ## Brand verification (getting the App name on screen)
130
+
131
+ Branding page → **Verify branding**. Google checks that every **authorized
132
+ domain** is verified in Search Console by an owner of the project, that the
133
+ home page and privacy policy live on those domains and name the app, and that
134
+ the support email belongs to the owner. Basic scopes: usually a few days.
135
+
136
+ - **Redirect URIs must be on a domain the user owns.** Platform hosts
137
+ (`*.fly.dev`, `*.vercel.app`, `*.run.app`, `*.awsapprunner.com`,
138
+ `*.netlify.app`) can never be verified, and while they are authorized
139
+ domains verification fails. Serve the auth callback from the product's own
140
+ domain (e.g. `app.<domain>`), then remove the platform redirect URIs and
141
+ their authorized domains.
142
+ - **Search Console**: add a Domain property for the apex and verify it with
143
+ the DNS TXT record it gives (through the DNS host's API if you have it,
144
+ otherwise ask the user), signed in as the same account that owns the project.
145
+
146
+ ## Moving sign-in to another domain
147
+
148
+ Order matters. Switching the app first breaks sign-in for everyone.
149
+
150
+ 1. Point the new host at the app (DNS + TLS) and check it serves.
151
+ 2. Add the new redirect URI (and JS origin if used) to the Google client **and**
152
+ to every other provider on the same auth base URL (GitHub OAuth/App callback
153
+ URLs, Slack, Linear …). `check-client` must show it accepted.
154
+ 3. Make every page that STARTS a sign-in redirect to the new host before
155
+ starting it: the OAuth state cookie is set on the host that starts the flow
156
+ and must be there when the callback lands. Native shells and old CLIs keep
157
+ using the old address, so bounce their login pages; don't strand them.
158
+ 4. Flip the auth base URL (`BETTER_AUTH_URL`, `NEXTAUTH_URL`, …) and deploy.
159
+ 5. Real sign-in on the new host for each provider; then remove the old redirect
160
+ URIs and authorized domains, and verify the branding.
161
+
162
+ ## Keeping an existing client
163
+
164
+ A client already shipped in apps (an iOS bundle, a published CLI, stored in a
165
+ host's secrets) can stay in a misnamed project when moving it would change the
166
+ client ID. Its consent screen can still say the product's name: set the
167
+ Branding and verify it. Note the exception in the report; never silently.
168
+
169
+ ## In the browser
170
+
171
+ - Google may ask for a passkey, security key or password re-auth (the URL
172
+ carries `rapt=`). Stop and ask the user to complete it; never type it.
173
+ - The client page's "Last used date" lags by days. Confirm which client is live
174
+ from the app's own sign-in redirect (`curl -sI <login url>` → `client_id=`),
175
+ not from that date.
176
+ - Settings "may take 5 minutes to a few hours"; `check-client` usually sees a
177
+ new redirect URI within a minute.
178
+
116
179
  ## Report
117
180
 
118
181
  Say which project (ID and display name), which APIs, which client (ID, never
119
182
  the secret), where the secrets live, the `check-client` result per redirect
120
- URI, what the real sign-in screen showed, the publishing status, and any
121
- verification still owed (with the user cap it implies).
183
+ URI (including the name Google shows today), what the real sign-in screen
184
+ showed, the publishing and brand-verification status, and any verification
185
+ still owed (with the user cap it implies).
@@ -329,9 +329,15 @@ async function probe(clientId, redirect, scopes) {
329
329
  const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
330
330
  url.search = new URLSearchParams({ client_id: clientId, redirect_uri: redirect, response_type: "code", scope: scopes.join(" "), prompt: "select_account" }).toString();
331
331
  const response = await fetch(url, { redirect: "follow", headers: { "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36", "accept-language": "en-US" } });
332
- const text = pageText(await response.text());
332
+ const html = await response.text();
333
+ const text = pageText(html);
333
334
  const error = text.match(/Error 400: (\w+)/)?.[1] ?? text.match(/Error 401: (\w+)/)?.[1] ?? text.match(/Error 403: (\w+)/)?.[1] ?? (response.url.includes("/signin/oauth/error") ? "unknown_error" : null);
334
- const appName = text.match(/to continue to ([^.]+?)(?:\s+(?:Email|Forgot|Not your|Create account|Next)\b|$)/)?.[1]?.trim() ?? null;
335
+ // The name sits in a data-app-name attribute (seen 2026-10); the text
336
+ // match is the fallback for older page shapes. Unverified branding shows
337
+ // the redirect URI's domain here, not the App name.
338
+ const appName = pageText(html.match(/data-app-name="([^"]+)"/)?.[1] ?? "").trim()
339
+ || text.match(/to continue to ([^.]+?)(?:\s+(?:Email|Forgot|Not your|Create account|Next)\b|$)/)?.[1]?.trim()
340
+ || null;
335
341
  return { error, appName, finalUrl: response.url };
336
342
  }
337
343
 
@@ -364,7 +370,7 @@ async function checkClient(o) {
364
370
  }
365
371
  const nameOk = !expect || r.appName.toLowerCase() === expect.toLowerCase();
366
372
  if (!nameOk) ok = false;
367
- console.log(`${nameOk ? "✓" : "✗"} ${redirect}: accepted; Google shows "continue to ${r.appName}"${nameOk ? "" : `, expected "${expect}". Fix the App name on the Branding page (it is not the project name).`}`);
373
+ console.log(`${nameOk ? "✓" : "✗"} ${redirect}: accepted; Google shows "continue to ${r.appName}"${nameOk ? "" : `, expected "${expect}". ${/\./.test(r.appName) ? "Google shows a domain until the branding is verified (Branding page, Verify branding)." : "Fix the App name on the Branding page (it is not the project name)."}`}`);
368
374
  }
369
375
  if (!ok) process.exit(1);
370
376
  }
@@ -0,0 +1,130 @@
1
+ ---
2
+ name: rudder-namecheap
3
+ description: Use WHENEVER Namecheap comes up, and for any domain registered there (nameservers dns1/dns2.registrar-servers.com). Covers the whole account through the Namecheap API and CLI — DNS records (A/AAAA/CNAME/TXT/MX, verification records, pointing a domain or subdomain at Vercel, Netlify or a server, removing the parking page), free email forwarding (hello@ to an inbox), URL redirects, nameservers, registrar lock, domain privacy (WhoisGuard), domain list, expiry and auto-renew, contacts, availability and pricing, registering and renewing domains, transfers, account balance, and API access setup. Does the change itself and never hands the user records to type into the Namecheap dashboard.
4
+ ---
5
+
6
+ # Namecheap
7
+
8
+ **The rule: do it through the API, never hand the user steps for the
9
+ dashboard.** If access is missing, the only acceptable ask is the one-time API
10
+ setup in section 6, stated once, after everything else is done.
11
+
12
+ Tool: `<this skill dir>/scripts/namecheap` (Python 3 stdlib, no installs).
13
+ `$NC --help` and `$NC <command> --help` list every option.
14
+
15
+ ```bash
16
+ NC="<this skill dir>/scripts/namecheap"
17
+ $NC check # credentials work + every domain in the account
18
+ ```
19
+
20
+ ## 1. Look before changing
21
+
22
+ | Question | Command |
23
+ |---|---|
24
+ | Which domains, when do they expire, auto-renew, privacy, lock | `$NC domains` (`--expiring`, `--expired`, `--search TEXT`) |
25
+ | One domain in full: status, expiry, privacy, nameservers, lock | `$NC info example.com` |
26
+ | Where its DNS is served from (public view) | `$NC lookup example.com NS` |
27
+ | Records Namecheap serves + email mode | `$NC list example.com` |
28
+ | Email forwards | `$NC forward example.com` |
29
+ | WHOIS contacts | `$NC contacts example.com` |
30
+ | Balance | `$NC balance` |
31
+ | Incoming transfers | `$NC transfers` |
32
+
33
+ If the nameservers are not `dns1/dns2.registrar-servers.com`, records live at
34
+ that DNS host, not at Namecheap: `*.vercel-dns.com` → `vercel dns add`;
35
+ Netlify (`dns*.p0*.nsone.net`) → the Netlify CLI/API; Cloudflare, Route 53 →
36
+ their CLI/API. Registrar settings (nameservers, lock, privacy, renewals) still
37
+ belong here.
38
+
39
+ ## 2. DNS records: list, dry-run, then apply
40
+
41
+ ```bash
42
+ $NC list example.com
43
+ $NC set example.com --add 'CNAME app site.netlify.app.' --dry-run
44
+ $NC set example.com --add 'CNAME app site.netlify.app.' # backs up, writes, re-reads, verifies
45
+ ```
46
+
47
+ Namecheap's `setHosts` **replaces every record on the domain**. Never call it
48
+ with curl or any other client: one call missing the MX or TXT records silently
49
+ breaks mail and verification. The tool merges with the live set, refuses record
50
+ types it cannot round-trip (CAA, SRV: tell the user those need the dashboard),
51
+ and backs up every write to `~/.config/namecheap/backups/`. Undo with
52
+ `$NC restore <backup.json>`.
53
+
54
+ | Goal | Command |
55
+ |---|---|
56
+ | Apex + www to Vercel (removes the parking page) | `$NC vercel example.com`, then `vercel domains add example.com <project>` and `vercel domains add www.example.com <project>` |
57
+ | Subdomain to Vercel | `$NC vercel example.com --sub app` |
58
+ | Verification TXT (Vercel, Google, …) | `$NC set example.com --add 'TXT _vercel "vc-domain-verify=..."'` |
59
+ | Subdomain to Netlify / any host | `$NC set example.com --add 'CNAME app site-name.netlify.app.'` |
60
+ | A record to a server | `$NC set example.com --add 'A api 203.0.113.7'` |
61
+ | Redirect a name (301) | `$NC set example.com --add 'URL301 old https://new.example.com'` |
62
+ | Third-party mail (MX) | `$NC set example.com --add 'MX @ mx1.provider.com. 1800 10'` |
63
+ | Replace a conflicting record | add `--replace` (without it the tool refuses and shows the clash) |
64
+ | Remove a record | `$NC set example.com --remove 'CNAME old'` (value optional) |
65
+ | Free email forwarding | `$NC forward example.com --add hello=you@example.com` (refuses to cut off live MX mail unless `--force`) |
66
+
67
+ Verify with `$NC list` (Namecheap's view, already re-read after the write) and
68
+ `$NC lookup example.com A` (public DNS, after the TTL).
69
+
70
+ ## 3. Registrar settings
71
+
72
+ | Goal | Command |
73
+ |---|---|
74
+ | Move DNS to another host (Vercel, Cloudflare, …) | `$NC nameservers example.com --set ns1.host.com ns2.host.com --yes` |
75
+ | Back to Namecheap BasicDNS | `$NC nameservers example.com --default --yes` |
76
+ | Transfer lock | `$NC lock example.com --on` / `--off` (off only to transfer it out) |
77
+ | Domain privacy | `$NC privacy example.com --on` / `--off`; `$NC privacy` lists all |
78
+
79
+ Switching nameservers moves the whole domain: copy the records to the new host
80
+ first, or mail and verification break. The tool asks for `--yes` for that reason.
81
+
82
+ ## 4. Buying and renewing (spends money)
83
+
84
+ ```bash
85
+ $NC available idea.dev other.com # free? premium?
86
+ $NC price dev # your register price; --action renew|transfer, --years N
87
+ $NC renew example.com --years 1 # prints price + balance, charges nothing
88
+ $NC register idea.dev --contacts-from example.com # same: prints, charges nothing
89
+ ```
90
+
91
+ Anything that charges the account does nothing without `--yes`. **Before
92
+ adding `--yes`, tell the user the item and total and get a clear yes in chat**,
93
+ every time; a yes for one purchase does not cover the next. Registration copies
94
+ WHOIS contacts from a domain the user already owns and turns free privacy on.
95
+ Premium names are refused (they need the dashboard). Payment comes from the
96
+ account balance; if it is short, say so and stop.
97
+
98
+ ## 5. Report
99
+
100
+ Say what changed (before → after), the backup path for record writes, what
101
+ `lookup` shows publicly, anything still propagating, and for purchases the
102
+ amount charged and the order id.
103
+
104
+ ## 6. When access is missing
105
+
106
+ The tool reads `NAMECHEAP_API_USER` / `NAMECHEAP_API_KEY` (optionally
107
+ `NAMECHEAP_USERNAME`, `NAMECHEAP_CLIENT_IP`) from the environment or
108
+ `~/.config/namecheap/credentials.env`. On a Rudder cloud machine the environment
109
+ comes from the Rudder secrets vault; `env | grep -c NAMECHEAP` shows whether it
110
+ has them.
111
+
112
+ If the tool prints `NO_NAMECHEAP_CREDENTIALS`, or an API error naming an IP that
113
+ isn't whitelisted, first do everything that doesn't need Namecheap. Then send
114
+ the user one short message with only this:
115
+
116
+ 1. Namecheap → Profile → Tools → **Namecheap API Access** → ON. Namecheap
117
+ requires a $50 balance, $50 spent in the last two years, or 20 domains.
118
+ 2. **Whitelisted IPs** → add the IP the tool printed (this machine's public
119
+ IPv4; only IPv4 works).
120
+ 3. Store the key once, from their computer, so every Rudder cloud machine has
121
+ it: `rudder cloud secrets set NAMECHEAP_API_USER <namecheap-username>` and
122
+ `rudder cloud secrets set NAMECHEAP_API_KEY` (prompts for the value). For
123
+ this machine only: `! <this skill dir>/scripts/namecheap setup <namecheap-username>`
124
+ (prompts for the key).
125
+
126
+ Never print the key, commit it, put it in a URL or pass it as a command-line
127
+ argument. Never sign in to the Namecheap dashboard yourself: the account
128
+ password is the user's. A cloud machine's outbound IP can change on restart;
129
+ when Namecheap rejects a call, the error names the new IP. Whitelist it too, or
130
+ pin a static egress IP, which costs money and needs the user's go-ahead.
@@ -0,0 +1,785 @@
1
+ #!/usr/bin/env python3
2
+ """namecheap: the Namecheap account from the command line, safely, for agents.
3
+
4
+ Namecheap's setHosts REPLACES every host record on a domain, so record changes
5
+ never write blind: read the live records, merge, back the old set up, write,
6
+ re-read and check. Anything that costs money (register, renew) shows the price
7
+ and the balance and does nothing without --yes.
8
+
9
+ Account
10
+ namecheap setup <api-user> [<api-key>] [--username U] [--client-ip IP]
11
+ namecheap check credentials + IP whitelist + domain list
12
+ namecheap balance account balance
13
+ namecheap domains [--expiring|--expired] [--search TEXT]
14
+ namecheap info <domain> status, expiry, auto-renew, privacy, nameservers
15
+ namecheap contacts <domain> registrant/admin/tech/billing contacts
16
+
17
+ Buying (prices first; --yes to spend)
18
+ namecheap available <domain> [...] availability + premium price
19
+ namecheap price <tld> [--action register|renew|transfer]
20
+ namecheap register <domain> --contacts-from <owned domain> [--years N] [--yes]
21
+ namecheap renew <domain> [--years N] [--yes]
22
+ namecheap transfers incoming transfers and their status
23
+
24
+ Registrar settings
25
+ namecheap nameservers <domain> [--set NS1 NS2 ...|--default]
26
+ namecheap lock <domain> [--on|--off] registrar (transfer) lock
27
+ namecheap privacy [<domain>] [--on [--email E]|--off] free domain privacy
28
+
29
+ DNS (Namecheap BasicDNS)
30
+ namecheap list <domain> host records and email mode
31
+ namecheap set <domain> --add "A @ 76.76.21.21" --remove "CNAME www" [--replace] [--dry-run]
32
+ namecheap vercel <domain> [--sub NAME ...] [--dry-run]
33
+ apex + www (or subdomains) to Vercel, parking removed
34
+ namecheap forward <domain> [--add hello=you@example.com] [--remove hello] [--dry-run]
35
+ namecheap lookup <name> [TYPE] DNS-over-HTTPS lookup (no dig needed)
36
+ namecheap restore <backup.json> [--dry-run]
37
+
38
+ Credentials come from the environment or ~/.config/namecheap/credentials.env:
39
+ NAMECHEAP_API_USER, NAMECHEAP_API_KEY, NAMECHEAP_USERNAME (defaults to the
40
+ API user), NAMECHEAP_CLIENT_IP (defaults to this machine's public IPv4),
41
+ NAMECHEAP_SANDBOX=1 for the sandbox API.
42
+ """
43
+ import argparse
44
+ import json
45
+ import os
46
+ import shlex
47
+ import sys
48
+ import urllib.error
49
+ import urllib.parse
50
+ import urllib.request
51
+ import xml.etree.ElementTree as ET
52
+ from datetime import datetime
53
+ from pathlib import Path
54
+
55
+ API = 'https://api.namecheap.com/xml.response'
56
+ SANDBOX_API = 'https://api.sandbox.namecheap.com/xml.response'
57
+ CONFIG_DIR = Path(os.environ.get('NAMECHEAP_CONFIG_DIR', Path.home() / '.config' / 'namecheap'))
58
+ CRED_FILE = CONFIG_DIR / 'credentials.env'
59
+ BACKUP_DIR = CONFIG_DIR / 'backups'
60
+ NS = '{http://api.namecheap.com/xml.response}'
61
+ TWO_LEVEL_TLDS = {
62
+ 'co.uk', 'org.uk', 'me.uk', 'ltd.uk', 'plc.uk', 'net.uk', 'com.au', 'net.au', 'org.au',
63
+ 'co.nz', 'net.nz', 'org.nz', 'co.jp', 'com.br', 'com.mx', 'co.in', 'co.za', 'com.sg', 'com.tr',
64
+ }
65
+ # Types this tool can read back and write again without losing fields.
66
+ ROUND_TRIP_TYPES = {'A', 'AAAA', 'ALIAS', 'CNAME', 'MX', 'MXE', 'NS', 'TXT', 'URL', 'URL301', 'FRAME'}
67
+ REDIRECT_TYPES = {'URL', 'URL301', 'FRAME'}
68
+ PARKING_TARGETS = ('parkingpage.namecheap.com',)
69
+ VERCEL_A = '76.76.21.21'
70
+ VERCEL_CNAME = 'cname.vercel-dns.com.'
71
+ DEFAULT_TTL = '1800'
72
+
73
+
74
+ class ApiError(Exception):
75
+ def __init__(self, errors, client_ip):
76
+ self.errors = errors
77
+ hints = []
78
+ numbers = {number for number, _ in errors}
79
+ text = ' '.join(message for _, message in errors).lower()
80
+ if '1011150' in numbers or ('ip' in text and ('whitelist' in text or 'invalid request ip' in text)):
81
+ hints.append(
82
+ f'This machine\'s IP {client_ip} is not whitelisted. Namecheap dashboard: Profile > Tools > '
83
+ 'Namecheap API Access > Whitelisted IPs > add it.')
84
+ if numbers & {'1011102', '1011104', '1010102', '1010104'} or 'api key' in text or 'apiuser' in text:
85
+ hints.append('API user or key rejected. Re-run `namecheap setup` with the key from Profile > Tools > Namecheap API Access.')
86
+ if 'not associated' in text or 'domain not found' in text or numbers & {'2019166', '2016166'}:
87
+ hints.append('That domain is not in this Namecheap account.')
88
+ detail = '; '.join(f'{number}: {message}' for number, message in errors)
89
+ super().__init__(detail + ''.join(f'\n -> {hint}' for hint in hints))
90
+
91
+
92
+ # ---------- credentials and transport ----------
93
+
94
+ def read_cred_file():
95
+ values = {}
96
+ if CRED_FILE.exists():
97
+ for line in CRED_FILE.read_text().splitlines():
98
+ line = line.strip()
99
+ if line and not line.startswith('#') and '=' in line:
100
+ key, value = line.split('=', 1)
101
+ values[key.strip()] = value.strip().strip('"').strip("'")
102
+ return values
103
+
104
+
105
+ def public_ip():
106
+ for url in ('https://api.ipify.org', 'https://ifconfig.me/ip', 'https://ipv4.icanhazip.com'):
107
+ try:
108
+ with urllib.request.urlopen(url, timeout=8) as response:
109
+ ip = response.read().decode().strip()
110
+ if ip.count('.') == 3:
111
+ return ip
112
+ except (urllib.error.URLError, OSError):
113
+ continue
114
+ sys.exit('Could not find this machine\'s public IPv4. Set NAMECHEAP_CLIENT_IP.')
115
+
116
+
117
+ def load_creds():
118
+ stored = read_cred_file()
119
+ get = lambda key: os.environ.get(key) or stored.get(key)
120
+ user, key = get('NAMECHEAP_API_USER'), get('NAMECHEAP_API_KEY')
121
+ if not user or not key:
122
+ sys.exit(
123
+ 'NO_NAMECHEAP_CREDENTIALS: no API user/key in the environment or ' + str(CRED_FILE) + '.\n'
124
+ 'One-time setup (the user does this once, then never again):\n'
125
+ ' 1. Namecheap dashboard > Profile > Tools > Namecheap API Access > turn ON\n'
126
+ ' (Namecheap requires $50 balance, $50 spent in 2 years, or 20 domains).\n'
127
+ f' 2. Whitelisted IPs > add {public_ip()} (this machine).\n'
128
+ ' 3. namecheap setup <namecheap-username> (prompts for the key)')
129
+ return {
130
+ 'ApiUser': user,
131
+ 'ApiKey': key,
132
+ 'UserName': get('NAMECHEAP_USERNAME') or user,
133
+ 'ClientIp': get('NAMECHEAP_CLIENT_IP') or public_ip(),
134
+ 'sandbox': get('NAMECHEAP_SANDBOX') == '1',
135
+ }
136
+
137
+
138
+ def call(creds, command, **params):
139
+ data = {name: creds[name] for name in ('ApiUser', 'ApiKey', 'UserName', 'ClientIp')}
140
+ data['Command'] = command
141
+ data.update({name: str(value) for name, value in params.items()})
142
+ request = urllib.request.Request(
143
+ SANDBOX_API if creds['sandbox'] else API,
144
+ data=urllib.parse.urlencode(data).encode(),
145
+ method='POST',
146
+ )
147
+ with urllib.request.urlopen(request, timeout=30) as response:
148
+ root = ET.fromstring(response.read())
149
+ if root.get('Status') != 'OK':
150
+ errors = [(error.get('Number', '?'), (error.text or '').strip()) for error in root.iter(NS + 'Error')]
151
+ raise ApiError(errors or [('?', 'unknown error')], creds['ClientIp'])
152
+ return root.find(NS + 'CommandResponse')
153
+
154
+
155
+ # ---------- domain records ----------
156
+
157
+ def split_domain(domain):
158
+ labels = domain.lower().strip().strip('.').split('.')
159
+ if len(labels) >= 3 and '.'.join(labels[-2:]) in TWO_LEVEL_TLDS:
160
+ sld, tld, extra = labels[-3], '.'.join(labels[-2:]), labels[:-3]
161
+ elif len(labels) >= 2:
162
+ sld, tld, extra = labels[-2], labels[-1], labels[:-2]
163
+ else:
164
+ sys.exit(f'Not a domain: {domain}')
165
+ if extra:
166
+ sys.exit(f'Pass the registered domain ({sld}.{tld}), not a subdomain; use the host field for "{".".join(extra)}".')
167
+ return sld, tld
168
+
169
+
170
+ def norm(record_type, value):
171
+ value = (value or '').strip()
172
+ return value if record_type == 'TXT' else value.rstrip('.').lower()
173
+
174
+
175
+ def same(a, b):
176
+ return (a['type'] == b['type'] and a['host'].lower() == b['host'].lower()
177
+ and norm(a['type'], a['address']) == norm(b['type'], b['address']))
178
+
179
+
180
+ def fmt(record):
181
+ extra = f" pref={record['mxpref']}" if record['type'] in ('MX', 'MXE') else ''
182
+ return f"{record['type']:<6} {record['host']:<14} {record['address']} ttl={record['ttl']}{extra}"
183
+
184
+
185
+ def get_hosts(creds, domain):
186
+ sld, tld = split_domain(domain)
187
+ result = call(creds, 'namecheap.domains.dns.getHosts', SLD=sld, TLD=tld).find(NS + 'DomainDNSGetHostsResult')
188
+ records = [{
189
+ 'type': host.get('Type', '').upper(),
190
+ 'host': host.get('Name', ''),
191
+ 'address': host.get('Address', ''),
192
+ 'ttl': host.get('TTL') or DEFAULT_TTL,
193
+ 'mxpref': host.get('MXPref') or '10',
194
+ } for host in result.iter(NS + 'host')]
195
+ return {
196
+ 'domain': domain,
197
+ 'records': records,
198
+ 'email_type': (result.get('EmailType') or '').upper(),
199
+ 'using_namecheap_dns': (result.get('IsUsingOurDNS') or '').lower() == 'true',
200
+ }
201
+
202
+
203
+ def set_hosts(creds, domain, records, email_type):
204
+ sld, tld = split_domain(domain)
205
+ params = {'SLD': sld, 'TLD': tld}
206
+ for index, record in enumerate(records, 1):
207
+ params[f'HostName{index}'] = record['host']
208
+ params[f'RecordType{index}'] = record['type']
209
+ params[f'Address{index}'] = record['address']
210
+ params[f'TTL{index}'] = record['ttl'] or DEFAULT_TTL
211
+ if record['type'] in ('MX', 'MXE'):
212
+ params[f'MXPref{index}'] = record['mxpref'] or '10'
213
+ if email_type and email_type != 'NONE':
214
+ params['EmailType'] = email_type
215
+ result = call(creds, 'namecheap.domains.dns.setHosts', **params).find(NS + 'DomainDNSSetHostsResult')
216
+ if result is None or (result.get('IsSuccess') or '').lower() != 'true':
217
+ sys.exit('setHosts did not report success; nothing verified. Check the domain in the dashboard.')
218
+
219
+
220
+ def parse_spec(spec, need_value):
221
+ parts = shlex.split(spec)
222
+ if len(parts) < (3 if need_value else 2):
223
+ sys.exit(f'Bad record "{spec}". Use "TYPE HOST VALUE [TTL] [MXPREF]", e.g. "A @ 76.76.21.21".')
224
+ record = {'type': parts[0].upper(), 'host': parts[1], 'address': parts[2] if len(parts) > 2 else None,
225
+ 'ttl': parts[3] if len(parts) > 3 else DEFAULT_TTL, 'mxpref': parts[4] if len(parts) > 4 else '10'}
226
+ if need_value and record['type'] not in ROUND_TRIP_TYPES:
227
+ sys.exit(f'Record type {record["type"]} is not supported by this tool.')
228
+ return record
229
+
230
+
231
+ def conflicts(existing, new):
232
+ """Records that cannot coexist with `new` at the same host."""
233
+ host = new['host'].lower()
234
+ out = []
235
+ for record in existing:
236
+ if record['host'].lower() != host or same(record, new):
237
+ continue
238
+ if new['type'] == 'CNAME' or record['type'] == 'CNAME':
239
+ out.append(record) # a CNAME owns its name outright
240
+ elif new['type'] in ('A', 'AAAA', 'ALIAS') and record['type'] in REDIRECT_TYPES | {'ALIAS'}:
241
+ out.append(record) # a redirect or ALIAS answers for the same name
242
+ elif new['type'] in REDIRECT_TYPES and record['type'] in {'A', 'AAAA', 'ALIAS'} | REDIRECT_TYPES:
243
+ out.append(record)
244
+ elif new['type'] == record['type'] and new['type'] in ('A', 'AAAA'):
245
+ out.append(record) # a second A would round-robin traffic to the old target
246
+ return out
247
+
248
+
249
+ def plan_change(current, adds, removes, replace):
250
+ records = [dict(record) for record in current]
251
+ removed, added = [], []
252
+ for spec in removes:
253
+ for record in list(records):
254
+ if (record['type'] == spec['type'] and record['host'].lower() == spec['host'].lower()
255
+ and (spec['address'] is None or norm(record['type'], record['address']) == norm(spec['type'], spec['address']))):
256
+ records.remove(record)
257
+ removed.append(record)
258
+ for new in adds:
259
+ if any(same(record, new) for record in records):
260
+ continue
261
+ clash = conflicts(records, new)
262
+ if clash and not replace:
263
+ sys.exit('Refusing: ' + fmt(new) + ' conflicts with existing record(s):\n '
264
+ + '\n '.join(fmt(record) for record in clash)
265
+ + '\nRe-run with --replace to swap them, or --remove them explicitly.')
266
+ for record in clash:
267
+ records.remove(record)
268
+ removed.append(record)
269
+ records.append(new)
270
+ added.append(new)
271
+ return records, removed, added
272
+
273
+
274
+ def backup(state):
275
+ BACKUP_DIR.mkdir(parents=True, exist_ok=True)
276
+ os.chmod(CONFIG_DIR, 0o700)
277
+ # Microseconds keep two writes in the same second from overwriting each other's backup.
278
+ path = BACKUP_DIR / f"{state['domain']}-{datetime.now().strftime('%Y%m%dT%H%M%S.%f')}.json"
279
+ path.write_text(json.dumps(state, indent=2))
280
+ return path
281
+
282
+
283
+ def apply(creds, domain, state, records, removed, added, dry_run, email_type=None):
284
+ unknown = [record for record in state['records'] if record['type'] not in ROUND_TRIP_TYPES]
285
+ if unknown:
286
+ sys.exit('Refusing to rewrite: the domain has record types this tool cannot round-trip:\n '
287
+ + '\n '.join(fmt(record) for record in unknown) + '\nChange them in the dashboard instead.')
288
+ if not state['using_namecheap_dns']:
289
+ sys.exit(f'{domain} is not on Namecheap DNS (custom nameservers). Change records at that DNS host instead.')
290
+ for record in removed:
291
+ print('- ' + fmt(record))
292
+ for record in added:
293
+ print('+ ' + fmt(record))
294
+ target_email = email_type or state['email_type']
295
+ if target_email != state['email_type']:
296
+ print(f"~ email mode {state['email_type'] or 'NONE'} -> {target_email}")
297
+ if not removed and not added and target_email == state['email_type']:
298
+ print('No change needed; the records are already in place.')
299
+ return
300
+ if dry_run:
301
+ print('(dry run: nothing written)')
302
+ return
303
+ saved = backup(state)
304
+ print(f'Backed up previous records to {saved}')
305
+ set_hosts(creds, domain, records, target_email)
306
+ after = get_hosts(creds, domain)
307
+ missing = [record for record in added if not any(same(record, live) for live in after['records'])]
308
+ lingering = [record for record in removed if any(same(record, live) for live in after['records'])]
309
+ if missing or lingering:
310
+ sys.exit('Verification failed after write. Missing: ' + ', '.join(map(fmt, missing))
311
+ + ' Still present: ' + ', '.join(map(fmt, lingering)) + f'\nRestore with: namecheap restore {saved}')
312
+ print(f'Verified: {len(after["records"])} records live on Namecheap. Public DNS catches up within the TTL:')
313
+ print(f' namecheap lookup {domain} A')
314
+
315
+
316
+ # ---------- commands ----------
317
+
318
+ def cmd_setup(args):
319
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
320
+ os.chmod(CONFIG_DIR, 0o700)
321
+ api_key = args.api_key
322
+ if not api_key:
323
+ import getpass
324
+ # Prompted, not an argument: a key on the command line lands in shell
325
+ # history and the process list.
326
+ api_key = getpass.getpass('Namecheap API key: ').strip()
327
+ if not api_key:
328
+ sys.exit('No API key given.')
329
+ lines = [f'NAMECHEAP_API_USER={args.api_user}', f'NAMECHEAP_API_KEY={api_key}']
330
+ if args.username:
331
+ lines.append(f'NAMECHEAP_USERNAME={args.username}')
332
+ if args.client_ip:
333
+ lines.append(f'NAMECHEAP_CLIENT_IP={args.client_ip}')
334
+ fd = os.open(CRED_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
335
+ with os.fdopen(fd, 'w') as handle:
336
+ handle.write('\n'.join(lines) + '\n')
337
+ os.chmod(CRED_FILE, 0o600)
338
+ print(f'Saved credentials to {CRED_FILE} (mode 600).')
339
+ cmd_check(args)
340
+
341
+
342
+ def cmd_check(_args):
343
+ creds = load_creds()
344
+ result = call(creds, 'namecheap.domains.getList', PageSize=100).find(NS + 'DomainGetListResult')
345
+ domains = [(domain.get('Name'), domain.get('IsOurDNS'), domain.get('Expires')) for domain in result.iter(NS + 'Domain')]
346
+ print(f"OK: API user {creds['ApiUser']} from IP {creds['ClientIp']}. {len(domains)} domain(s):")
347
+ for name, ours, expires in domains:
348
+ print(f" {name:<32} {'Namecheap DNS' if (ours or '').lower() == 'true' else 'custom nameservers':<20} expires {expires}")
349
+
350
+
351
+ def cmd_list(args):
352
+ state = get_hosts(load_creds(), args.domain)
353
+ print(f"{args.domain}: {'Namecheap DNS' if state['using_namecheap_dns'] else 'custom nameservers'}, "
354
+ f"email mode {state['email_type'] or 'NONE'}")
355
+ for record in state['records']:
356
+ print(' ' + fmt(record))
357
+
358
+
359
+ def cmd_set(args):
360
+ creds = load_creds()
361
+ state = get_hosts(creds, args.domain)
362
+ adds = [parse_spec(spec, True) for spec in args.add]
363
+ removes = [parse_spec(spec, False) for spec in args.remove]
364
+ records, removed, added = plan_change(state['records'], adds, removes, args.replace)
365
+ apply(creds, args.domain, state, records, removed, added, args.dry_run)
366
+
367
+
368
+ def cmd_vercel(args):
369
+ creds = load_creds()
370
+ state = get_hosts(creds, args.domain)
371
+ removes = []
372
+ for record in state['records']:
373
+ if norm('CNAME', record['address']).endswith(PARKING_TARGETS) or (
374
+ record['type'] in REDIRECT_TYPES and record['host'] in ('@', 'www')):
375
+ removes.append(dict(record)) # Namecheap's default parking page and redirects
376
+ names = args.sub or ['@', 'www']
377
+ adds = []
378
+ for name in names:
379
+ if name == '@':
380
+ adds.append({'type': 'A', 'host': '@', 'address': VERCEL_A, 'ttl': DEFAULT_TTL, 'mxpref': '10'})
381
+ else:
382
+ adds.append({'type': 'CNAME', 'host': name, 'address': VERCEL_CNAME, 'ttl': DEFAULT_TTL, 'mxpref': '10'})
383
+ records, removed, added = plan_change(state['records'], adds, removes, replace=True)
384
+ apply(creds, args.domain, state, records, removed, added, args.dry_run)
385
+ if not args.dry_run:
386
+ print('Next: `vercel domains add ' + (args.domain if names == ['@', 'www'] else f'{names[0]}.{args.domain}')
387
+ + ' <project>` if not attached yet, then `vercel domains inspect` to confirm.')
388
+
389
+
390
+ def cmd_forward(args):
391
+ creds = load_creds()
392
+ domain = args.domain.lower()
393
+ result = call(creds, 'namecheap.domains.dns.getEmailForwarding', DomainName=domain).find(
394
+ NS + 'DomainDNSGetEmailForwardingResult')
395
+ forwards = [(forward.get('mailbox'), (forward.text or '').strip()) for forward in result.iter(NS + 'Forward')]
396
+ if not args.add and not args.remove:
397
+ print(f'{domain} email forwards:' if forwards else f'{domain}: no email forwards')
398
+ for mailbox, target in forwards:
399
+ print(f' {mailbox}@{domain} -> {target}')
400
+ return
401
+ wanted = [(mailbox, target) for mailbox, target in forwards if mailbox not in set(args.remove)]
402
+ for spec in args.add:
403
+ if '=' not in spec:
404
+ sys.exit(f'Bad forward "{spec}". Use mailbox=destination, e.g. hello=you@example.com')
405
+ mailbox, target = (part.strip() for part in spec.split('=', 1))
406
+ wanted = [(m, t) for m, t in wanted if m != mailbox] + [(mailbox, target)]
407
+ state = get_hosts(creds, domain)
408
+ if state['email_type'] != 'FWD':
409
+ has_mx = any(record['type'] in ('MX', 'MXE') for record in state['records'])
410
+ if has_mx and not args.force:
411
+ sys.exit(f"{domain} receives mail through MX records (mode {state['email_type']}). Switching to "
412
+ 'forwarding would cut that mail off. Re-run with --force only if that mail setup is unused.')
413
+ print(f"Switching {domain} email mode {state['email_type'] or 'NONE'} -> FWD")
414
+ records = [record for record in state['records'] if record['type'] not in ('MX', 'MXE')]
415
+ removed = [record for record in state['records'] if record['type'] in ('MX', 'MXE')]
416
+ apply(creds, domain, state, records, removed, [], args.dry_run, email_type='FWD')
417
+ for mailbox, target in wanted:
418
+ print(f' {mailbox}@{domain} -> {target}')
419
+ if args.dry_run:
420
+ print('(dry run: forwards not written)')
421
+ return
422
+ params = {'DomainName': domain}
423
+ for index, (mailbox, target) in enumerate(wanted, 1):
424
+ params[f'MailBox{index}'] = mailbox
425
+ params[f'ForwardTo{index}'] = target
426
+ call(creds, 'namecheap.domains.dns.setEmailForwarding', **params)
427
+ print('Forwards saved.')
428
+
429
+
430
+ def text(node, tag):
431
+ found = node.find(NS + tag) if node is not None else None
432
+ return (found.text or '').strip() if found is not None else ''
433
+
434
+
435
+ def cmd_balance(_args):
436
+ result = call(load_creds(), 'namecheap.users.getBalances').find(NS + 'UserGetBalancesResult')
437
+ print(f"Available {result.get('AvailableBalance')} {result.get('Currency')} "
438
+ f"(account {result.get('AccountBalance')}, earned {result.get('EarnedAmount')}, "
439
+ f"funds needed for auto-renew {result.get('FundsRequiredForAutoRenew')})")
440
+
441
+
442
+ def all_domains(creds, list_type='ALL', search=None):
443
+ page, out = 1, []
444
+ while True:
445
+ params = {'PageSize': 100, 'Page': page, 'ListType': list_type}
446
+ if search:
447
+ params['SearchTerm'] = search
448
+ response = call(creds, 'namecheap.domains.getList', **params)
449
+ batch = list(response.find(NS + 'DomainGetListResult').iter(NS + 'Domain'))
450
+ out.extend(batch)
451
+ paging = response.find(NS + 'Paging')
452
+ total = int(text(paging, 'TotalItems') or len(out))
453
+ if not batch or len(out) >= total:
454
+ return out
455
+ page += 1
456
+
457
+
458
+ def cmd_domains(args):
459
+ list_type = 'EXPIRING' if args.expiring else 'EXPIRED' if args.expired else 'ALL'
460
+ domains = all_domains(load_creds(), list_type, args.search)
461
+ print(f'{len(domains)} domain(s):')
462
+ for domain in domains:
463
+ flags = [label for label, attr in (('auto-renew', 'AutoRenew'), ('locked', 'IsLocked'), ('privacy', 'WhoisGuard'))
464
+ if (domain.get(attr) or '').lower() in ('true', 'enabled')]
465
+ dns = 'Namecheap DNS' if (domain.get('IsOurDNS') or '').lower() == 'true' else 'custom NS'
466
+ print(f" {domain.get('Name'):<32} expires {domain.get('Expires'):<11} {dns:<14} {' '.join(flags)}")
467
+
468
+
469
+ def cmd_info(args):
470
+ creds = load_creds()
471
+ result = call(creds, 'namecheap.domains.getInfo', DomainName=args.domain).find(NS + 'DomainGetInfoResult')
472
+ details = result.find(NS + 'DomainDetails')
473
+ privacy = result.find(NS + 'Whoisguard')
474
+ dns = result.find(NS + 'DnsDetails')
475
+ print(f"{result.get('DomainName')}: status {result.get('Status')}, owner {result.get('OwnerName')}, "
476
+ f"premium {result.get('IsPremium')}")
477
+ print(f" created {text(details, 'CreatedDate')}, expires {text(details, 'ExpiredDate')}")
478
+ print(f" privacy {privacy.get('Enabled') if privacy is not None else '?'}"
479
+ f"{' until ' + text(privacy, 'ExpiredDate') if privacy is not None and text(privacy, 'ExpiredDate') else ''}")
480
+ if dns is not None:
481
+ servers = [(server.text or '').strip() for server in dns.iter(NS + 'Nameserver')]
482
+ print(f" DNS {dns.get('ProviderType')}: {', '.join(servers) or '(none listed)'}")
483
+ lock = call(creds, 'namecheap.domains.getRegistrarLock', DomainName=args.domain).find(NS + 'DomainGetRegistrarLockResult')
484
+ print(f" registrar lock {lock.get('RegistrarLockStatus') if lock is not None else '?'}")
485
+
486
+
487
+ CONTACT_ROLES = ('Registrant', 'Tech', 'Admin', 'AuxBilling')
488
+ CONTACT_FIELDS = ('OrganizationName', 'JobTitle', 'FirstName', 'LastName', 'Address1', 'Address2', 'City',
489
+ 'StateProvince', 'StateProvinceChoice', 'PostalCode', 'Country', 'Phone', 'PhoneExt', 'Fax',
490
+ 'EmailAddress')
491
+
492
+
493
+ def get_contacts(creds, domain):
494
+ result = call(creds, 'namecheap.domains.getContacts', DomainName=domain).find(NS + 'DomainContactsResult')
495
+ return {role: {field: text(result.find(NS + role), field) for field in CONTACT_FIELDS} for role in CONTACT_ROLES}
496
+
497
+
498
+ def cmd_contacts(args):
499
+ for role, fields in get_contacts(load_creds(), args.domain).items():
500
+ name = ' '.join(filter(None, (fields['FirstName'], fields['LastName'])))
501
+ place = ', '.join(filter(None, (fields['City'], fields['StateProvince'], fields['Country'])))
502
+ print(f" {role:<10} {name} <{fields['EmailAddress']}> {fields['OrganizationName']} {place}".rstrip())
503
+
504
+
505
+ def cmd_available(args):
506
+ result = call(load_creds(), 'namecheap.domains.check', DomainList=','.join(args.domains))
507
+ for domain in result.iter(NS + 'DomainCheckResult'):
508
+ available = (domain.get('Available') or '').lower() == 'true'
509
+ premium = (domain.get('IsPremiumName') or '').lower() == 'true'
510
+ price = f" premium {domain.get('PremiumRegistrationPrice')}" if premium else ''
511
+ print(f" {domain.get('Domain'):<32} {'AVAILABLE' if available else 'taken'}{price}")
512
+
513
+
514
+ def tld_price(creds, tld, action, years=1):
515
+ response = call(creds, 'namecheap.users.getPricing', ProductType='DOMAIN',
516
+ ProductCategory=action.upper(), ProductName=tld)
517
+ for product in response.iter(NS + 'Product'):
518
+ for price in product.iter(NS + 'Price'):
519
+ if price.get('Duration') == str(years):
520
+ return price
521
+ return None
522
+
523
+
524
+ def cmd_price(args):
525
+ tld = args.tld.lstrip('.').lower()
526
+ price = tld_price(load_creds(), tld, args.action, args.years)
527
+ if price is None:
528
+ sys.exit(f'No {args.action} price for .{tld} ({args.years} year).')
529
+ print(f".{tld} {args.action} {args.years} year(s): {price.get('YourPrice') or price.get('Price')} {price.get('Currency')}"
530
+ f" (list {price.get('RegularPrice') or price.get('Price')}{', +ICANN fee ' + price.get('YourAdditonalCost') if price.get('YourAdditonalCost') else ''})")
531
+
532
+
533
+ def money_gate(creds, what, price_node, years, yes):
534
+ balance = call(creds, 'namecheap.users.getBalances').find(NS + 'UserGetBalancesResult')
535
+ cost = price_node.get('YourPrice') or price_node.get('Price') if price_node is not None else '?'
536
+ currency = price_node.get('Currency') if price_node is not None else ''
537
+ print(f"{what}: {cost} {currency} per year x {years}; account balance {balance.get('AvailableBalance')} {balance.get('Currency')}")
538
+ if not yes:
539
+ sys.exit('Not charged. This spends money: confirm the item and total with the user, then re-run with --yes.')
540
+
541
+
542
+ def cmd_renew(args):
543
+ creds = load_creds()
544
+ _, tld = split_domain(args.domain)
545
+ money_gate(creds, f'Renew {args.domain}', tld_price(creds, tld, 'renew', 1), args.years, args.yes)
546
+ result = call(creds, 'namecheap.domains.renew', DomainName=args.domain, Years=args.years).find(NS + 'DomainRenewResult')
547
+ print(f"Renewed {args.domain}: charged {result.get('ChargedAmount')}, order {result.get('OrderID')}")
548
+
549
+
550
+ def cmd_register(args):
551
+ creds = load_creds()
552
+ _, tld = split_domain(args.domain)
553
+ check = call(creds, 'namecheap.domains.check', DomainList=args.domain).find(NS + 'DomainCheckResult')
554
+ if (check.get('Available') or '').lower() != 'true':
555
+ sys.exit(f'{args.domain} is not available.')
556
+ if (check.get('IsPremiumName') or '').lower() == 'true':
557
+ sys.exit(f"{args.domain} is a premium name ({check.get('PremiumRegistrationPrice')}); register it in the dashboard.")
558
+ contacts = get_contacts(creds, args.contacts_from)
559
+ money_gate(creds, f'Register {args.domain}', tld_price(creds, tld, 'register', 1), args.years, args.yes)
560
+ params = {'DomainName': args.domain, 'Years': args.years, 'AddFreeWhoisguard': 'yes', 'WGEnabled': 'yes'}
561
+ for role, fields in contacts.items():
562
+ for field, value in fields.items():
563
+ if value:
564
+ params[role + field] = value
565
+ if args.nameservers:
566
+ params['Nameservers'] = ','.join(args.nameservers)
567
+ result = call(creds, 'namecheap.domains.create', **params).find(NS + 'DomainCreateResult')
568
+ print(f"Registered {result.get('Domain')}: {result.get('Registered')}, charged {result.get('ChargedAmount')}, "
569
+ f"order {result.get('OrderID')}, privacy on")
570
+
571
+
572
+ def cmd_transfers(_args):
573
+ result = call(load_creds(), 'namecheap.domains.transfer.getList', PageSize=100).find(NS + 'TransferGetListResult')
574
+ transfers = list(result.iter(NS + 'Transfer')) if result is not None else []
575
+ print(f'{len(transfers)} transfer(s):')
576
+ for transfer in transfers:
577
+ print(f" {transfer.get('DomainName'):<32} {transfer.get('Status')} ({transfer.get('StatusDescription')}) "
578
+ f"id {transfer.get('ID')} {transfer.get('TransferDate')}")
579
+
580
+
581
+ def cmd_nameservers(args):
582
+ creds = load_creds()
583
+ sld, tld = split_domain(args.domain)
584
+ if args.set and args.default:
585
+ sys.exit('Use --set or --default, not both.')
586
+ if args.set:
587
+ if len(args.set) < 2:
588
+ sys.exit('Give at least two nameservers.')
589
+ print(f"{args.domain}: nameservers -> {', '.join(args.set)}. Namecheap's own records stop being served.")
590
+ if not args.yes:
591
+ sys.exit('Not changed: re-run with --yes (it moves the whole domain to another DNS host).')
592
+ call(creds, 'namecheap.domains.dns.setCustom', SLD=sld, TLD=tld, Nameservers=','.join(args.set))
593
+ elif args.default:
594
+ print(f'{args.domain}: nameservers -> Namecheap BasicDNS.')
595
+ if not args.yes:
596
+ sys.exit('Not changed: re-run with --yes (records at the current DNS host stop being served).')
597
+ call(creds, 'namecheap.domains.dns.setDefault', SLD=sld, TLD=tld)
598
+ result = call(creds, 'namecheap.domains.dns.getList', SLD=sld, TLD=tld).find(NS + 'DomainDNSGetListResult')
599
+ servers = [(server.text or '').strip() for server in result.iter(NS + 'Nameserver')]
600
+ kind = 'Namecheap DNS' if (result.get('IsUsingOurDNS') or '').lower() == 'true' else 'custom'
601
+ print(f"{args.domain}: {kind}: {', '.join(servers)}")
602
+
603
+
604
+ def cmd_lock(args):
605
+ creds = load_creds()
606
+ if args.on or args.off:
607
+ call(creds, 'namecheap.domains.setRegistrarLock', DomainName=args.domain, LockAction='LOCK' if args.on else 'UNLOCK')
608
+ result = call(creds, 'namecheap.domains.getRegistrarLock', DomainName=args.domain).find(NS + 'DomainGetRegistrarLockResult')
609
+ print(f"{args.domain}: registrar lock {result.get('RegistrarLockStatus')}")
610
+
611
+
612
+ def cmd_privacy(args):
613
+ creds = load_creds()
614
+ result = call(creds, 'namecheap.whoisguard.getList', PageSize=100, ListType='ALL').find(NS + 'WhoisguardGetListResult')
615
+ entries = list(result.iter(NS + 'Whoisguard')) if result is not None else []
616
+ if args.domain:
617
+ entries = [entry for entry in entries if (entry.get('DomainName') or '').lower() == args.domain.lower()]
618
+ if not entries:
619
+ sys.exit(f'No domain privacy subscription found for {args.domain}.')
620
+ if args.on or args.off:
621
+ if not args.domain:
622
+ sys.exit('Name the domain to change.')
623
+ entry = entries[0]
624
+ if args.on:
625
+ email = args.email or get_contacts(creds, args.domain)['Registrant']['EmailAddress']
626
+ call(creds, 'namecheap.whoisguard.enable', WhoisguardID=entry.get('ID'), ForwardedToEmail=email)
627
+ else:
628
+ call(creds, 'namecheap.whoisguard.disable', WhoisguardID=entry.get('ID'))
629
+ return cmd_privacy(argparse.Namespace(domain=args.domain, on=False, off=False, email=None))
630
+ for entry in entries:
631
+ print(f" {entry.get('DomainName') or '(unassigned)':<32} {entry.get('Status'):<10} id {entry.get('ID')} expires {entry.get('Expires')}")
632
+
633
+
634
+ def cmd_lookup(args):
635
+ """Resolve through Google and Cloudflare DNS-over-HTTPS; works where dig is not installed."""
636
+ for resolver in ('https://dns.google/resolve', 'https://cloudflare-dns.com/dns-query'):
637
+ url = f'{resolver}?' + urllib.parse.urlencode({'name': args.name, 'type': args.type.upper()})
638
+ request = urllib.request.Request(url, headers={'Accept': 'application/dns-json'})
639
+ try:
640
+ with urllib.request.urlopen(request, timeout=10) as response:
641
+ answers = json.load(response).get('Answer', [])
642
+ except (urllib.error.URLError, OSError, ValueError):
643
+ continue
644
+ records = [answer['data'] for answer in answers]
645
+ print(f"{args.name} {args.type.upper()} via {resolver.split('/')[2]}: " + (', '.join(records) or '(no answer)'))
646
+ if args.type.upper() == 'NS' and any('registrar-servers.com' in record for record in records):
647
+ print('-> Namecheap BasicDNS: use this tool.')
648
+ return
649
+ sys.exit('Both DNS-over-HTTPS resolvers were unreachable.')
650
+
651
+
652
+ def cmd_restore(args):
653
+ saved = json.loads(Path(args.backup).read_text())
654
+ creds = load_creds()
655
+ state = get_hosts(creds, saved['domain'])
656
+ removed = [record for record in state['records'] if not any(same(record, old) for old in saved['records'])]
657
+ added = [record for record in saved['records'] if not any(same(record, live) for live in state['records'])]
658
+ apply(creds, saved['domain'], state, saved['records'], removed, added, args.dry_run, email_type=saved['email_type'])
659
+
660
+
661
+ def main():
662
+ parser = argparse.ArgumentParser(prog='namecheap', description=__doc__,
663
+ formatter_class=argparse.RawDescriptionHelpFormatter)
664
+ sub = parser.add_subparsers(dest='command', required=True)
665
+
666
+ p = sub.add_parser('setup', help='store API credentials, then check them')
667
+ p.add_argument('api_user')
668
+ p.add_argument('api_key', nargs='?', help='omit to be prompted (keeps it out of shell history)')
669
+ p.add_argument('--username')
670
+ p.add_argument('--client-ip')
671
+ p.set_defaults(func=cmd_setup)
672
+
673
+ sub.add_parser('check', help='verify credentials and list domains').set_defaults(func=cmd_check)
674
+
675
+ sub.add_parser('balance', help='account balance').set_defaults(func=cmd_balance)
676
+
677
+ p = sub.add_parser('domains', help='list domains in the account')
678
+ p.add_argument('--expiring', action='store_true')
679
+ p.add_argument('--expired', action='store_true')
680
+ p.add_argument('--search')
681
+ p.set_defaults(func=cmd_domains)
682
+
683
+ p = sub.add_parser('info', help='status, expiry, privacy, nameservers, lock')
684
+ p.add_argument('domain')
685
+ p.set_defaults(func=cmd_info)
686
+
687
+ p = sub.add_parser('contacts', help='WHOIS contacts on a domain')
688
+ p.add_argument('domain')
689
+ p.set_defaults(func=cmd_contacts)
690
+
691
+ p = sub.add_parser('available', help='is a domain free to register')
692
+ p.add_argument('domains', nargs='+')
693
+ p.set_defaults(func=cmd_available)
694
+
695
+ p = sub.add_parser('price', help='your price for a TLD')
696
+ p.add_argument('tld')
697
+ p.add_argument('--action', choices=('register', 'renew', 'transfer'), default='register')
698
+ p.add_argument('--years', type=int, default=1)
699
+ p.set_defaults(func=cmd_price)
700
+
701
+ p = sub.add_parser('register', help='buy a domain (price first; --yes to charge)')
702
+ p.add_argument('domain')
703
+ p.add_argument('--contacts-from', required=True, metavar='OWNED_DOMAIN', help='copy WHOIS contacts from a domain you own')
704
+ p.add_argument('--years', type=int, default=1)
705
+ p.add_argument('--nameservers', nargs='+', help='custom nameservers (default: Namecheap BasicDNS)')
706
+ p.add_argument('--yes', action='store_true', help='actually charge the account')
707
+ p.set_defaults(func=cmd_register)
708
+
709
+ p = sub.add_parser('renew', help='renew a domain (price first; --yes to charge)')
710
+ p.add_argument('domain')
711
+ p.add_argument('--years', type=int, default=1)
712
+ p.add_argument('--yes', action='store_true', help='actually charge the account')
713
+ p.set_defaults(func=cmd_renew)
714
+
715
+ sub.add_parser('transfers', help='incoming domain transfers').set_defaults(func=cmd_transfers)
716
+
717
+ p = sub.add_parser('nameservers', help='show or change nameservers')
718
+ p.add_argument('domain')
719
+ p.add_argument('--set', nargs='+', metavar='NS')
720
+ p.add_argument('--default', action='store_true', help='back to Namecheap BasicDNS')
721
+ p.add_argument('--yes', action='store_true', help='confirm the switch')
722
+ p.set_defaults(func=cmd_nameservers)
723
+
724
+ p = sub.add_parser('lock', help='show or change the registrar lock')
725
+ p.add_argument('domain')
726
+ group = p.add_mutually_exclusive_group()
727
+ group.add_argument('--on', action='store_true')
728
+ group.add_argument('--off', action='store_true')
729
+ p.set_defaults(func=cmd_lock)
730
+
731
+ p = sub.add_parser('privacy', help='show or change free domain privacy')
732
+ p.add_argument('domain', nargs='?')
733
+ group = p.add_mutually_exclusive_group()
734
+ group.add_argument('--on', action='store_true')
735
+ group.add_argument('--off', action='store_true')
736
+ p.add_argument('--email', help='where privacy forwards mail (default: the registrant email)')
737
+ p.set_defaults(func=cmd_privacy)
738
+
739
+ p = sub.add_parser('list', help='show host records')
740
+ p.add_argument('domain')
741
+ p.set_defaults(func=cmd_list)
742
+
743
+ p = sub.add_parser('set', help='add/remove host records (merged, backed up, verified)')
744
+ p.add_argument('domain')
745
+ p.add_argument('--add', action='append', default=[], metavar='"TYPE HOST VALUE [TTL] [MXPREF]"')
746
+ p.add_argument('--remove', action='append', default=[], metavar='"TYPE HOST [VALUE]"')
747
+ p.add_argument('--replace', action='store_true', help='swap out records that conflict with an added one')
748
+ p.add_argument('--dry-run', action='store_true')
749
+ p.set_defaults(func=cmd_set)
750
+
751
+ p = sub.add_parser('vercel', help='point the apex + www (or --sub names) at Vercel')
752
+ p.add_argument('domain')
753
+ p.add_argument('--sub', action='append', metavar='NAME', help='subdomain(s) to CNAME to Vercel instead of @ + www')
754
+ p.add_argument('--dry-run', action='store_true')
755
+ p.set_defaults(func=cmd_vercel)
756
+
757
+ p = sub.add_parser('forward', help='list or change free email forwarding')
758
+ p.add_argument('domain')
759
+ p.add_argument('--add', action='append', default=[], metavar='mailbox=destination')
760
+ p.add_argument('--remove', action='append', default=[], metavar='mailbox')
761
+ p.add_argument('--force', action='store_true', help='allow switching a domain off MX mail to forwarding')
762
+ p.add_argument('--dry-run', action='store_true')
763
+ p.set_defaults(func=cmd_forward)
764
+
765
+ p = sub.add_parser('lookup', help='resolve a name over DNS-over-HTTPS (no dig needed)')
766
+ p.add_argument('name')
767
+ p.add_argument('type', nargs='?', default='A')
768
+ p.set_defaults(func=cmd_lookup)
769
+
770
+ p = sub.add_parser('restore', help='put a backup back')
771
+ p.add_argument('backup')
772
+ p.add_argument('--dry-run', action='store_true')
773
+ p.set_defaults(func=cmd_restore)
774
+
775
+ args = parser.parse_args()
776
+ try:
777
+ args.func(args)
778
+ except ApiError as error:
779
+ sys.exit(f'Namecheap API error: {error}')
780
+ except urllib.error.URLError as error:
781
+ sys.exit(f'Could not reach the Namecheap API: {error}')
782
+
783
+
784
+ if __name__ == '__main__':
785
+ main()
@@ -4,9 +4,11 @@ export type AgentSkillTarget = {
4
4
  };
5
5
  export declare function agentSkillsSourceDir(): string;
6
6
  export declare function agentSkillTargets(home?: string, env?: NodeJS.ProcessEnv): AgentSkillTarget[];
7
+ export declare const RETIRED_AGENT_SKILLS: string[];
7
8
  export type InstallResult = {
8
9
  installed: string[];
9
10
  updatedFiles: number;
11
+ removed: string[];
10
12
  };
11
13
  export declare function installAgentSkills(options?: {
12
14
  sourceDir?: string;
@@ -23,6 +23,10 @@ async function listFiles(root, rel = "") {
23
23
  }
24
24
  return files;
25
25
  }
26
+ // Skills Rudder once shipped under another name. Their rudder-<name> copies are
27
+ // Rudder's own (never the user's), so an upgrade removes them rather than leave
28
+ // agents two skills for the same job.
29
+ export const RETIRED_AGENT_SKILLS = ["namecheap-dns"];
26
30
  export async function installAgentSkills(options = {}) {
27
31
  const sourceDir = options.sourceDir ?? agentSkillsSourceDir();
28
32
  const targets = options.targets ?? agentSkillTargets();
@@ -31,7 +35,7 @@ export async function installAgentSkills(options = {}) {
31
35
  skills = (await fsp.readdir(sourceDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
32
36
  }
33
37
  catch {
34
- return { installed: [], updatedFiles: 0 };
38
+ return { installed: [], updatedFiles: 0, removed: [] };
35
39
  }
36
40
  const installed = [];
37
41
  let updatedFiles = 0;
@@ -57,7 +61,19 @@ export async function installAgentSkills(options = {}) {
57
61
  installed.push(`${target.agent}:rudder-${skill}`);
58
62
  }
59
63
  }
60
- return { installed, updatedFiles };
64
+ const removed = [];
65
+ for (const retired of RETIRED_AGENT_SKILLS) {
66
+ if (skills.includes(retired))
67
+ continue;
68
+ for (const target of targets) {
69
+ const dir = path.join(target.dir, `rudder-${retired}`);
70
+ if (!(await fsp.stat(dir).catch(() => null)))
71
+ continue;
72
+ await fsp.rm(dir, { recursive: true, force: true });
73
+ removed.push(`${target.agent}:rudder-${retired}`);
74
+ }
75
+ }
76
+ return { installed, updatedFiles, removed };
61
77
  }
62
78
  /** Best effort: a read-only home or odd permissions must never block a run. */
63
79
  export async function ensureAgentSkills(env = process.env) {
@@ -1 +1 @@
1
- {"version":3,"file":"agent-skills.js","sourceRoot":"","sources":["../src/agent-skills.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"agent-skills.js","sourceRoot":"","sources":["../src/agent-skills.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viraatdas/rudder",
3
- "version": "3.9.2",
3
+ "version": "3.9.5",
4
4
  "description": "A TUI for managing coding agents: run Claude Code, Codex and opencode side by side, each in its own isolated jj workspace.",
5
5
  "keywords": [
6
6
  "tui",