Skill: jarekbird/namecheap-dns (version 1.1.0)

--- name: namecheap-dns description: Safe DNS record management for Namecheap domains. Fetch, add, remove, backup, and restore DNS records with automatic safety checks and dry-run mode. Prevents accidental DNS record wipeout via the Namecheap API's destructive setHosts method. homepage: https://www.namecheap.com/support/api/ metadata: { "openclaw": { "emoji": "🌐", "requires": { "bins": ["node", "npm"] }, }, } --- # Namecheap DNS Management Safe wrapper around the Namecheap API for DNS operations. **Prevents accidental record wipeout** by always fetching existing records first and merging changes. ## ⚠️ Why This Skill Exists The Namecheap API's `setHosts` method **replaces ALL DNS records** for a domain. One wrong API call = your entire DNS config is gone. This skill: - ✅ Always fetches existing records first - ✅ Merges new records with existing ones (unless explicitly replacing) - ✅ Shows a diff preview before applying changes - ✅ Auto-backups before every change - ✅ Supports dry-run mode for safe testing - ✅ One-command rollback from backups ## Setup ### 1. Install dependencies ```bash cd ~/.openclaw/workspace/skills/namecheap-dns npm install ``` ### 2. Enable Namecheap API access 1. Go to https://ap.www.namecheap.com/settings/tools/apiaccess/ 2. Toggle "API Access" ON 3. Whitelist your IP address 4. Copy your API key ### 3. Set environment variables Add to `~/.zshrc` or `~/.bashrc`: ```bash export NAMECHEAP_API_KEY="your-api-key-here" export NAMECHEAP_USERNAME="your-username" export NAMECHEAP_API_USER="your-username" # Usually same as username ``` ## Usage ### Verify DNS and detect ghost records **⚠️ IMPORTANT: Run this first!** ```bash ./namecheap-dns.js verify example.com ``` This command compares DNS records visible to the Namecheap API with actual live DNS records (via `dig`). It will warn you about "ghost records" that exist in DNS but are invisible to the API (email forwarding, URL redirects, etc.). ### List current DNS records ```bash ./namecheap-dns.js list example.com ``` **Note:** This only shows records visible to the API. Use `verify` to see ALL records including those managed by Namecheap subsystems. ### Add records (safe merge) ```bash # Add a single TXT record ./namecheap-dns.js add example.com \ --txt "mail.example.com=v=spf1 include:mailgun.org ~all" # Add multiple records at once ./namecheap-dns.js add example.com \ --txt "mail=v=spf1 include:mailgun.org ~all" \ --cname "email.mail=mailgun.org" \ --mx "mail=10 mxa.mailgun.org" # Dry-run (preview changes without applying) ./namecheap-dns.js add example.com \ --txt "test=hello" \ --dry-run # Force override safety check (if you know ghost records can be deleted) ./namecheap-dns.js add example.com \ --txt "test=hello" \ --force ``` **Safety:** The skill automatically checks for "ghost records" before making changes. If detected, it will refuse to proceed unless you use `--force`. ### Remove records ```bash # Remove by host + type ./namecheap-dns.js remove example.com \ --host "old-record" \ --type "TXT" # Dry-run first ./namecheap-dns.js remove example.com \ --host "old-record" \ --type "TXT" \ --dry-run ``` ### Backup & Restore ```bash # Create manual backup ./namecheap-dns.js backup example.com # List available backups ./namecheap-dns.js backups example.com # Restore from latest backup ./namecheap-dns.js restore example.com # Restore from specific backup ./namecheap-dns.js restore example.com \ --backup "example.com-20260213-114500.json" ``` ## Record Format ### TXT Records ``` --txt "subdomain=value" --txt "@=value" # Root domain ``` ### CNAME Records ``` --cname "subdomain=target.com" ``` ### MX Records ``` --mx "subdomain=10 mx.target.com" --mx "@=10 mx.target.com" # Root domain ``` ### A Records ``` --a "subdomain=192.168.1.1" --a "@=192.168.1.1" # Root domain ``` ## Backup Location **Default:** `./backups/` (relative to skill directory) **Configurable via environment variable:** ```bash export NAMECHEAP_BACKUP_DIR="/custom/path/to/backups" ``` Format: `{domain}-{timestamp}.json` Each backup includes: - `apiHosts`: Records visible to Namecheap API - `liveDNS`: Actual DNS records captured via `dig` - Timestamp and domain metadata This allows you to see what was ACTUALLY live in DNS, not just what the API knew about. ## Safety Features 1. **Ghost record detection** — automatic check for records invisible to API 2. **Auto-backup before changes** — every `add` or `remove` creates a timestamped backup (includes DNS snapshot) 3. **Dry-run mode** — `--dry-run` shows what will change without applying 4. **Diff preview** — see exactly what records will be added/removed 5. **Fetch-first** — always gets current DNS state before changes 6. **Merge logic** — adds to existing records instead of replacing 7. **Rollback** — one command to restore from backup 8. **Safety override** — `--force` flag for when you need to bypass ghost record warnings ## Examples ### Mailgun Setup ```bash ./namecheap-dns.js add menuhq.ai \ --txt "mail.menuhq.ai=v=spf1 include:mailgun.org ~all" \ --txt "smtp._domainkey.mail.menuhq.ai=k=rsa; p=MIGfMA0..." \ --txt "_dmarc.mail.menuhq.ai=v=DMARC1; p=quarantine;" \ --cname "email.mail.menuhq.ai=mailgun.org" \ --mx "mail.menuhq.ai=10 mxa.mailgun.org" \ --mx "mail.menuhq.ai=20 mxb.mailgun.org" \ --dry-run ``` Review the diff, then run without `--dry-run` to apply. ## Known Limitations ### ⚠️ The Namecheap API is Destructive The Namecheap `domains.dns.setHosts` API method **replaces ALL DNS records** for a domain. There is no "add one record" or "update one record" endpoint. Every change requires: 1. Fetch all existing records (`getHosts`) 2. Modify the list 3. Upload the entire list (`setHosts`) **This skill handles this for you** by always fetching first and merging changes. ### 🔍 Ghost Records: The Hidden Danger **Problem:** `domains.dns.getHosts` does NOT return all DNS records. Records managed by Namecheap subsystems are invisible to the API: - **Email Forwarding** — MX, SPF, and DKIM records - **URL Redirect** — A/CNAME records for domain parking/redirects - **Third-party integrations** — Records added through Namecheap's dashboard for services Since `setHosts` **replaces all records**, using the API can silently delete these hidden records. ### 🛡️ How This Skill Protects You 1. **`verify` command** — Compares API records with actual live DNS (via `dig`) and warns about ghost records 2. **Automatic safety check** — Before any `add`, `remove`, or `restore`, the skill checks for ghost records 3. **Refuses to proceed** — If ghost records are detected, the operation is blocked (unless `--force` is used) 4. **Clear warnings** — Shows exactly which records will be lost if you proceed 5. **DNS snapshots in backups** — Captures actual DNS state via `dig`, not just API state ### When to Use `--force` Only use the `--force` flag when: - You've manually verified the ghost records are no longer needed - You're intentionally removing email forwarding or URL redirects - You understand and accept that those records will be deleted **Never use `--force` blindly.** Always run `verify` first to see what will be lost. ### Example: The Production Incident This skill was created after adding Mailgun DNS records via the API wiped out Namecheap's email forwarding records. The email forwarding MX/SPF/TXT records were invisible to `getHosts`, so the fetch-merge-write pattern deleted them. Now, the skill would have: 1. Detected the ghost records during `verify` 2. Refused to proceed without `--force` 3. Shown exactly which email forwarding records would be deleted 4. Created a backup including the DNS snapshot ## Troubleshooting ### "API request failed: IP not whitelisted" - Add your current IP to https://ap.www.namecheap.com/settings/tools/apiaccess/ - Check with: `curl ifconfig.me` ### "Invalid API key" - Verify `NAMECHEAP_API_KEY` is set correctly - Re-enable API access if needed ### "Domain not found" - Ensure domain is in your Namecheap account - Check spelling (case-sensitive) ## API Reference Namecheap API docs: https://www.namecheap.com/support/api/methods/domains-dns/

