Skip to content
Open
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
59 changes: 55 additions & 4 deletions src/commands/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import { domainDeps, domainTarget, setDomain, checkDomain, removeDomain, type Do
type Quote = { domainName: string; purchasable: boolean; priceCents?: number; renewalPriceCents?: number; reason?: string }
type Order = { id: string; domainName: string; years: number; status: string; priceCents: number; renewalPriceCents: number | null; checkoutUrl?: string; failedReason: string | null }
type HostnameState = { hostname: string; state: string; reason?: string; service: string | null }
type Purchased = { domainName: string; status: string; hostnames: HostnameState[]; expiresAt: string | null; autorenew: boolean }
type Purchased = { domainName: string; status: string; hostnames: HostnameState[]; expiresAt: string | null; autorenew: boolean; locked: boolean; nameservers: string[]; delegated: boolean; transferLockExpiresAt: string | null }
type DnsRecord = { id: number; type: string; fqdn: string; answer: string; ttl: number; priority?: number; managed: boolean; hostname?: string }

type RecordsOpts = { org?: string; json?: boolean }

const usd = (cents: number): string => `$${(cents / 100).toFixed(2)}`

// --org wins; otherwise the linked project's org — the org verbs must not force a project link.
Expand Down Expand Up @@ -100,12 +102,19 @@ export async function domainAttach(host: string, opts: { branch?: string; group?
function domainLines(d: Purchased, linked = true): string[] {
const out = [`${d.domainName} ${d.status}${d.expiresAt ? ` (expires ${d.expiresAt.slice(0, 10)}${d.autorenew ? ', auto-renews' : ''})` : ''}`]
// Vacuously true for a domain with no hostnames, which is every domain until something attaches.
if (linked && d.hostnames.every((h) => h.state === 'failed')) {
// Not while delegated: the platform fails every hostname on the way out, and refuses the attach.
if (linked && !d.delegated && d.hostnames.every((h) => h.state === 'failed')) {
const names = d.hostnames.map((h) => h.hostname)
// Attaching the bought name itself re-attaches its www.
const retry = names.includes(d.domainName) ? names.filter((h) => h !== `www.${d.domainName}`) : names
out.push(` nothing serving — ${(retry.length ? retry : [d.domainName]).map((h) => `insta domain attach ${h}`).join('; ')}`)
}
// The zone answers elsewhere, so nothing published here resolves and an attach is refused. The
// repair is a next action, so it follows `linked` like the others: under --org it would name this org.
if (d.delegated) {
out.push(` delegated to ${d.nameservers.join(', ')}${linked ? '' : ' — attach is refused'}`)
if (linked) out.push(` attach is refused until: insta domain nameservers reset ${d.domainName}`)
}
const w = Math.max(0, ...d.hostnames.map((x) => x.hostname.length))
for (const h of d.hostnames) out.push(` ${h.hostname.padEnd(w)} ${h.state}${h.service ? ` → ${h.service}` : ''}${h.reason ? ` — ${h.reason}` : ''}`)
return out
Expand Down Expand Up @@ -139,9 +148,51 @@ export async function domainStatus(name: string, opts: { org?: string; json?: bo
for (const line of domain ? domainLines(domain, !opts.org) : orderStatusLines(order!, !opts.org)) info(line)
}

// ---- records ----
// ---- nameservers and transferring out ----

type RecordsOpts = { org?: string; json?: boolean }
const domainPath = (orgId: string, domainName: string): string =>
`/orgs/${orgId}/domains/${encodeURIComponent(domainName)}`

export async function domainNameserversSet(domainName: string, hosts: string[], opts: RecordsOpts, deps?: DomainDeps): Promise<void> {
const nameservers = hosts.flatMap((h) => h.split(/[\s,]+/)).map((h) => h.replace(/\.$/, '')).filter(Boolean)
if (!nameservers.length) die("name at least one nameserver, or `insta domain nameservers reset <domain>` to restore the registrar's own")
const { api, orgId } = await orgDeps(opts, deps)
const r = await api.request<Purchased>('PUT', `${domainPath(orgId, domainName)}/nameservers`, { nameservers })
if (opts.json) return printJson(r)
for (const line of domainLines(r, !opts.org)) info(line)
}

export async function domainNameserversReset(domainName: string, opts: RecordsOpts, deps?: DomainDeps): Promise<void> {
const { api, orgId } = await orgDeps(opts, deps)
const r = await api.request<Purchased>('DELETE', `${domainPath(orgId, domainName)}/nameservers`)
if (opts.json) return printJson(r)
for (const line of domainLines(r, !opts.org)) info(line)
}

export async function domainTransferLock(domainName: string, mode: string, opts: RecordsOpts, deps?: DomainDeps): Promise<void> {
if (mode !== 'on' && mode !== 'off') die('mode must be on or off')
const locked = mode === 'on'
const { api, orgId } = await orgDeps(opts, deps)
const r = await api.request<Purchased>('PATCH', domainPath(orgId, domainName), { locked })
if (opts.json) return printJson(r)
info(`${r.domainName} transfer lock ${r.locked ? 'on' : 'off'}`)
if (!r.locked) {
// ICANN's post-registration lock outranks this one and nothing here can waive it.
if (r.transferLockExpiresAt && new Date(r.transferLockExpiresAt).getTime() > Date.now()) {
info(` ICANN holds the registration until ${r.transferLockExpiresAt.slice(0, 10)} whatever this says`)
}
if (!opts.org) info(` authorization code: insta domain transfer code ${r.domainName}`)
}
}

export async function domainTransferCode(domainName: string, opts: RecordsOpts, deps?: DomainDeps): Promise<void> {
const { api, orgId } = await orgDeps(opts, deps)
const r = await api.request<{ authCode: string }>('POST', `${domainPath(orgId, domainName)}/auth-code`)
if (opts.json) return printJson(r)
info(r.authCode)
}

// ---- records ----

export function recordLines(records: DnsRecord[]): string[] {
const w = (pick: (r: DnsRecord) => string) => Math.max(...records.map((r) => pick(r).length))
Expand Down
16 changes: 16 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,22 @@ dom.command('list').description("Domains bought through InstaCloud in this org
dom.command('status <name>').description("A bought domain's order and attach state")
.option('--org <id>', "target org (default: linked project's org)").option('--json')
.action(guard((name, o) => domainCmd.domainStatus(name, o)))
const ns = dom.command('nameservers').description("Delegate a bought domain's zone to nameservers you name, or put it back on InstaCloud's registrar")
ns.command('set <domain> <nameservers...>').description("Delegate the zone — the nameservers must already host it. Every hostname the domain serves stops answering, unless they are the registrar's own: the records an attach published live in the zone you are leaving")
.option('--org <id>', "target org (default: linked project's org)").option('--json')
.action(guard((domain, hosts, o) => domainCmd.domainNameserversSet(domain, hosts, o)))
ns.command('reset <domain>').description("Put the zone back on the registrar's own nameservers; a hostname it took down is re-attached with `insta domain attach`")
.option('--org <id>', "target org (default: linked project's org)").option('--json')
.action(guard((domain, o) => domainCmd.domainNameserversReset(domain, o)))

const xfer = dom.command('transfer').description('Take a bought domain to another registrar — open the lock, then read the code (all three need org admin)')
xfer.command('lock <domain> <mode>').description("Open or close the registrar transfer lock (mode: on|off). ICANN's own 60-day lock on a new registration outranks it")
.option('--org <id>', "target org (default: linked project's org)").option('--json')
.action(guard((domain, mode, o) => domainCmd.domainTransferLock(domain, mode, o)))
xfer.command('code <domain>').description('The EPP authorization code the gaining registrar asks for — it moves nothing on its own')
.option('--org <id>', "target org (default: linked project's org)").option('--json')
.action(guard((domain, o) => domainCmd.domainTransferCode(domain, o)))

const rec = dom.command('records').description('DNS records of a bought domain — the zone InstaCloud holds at the registrar')
rec.command('list <domain>').description('Every record in the zone, managed ones marked')
.option('--org <id>', "target org (default: linked project's org)").option('--json')
Expand Down
136 changes: 135 additions & 1 deletion test/domain.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach, afterAll } from 'vitest'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new commands domainNameserversSet, domainNameserversReset, and domainTransferLock each print the full Purchased domain object under --json, but none of the added tests exercises those --json branches, while the PR calls out JSON shape as an addressed edge case. Add a --json test for each (e.g. asserting JSON.parse(out()) equals the returned domain) so the JSON contract of the new commands is locked down.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/domain.test.ts, line 422:

<comment>The new commands `domainNameserversSet`, `domainNameserversReset`, and `domainTransferLock` each print the full `Purchased` domain object under `--json`, but none of the added tests exercises those `--json` branches, while the PR calls out JSON shape as an addressed edge case. Add a `--json` test for each (e.g. asserting `JSON.parse(out())` equals the returned domain) so the JSON contract of the new commands is locked down.</comment>

<file context>
@@ -319,3 +319,107 @@ describe('domain records', () => {
+  // The one new shape that is not a purchased domain.
+  it('answers --json with the code object, not the domain', async () => {
+    const { deps: d } = deps({ '/auth-code': { authCode: 'EPP-123' } })
+    await domainTransferCode('myapp.com', { json: true }, d)
+    expect(JSON.parse(out())).toEqual({ authCode: 'EPP-123' })
+  })
</file context>

import { domainSearch, domainBuy, domainAttach, domainCheck, domainDetach, domainList, domainStatus, domainRecordsAdd, domainRecordsList, domainRecordsRemove, domainRecordsSet, ownerOf, searchLines } from '../src/commands/domain.js'
import { domainSearch, domainBuy, domainAttach, domainCheck, domainDetach, domainList, domainNameserversReset, domainNameserversSet, domainStatus, domainTransferCode, domainTransferLock, domainRecordsAdd, domainRecordsList, domainRecordsRemove, domainRecordsSet, ownerOf, searchLines } from '../src/commands/domain.js'
import type { DomainDeps } from '../src/commands/compute.js'

const services = [
Expand Down Expand Up @@ -319,3 +319,137 @@ describe('domain records', () => {
expect(JSON.parse(stdout.at(-1)!)).toEqual({ ok: true })
})
})

const bought = (over: Record<string, unknown> = {}) => ({
domainName: 'myapp.com',
status: 'active',
hostnames: [{ hostname: 'www.myapp.com', state: 'active', service: 'web' }],
expiresAt: null,
autorenew: true,
locked: true,
nameservers: [],
delegated: false,
transferLockExpiresAt: null,
...over,
})

describe('domain nameservers', () => {
it('sends one list however the nameservers were separated, trailing dots and all', async () => {
const { deps: d, calls } = deps({ '/nameservers': bought() })
await domainNameserversSet('myapp.com', ['kate.ns.cloudflare.com.,rob.ns.cloudflare.com'], {}, d)
expect(calls[0]).toMatchObject({
method: 'PUT',
path: '/orgs/org1/domains/myapp.com/nameservers',
body: { nameservers: ['kate.ns.cloudflare.com', 'rob.ns.cloudflare.com'] },
})
})

it('refuses an empty list rather than sending one, and names the way back', async () => {
const { deps: d, calls } = deps({})
await expect(domainNameserversSet('myapp.com', [' '], {}, d)).rejects.toThrow('exit 1')
expect(calls).toEqual([])
expect(stderr.join('')).toContain('nameservers reset')
})

// The answer is the domain, so the hostnames it just took down are printed by the one renderer
// `list` and `status` already use — reason included.
it('prints what the move took down, and why an attach is now refused', async () => {
const answer = bought({
nameservers: ['kate.ns.cloudflare.com'],
delegated: true,
hostnames: [{ hostname: 'www.myapp.com', state: 'failed', service: 'web', reason: 'myapp.com answers from kate.ns.cloudflare.com' }],
})
const { deps: d } = deps({ '/nameservers': answer })
await domainNameserversSet('myapp.com', ['kate.ns.cloudflare.com'], {}, d)
expect(out()).toContain('delegated to kate.ns.cloudflare.com')
expect(out()).toContain('insta domain nameservers reset myapp.com')
expect(out()).toContain('www.myapp.com failed → web — myapp.com answers from kate.ns.cloudflare.com')
})

// The platform fails every hostname on the way out, so the "nothing serving" retry would fire on
// the success path and name an attach the same answer says is refused.
it('does not offer the attach it just made impossible', async () => {
const answer = bought({
nameservers: ['kate.ns.cloudflare.com'],
delegated: true,
hostnames: [{ hostname: 'www.myapp.com', state: 'failed', service: 'web' }],
})
const { deps: d } = deps({ '/nameservers': answer })
await domainNameserversSet('myapp.com', ['kate.ns.cloudflare.com'], {}, d)
expect(out()).not.toContain('insta domain attach')
expect(out()).toContain('insta domain nameservers reset myapp.com')
})

// `--org` names an org the printed commands would not act on, so they are dropped, not rewritten.
it('drops the follow-up commands when --org names another org', async () => {
const answer = bought({ nameservers: ['kate.ns.cloudflare.com'], delegated: true, hostnames: [] })
const { deps: d } = deps({ '/nameservers': answer })
await domainNameserversSet('myapp.com', ['kate.ns.cloudflare.com'], { org: 'org2' }, d)
expect(out()).toContain('delegated to kate.ns.cloudflare.com — attach is refused')
expect(out()).not.toContain('insta domain nameservers reset')
})

it('restores the registrar own, and then says nothing about being delegated', async () => {
const { deps: d, calls } = deps({ '/nameservers': bought() })
await domainNameserversReset('myapp.com', {}, d)
expect(calls[0]).toMatchObject({ method: 'DELETE', path: '/orgs/org1/domains/myapp.com/nameservers' })
expect(out()).not.toContain('delegated to')
})
})

describe('domain transfer', () => {
it('opens the lock and points at the code, naming ICANN while it still holds the registration', async () => {
const held = new Date(Date.now() + 86_400_000).toISOString()
const { deps: d, calls } = deps({ '/domains/myapp.com': bought({ locked: false, transferLockExpiresAt: held }) })
await domainTransferLock('myapp.com', 'off', {}, d)
expect(calls[0]).toMatchObject({ method: 'PATCH', path: '/orgs/org1/domains/myapp.com', body: { locked: false } })
expect(out()).toContain('transfer lock off')
expect(out()).toContain(`ICANN holds the registration until ${held.slice(0, 10)}`)
expect(out()).toContain('insta domain transfer code myapp.com')
})

// The platform never clears the date, so an old domain always carries a past one.
it('does not announce an ICANN lock that lapsed', async () => {
const { deps: d } = deps({ '/domains/myapp.com': bought({ locked: false, transferLockExpiresAt: '2020-01-01T00:00:00.000Z' }) })
await domainTransferLock('myapp.com', 'off', {}, d)
expect(out()).not.toContain('ICANN holds')
expect(out()).toContain('insta domain transfer code myapp.com')
})

it('drops the code hint when --org names another org', async () => {
const { deps: d } = deps({ '/domains/myapp.com': bought({ locked: false }) })
await domainTransferLock('myapp.com', 'off', { org: 'org2' }, d)
expect(out()).toContain('transfer lock off')
expect(out()).not.toContain('insta domain transfer code')
})

it('closes it again, and then points at neither', async () => {
const held = new Date(Date.now() + 86_400_000).toISOString()
const { deps: d, calls } = deps({ '/domains/myapp.com': bought({ locked: true, transferLockExpiresAt: held }) })
await domainTransferLock('myapp.com', 'on', {}, d)
expect(calls[0]).toMatchObject({ body: { locked: true } })
expect(out()).toContain('transfer lock on')
expect(out()).not.toContain('transfer code')
expect(out()).not.toContain('ICANN holds')
})

it('refuses a mode that is not on or off, and sends nothing', async () => {
const { deps: d, calls } = deps({})
await expect(domainTransferLock('myapp.com', 'yes', {}, d)).rejects.toThrow('exit 1')
expect(calls).toEqual([])
})

it('reads the code with a POST, so an agent credential is refused rather than shown it', async () => {
const { deps: d, calls } = deps({ '/auth-code': { authCode: 'EPP-123' } })
await domainTransferCode('myapp.com', {}, d)
expect(calls[0]).toMatchObject({ method: 'POST', path: '/orgs/org1/domains/myapp.com/auth-code' })
expect(out()).toContain('EPP-123')
})

// The one new shape that is not a purchased domain.
it('answers --json with the code object, not the domain', async () => {
const { deps: d } = deps({ '/auth-code': { authCode: 'EPP-123' } })
await domainTransferCode('myapp.com', { json: true }, d)
expect(JSON.parse(out())).toEqual({ authCode: 'EPP-123' })
})
})
Loading