diff --git a/src/commands/domain.ts b/src/commands/domain.ts index 475b8d5..576e502 100644 --- a/src/commands/domain.ts +++ b/src/commands/domain.ts @@ -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 { + if (mode !== 'on' && mode !== 'off') die('mode must be on or off') + const { api, orgId } = await orgDeps(opts, deps) + const r = await api.request('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 { if (mode !== 'on' && mode !== 'off') die('mode must be on or off') const locked = mode === 'on' diff --git a/src/index.ts b/src/index.ts index a0d44a2..4789eae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -304,6 +304,9 @@ dom.command('list').description("Domains bought through InstaCloud in this org dom.command('status ').description("A bought domain's order and attach state") .option('--org ', "target org (default: linked project's org)").option('--json') .action(guard((name, o) => domainCmd.domainStatus(name, o))) +dom.command('renew ').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 ', "target org (default: linked project's org)").option('--json') + .action(guard((domain, mode, o) => domainCmd.domainRenew(domain, mode, o))) dom.command('delegate ').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 ', "target org (default: linked project's org)").option('--json') .action(guard((domain, o) => domainCmd.domainDelegate(domain, o))) diff --git a/test/domain-renew.test.ts b/test/domain-renew.test.ts new file mode 100644 index 0000000..d1596a4 --- /dev/null +++ b/test/domain-renew.test.ts @@ -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(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(resolve => server.close(() => resolve())) + rmSync(scratch, { recursive: true, force: true }) + } +}, 20_000) diff --git a/test/domain.test.ts b/test/domain.test.ts index bb96950..8389ac9 100644 --- a/test/domain.test.ts +++ b/test/domain.test.ts @@ -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 = [ @@ -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') + }) +})