path: README.md
content: # Namecheap DNS Skill

Safe DNS record management for Namecheap domains. Prevents accidental DNS wipeout by the Namecheap API's destructive `setHosts` method.

## ⚠️ Why This Exists

The Namecheap API's `setHosts` method **replaces ALL DNS records** at once. One wrong call = your entire DNS is gone. This skill wraps the API with safety features:

- ✅ Always fetches existing records first
- ✅ Merges new records with existing (no accidental wipeout)
- ✅ Shows diff preview before applying
- ✅ Auto-backups before every change
- ✅ Dry-run mode for safe testing
- ✅ One-command rollback

## Quick Start

```bash
# List current DNS
./namecheap-dns.js list menuhq.ai

# Add a TXT record (dry-run first)
./namecheap-dns.js add menuhq.ai \
  --txt "mail=v=spf1 include:mailgun.org ~all" \
  --dry-run

# Apply it
./namecheap-dns.js add menuhq.ai \
  --txt "mail=v=spf1 include:mailgun.org ~all"

# Rollback if needed
./namecheap-dns.js restore menuhq.ai
```

## Installation

See `SKILL.md` for full setup instructions.

## Examples

### Mailgun Email Setup

```bash
# Add all Mailgun DNS records at once
./namecheap-dns.js add menuhq.ai \
  --txt "mail.menuhq.ai=v=spf1 include:mailgun.org ~all" \
  --txt "smtp._domainkey.mail.menuhq.ai=k=rsa; p=MIGfMA0GCS..." \
  --txt "_dmarc.mail.menuhq.ai=v=DMARC1; p=quarantine;" \
  --cname "email.mail.menuhq.ai=mailgun.org" \
  --mx "mail.menuhq.ai=10 mxa.mailgun.org" \
  --mx "mail.menuhq.ai=20 mxb.mailgun.org" \
  --dry-run
```

