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
8 changes: 8 additions & 0 deletions src/commands/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,14 @@ export async function domainNameserversReset(domainName: string, opts: RecordsOp
for (const line of domainLines(r, !opts.org)) info(line)
}

export async function domainRenew(domainName: string, mode: string, opts: RecordsOpts, deps?: DomainDeps): Promise<void> {
if (mode !== 'on' && mode !== 'off') die('mode must be on or off')
const { api, orgId } = await orgDeps(opts, deps)
const r = await api.request<Purchased>('PATCH', domainPath(orgId, domainName), { autorenew: mode === 'on' })
if (opts.json) return printJson(r)
info(`${r.domainName} auto-renew ${r.autorenew ? 'on' : 'off'}`)
}

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'
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,9 @@ 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)))
dom.command('renew <domain> <mode>').description('Turn automatic renewal on or off for a bought domain (mode: on|off). Does not renew immediately. Agent credentials are refused; run with a human login')
.option('--org <id>', "target org (default: linked project's org)").option('--json')
.action(guard((domain, mode, o) => domainCmd.domainRenew(domain, mode, o)))
dom.command('delegate <domain>').description("Move a bought domain's DNS onto an InstaCloud-managed zone — the way an apex hostname gets a certificate and serves. Every record is copied first and the nameservers switch after, so serving hostnames stay up and ones that failed by delegating away revive on their own. While managed, `domain records` answers 409 for every verb (managed-zone record editing is not covered yet); `nameservers reset` is the way back (org admin; gated: domain.delegate — agent mode gates from a linked project, an unlinked --org call falls under org administration instead)")
.option('--org <id>', "target org (default: linked project's org)").option('--json')
.action(guard((domain, o) => domainCmd.domainDelegate(domain, o)))
Expand Down
47 changes: 47 additions & 0 deletions test/domain-renew.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { execFile } from 'node:child_process'
import { createServer } from 'node:http'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import type { AddressInfo } from 'node:net'
import { expect, it } from 'vitest'
import { cliVersion } from '../src/version.js'

it('domain renew sends only the selected renewal setting through the CLI', async () => {
const scratch = mkdtempSync(join(tmpdir(), 'insta-domain-renew-'))
const updateCache = join(scratch, 'update-check.json')
writeFileSync(updateCache, JSON.stringify({ checkedAt: Date.now(), latest: cliVersion() }))
const calls: Array<{ method: string; path: string; body: unknown }> = []
const response = { domainName: 'example.com', autorenew: false, locked: true }
const server = createServer((req, res) => {
let body = ''
req.on('data', chunk => { body += chunk })
req.on('end', () => {
calls.push({ method: req.method!, path: req.url!, body: body ? JSON.parse(body) : undefined })
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify(response))
})
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const env: NodeJS.ProcessEnv = {
...process.env, HOME: scratch, USERPROFILE: scratch,
INSTA_API_URL: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
INSTA_UPDATE_CACHE: updateCache, INSTA_NO_AUTOUPDATE: '1', INSTA_NO_TELEMETRY: '1',
}
for (const key of ['CODEX_THREAD_ID', 'CODEX_CI', 'CLAUDECODE', 'CURSOR_AGENT', 'INSTA_ENV']) delete env[key]
try {
const { stdout } = await promisify(execFile)(process.execPath, [
'--import', pathToFileURL(createRequire(import.meta.url).resolve('tsx')).href,
fileURLToPath(new URL('../src/index.ts', import.meta.url)),
'domain', 'renew', 'example.com', 'off', '--org', 'org9', '--json',
], { cwd: scratch, env, timeout: 15_000 })
expect(calls).toEqual([{ method: 'PATCH', path: '/orgs/org9/domains/example.com', body: { autorenew: false } }])
expect(JSON.parse(stdout)).toEqual(response)
} finally {
await new Promise<void>(resolve => server.close(() => resolve()))
rmSync(scratch, { recursive: true, force: true })
}
}, 20_000)
31 changes: 30 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'
import { domainSearch, domainBuy, domainAttach, domainCheck, domainDelegate, domainDetach, domainList, domainNameserversReset, domainNameserversSet, domainStatus, domainTransferCode, domainTransferLock, domainRecordsAdd, domainRecordsList, domainRecordsRemove, domainRecordsSet, ownerOf, searchLines } from '../src/commands/domain.js'
import { domainSearch, domainBuy, domainAttach, domainCheck, domainDelegate, domainDetach, domainList, domainNameserversReset, domainNameserversSet, domainStatus, domainRenew, 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 @@ -513,3 +513,32 @@ describe('domain transfer', () => {
expect(JSON.parse(out())).toEqual({ authCode: 'EPP-123' })
})
})


describe('domain renewal', () => {
it.each(['on', 'off'])('sets auto-renew %s without altering other settings', async (mode) => {
const autorenew = mode === 'on'
const { deps: d, calls } = deps({ '/domains/myapp.com': { domainName: 'myapp.com', autorenew } })
await domainRenew('myapp.com', mode, {}, d)
expect(calls).toEqual([{ method: 'PATCH', path: '/orgs/org1/domains/myapp.com', body: { autorenew } }])
expect(out()).toBe(`myapp.com auto-renew ${mode}\n`)
})
it('uses the selected org and encoded name and returns the platform JSON', async () => {
const response = { domainName: 'myapp.com', autorenew: false, locked: true }
const { deps: d, calls } = deps({ '/domains/myapp.com%2Fother': response })
await domainRenew('myapp.com/other', 'off', { org: 'org9', json: true }, d)
expect(calls).toEqual([{ method: 'PATCH', path: '/orgs/org9/domains/myapp.com%2Fother', body: { autorenew: false } }])
expect(JSON.parse(out())).toEqual(response)
})
it('prints the returned renewal setting', async () => {
const { deps: d } = deps({ '/domains/myapp.com': { domainName: 'myapp.com', autorenew: false } })
await domainRenew('myapp.com', 'on', {}, d)
expect(out()).toBe('myapp.com auto-renew off\n')
})
it('rejects an invalid mode before making any request', async () => {
const { deps: d, calls } = deps()
await expect(domainRenew('myapp.com', 'yes', {}, d)).rejects.toThrow('exit 1')
expect(calls).toEqual([])
expect(stderr.join('')).toContain('mode must be on or off')
})
})
Loading