Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ parsed should not change shape when it fails.

### `porkbun`

DNS at Porkbun, without the dashboard.
Domains and DNS at Porkbun, without the dashboard.

```sh
porkbun ls example.com # the zone, apex first
Expand All @@ -471,6 +471,8 @@ porkbun set example.com @ ALIAS app.up.railway.app # apex, via Porkbun's A
porkbun rm example.com www --type CNAME --yes
porkbun unpark example.com # stop the parking page winning
porkbun domains # everything on the account
porkbun check example.com # available? at what price?
porkbun register example.com --max-price 20 # buy it
```

Write the host the way you would say it. `@`, an empty value and the bare domain
Expand Down Expand Up @@ -498,6 +500,22 @@ records pointing at `porkbun.com`. The `MX` records at `fwd1.porkbun.com` are
Porkbun's *email forwarding* and the `NS` records are the zone's delegation —
sweeping either up would break mail or take the domain off the internet.

`register` spends real money, so it is built to make that hard to do by accident.
It resolves one plan — the TLD's rules, then availability and price — prints it,
and asks; the number in the prompt is the number sent to the registrar, because
it is the same number. `--dry-run` prices it and stops, `--max-price` refuses
anything dearer (a premium name can be hundreds), and a promotional first year is
called out because it is not what you will pay next year. WHOIS privacy is on
unless you pass `--no-whois-privacy`, and a TLD that cannot do privacy is refused
rather than quietly publishing your address. It pays from the account balance,
topping up the card on file if that is short.

Two things about it. Availability is **rate limited to one check per ten seconds**,
which is why the price is fetched once and carried rather than re-checked just
before buying. And prices are quoted as dollar strings while `/domain/create`
wants integer cents that match the quote exactly — `parseFloat('11.08') * 100` is
`1107.9999999999998`, so the conversion is done as text and never becomes a float.

Two Porkbun-specific traps the errors call out by name. Every call is a `POST`
with the credentials in the body, and **`status` is a field rather than the HTTP
code**: a bad key, an unknown domain and a malformed record all return `200 OK`
Expand Down
96 changes: 94 additions & 2 deletions bin/porkbun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
MIN_TTL,
PorkbunError,
type RecordInput,
checkAvailability,
createRecord,
credentialsFrom,
deleteForward,
Expand All @@ -35,8 +36,11 @@ import {
listRecords,
matchRecords,
ping,
planRegistration,
planUnpark,
porkbunCaller,
priceCents,
registerDomain,
setRecord,
sortRecords,
} from '../src/porkbun.ts';
Expand All @@ -50,6 +54,8 @@ const USAGE = `Usage:
porkbun rm <domain> (<id> | <host> --type TYPE) [--yes]
porkbun forwards <domain> [--json]
porkbun unpark <domain> [--dry-run] [--yes]
porkbun check <domain> [--json]
porkbun register <domain> [--max-price N] [--no-whois-privacy] [--dry-run] [--yes]

Commands:
ping check the credentials and show the IP Porkbun sees
Expand All @@ -60,6 +66,8 @@ Commands:
rm delete by record id, or by host + --type
forwards list URL forwarding rules
unpark remove URL forwarding and the parking records it owns
check is a domain available, and what would it cost
register buy a domain — spends real money, so it confirms first

The host is written as you would say it: \`@\` or the bare domain for the apex,
\`www\` or \`www.example.com\` for a subdomain. Both forms mean the same record.
Expand All @@ -71,13 +79,21 @@ Options:
--prio N priority, for MX and SRV
--json raw JSON instead of a table
--dry-run for unpark: print what would be deleted, delete nothing
for register: price it and stop, buy nothing
--max-price N for register: refuse to spend more than N dollars
--no-whois-privacy
for register: publish your contact details (privacy is on by default)
--yes skip the confirmation prompt
-h, --help show this help

Credentials come from PORKBUN_API_KEY and PORKBUN_SECRET_API_KEY, via
\`cli-tools config pull\` or the environment. Porkbun also requires API access
to be switched on per domain, in the domain's settings — a key that pings fine
still gets "Invalid domain" until that is on.

\`register\` pays from the Porkbun account balance, topping up the card on file
if it is short. It registers for the TLD's minimum term with auto-renew on and
WHOIS privacy on, using the account's default contacts.
`;