Review the preview, then run without `--dry-run`.

### Add Custom Domain

```bash
# Point subdomain to server
./namecheap-dns.js add example.com \
  --a "app=192.168.1.100" \
  --cname "www=app.example.com"
```

### Remove Old Records

```bash
# Remove by host + type
./namecheap-dns.js remove example.com \
  --host "old-service" \
  --type "A" \
  --dry-run
```

## Safety Features

1. **Auto-backup** — every change creates `~/.openclaw/workspace/data/namecheap-backups/{domain}-{timestamp}.json`
2. **Dry-run** — `--dry-run` shows what will change without applying
3. **Diff preview** — always shows exactly what's added/removed
4. **Merge logic** — adds to existing records, doesn't replace
5. **Rollback** — `restore` command brings back the last state

## Commands

| Command | Description |
|---------|-------------|
| `list <domain>` | Show current DNS records |
| `add <domain> [options]` | Add records (merges with existing) |
| `remove <domain> [options]` | Remove specific records |
| `backup <domain>` | Create manual backup |
| `backups <domain>` | List available backups |
| `restore <domain> [options]` | Restore from backup |

## Record Types Supported

- **TXT** — `--txt "host=value"`
- **CNAME** — `--cname "host=target.com"`
- **MX** — `--mx "host=10 mx.example.com"`
- **A** — `--a "host=192.168.1.1"`

Use `@` for root domain: `--a "@=192.168.1.1"`

## Limitations

- Only works with domains using **Namecheap nameservers** (`registrar-servers.com`)
- Domains using custom nameservers (Cloudflare, Route53, etc.) must be managed via those platforms
- Check nameservers: `dig example.com NS +short`

## Troubleshooting

### "IP not whitelisted"
Add your IP at https://ap.www.namecheap.com/settings/tools/apiaccess/
Check current IP: `curl ifconfig.me`

### "Invalid API key"
Verify `NAMECHEAP_API_KEY` in `~/.zshrc`

### Records not showing
Domain might use custom nameservers. Check: `dig example.com NS +short`

## Publishing to ClawHub

This skill is designed to be shared on ClawHub so others don't make DNS mistakes.

```bash
cd ~/.openclaw/workspace/skills/namecheap-dns
clawhub publish .
```

## License

MIT — Use freely, share improvements


path: namecheap-dns.js
content: #!/usr/bin/env node

import axios from 'axios';
import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { homedir } from 'os';
import { execSync } from 'child_process';

const __dirname = dirname(fileURLToPath(import.meta.url));

const API_BASE = 'https://api.namecheap.com/xml.response';

// Configurable backup directory
function getBackupDir() {
  if (process.env.NAMECHEAP_BACKUP_DIR) {
    return process.env.NAMECHEAP_BACKUP_DIR;
  }
  // Default fallback
  return join(__dirname, 'backups');
}

const BACKUP_DIR = getBackupDir();

// Ensure backup directory exists
if (!existsSync(BACKUP_DIR)) {
  mkdirSync(BACKUP_DIR, { recursive: true });
}

// Parse CLI args
const args = process.argv.slice(2);
const command = args[0];
const domain = args[1];

function getEnv(key) {
  const value = process.env[key];
  if (!value) {
    console.error(`❌ Error: ${key} environment variable not set`);
    console.error(`   Add to ~/.zshrc: export ${key}="your-value"`);
    process.exit(1);
  }
  return value;
}

// Only require env vars for actual commands, not help
let API_KEY, USERNAME, API_USER;
if (command && command !== 'help' && args[1]) {
  [REDACTED_SECRET]'NAMECHEAP_API_KEY');
  USERNAME = getEnv('NAMECHEAP_USERNAME');
  API_USER = getEnv('NAMECHEAP_API_USER');
}

// Get client IP
async function getClientIP() {
  try {
    const response = await axios.get('https://ifconfig.me/ip');
    return response.data.trim();
  } catch (error) {
    console.error('❌ Failed to get client IP:', error.message);
    process.exit(1);
  }
}

// Parse domain into SLD and TLD
function parseDomain(fullDomain) {
  const parts = fullDomain.split('.');
  if (parts.length < 2) {
    console.error(`❌ Invalid domain: ${fullDomain}`);
    process.exit(1);
  }
  return {
    sld: parts.slice(0, -1).join('.'),
    tld: parts[parts.length - 1],
  };
}

// Make API request
async function apiRequest(command, extraParams = {}) {
  const clientIp = await getClientIP();
  const params = {
    ApiUser: API_USER,
    [REDACTED_SECRET],
    UserName: USERNAME,
    Command: command,
    ClientIp: clientIp,
    ...extraParams,
  };

  try {
    const response = await axios.get(API_BASE, { params });
    const xml = response.data;

    // Check for API errors
    if (xml.includes('<Status>ERROR</Status>')) {
      const errorMatch = xml.match(/<Error[^>]*>(.*?)<\/Error>/);
      const error = errorMatch ? errorMatch[1] : 'Unknown error';
      throw new Error(error);
    }

    return xml;
  } catch (error) {
    if (error.message.includes('IP not whitelisted')) {
      console.error('❌ API Error: Your IP is not whitelisted');
      console.error(`   Current IP: ${clientIp}`);
      console.error('   Add it at: https://ap.www.namecheap.com/settings/tools/apiaccess/');
    } else {
      console.error('❌ API Error:', error.message);
    }
    process.exit(1);
  }
}

// Get current DNS hosts
async function getHosts(fullDomain) {
  const { sld, tld } = parseDomain(fullDomain);
  const xml = await apiRequest('namecheap.domains.dns.getHosts', { SLD: sld, TLD: tld });

  // Debug: log raw XML if DEBUG env var is set
  if (process.env.DEBUG) {
    console.error('[DEBUG] Raw XML response:', xml);
  }

  const hosts = [];
  const hostRegex = /<host ([^>]*)\/>/g;
  let match;

  while ((match = hostRegex.exec(xml)) !== null) {
    const attrs = match[1];
    const getAttr = (name) => {
      const m = attrs.match(new RegExp(`${name}="([^"]*)"`));
      return m ? m[1] : '';
    };

    hosts.push({
      hostId: getAttr('HostId'),
      name: getAttr('Name'),
      type: getAttr('Type'),
      address: getAttr('Address'),
      mxPref: getAttr('MXPref') || '10',
      ttl: getAttr('TTL') || '1800',
    });
  }

  return hosts;
}

// Set DNS hosts (replaces ALL records)
async function setHosts(fullDomain, hosts) {
  const { sld, tld } = parseDomain(fullDomain);
  const params = { SLD: sld, TLD: tld };

  hosts.forEach((host, i) => {
    const idx = i + 1;
    params[`HostName${idx}`] = host.name;
    params[`RecordType${idx}`] = host.type;
    params[`Address${idx}`] = host.address;
    if (host.type === 'MX') {
      params[`MXPref${idx}`] = host.mxPref;
    }
    params[`TTL${idx}`] = host.ttl;
  });

  await apiRequest('namecheap.domains.dns.setHosts', params);
}

// Query live DNS records via dig
function queryLiveDNS(fullDomain) {
  const types = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS'];
  const records = [];

  for (const type of types) {
    try {
      const output = execSync(`dig +short ${fullDomain} ${type}`, { 
        encoding: 'utf8',
        timeout: 5000,
        stdio: ['pipe', 'pipe', 'ignore'] // suppress stderr
      }).trim();

      if (!output) continue;

      const lines = output.split('\n').filter(l => l.trim());
      
      for (const line of lines) {
        if (type === 'MX') {
          // Format: "10 mail.example.com."
          const match = line.match(/^(\d+)\s+(.+)$/);
          if (match) {
            records.push({
              name: '@',
              type: 'MX',
              address: match[2].replace(/\.$/, ''),
              mxPref: match[1],
            });
          }
        } else if (type === 'TXT') {
          // Remove quotes from TXT records
          records.push({
            name: '@',
            type: 'TXT',
            address: line.replace(/^"(.*)"$/, '$1'),
          });
        } else {
          records.push({
            name: '@',
            type: type,
            address: line.replace(/\.$/, ''),
          });
        }
      }
    } catch (error) {
      // dig failed or timed out - skip this type
      continue;
    }
  }

  // Also query common subdomains
  const subdomains = ['www', 'mail', 'email', 'smtp', 'imap', 'pop', 'ftp'];
  for (const sub of subdomains) {
    for (const type of ['A', 'CNAME', 'MX', 'TXT']) {
      try {
        const output = execSync(`dig +short ${sub}.${fullDomain} ${type}`, {
          encoding: 'utf8',
          timeout: 3000,
          stdio: ['pipe', 'pipe', 'ignore']
        }).trim();

        if (!output) continue;

        const lines = output.split('\n').filter(l => l.trim());
        for (const line of lines) {
          if (type === 'MX') {
            const match = line.match(/^(\d+)\s+(.+)$/);
            if (match) {
              records.push({
                name: sub,
                type: 'MX',
                address: match[2].replace(/\.$/, ''),
                mxPref: match[1],
              });
            }
          } else if (type === 'TXT') {
            records.push({
              name: sub,
              type: 'TXT',
              address: line.replace(/^"(.*)"$/, '$1'),
            });
          } else {
            records.push({
              name: sub,
              type: type,
              address: line.replace(/\.$/, ''),
            });
          }
        }
      } catch (error) {
        continue;
      }
    }
  }

  return records;
}