function fail(message: string, code = 2): never {
Expand All @@ -101,8 +117,8 @@ async function confirm(question: string): Promise<boolean> {
if (isMain(import.meta.url)) {
try {
const parsed = parseArgs(process.argv.slice(2), {
boolean: ['--json', '--dry-run', '--yes', '-h', '--help'],
string: ['--type', '--name', '--ttl', '--prio'],
boolean: ['--json', '--dry-run', '--yes', '--no-whois-privacy', '-h', '--help'],
string: ['--type', '--name', '--ttl', '--prio', '--max-price'],
});

if (parsed.flags.has('-h') || parsed.flags.has('--help') || parsed.positional.length === 0) {
Expand Down Expand Up @@ -294,6 +310,82 @@ if (isMain(import.meta.url)) {
break;
}

case 'check': {
const domain = needDomain();
const availability = await checkAvailability(call, domain);

if (json) {
process.stdout.write(`${JSON.stringify(availability, null, 2)}\n`);
break;
}
if (!availability.available) {
process.stdout.write(`${domain} is taken\n`);
process.exit(1);
}
process.stdout.write(
`${domain} is available — ${availability.price}` +
`${availability.minDuration > 1 ? ` for ${availability.minDuration} years` : '/yr'}` +
`${availability.premium ? ' (premium)' : ''}\n` +
(availability.renewal && availability.renewal !== availability.price
? `renews at ${availability.renewal}/yr\n`
: ''),
);
break;
}

case 'register': {
const domain = needDomain();

// Priced in dollars because that is how the prompt reads it back;
// `integer` would reject the cents, so the shared money parser does it.
let maxCents: number | undefined;
const maxPrice = parsed.values.get('--max-price');
if (maxPrice !== undefined) {
try {
maxCents = priceCents(maxPrice.replace(/^\$/, ''));
} catch {
throw new UsageError(`--max-price must be an amount in dollars, got ${JSON.stringify(maxPrice)}`);
}
}

const plan = await planRegistration(call, domain, {
whoisPrivacy: !parsed.flags.has('--no-whois-privacy'),
...(maxCents === undefined ? {} : { maxCents }),
});

const term = plan.years && plan.years > 1 ? `${plan.years} years` : '1 year';
process.stderr.write(
` ${plan.domain} ${plan.price} for ${term}${plan.premium ? ' (premium)' : ''}\n` +
` whois privacy: ${plan.whoisPrivacy ? 'on' : 'OFF — your contacts will be public'}\n` +
` auto-renew: on${plan.renewal ? `, at ${plan.renewal}/yr` : ''}\n`,
);
// A first year that renews dearer is the one surprise worth shouting
// about: the price agreed to here is not the price paid next year.
if (plan.firstYearPromo) {
process.stderr.write(' note: promotional first year — the renewal price is higher\n');
}

if (parsed.flags.has('--dry-run')) {
process.stdout.write('--dry-run: nothing registered\n');
break;
}
if (!assumeYes && !(await confirm(`register ${plan.domain} for ${plan.price}?`))) {
process.stderr.write('cancelled\n');
process.exit(1);
}

await registerDomain(call, plan);
process.stdout.write(`registered ${plan.domain} for ${plan.price}\n`);
// API access is per-domain and off by default, so the very next thing
// anyone tries — pointing the new name somewhere — fails with "Invalid
// domain" until it is switched on. Say so before that happens.
process.stdout.write(
`turn on API access for it at https://porkbun.com/account/domainsSpeedy ` +
`before \`porkbun set ${plan.domain} ...\` will work\n`,
);
break;
}

default:
throw new UsageError(`unknown command: ${command}`);
}
Expand Down
Loading
Loading