// Compare API records with live DNS
function findGhostRecords(apiHosts, liveRecords) {
  const apiSet = new Set();
  
  apiHosts.forEach(h => {
    const name = h.name || '@';
    const key = `${name}:${h.type}:${h.address.toLowerCase()}`;
    apiSet.add(key);
  });

  const ghosts = [];
  
  liveRecords.forEach(live => {
    const name = live.name || '@';
    const key = `${name}:${live.type}:${live.address.toLowerCase()}`;
    
    if (!apiSet.has(key)) {
      ghosts.push(live);
    }
  });

  return ghosts;
}

// Verify DNS and check for ghost records
async function verifyDNS(fullDomain, silent = false) {
  if (!silent) {
    console.log(`🔍 Verifying DNS for ${fullDomain}...`);
  }

  const apiHosts = await getHosts(fullDomain);
  
  if (!silent) {
    console.log(`📡 Querying live DNS...`);
  }
  
  const liveRecords = queryLiveDNS(fullDomain);
  const ghosts = findGhostRecords(apiHosts, liveRecords);

  if (!silent) {
    console.log(`\n📊 Results:`);
    console.log(`   API records: ${apiHosts.length}`);
    console.log(`   Live DNS records: ${liveRecords.length}`);
    console.log(`   Ghost records: ${ghosts.length}`);
  }

  if (ghosts.length > 0 && !silent) {
    console.log(`\n⚠️  Ghost Records Detected (in DNS but NOT in API):\n`);
    ghosts.forEach(g => {
      const name = g.name || '@';
      const mxPref = g.type === 'MX' ? ` [${g.mxPref}]` : '';
      console.log(`   ${g.type.padEnd(6)} ${name.padEnd(25)} → ${g.address}${mxPref}`);
    });
    console.log(`\n⚠️  WARNING: These records are managed by Namecheap subsystems`);
    console.log(`   (email forwarding, URL redirects, etc.) and are INVISIBLE to the API.`);
    console.log(`   Using setHosts will DELETE these records!`);
  } else if (!silent) {
    console.log(`\n✅ No ghost records detected - API and DNS are in sync`);
  }

  return ghosts;
}

// Safety check before setHosts - returns true if safe to proceed
async function safetyCheck(fullDomain, force = false) {
  const ghosts = await verifyDNS(fullDomain, true);
  
  if (ghosts.length === 0) {
    return true;
  }

  console.log(`\n⚠️  SAFETY CHECK FAILED: Ghost records detected!\n`);
  console.log(`   The following records exist in DNS but are NOT visible to the API:\n`);
  
  ghosts.forEach(g => {
    const name = g.name || '@';
    const mxPref = g.type === 'MX' ? ` [${g.mxPref}]` : '';
    console.log(`   ${g.type.padEnd(6)} ${name.padEnd(25)} → ${g.address}${mxPref}`);
  });

  console.log(`\n   These records will be DELETED if you proceed!`);
  console.log(`   They are likely managed by Namecheap subsystems:`);
  console.log(`   - Email forwarding (MX, SPF, DKIM records)`);
  console.log(`   - URL Redirect records`);
  console.log(`   - Domain parking records\n`);

  if (force) {
    console.log(`⚠️  --force flag detected: Proceeding anyway (you've been warned!)\n`);
    return true;
  }

  console.log(`❌ REFUSING to proceed. To override, use --force flag.\n`);
  return false;
}

// Create backup with DNS snapshot
function createBackup(fullDomain, hosts) {
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T').join('-').slice(0, -5);
  const filename = `${fullDomain}-${timestamp}.json`;
  const path = join(BACKUP_DIR, filename);

  // Also capture live DNS state
  const liveDNS = queryLiveDNS(fullDomain);

  const backup = {
    domain: fullDomain,
    timestamp,
    apiHosts: hosts,
    liveDNS: liveDNS,
    note: 'apiHosts = records visible to Namecheap API; liveDNS = actual DNS records via dig'
  };

  writeFileSync(path, JSON.stringify(backup, null, 2));
  console.log(`📦 Backup created: ${filename}`);
  console.log(`   - API records: ${hosts.length}`);
  console.log(`   - Live DNS records: ${liveDNS.length}`);
  return filename;
}

// List backups
function listBackups(fullDomain) {
  if (!existsSync(BACKUP_DIR)) {
    return [];
  }

  return readdirSync(BACKUP_DIR)
    .filter(f => f.startsWith(fullDomain) && f.endsWith('.json'))
    .sort()
    .reverse();
}

// Load backup
function loadBackup(filename) {
  const path = join(BACKUP_DIR, filename);
  if (!existsSync(path)) {
    console.error(`❌ Backup not found: ${filename}`);
    process.exit(1);
  }
  const backup = JSON.parse(readFileSync(path, 'utf8'));
  
  // Support old format (hosts) and new format (apiHosts)
  if (!backup.apiHosts && backup.hosts) {
    backup.apiHosts = backup.hosts;
  }
  
  return backup;
}

// Show diff
function showDiff(oldHosts, newHosts) {
  const oldSet = new Set(oldHosts.map(h => `${h.name}:${h.type}:${h.address}`));
  const newSet = new Set(newHosts.map(h => `${h.name}:${h.type}:${h.address}`));

  const added = newHosts.filter(h => !oldSet.has(`${h.name}:${h.type}:${h.address}`));
  const removed = oldHosts.filter(h => !newSet.has(`${h.name}:${h.type}:${h.address}`));

  if (added.length === 0 && removed.length === 0) {
    console.log('✅ No changes');
    return false;
  }

  console.log('\n📋 Changes:');
  if (added.length > 0) {
    console.log('\n  ➕ Added:');
    added.forEach(h => console.log(`     ${h.type.padEnd(6)} ${h.name || '@'} → ${h.address}`));
  }
  if (removed.length > 0) {
    console.log('\n  ➖ Removed:');
    removed.forEach(h => console.log(`     ${h.type.padEnd(6)} ${h.name || '@'} → ${h.address}`));
  }
  console.log();

  return true;
}

// Parse record arg (format: "host=value" or "host=priority value" for MX)
function parseRecord(arg, type) {
  const [name, ...valueParts] = arg.split('=');
  const value = valueParts.join('=');

  if (type === 'MX') {
    // Format: "host=10 mx.example.com"
    const match = value.match(/^(\d+)\s+(.+)$/);
    if (!match) {
      console.error(`❌ Invalid MX format: ${arg}`);
      console.error('   Expected: host=10 mx.example.com');
      process.exit(1);
    }
    return { name, mxPref: match[1], address: match[2] };
  }

  return { name, address: value };
}

// Commands

async function cmdVerify() {
  if (!domain) {
    console.error('Usage: namecheap-dns verify <domain>');
    process.exit(1);
  }

  await verifyDNS(domain, false);
}

async function cmdList() {
  if (!domain) {
    console.error('Usage: namecheap-dns list <domain>');
    process.exit(1);
  }

  console.log(`🔍 Fetching DNS records for ${domain}...`);
  const hosts = await getHosts(domain);

  if (hosts.length === 0) {
    console.log('  (no records)');
    return;
  }

  console.log(`\n📋 Current records (${hosts.length}):\n`);
  hosts.forEach(h => {
    const name = h.name || '@';
    const mxPref = h.type === 'MX' ? ` [${h.mxPref}]` : '';
    console.log(`  ${h.type.padEnd(6)} ${name.padEnd(25)} → ${h.address}${mxPref}`);
  });
  console.log();
}

async function cmdAdd() {
  if (!domain) {
    console.error('Usage: namecheap-dns add <domain> [options]');
    console.error('Options:');
    console.error('  --txt "host=value"');
    console.error('  --cname "host=target"');
    console.error('  --mx "host=10 mx.example.com"');
    console.error('  --a "host=1.2.3.4"');
    console.error('  --dry-run');
    console.error('  --force     (override safety check for ghost records)');
    process.exit(1);
  }

  const dryRun = args.includes('--dry-run');
  const force = args.includes('--force');
  const newRecords = [];

  for (let i = 2; i < args.length; i++) {
    const arg = args[i];
    if (arg === '--dry-run' || arg === '--force') continue;

    const nextArg = args[i + 1];
    if (!nextArg || nextArg.startsWith('--')) {
      console.error(`❌ Missing value for ${arg}`);
      process.exit(1);
    }

    const type = arg.replace('--', '').toUpperCase();
    const parsed = parseRecord(nextArg, type);

    newRecords.push({
      name: parsed.name === '@' ? '' : parsed.name,
      type,
      address: parsed.address,
      mxPref: parsed.mxPref || '10',
      ttl: '1800',
    });

    i++; // skip next arg
  }

  if (newRecords.length === 0) {
    console.error('❌ No records to add');
    process.exit(1);
  }

  console.log(`🔍 Fetching current DNS records for ${domain}...`);
  const currentHosts = await getHosts(domain);

  // Merge (skip duplicates)
  const merged = [...currentHosts];
  newRecords.forEach(newRec => {
    const exists = currentHosts.some(
      h => h.name === newRec.name && h.type === newRec.type && h.address === newRec.address
    );
    if (!exists) {
      merged.push(newRec);
    }
  });

  const hasChanges = showDiff(currentHosts, merged);
  if (!hasChanges) {
    console.log('✅ All records already exist, nothing to do');
    return;
  }

  if (dryRun) {
    console.log('🔍 Dry-run mode: No changes applied');
    return;
  }

  // Safety check for ghost records
  const safe = await safetyCheck(domain, force);
  if (!safe) {
    process.exit(1);
  }

  // Create backup before applying
  createBackup(domain, currentHosts);

  console.log('⚙️  Applying changes...');
  await setHosts(domain, merged);
  console.log('✅ DNS records updated successfully');
}

async function cmdRemove() {
  if (!domain) {
    console.error('Usage: namecheap-dns remove <domain> --host <name> --type <type> [--dry-run] [--force]');
    process.exit(1);
  }

  const hostIdx = args.indexOf('--host');
  const typeIdx = args.indexOf('--type');
  const dryRun = args.includes('--dry-run');
  const force = args.includes('--force');

  if (hostIdx === -1 || typeIdx === -1) {
    console.error('❌ Missing --host or --type');
    process.exit(1);
  }

  const hostName = args[hostIdx + 1] === '@' ? '' : args[hostIdx + 1];
  const hostType = args[typeIdx + 1].toUpperCase();

  console.log(`🔍 Fetching current DNS records for ${domain}...`);
  const currentHosts = await getHosts(domain);

  const filtered = currentHosts.filter(h => !(h.name === hostName && h.type === hostType));

  const hasChanges = showDiff(currentHosts, filtered);
  if (!hasChanges) {
    console.log('✅ Record not found, nothing to remove');
    return;
  }

  if (dryRun) {
    console.log('🔍 Dry-run mode: No changes applied');
    return;
  }

  // Safety check for ghost records
  const safe = await safetyCheck(domain, force);
  if (!safe) {
    process.exit(1);
  }

  // Create backup before applying
  createBackup(domain, currentHosts);

  console.log('⚙️  Applying changes...');
  await setHosts(domain, filtered);
  console.log('✅ DNS records updated successfully');
}

async function cmdBackup() {
  if (!domain) {
    console.error('Usage: namecheap-dns backup <domain>');
    process.exit(1);
  }

  console.log(`🔍 Fetching current DNS records for ${domain}...`);
  const hosts = await getHosts(domain);
  createBackup(domain, hosts);
}

async function cmdBackups() {
  if (!domain) {
    console.error('Usage: namecheap-dns backups <domain>');
    process.exit(1);
  }

  const backups = listBackups(domain);
  if (backups.length === 0) {
    console.log(`📦 No backups found for ${domain}`);
    return;
  }

  console.log(`\n📦 Backups for ${domain} (${backups.length}):\n`);
  backups.forEach((b, i) => {
    const label = i === 0 ? ' [latest]' : '';
    console.log(`  ${b}${label}`);
  });
  console.log();
}

async function cmdRestore() {
  if (!domain) {
    console.error('Usage: namecheap-dns restore <domain> [--backup <filename>] [--force]');
    process.exit(1);
  }

  const backupIdx = args.indexOf('--backup');
  const force = args.includes('--force');
  let backupFile;

  if (backupIdx === -1) {
    // Use latest
    const backups = listBackups(domain);
    if (backups.length === 0) {
      console.error(`❌ No backups found for ${domain}`);
      process.exit(1);
    }
    backupFile = backups[0];
    console.log(`📦 Using latest backup: ${backupFile}`);
  } else {
    backupFile = args[backupIdx + 1];
  }

  const backup = loadBackup(backupFile);
  console.log(`📦 Loaded backup from ${backup.timestamp}`);

  console.log(`🔍 Fetching current DNS records for ${domain}...`);
  const currentHosts = await getHosts(domain);

  const hasChanges = showDiff(currentHosts, backup.apiHosts);
  if (!hasChanges) {
    console.log('✅ DNS already matches backup, nothing to restore');
    return;
  }

  console.log('⚠️  About to restore DNS from backup. Continue? (Ctrl+C to cancel)');
  await new Promise(resolve => setTimeout(resolve, 3000));

  // Safety check for ghost records
  const safe = await safetyCheck(domain, force);
  if (!safe) {
    process.exit(1);
  }

  // Create backup of current state before restoring
  createBackup(domain, currentHosts);

  console.log('⚙️  Restoring from backup...');
  await setHosts(domain, backup.apiHosts);
  console.log('✅ DNS records restored successfully');
}

// Main
(async () => {
  try {
    switch (command) {
      case 'verify':
        await cmdVerify();
        break;
      case 'list':
        await cmdList();
        break;
      case 'add':
        await cmdAdd();
        break;
      case 'remove':
        await cmdRemove();
        break;
      case 'backup':
        await cmdBackup();
        break;
      case 'backups':
        await cmdBackups();
        break;
      case 'restore':
        await cmdRestore();
        break;
      default:
        console.log('Namecheap DNS Manager v1.1.0');
        console.log('');
        console.log('Usage: namecheap-dns <command> [options]');
        console.log('');
        console.log('Commands:');
        console.log('  verify <domain>                  Verify DNS and detect ghost records');
        console.log('  list <domain>                    List current DNS records (API only)');
        console.log('  add <domain> [options]           Add DNS records (merges with existing)');
        console.log('  remove <domain> [options]        Remove DNS records');
        console.log('  backup <domain>                  Create manual backup (includes DNS snapshot)');
        console.log('  backups <domain>                 List available backups');
        console.log('  restore <domain> [options]       Restore from backup');
        console.log('');
        console.log('Safety Options:');
        console.log('  --force                          Override ghost record safety check');
        console.log('  --dry-run                        Preview changes without applying');
        console.log('');
        console.log('Examples:');
        console.log('  namecheap-dns verify example.com');
        console.log('  namecheap-dns list example.com');
        console.log('  namecheap-dns add example.com --txt "mail=v=spf1 include:mailgun.org ~all"');
        console.log('  namecheap-dns add example.com --cname "www=example.com" --dry-run');
        console.log('  namecheap-dns add example.com --txt "test=value" --force');
        console.log('  namecheap-dns remove example.com --host "old" --type "TXT"');
        console.log('  namecheap-dns restore example.com');
        process.exit(command ? 1 : 0);
    }
  } catch (error) {
    console.error('❌ Fatal error:', error.message);
    process.exit(1);
  }
})();


path: package-lock.json
content: {
  "name": "namecheap-dns",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "namecheap-dns",
      "version": "1.0.0",
      "dependencies": {
        "axios": "^1.6.0"
      },
      "bin": {
        "namecheap-dns": "namecheap-dns.js"
      }
    },
    "node_modules/asynckit": {
      "version": "0.4.0",
      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/axios": {
      "version": "1.13.5",
      "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "follow-redirects": "^1.15.11",
        "form-data": "^4.0.5",
        "proxy-from-env": "^1.1.0"
      }
    },
    "node_modules/call-bind-apply-helpers": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "function-bind": "^1.1.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/combined-stream": {
      "version": "1.0.8",
      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "delayed-stream": "~1.0.0"
      },
      "engines": {
        "node": ">= 0.8"
      }
    },
    "node_modules/delayed-stream": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=0.4.0"
      }
    },
    "node_modules/dunder-proto": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "call-bind-apply-helpers": "^1.0.1",
        "es-errors": "^1.3.0",
        "gopd": "^1.2.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-define-property": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-errors": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-object-atoms": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-set-tostringtag": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "get-intrinsic": "^1.2.6",
        "has-tostringtag": "^1.0.2",
        "hasown": "^2.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/follow-redirects": {
      "version": "1.15.11",
      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "individual",
          "url": "https://github.com/sponsors/RubenVerborgh"
        }
      ],
      "license": "MIT",
      "engines": {
        "node": ">=4.0"
      },
      "peerDependenciesMeta": {
        "debug": {
          "optional": true
        }
      }
    },
    "node_modules/form-data": {
      "version": "4.0.5",
      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "asynckit": "^0.4.0",
        "combined-stream": "^1.0.8",
        "es-set-tostringtag": "^2.1.0",
        "hasown": "^2.0.2",
        "mime-types": "^2.1.12"
      },
      "engines": {
        "node": ">= 6"
      }
    },
    "node_modules/function-bind": {
      "version": "1.1.2",
      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/get-intrinsic": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "call-bind-apply-helpers": "^1.0.2",
        "es-define-property": "^1.0.1",
        "es-errors": "^1.3.0",
        "es-object-atoms": "^1.1.1",
        "function-bind": "^1.1.2",
        "get-proto": "^1.0.1",
        "gopd": "^1.2.0",
        "has-symbols": "^1.1.0",
        "hasown": "^2.0.2",
        "math-intrinsics": "^1.1.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/get-proto": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "dunder-proto": "^1.0.1",
        "es-object-atoms": "^1.0.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/gopd": {
      "version": "1.2.0",
      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/has-symbols": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/has-tostringtag": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "has-symbols": "^1.0.3"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/hasown": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "function-bind": "^1.1.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/math-intrinsics": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/mime-db": {
      "version": "1.52.0",
      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/mime-types": {
      "version": "2.1.35",
      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "mime-db": "1.52.0"
      },
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/proxy-from-env": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    }
  }
}


path: package.json
content: {
  "name": "namecheap-dns",
  "version": "1.1.0",
  "description": "Safe DNS management for Namecheap domains with ghost record detection",
  "main": "namecheap-dns.js",
  "type": "module",
  "dependencies": {
    "axios": "^1.6.0"
  },
  "bin": {
    "namecheap-dns": "./namecheap-dns.js"
  }
}


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: suspicious | SkillSpector: suspicious (MEDIUM)