diff --git a/README.md b/README.md index 12349b3..557082c 100644 --- a/README.md +++ b/README.md @@ -543,6 +543,61 @@ npx @insforge/cli deployments env delete # delete a variable --- +### Domains — `npx @insforge/cli domains` + +Register a domain through the user's Cloudflare account, attach it to the linked InsForge deployment, sync Cloudflare DNS records, and verify SSL/custom domain readiness. + +Cloudflare is connected through OAuth and saved locally in `~/.insforge/cloudflare.json`: + +```bash +npx @insforge/cli domains cloudflare login +npx @insforge/cli domains cloudflare login --account-id # skip account selection +``` + +The CLI opens Cloudflare in the browser, receives the OAuth callback on +`http://127.0.0.1:8787/callback`, stores the returned Cloudflare tokens locally, +and discovers the Cloudflare account selected during authorization. For +non-browser environments, pass `--skip-browser` and open the printed URL +manually. `CLOUDFLARE_ACCOUNT_ID` / `CLOUDFLARE_ACCESS_TOKEN` can override the +local OAuth credentials for automation. + +Use the split commands when you want to inspect or resume a workflow: + +```bash +npx @insforge/cli domains search my-app +npx @insforge/cli domains search my-app --tlds com,app,dev # optional local filter +npx @insforge/cli domains check my-app.dev +npx @insforge/cli domains buy my-app.dev +npx @insforge/cli domains attach my-app.dev +npx @insforge/cli domains dns sync my-app.dev +npx @insforge/cli domains verify my-app.dev +npx @insforge/cli domains status my-app.dev --cloudflare +``` + +Cloudflare only allows programmatic registration for TLDs currently supported +by its Registrar API. The CLI surfaces Cloudflare's availability reason when a +TLD is dashboard-only or not supported by the API. + +For agent runs, use explicit purchase confirmations. The global `--yes` flag does not bypass domain purchase confirmation: + +```bash +npx @insforge/cli domains buy-and-attach my-app.dev \ + --confirm-domain my-app.dev \ + --confirm-price 10.11 \ + --confirm-currency USD \ + --confirm-cloudflare-billing \ + --confirm-non-refundable \ + --json +``` + +If Cloudflare registration is still in progress, retry with: + +```bash +npx @insforge/cli domains resume my-app.dev +``` + +--- + ### Payments — `npx @insforge/cli payments` Manage the payments foundation for the linked InsForge project. Provider-specific commands live under `payments stripe` and `payments razorpay`. These commands are intended for developers and agents configuring provider keys, syncing mirrored provider state, inspecting customers, and managing provider catalog records. Runtime checkout/order/subscription calls should usually be made from the app via the SDK. diff --git a/package-lock.json b/package-lock.json index 516a1ef..7fa9def 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@insforge/cli", - "version": "0.1.89", + "version": "0.1.90", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@insforge/cli", - "version": "0.1.89", + "version": "0.1.90", "license": "Apache-2.0", "dependencies": { "@clack/prompts": "^0.9.1", diff --git a/package.json b/package.json index 2ad582a..6e2c85b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@insforge/cli", - "version": "0.1.89", + "version": "0.1.90", "description": "InsForge CLI - Command line tool for InsForge platform", "type": "module", "bin": { diff --git a/src/commands/domains/index.test.ts b/src/commands/domains/index.test.ts new file mode 100644 index 0000000..a6190e3 --- /dev/null +++ b/src/commands/domains/index.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { + buildDnsSetupRecords, + confirmPurchase, + hasExplicitPurchaseConfirmation, + type DnsSetupRecord, +} from './index.js'; + +describe('domains command helpers', () => { + it('builds apex routing and verification DNS records', () => { + const records = buildDnsSetupRecords({ + domain: 'example.com', + apexDomain: 'example.com', + verified: false, + misconfigured: false, + cnameTarget: null, + aRecordValue: '216.150.16.1', + verification: [ + { + type: 'TXT', + domain: '_vercel.example.com', + value: 'vc-domain-verify=example.com,abc123', + }, + ], + }); + + expect(records).toEqual([ + { + type: 'A', + name: 'example.com', + content: '216.150.16.1', + purpose: 'routing', + }, + { + type: 'TXT', + name: '_vercel.example.com', + content: 'vc-domain-verify=example.com,abc123', + purpose: 'verification', + }, + ]); + }); + + it('builds subdomain CNAME records', () => { + const records = buildDnsSetupRecords({ + domain: 'www.example.com', + apexDomain: 'example.com', + verified: false, + misconfigured: false, + cnameTarget: 'cname.vercel-dns.com', + aRecordValue: '216.150.16.1', + verification: [], + }); + + expect(records).toEqual([ + { + type: 'CNAME', + name: 'www.example.com', + content: 'cname.vercel-dns.com', + purpose: 'routing', + }, + ]); + }); + + it('requires all purchase confirmation fields to match exactly', () => { + const candidate = { + name: 'example.dev', + registrable: true, + pricing: { + currency: 'USD', + registration_cost: '10.11', + renewal_cost: '10.11', + }, + }; + + expect( + hasExplicitPurchaseConfirmation('example.dev', candidate, { + confirmDomain: 'example.dev', + confirmPrice: '10.11', + confirmCurrency: 'usd', + confirmCloudflareBilling: true, + confirmNonRefundable: true, + }), + ).toBe(true); + + expect( + hasExplicitPurchaseConfirmation('example.dev', candidate, { + confirmDomain: 'example.dev', + confirmPrice: '10.10', + confirmCurrency: 'USD', + confirmCloudflareBilling: true, + confirmNonRefundable: true, + }), + ).toBe(false); + }); + + it('rejects non-interactive purchases without explicit confirmation flags', async () => { + await expect(confirmPurchase('example.dev', { + name: 'example.dev', + registrable: true, + pricing: { + currency: 'USD', + registration_cost: '10.11', + renewal_cost: '10.11', + }, + }, {})).rejects.toMatchObject({ + code: 'DOMAIN_PURCHASE_CONFIRMATION_REQUIRED', + }); + }); +}); diff --git a/src/commands/domains/index.ts b/src/commands/domains/index.ts new file mode 100644 index 0000000..0372541 --- /dev/null +++ b/src/commands/domains/index.ts @@ -0,0 +1,667 @@ +import pc from 'picocolors'; +import type { Command } from 'commander'; +import * as prompts from '../../lib/prompts.js'; +import { ossFetch } from '../../lib/api/oss.js'; +import { + checkCloudflareDomains, + ensureCloudflareZone, + getCloudflareRegistration, + getCloudflareRegistrationStatus, + performCloudflareOAuthLogin, + registerCloudflareDomain, + searchCloudflareDomains, + upsertCloudflareDnsRecord, + type CloudflareAccount, + type CloudflareDomainCandidate, + type CloudflareRegistration, + type CloudflareRegistrationWorkflow, +} from '../../lib/cloudflare.js'; +import { requireAuth } from '../../lib/credentials.js'; +import { CLIError, getRootOpts, handleError } from '../../lib/errors.js'; +import { outputJson, outputSuccess, outputTable } from '../../lib/output.js'; +import { trackDomainUsage } from './telemetry.js'; + +interface DomainVerificationRecord { + type: string; + domain: string; + value: string; +} + +interface CustomDomain { + domain: string; + apexDomain: string; + verified: boolean; + misconfigured: boolean; + verification: DomainVerificationRecord[]; + cnameTarget: string | null; + aRecordValue: string | null; +} + +interface ListCustomDomainsResponse { + domains: CustomDomain[]; +} + +export interface DnsSetupRecord { + type: string; + name: string; + content: string; + purpose: 'routing' | 'verification'; +} + +interface PurchaseConfirmationOptions { + confirmDomain?: string; + confirmPrice?: string; + confirmCurrency?: string; + confirmCloudflareBilling?: boolean; + confirmNonRefundable?: boolean; +} + +interface DomainCommandOptions extends PurchaseConfirmationOptions { + accountId?: string; + skipBrowser?: boolean; + limit?: string; + tlds?: string; + pollSeconds?: string; + cloudflare?: boolean; +} + +function normalizeDomain(domain: string): string { + return domain.trim().toLowerCase().replace(/\.$/, ''); +} + +function parsePositiveInteger(value: string | undefined, option: string, fallback: number): number { + if (value === undefined) return fallback; + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new CLIError(`${option} must be a non-negative integer.`); + } + return parsed; +} + +function parseTlds(value: string): string[] { + return value + .split(',') + .map((entry) => entry.trim().toLowerCase().replace(/^\./, '')) + .filter(Boolean); +} + +function getTld(domain: string): string { + return domain.split('.').pop()?.toLowerCase() ?? ''; +} + +function formatPrice(candidate: CloudflareDomainCandidate): string { + if (!candidate.pricing) return '-'; + return `${candidate.pricing.currency} ${candidate.pricing.registration_cost}`; +} + +function formatRenewal(candidate: CloudflareDomainCandidate): string { + if (!candidate.pricing) return '-'; + return `${candidate.pricing.currency} ${candidate.pricing.renewal_cost}`; +} + +function assertRegistrable(candidate: CloudflareDomainCandidate | undefined, domain: string): CloudflareDomainCandidate { + if (!candidate) { + throw new CLIError(`Cloudflare did not return availability for ${domain}.`, 1, 'DOMAIN_CHECK_MISSING'); + } + if (!candidate.registrable) { + throw new CLIError( + `${domain} is not registrable${candidate.reason ? `: ${candidate.reason}` : ''}.`, + 1, + 'DOMAIN_NOT_REGISTRABLE', + ); + } + if (!candidate.pricing) { + throw new CLIError(`Cloudflare did not return pricing for ${domain}.`, 1, 'DOMAIN_PRICE_MISSING'); + } + return candidate; +} + +export function hasExplicitPurchaseConfirmation( + domain: string, + candidate: CloudflareDomainCandidate, + opts: PurchaseConfirmationOptions, +): boolean { + return opts.confirmDomain === domain && + opts.confirmPrice === candidate.pricing?.registration_cost && + opts.confirmCurrency?.toUpperCase() === candidate.pricing?.currency.toUpperCase() && + opts.confirmCloudflareBilling === true && + opts.confirmNonRefundable === true; +} + +export async function confirmPurchase( + domain: string, + candidate: CloudflareDomainCandidate, + opts: PurchaseConfirmationOptions, +): Promise { + if (hasExplicitPurchaseConfirmation(domain, candidate, opts)) { + return; + } + + if (!prompts.isInteractive) { + throw new CLIError( + [ + 'Domain registration requires explicit confirmation.', + `Re-run with --confirm-domain ${domain}`, + `--confirm-price ${candidate.pricing?.registration_cost}`, + `--confirm-currency ${candidate.pricing?.currency}`, + '--confirm-cloudflare-billing', + '--confirm-non-refundable', + ].join(' '), + 1, + 'DOMAIN_PURCHASE_CONFIRMATION_REQUIRED', + ); + } + + const confirmed = await prompts.confirm({ + message: `Register ${domain} for ${formatPrice(candidate)}? Cloudflare will charge the account default payment method and successful registrations are non-refundable.`, + initialValue: false, + }); + if (prompts.isCancel(confirmed) || !confirmed) { + throw new CLIError('Domain registration cancelled.', 1, 'DOMAIN_PURCHASE_CANCELLED'); + } +} + +export function buildDnsSetupRecords(domain: CustomDomain): DnsSetupRecord[] { + const records: DnsSetupRecord[] = []; + if (domain.domain === domain.apexDomain && domain.aRecordValue) { + records.push({ + type: 'A', + name: domain.apexDomain, + content: domain.aRecordValue, + purpose: 'routing', + }); + } + if (domain.domain !== domain.apexDomain && domain.cnameTarget) { + records.push({ + type: 'CNAME', + name: domain.domain, + content: domain.cnameTarget, + purpose: 'routing', + }); + } + for (const record of domain.verification) { + records.push({ + type: record.type, + name: record.domain, + content: record.value, + purpose: 'verification', + }); + } + return records; +} + +async function getInsForgeCustomDomain(domain: string): Promise { + const res = await ossFetch('/api/deployments/domains'); + const data = await res.json() as ListCustomDomainsResponse; + return data.domains.find((entry) => normalizeDomain(entry.domain) === domain) ?? null; +} + +async function attachInsForgeCustomDomain(domain: string): Promise { + try { + const res = await ossFetch('/api/deployments/domains', { + method: 'POST', + body: JSON.stringify({ domain }), + }); + return await res.json() as CustomDomain; + } catch (err) { + if (err instanceof CLIError && err.code === 'DOMAIN_ALREADY_EXISTS') { + const existing = await getInsForgeCustomDomain(domain); + if (existing) return existing; + } + throw err; + } +} + +async function verifyInsForgeCustomDomain(domain: string): Promise { + const res = await ossFetch(`/api/deployments/domains/${encodeURIComponent(domain)}/verify`, { + method: 'POST', + }); + return await res.json() as CustomDomain; +} + +async function getOptionalCloudflareRegistration(domain: string): Promise { + try { + return await getCloudflareRegistration(domain); + } catch (err) { + if (err instanceof CLIError && err.statusCode === 404) { + return null; + } + throw err; + } +} + +async function syncCloudflareDns(domain: CustomDomain): Promise { + const records = buildDnsSetupRecords(domain); + if (records.length === 0) { + throw new CLIError( + 'InsForge did not return DNS records for this domain yet. Run `domains status` and retry `domains dns sync` later.', + 1, + 'DOMAIN_DNS_RECORDS_PENDING', + ); + } + + const zone = await ensureCloudflareZone(domain.apexDomain); + for (const record of records) { + await upsertCloudflareDnsRecord(zone.id, { + type: record.type, + name: record.name, + content: record.content, + proxied: false, + }); + } + return records; +} + +function printDomains(domains: CloudflareDomainCandidate[], json: boolean): void { + if (json) { + outputJson({ domains }); + return; + } + outputTable( + ['Domain', 'Registrable', 'Price', 'Renewal', 'Reason'], + domains.map((entry) => [ + entry.name, + entry.registrable ? 'Yes' : 'No', + formatPrice(entry), + formatRenewal(entry), + entry.reason ?? '-', + ]), + ); +} + +function printCustomDomain(domain: CustomDomain, json: boolean): void { + if (json) { + outputJson(domain); + return; + } + outputTable( + ['Field', 'Value'], + [ + ['Domain', domain.domain], + ['Apex Domain', domain.apexDomain], + ['Verified', domain.verified ? 'Yes' : 'No'], + ['Misconfigured', domain.misconfigured ? 'Yes' : 'No'], + ['CNAME Target', domain.cnameTarget ?? '-'], + ['A Record', domain.aRecordValue ?? '-'], + ], + ); +} + +function printDnsRecords(records: DnsSetupRecord[], json: boolean): void { + if (json) { + outputJson({ records }); + return; + } + outputTable( + ['Type', 'Name', 'Content', 'Purpose'], + records.map((record) => [record.type, record.name, record.content, record.purpose]), + ); +} + +function isTerminalRegistrationState(workflow: CloudflareRegistrationWorkflow): boolean { + return workflow.state === 'succeeded' || + workflow.state === 'failed' || + workflow.state === 'action_required' || + workflow.state === 'blocked'; +} + +async function pollRegistration(domain: string, seconds: number): Promise { + if (seconds <= 0) return null; + const deadline = Date.now() + seconds * 1000; + let latest: CloudflareRegistrationWorkflow | null = null; + while (Date.now() < deadline) { + latest = await getCloudflareRegistrationStatus(domain); + if (isTerminalRegistrationState(latest)) return latest; + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + return latest; +} + +async function registerAfterCheck( + domain: string, + opts: DomainCommandOptions, + pollSeconds: number, +): Promise { + const checked = await checkCloudflareDomains([domain]); + const candidate = assertRegistrable(checked.find((entry) => normalizeDomain(entry.name) === domain), domain); + await confirmPurchase(domain, candidate, opts); + const workflow = await registerCloudflareDomain(domain); + if (isTerminalRegistrationState(workflow)) return workflow; + return await pollRegistration(domain, pollSeconds) ?? workflow; +} + +function assertRegistrationSucceeded(workflow: CloudflareRegistrationWorkflow): void { + if (workflow.state !== 'succeeded') { + const detail = workflow.error?.message ? ` ${workflow.error.message}` : ''; + throw new CLIError( + `Cloudflare registration is ${workflow.state}.${detail} Run \`npx @insforge/cli domains resume ${workflow.domain_name}\` after it succeeds.`, + 1, + 'DOMAIN_REGISTRATION_NOT_READY', + ); + } +} + +async function selectCloudflareAccount(accounts: CloudflareAccount[]): Promise { + const selected = await prompts.select({ + message: 'Cloudflare account', + options: accounts.map((account) => ({ + value: account.id, + label: account.name, + hint: account.id, + })), + }); + if (prompts.isCancel(selected)) throw new CLIError('Cloudflare login cancelled.'); + return selected; +} + +export function registerDomainsCommands(program: Command): void { + const domainsCmd = program.command('domains').description('Register and attach custom domains'); + + const cloudflareCmd = domainsCmd.command('cloudflare').description('Manage Cloudflare OAuth connection'); + cloudflareCmd + .command('login') + .description('Connect Cloudflare through OAuth') + .option('--account-id ', 'Cloudflare account ID') + .option('--skip-browser', 'Do not auto-open the browser; only print the OAuth URL') + .action(async (opts: DomainCommandOptions, cmd) => { + const { json } = getRootOpts(cmd); + const telemetry = { account_id_provided: Boolean(opts.accountId) }; + try { + const creds = await performCloudflareOAuthLogin({ + accountId: opts.accountId, + skipBrowser: opts.skipBrowser, + selectAccount: prompts.isInteractive && !json ? selectCloudflareAccount : undefined, + }); + await trackDomainUsage('cloudflare_login', true, telemetry); + if (json) { + outputJson({ success: true, accountId: creds.accountId, scope: creds.scope }); + } else { + outputSuccess(`Cloudflare connected for account ${pc.bold(creds.accountId)}`); + } + } catch (err) { + await trackDomainUsage('cloudflare_login', false, telemetry, err); + handleError(err, json); + } + }); + + domainsCmd + .command('search ') + .description('Search for available domains through Cloudflare Registrar') + .option('--limit ', 'Maximum Cloudflare search results', '10') + .option('--tlds ', 'Optional comma-separated TLD filter') + .action(async (query: string, opts: DomainCommandOptions, cmd) => { + const { json } = getRootOpts(cmd); + const telemetry = { has_tlds_filter: Boolean(opts.tlds) }; + try { + const limit = parsePositiveInteger(opts.limit, '--limit', 10); + const domains = await searchCloudflareDomains(query, limit); + const tlds = opts.tlds ? new Set(parseTlds(opts.tlds)) : null; + const filtered = tlds ? domains.filter((entry) => tlds.has(getTld(entry.name))) : domains; + await trackDomainUsage('search', true, { + ...telemetry, + result_count: filtered.length, + }); + printDomains(filtered, json); + } catch (err) { + await trackDomainUsage('search', false, telemetry, err); + handleError(err, json); + } + }); + + domainsCmd + .command('check ') + .description('Check real-time availability and pricing') + .action(async (domains: string[], _opts, cmd) => { + const { json } = getRootOpts(cmd); + const normalized = domains.map(normalizeDomain); + const telemetry = { + tld: normalized.length === 1 ? getTld(normalized[0]) : undefined, + result_count: normalized.length, + }; + try { + const checked = await checkCloudflareDomains(normalized); + await trackDomainUsage('check', true, { + ...telemetry, + result_count: checked.length, + }); + printDomains(checked, json); + } catch (err) { + await trackDomainUsage('check', false, telemetry, err); + handleError(err, json); + } + }); + + domainsCmd + .command('buy ') + .description('Register a domain in the connected Cloudflare account') + .option('--confirm-domain ', 'Required non-interactive purchase confirmation') + .option('--confirm-price ', 'Required non-interactive purchase confirmation') + .option('--confirm-currency ', 'Required non-interactive purchase confirmation') + .option('--confirm-cloudflare-billing', 'Confirm Cloudflare will charge the account default payment method') + .option('--confirm-non-refundable', 'Confirm successful registrations are non-refundable') + .option('--poll-seconds ', 'Wait for Cloudflare registration completion', '0') + .action(async (rawDomain: string, opts: DomainCommandOptions, cmd) => { + const { json } = getRootOpts(cmd); + const domain = normalizeDomain(rawDomain); + const baseTelemetry = { tld: getTld(domain) }; + try { + const pollSeconds = parsePositiveInteger(opts.pollSeconds, '--poll-seconds', 0); + const workflow = await registerAfterCheck( + domain, + opts, + pollSeconds, + ); + await trackDomainUsage('buy', true, { + ...baseTelemetry, + poll_seconds: pollSeconds, + confirmed: true, + registration_state: workflow.state, + registration_completed: workflow.completed, + }); + if (json) { + outputJson(workflow); + } else { + outputSuccess(`Cloudflare registration workflow is ${workflow.state}`); + outputTable( + ['Field', 'Value'], + [ + ['Domain', workflow.domain_name], + ['State', workflow.state], + ['Completed', workflow.completed ? 'Yes' : 'No'], + ], + ); + } + } catch (err) { + await trackDomainUsage('buy', false, baseTelemetry, err); + handleError(err, json); + } + }); + + domainsCmd + .command('attach ') + .description('Attach a domain to the linked InsForge deployment') + .action(async (rawDomain: string, _opts, cmd) => { + const { json, apiUrl } = getRootOpts(cmd); + const domainName = normalizeDomain(rawDomain); + const telemetry = { tld: getTld(domainName) }; + try { + await requireAuth(apiUrl); + const domain = await attachInsForgeCustomDomain(domainName); + await trackDomainUsage('attach', true, telemetry); + printCustomDomain(domain, json); + } catch (err) { + await trackDomainUsage('attach', false, telemetry, err); + handleError(err, json); + } + }); + + const dnsCmd = domainsCmd.command('dns').description('Manage DNS records for attached domains'); + dnsCmd + .command('sync ') + .description('Write InsForge/Vercel DNS records to Cloudflare DNS') + .action(async (rawDomain: string, _opts, cmd) => { + const { json, apiUrl } = getRootOpts(cmd); + const domain = normalizeDomain(rawDomain); + const telemetry = { tld: getTld(domain) }; + try { + await requireAuth(apiUrl); + const attached = await getInsForgeCustomDomain(domain); + if (!attached) { + throw new CLIError(`Domain ${domain} is not attached to this InsForge project.`, 4, 'DOMAIN_NOT_FOUND'); + } + const records = await syncCloudflareDns(attached); + await trackDomainUsage('dns_sync', true, { + ...telemetry, + result_count: records.length, + }); + printDnsRecords(records, json); + } catch (err) { + await trackDomainUsage('dns_sync', false, telemetry, err); + handleError(err, json); + } + }); + + domainsCmd + .command('verify ') + .description('Verify an attached domain through InsForge') + .action(async (rawDomain: string, _opts, cmd) => { + const { json, apiUrl } = getRootOpts(cmd); + const domainName = normalizeDomain(rawDomain); + const telemetry = { tld: getTld(domainName) }; + try { + await requireAuth(apiUrl); + const domain = await verifyInsForgeCustomDomain(domainName); + await trackDomainUsage('verify', true, telemetry); + printCustomDomain(domain, json); + } catch (err) { + await trackDomainUsage('verify', false, telemetry, err); + handleError(err, json); + } + }); + + domainsCmd + .command('status ') + .description('Show InsForge domain status, optionally including Cloudflare registration status') + .option('--cloudflare', 'Also fetch Cloudflare registration status') + .action(async (rawDomain: string, opts: DomainCommandOptions, cmd) => { + const { json, apiUrl } = getRootOpts(cmd); + const domain = normalizeDomain(rawDomain); + const telemetry = { + tld: getTld(domain), + cloudflare: Boolean(opts.cloudflare), + }; + try { + await requireAuth(apiUrl); + const attached = await getInsForgeCustomDomain(domain); + const registration = opts.cloudflare + ? await getOptionalCloudflareRegistration(domain) + : null; + await trackDomainUsage('status', true, { + ...telemetry, + registration_state: registration?.status, + }); + if (json) { + outputJson({ domain: attached, registration }); + } else if (!attached) { + console.log(`Domain ${domain} is not attached to this InsForge project.`); + } else { + printCustomDomain(attached, false); + if (registration) { + outputTable( + ['Cloudflare Field', 'Value'], + [ + ['Status', registration.status], + ['Expires', registration.expires_at ?? '-'], + ['Auto Renew', registration.auto_renew === undefined ? '-' : registration.auto_renew ? 'Yes' : 'No'], + ['Privacy', registration.privacy_mode ?? '-'], + ], + ); + } + } + } catch (err) { + await trackDomainUsage('status', false, telemetry, err); + handleError(err, json); + } + }); + + domainsCmd + .command('resume ') + .description('Resume attach, DNS sync, and verification after Cloudflare registration completes') + .action(async (rawDomain: string, _opts, cmd) => { + const { json, apiUrl } = getRootOpts(cmd); + const domain = normalizeDomain(rawDomain); + const telemetry = { tld: getTld(domain) }; + try { + await requireAuth(apiUrl); + const workflow = await getCloudflareRegistrationStatus(domain); + assertRegistrationSucceeded(workflow); + const attached = await attachInsForgeCustomDomain(domain); + const records = await syncCloudflareDns(attached); + const verified = await verifyInsForgeCustomDomain(domain); + await trackDomainUsage('resume', true, { + ...telemetry, + registration_state: workflow.state, + registration_completed: workflow.completed, + result_count: records.length, + }); + if (json) { + outputJson({ registration: workflow, domain: verified, dnsRecords: records }); + } else { + outputSuccess(`Resumed ${domain}`); + printCustomDomain(verified, false); + printDnsRecords(records, false); + } + } catch (err) { + await trackDomainUsage('resume', false, telemetry, err); + handleError(err, json); + } + }); + + domainsCmd + .command('buy-and-attach ') + .description('Register a domain, attach it to InsForge, sync Cloudflare DNS, and verify') + .option('--confirm-domain ', 'Required non-interactive purchase confirmation') + .option('--confirm-price ', 'Required non-interactive purchase confirmation') + .option('--confirm-currency ', 'Required non-interactive purchase confirmation') + .option('--confirm-cloudflare-billing', 'Confirm Cloudflare will charge the account default payment method') + .option('--confirm-non-refundable', 'Confirm successful registrations are non-refundable') + .option('--poll-seconds ', 'Wait for Cloudflare registration completion', '90') + .action(async (rawDomain: string, opts: DomainCommandOptions, cmd) => { + const { json, apiUrl } = getRootOpts(cmd); + const domain = normalizeDomain(rawDomain); + const baseTelemetry = { tld: getTld(domain) }; + try { + await requireAuth(apiUrl); + const pollSeconds = parsePositiveInteger(opts.pollSeconds, '--poll-seconds', 90); + const registration = await registerAfterCheck( + domain, + opts, + pollSeconds, + ); + assertRegistrationSucceeded(registration); + + const attached = await attachInsForgeCustomDomain(domain); + const dnsRecords = await syncCloudflareDns(attached); + const verified = await verifyInsForgeCustomDomain(domain); + await trackDomainUsage('buy_and_attach', true, { + ...baseTelemetry, + poll_seconds: pollSeconds, + confirmed: true, + registration_state: registration.state, + registration_completed: registration.completed, + result_count: dnsRecords.length, + }); + + if (json) { + outputJson({ registration, domain: verified, dnsRecords }); + } else { + outputSuccess(`Registered and attached ${domain}`); + printCustomDomain(verified, false); + printDnsRecords(dnsRecords, false); + } + } catch (err) { + await trackDomainUsage('buy_and_attach', false, baseTelemetry, err); + handleError(err, json); + } + }); +} diff --git a/src/commands/domains/telemetry.test.ts b/src/commands/domains/telemetry.test.ts new file mode 100644 index 0000000..c560120 --- /dev/null +++ b/src/commands/domains/telemetry.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const configMock = vi.hoisted(() => ({ + getProjectConfig: vi.fn(() => ({ + project_id: 'p1', + project_name: 'Test Project', + org_id: 'o1', + region: 'us', + api_key: 'secret', + appkey: 'app', + oss_host: 'https://app.us.insforge.app', + })), +})); +vi.mock('../../lib/config.js', () => configMock); + +const analyticsMock = vi.hoisted(() => ({ + trackDomains: vi.fn(), + shutdownAnalytics: vi.fn(async () => {}), +})); +vi.mock('../../lib/analytics.js', () => analyticsMock); + +import { CLIError } from '../../lib/errors.js'; +import { trackDomainUsage } from './telemetry.js'; + +describe('domain command telemetry', () => { + beforeEach(() => { + analyticsMock.trackDomains.mockClear(); + analyticsMock.shutdownAnalytics.mockClear(); + configMock.getProjectConfig.mockReturnValue({ + project_id: 'p1', + project_name: 'Test Project', + org_id: 'o1', + region: 'us', + api_key: 'secret', + appkey: 'app', + oss_host: 'https://app.us.insforge.app', + }); + }); + + it('tracks safe domain command fields and structured errors only', async () => { + const error = new CLIError( + 'failed to register very-sensitive-example.dev', + 1, + 'DOMAIN_REGISTRATION_NOT_READY', + 409, + ); + + await trackDomainUsage('buy', false, { + tld: '.DEV', + poll_seconds: 90, + confirmed: true, + // Should be ignored if a caller accidentally passes it. + domain: 'very-sensitive-example.dev', + }, error); + + expect(analyticsMock.trackDomains).toHaveBeenCalledWith( + 'buy', + expect.objectContaining({ project_id: 'p1' }), + expect.objectContaining({ + success: false, + tld: 'dev', + poll_seconds: 90, + confirmed: true, + error_name: 'CLIError', + error_code: 'DOMAIN_REGISTRATION_NOT_READY', + exit_code: 1, + status_code: 409, + }), + ); + + const properties = analyticsMock.trackDomains.mock.calls[0]?.[2] as + | Record + | undefined; + expect(properties).not.toHaveProperty('domain'); + expect(properties).not.toHaveProperty('error_message'); + expect(analyticsMock.shutdownAnalytics).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/commands/domains/telemetry.ts b/src/commands/domains/telemetry.ts new file mode 100644 index 0000000..b77cf58 --- /dev/null +++ b/src/commands/domains/telemetry.ts @@ -0,0 +1,91 @@ +import { shutdownAnalytics, trackDomains } from '../../lib/analytics.js'; +import { getProjectConfig } from '../../lib/config.js'; +import { CLIError } from '../../lib/errors.js'; + +export type DomainCommandTelemetry = Record; + +const STRING_KEYS = new Set([ + 'tld', + 'registration_state', + 'error_name', + 'error_code', +]); + +const NUMBER_KEYS = new Set([ + 'account_count', + 'exit_code', + 'poll_seconds', + 'result_count', + 'status_code', +]); + +const BOOLEAN_KEYS = new Set([ + 'account_id_provided', + 'cloudflare', + 'confirmed', + 'has_tlds_filter', + 'registration_completed', +]); + +function sanitizeTld(value: string): string | undefined { + const normalized = value.trim().toLowerCase().replace(/^\./, ''); + return /^[a-z0-9-]{2,63}$/.test(normalized) ? normalized : undefined; +} + +function sanitizeDomainTelemetry(properties: DomainCommandTelemetry): DomainCommandTelemetry { + const sanitized: DomainCommandTelemetry = {}; + for (const [key, value] of Object.entries(properties)) { + if (value === undefined) continue; + if (key === 'tld' && typeof value === 'string') { + const tld = sanitizeTld(value); + if (tld) sanitized.tld = tld; + continue; + } + if (STRING_KEYS.has(key) && typeof value === 'string') { + sanitized[key] = value.slice(0, 80); + continue; + } + if (NUMBER_KEYS.has(key) && typeof value === 'number' && Number.isFinite(value)) { + sanitized[key] = value; + continue; + } + if (BOOLEAN_KEYS.has(key) && typeof value === 'boolean') { + sanitized[key] = value; + } + } + return sanitized; +} + +function getErrorTelemetry(error: unknown): DomainCommandTelemetry { + return { + error_name: error instanceof Error ? error.name : typeof error, + ...(error instanceof CLIError + ? { + error_code: error.code, + exit_code: error.exitCode, + status_code: error.statusCode, + } + : {}), + }; +} + +export async function trackDomainUsage( + subcommand: string, + success: boolean, + properties: DomainCommandTelemetry = {}, + error?: unknown, +): Promise { + try { + trackDomains(subcommand, getProjectConfig(), { + success, + ...sanitizeDomainTelemetry({ + ...properties, + ...(error !== undefined ? getErrorTelemetry(error) : {}), + }), + }); + } catch { + // Telemetry should never affect command behavior. + } finally { + await shutdownAnalytics(); + } +} diff --git a/src/index.ts b/src/index.ts index 0210186..568a8c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,6 +77,7 @@ import { registerPaymentsCommands } from './commands/payments/index.js'; import { registerPosthogSetupCommand } from './commands/posthog/setup.js'; import { registerConfigCommand } from './commands/config/index.js'; import { registerAiCommands } from './commands/ai/index.js'; +import { registerDomainsCommands } from './commands/domains/index.js'; import { registerMemoryCommands } from './commands/memory/index.js'; import { guardHook } from './lib/guard/index.js'; @@ -225,6 +226,9 @@ registerPosthogSetupCommand(posthogCmd); const aiCmd = program.command('ai').description('Manage AI model gateway setup'); registerAiCommands(aiCmd); +// Domain commands +registerDomainsCommands(program); + const memoryCmd = program.command('memory').description('Store and recall durable agent memories'); registerMemoryCommands(memoryCmd); diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 824a89a..5806114 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -77,6 +77,23 @@ export function trackDeployments( }); } +export function trackDomains( + subcommand: string, + config: ProjectConfig | null, + properties?: Record, +): void { + const distinctId = config?.project_id ?? FAKE_PROJECT_ID; + captureEvent(distinctId, 'cli_domains_invoked', { + subcommand, + project_id: config?.project_id, + project_name: config?.project_name, + org_id: config?.org_id, + region: config?.region, + oss_mode: !config || config.project_id === FAKE_PROJECT_ID, + ...properties, + }); +} + // Step 2 of the "dashboard connect → CLI posthog setup" funnel; pair with // backend `posthog_connect_started` joined on project_id. export function trackPosthog( diff --git a/src/lib/cloudflare.test.ts b/src/lib/cloudflare.test.ts new file mode 100644 index 0000000..e5aaa07 --- /dev/null +++ b/src/lib/cloudflare.test.ts @@ -0,0 +1,226 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + buildCloudflareAuthorizeUrl, + listCloudflareAccounts, + performCloudflareOAuthLogin, + registerCloudflareDomain, + upsertCloudflareDnsRecord, +} from './cloudflare.js'; + +describe('Cloudflare OAuth helpers', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it('builds the Cloudflare OAuth URL with PKCE and the fixed CLI callback', () => { + const url = new URL(buildCloudflareAuthorizeUrl({ + state: 'state-123456', + codeChallenge: 'challenge-abc', + })); + + expect(url.origin + url.pathname).toBe('https://dash.cloudflare.com/oauth2/auth'); + expect(url.searchParams.get('client_id')).toBe('18cf4d9bc2f1b53f205cf92ec4f143c8'); + expect(url.searchParams.get('response_type')).toBe('code'); + expect(url.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:8787/callback'); + expect(url.searchParams.get('code_challenge')).toBe('challenge-abc'); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(url.searchParams.get('scope')).toBe( + 'registrar-domains.admin registrar-domains.read dns.write dns.read zone.write zone.read account-settings.read', + ); + }); + + it('allows a development override for the Cloudflare OAuth client id', () => { + vi.stubEnv('INSFORGE_CLOUDFLARE_OAUTH_CLIENT_ID', 'dev-client-id'); + const url = new URL(buildCloudflareAuthorizeUrl({ + state: 'state-123456', + codeChallenge: 'challenge-abc', + })); + + expect(url.searchParams.get('client_id')).toBe('dev-client-id'); + }); + + it('registers domains with auto-renew and WHOIS redaction enabled', async () => { + vi.stubEnv('CLOUDFLARE_ACCOUNT_ID', 'account-123'); + vi.stubEnv('CLOUDFLARE_ACCESS_TOKEN', 'oauth-access-token'); + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ + success: true, + result: { + domain_name: 'example.dev', + state: 'in_progress', + completed: false, + }, + errors: [], + messages: [], + }), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await registerCloudflareDomain('example.dev'); + + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse(init?.body as string)).toEqual({ + domain_name: 'example.dev', + auto_renew: true, + privacy_mode: 'redaction', + }); + }); + + it('lists Cloudflare accounts with the OAuth access token', async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ + success: true, + result: [ + { + id: 'account-123', + name: 'Demo Account', + type: 'standard', + }, + ], + errors: [], + messages: [], + }), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + const accounts = await listCloudflareAccounts({ + accountId: '', + accessToken: 'oauth-access-token', + }); + + expect(accounts).toEqual([ + { + id: 'account-123', + name: 'Demo Account', + type: 'standard', + }, + ]); + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.cloudflare.com/client/v4/accounts', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer oauth-access-token', + }), + }), + ); + }); + + it('creates a new TXT record instead of overwriting an unrelated one', async () => { + vi.stubEnv('CLOUDFLARE_ACCOUNT_ID', 'account-123'); + vi.stubEnv('CLOUDFLARE_ACCESS_TOKEN', 'oauth-access-token'); + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method === 'POST') { + return new Response(JSON.stringify({ + success: true, + result: { + id: 'new-record', + type: 'TXT', + name: '_vercel.example.com', + content: 'vc-domain-verify=example.com,new', + }, + errors: [], + messages: [], + }), { status: 200 }); + } + return new Response(JSON.stringify({ + success: true, + result: [ + { + id: 'existing-record', + type: 'TXT', + name: '_vercel.example.com', + content: 'vc-domain-verify=example.com,old', + }, + ], + errors: [], + messages: [], + }), { status: 200 }); + }); + vi.stubGlobal('fetch', fetchMock); + + await upsertCloudflareDnsRecord('zone-123', { + type: 'TXT', + name: '_vercel.example.com', + content: 'vc-domain-verify=example.com,new', + }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'https://api.cloudflare.com/client/v4/zones/zone-123/dns_records?type=TXT&name=_vercel.example.com', + expect.any(Object), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.cloudflare.com/client/v4/zones/zone-123/dns_records', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('updates an existing TXT record when the content already matches', async () => { + vi.stubEnv('CLOUDFLARE_ACCOUNT_ID', 'account-123'); + vi.stubEnv('CLOUDFLARE_ACCESS_TOKEN', 'oauth-access-token'); + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === 'PUT') { + return new Response(JSON.stringify({ + success: true, + result: { + id: 'existing-record', + type: 'TXT', + name: '_vercel.example.com', + content: 'vc-domain-verify=example.com,same', + }, + errors: [], + messages: [], + }), { status: 200 }); + } + return new Response(JSON.stringify({ + success: true, + result: [ + { + id: 'existing-record', + type: 'TXT', + name: '_vercel.example.com', + content: 'vc-domain-verify=example.com,same', + }, + ], + errors: [], + messages: [], + }), { status: 200 }); + }); + vi.stubGlobal('fetch', fetchMock); + + await upsertCloudflareDnsRecord('zone-123', { + type: 'TXT', + name: '_vercel.example.com', + content: 'vc-domain-verify=example.com,same', + }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.cloudflare.com/client/v4/zones/zone-123/dns_records/existing-record', + expect.objectContaining({ method: 'PUT' }), + ); + }); + + it('rejects login immediately when the OAuth callback state does not match', async () => { + // Silence the OAuth URL banner the login flow writes to stderr. + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + + // Without the fix this never settles and the test times out instead of + // hanging the real CLI for the full 5-minute callback timeout. + const loginPromise = performCloudflareOAuthLogin({ skipBrowser: true }); + const rejection = expect(loginPromise).rejects.toThrow('Cloudflare OAuth state mismatch.'); + + // Wait for the callback server to bind, then deliver a mismatched state. + let delivered = false; + for (let attempt = 0; attempt < 50 && !delivered; attempt += 1) { + try { + await fetch('http://127.0.0.1:8787/callback?code=test-code&state=wrong-state'); + delivered = true; + } catch { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + } + expect(delivered).toBe(true); + + await rejection; + stderrSpy.mockRestore(); + }); +}); diff --git a/src/lib/cloudflare.ts b/src/lib/cloudflare.ts new file mode 100644 index 0000000..566027c --- /dev/null +++ b/src/lib/cloudflare.ts @@ -0,0 +1,636 @@ +import { createServer } from 'node:http'; +import { createHash, randomBytes } from 'node:crypto'; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { CLIError } from './errors.js'; + +const CLOUDFLARE_API_BASE = 'https://api.cloudflare.com/client/v4'; +const CLOUDFLARE_OAUTH_AUTHORIZE_URL = 'https://dash.cloudflare.com/oauth2/auth'; +const CLOUDFLARE_OAUTH_TOKEN_URL = 'https://dash.cloudflare.com/oauth2/token'; +const CLOUDFLARE_OAUTH_REDIRECT_URI = 'http://127.0.0.1:8787/callback'; +const CLOUDFLARE_OAUTH_CLIENT_ID = '18cf4d9bc2f1b53f205cf92ec4f143c8'; +const CLOUDFLARE_OAUTH_SCOPES = [ + 'registrar-domains.admin', + 'registrar-domains.read', + 'dns.write', + 'dns.read', + 'zone.write', + 'zone.read', + 'account-settings.read', +]; +const GLOBAL_DIR = join(homedir(), '.insforge'); +const CLOUDFLARE_FILE = join(GLOBAL_DIR, 'cloudflare.json'); + +export interface CloudflareCredentials { + accountId: string; + accessToken: string; + refreshToken?: string; + expiresAt?: number; + scope?: string; +} + +export interface CloudflareOAuthCallbackResult { + code: string; + state: string; +} + +export interface CloudflareAccount { + id: string; + name: string; + type?: string; +} + +export interface CloudflarePricing { + currency: string; + registration_cost: string; + renewal_cost: string; +} + +export interface CloudflareDomainCandidate { + name: string; + registrable: boolean; + tier?: string; + reason?: string; + pricing?: CloudflarePricing; +} + +export interface CloudflareRegistrationWorkflow { + domain_name: string; + state: 'in_progress' | 'succeeded' | 'failed' | 'action_required' | 'blocked' | string; + completed: boolean; + created_at?: string; + updated_at?: string; + context?: { + registration?: CloudflareRegistration; + }; + links?: { + self?: string; + resource?: string; + }; + error?: { + code?: string; + message?: string; + }; +} + +export interface CloudflareRegistration { + domain_name: string; + status: string; + created_at?: string; + expires_at?: string; + auto_renew?: boolean; + privacy_mode?: string; + locked?: boolean; +} + +export interface CloudflareZone { + id: string; + name: string; + status?: string; +} + +export interface CloudflareDnsRecord { + id: string; + type: string; + name: string; + content: string; + ttl?: number; + proxied?: boolean; +} + +interface CloudflareApiResponse { + success: boolean; + errors?: Array<{ code?: number | string; message?: string }>; + messages?: Array<{ code?: number | string; message?: string }>; + result: T; +} + +function ensureGlobalDir(): void { + if (!existsSync(GLOBAL_DIR)) { + mkdirSync(GLOBAL_DIR, { recursive: true }); + } +} + +function base64url(buffer: Buffer): string { + return buffer + .toString('base64') + .replaceAll('+', '-') + .replaceAll('/', '_') + .replaceAll('=', ''); +} + +function getCloudflareOAuthClientId(): string { + return process.env.INSFORGE_CLOUDFLARE_OAUTH_CLIENT_ID ?? CLOUDFLARE_OAUTH_CLIENT_ID; +} + +function generatePkce(): { codeVerifier: string; codeChallenge: string } { + const codeVerifier = base64url(randomBytes(64)); + const codeChallenge = base64url(createHash('sha256').update(codeVerifier).digest()); + return { codeVerifier, codeChallenge }; +} + +function generateState(): string { + return base64url(randomBytes(32)); +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +export function buildCloudflareAuthorizeUrl(params: { + clientId?: string; + redirectUri?: string; + state: string; + codeChallenge: string; + scopes?: string[]; +}): string { + const url = new URL(CLOUDFLARE_OAUTH_AUTHORIZE_URL); + url.searchParams.set('client_id', params.clientId ?? getCloudflareOAuthClientId()); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('redirect_uri', params.redirectUri ?? CLOUDFLARE_OAUTH_REDIRECT_URI); + url.searchParams.set('scope', (params.scopes ?? CLOUDFLARE_OAUTH_SCOPES).join(' ')); + url.searchParams.set('state', params.state); + url.searchParams.set('code_challenge', params.codeChallenge); + url.searchParams.set('code_challenge_method', 'S256'); + return url.toString(); +} + +function startCloudflareCallbackServer(expectedState: string): Promise<{ + result: Promise; + close: () => void; +}> { + return new Promise((resolveServer, rejectServer) => { + let resolveResult: (value: CloudflareOAuthCallbackResult) => void; + let rejectResult: (reason: Error) => void; + + const resultPromise = new Promise((resolve, reject) => { + resolveResult = resolve; + rejectResult = reject; + }); + + const server = createServer((req, res) => { + const url = new URL(req.url ?? '/', CLOUDFLARE_OAUTH_REDIRECT_URI); + if (url.pathname !== '/callback') { + res.writeHead(404); + res.end('Not found'); + return; + } + + const error = url.searchParams.get('error'); + if (error) { + const desc = url.searchParams.get('error_description') ?? error; + const safeDesc = escapeHtml(desc); + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(`

Cloudflare authorization failed

${safeDesc}

You can close this window.

`); + rejectResult(new Error(desc)); + return; + } + + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + if (!code || !state) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end('

Invalid Cloudflare callback

Missing authorization code.

'); + rejectResult(new Error('Invalid Cloudflare callback: missing authorization code or state.')); + return; + } + if (state !== expectedState) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end('

Invalid Cloudflare callback

State mismatch.

'); + rejectResult(new Error('Cloudflare OAuth state mismatch.')); + return; + } + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Cloudflare connected

You can close this window and return to the terminal.

'); + resolveResult({ code, state }); + }); + + server.once('error', (err) => { + rejectServer(new CLIError( + `Could not start Cloudflare OAuth callback server on ${CLOUDFLARE_OAUTH_REDIRECT_URI}: ${err.message}`, + 1, + 'CLOUDFLARE_OAUTH_CALLBACK_UNAVAILABLE', + )); + }); + + server.listen(8787, '127.0.0.1', () => { + resolveServer({ + result: resultPromise, + close: () => { + server.close(); + if (typeof server.closeAllConnections === 'function') { + server.closeAllConnections(); + } + }, + }); + }); + + setTimeout(() => { + rejectResult(new Error('Cloudflare authorization timed out. Please try again.')); + server.close(); + }, 5 * 60 * 1000).unref(); + }); +} + +async function exchangeCloudflareOAuthCode(params: { + code: string; + codeVerifier: string; +}): Promise<{ access_token: string; refresh_token?: string; expires_in?: number; scope?: string }> { + const res = await fetch(CLOUDFLARE_OAUTH_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: getCloudflareOAuthClientId(), + code: params.code, + redirect_uri: CLOUDFLARE_OAUTH_REDIRECT_URI, + code_verifier: params.codeVerifier, + }), + }); + + const body = await res.json().catch(() => null) as + | { access_token?: string; refresh_token?: string; expires_in?: number; scope?: string; error?: string; error_description?: string } + | null; + if (!res.ok || !body?.access_token) { + throw new CLIError( + body?.error_description ?? body?.error ?? `Cloudflare token exchange failed (HTTP ${res.status})`, + 1, + 'CLOUDFLARE_OAUTH_TOKEN_EXCHANGE_FAILED', + res.status, + ); + } + return { + access_token: body.access_token, + refresh_token: body.refresh_token, + expires_in: body.expires_in, + scope: body.scope, + }; +} + +async function refreshCloudflareCredentials(creds: CloudflareCredentials): Promise { + if (!creds.refreshToken) { + return creds; + } + + const res = await fetch(CLOUDFLARE_OAUTH_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: getCloudflareOAuthClientId(), + refresh_token: creds.refreshToken, + }), + }); + + const body = await res.json().catch(() => null) as + | { access_token?: string; refresh_token?: string; expires_in?: number; scope?: string; error?: string; error_description?: string } + | null; + if (!res.ok || !body?.access_token) { + throw new CLIError( + body?.error_description ?? body?.error ?? `Cloudflare token refresh failed (HTTP ${res.status})`, + 2, + 'CLOUDFLARE_OAUTH_REFRESH_FAILED', + res.status, + ); + } + + const refreshed: CloudflareCredentials = { + accountId: creds.accountId, + accessToken: body.access_token, + refreshToken: body.refresh_token ?? creds.refreshToken, + expiresAt: body.expires_in ? Date.now() + body.expires_in * 1000 : undefined, + scope: body.scope ?? creds.scope, + }; + saveCloudflareCredentials(refreshed); + return refreshed; +} + +function isExpiringSoon(creds: CloudflareCredentials): boolean { + return creds.expiresAt !== undefined && creds.expiresAt <= Date.now() + 60_000; +} + +export function saveCloudflareCredentials(creds: CloudflareCredentials): void { + ensureGlobalDir(); + writeFileSync(CLOUDFLARE_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 }); + chmodSync(CLOUDFLARE_FILE, 0o600); +} + +export function getCloudflareCredentials(): CloudflareCredentials | null { + const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; + const accessToken = process.env.CLOUDFLARE_ACCESS_TOKEN; + if (accountId && accessToken) { + return { accountId, accessToken }; + } + + if (!existsSync(CLOUDFLARE_FILE)) { + return null; + } + + let raw: Partial; + try { + raw = JSON.parse(readFileSync(CLOUDFLARE_FILE, 'utf-8')) as Partial; + } catch { + return null; + } + if (!raw.accountId || !raw.accessToken) { + return null; + } + return { + accountId: raw.accountId, + accessToken: raw.accessToken, + refreshToken: raw.refreshToken, + expiresAt: raw.expiresAt, + scope: raw.scope, + }; +} + +export async function requireCloudflareCredentials(): Promise { + const creds = getCloudflareCredentials(); + if (!creds) { + throw new CLIError( + 'Cloudflare is not connected. Run `npx @insforge/cli domains cloudflare login` or set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_ACCESS_TOKEN.', + 2, + 'CLOUDFLARE_AUTH_REQUIRED', + ); + } + return isExpiringSoon(creds) ? await refreshCloudflareCredentials(creds) : creds; +} + +export async function listCloudflareAccounts(creds?: CloudflareCredentials): Promise { + return cloudflareFetch('/accounts', {}, creds); +} + +export async function performCloudflareOAuthLogin(params: { + accountId?: string; + skipBrowser?: boolean; + selectAccount?: (accounts: CloudflareAccount[]) => Promise; +}): Promise { + const pkce = generatePkce(); + const state = generateState(); + const { result, close } = await startCloudflareCallbackServer(state); + const authUrl = buildCloudflareAuthorizeUrl({ + state, + codeChallenge: pkce.codeChallenge, + }); + + process.stderr.write(`\nTo connect Cloudflare, open this URL in your browser:\n\n ${authUrl}\n\n`); + if (!params.skipBrowser) { + try { + const open = (await import('open')).default; + await open(authUrl); + } catch { + // Best-effort; URL is printed above. + } + } + + try { + const callback = await result; + const tokens = await exchangeCloudflareOAuthCode({ + code: callback.code, + codeVerifier: pkce.codeVerifier, + }); + const discoveredCreds: CloudflareCredentials = { + accountId: params.accountId ?? '', + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined, + scope: tokens.scope, + }; + if (!params.accountId) { + const accounts = await listCloudflareAccounts(discoveredCreds).catch((err: unknown) => { + throw new CLIError( + err instanceof Error + ? `Could not discover Cloudflare accounts: ${err.message}.` + : 'Could not discover Cloudflare accounts.', + 1, + 'CLOUDFLARE_ACCOUNT_DISCOVERY_FAILED', + ); + }); + + if (accounts.length === 0) { + throw new CLIError( + 'Cloudflare OAuth succeeded, but account discovery returned no accounts.', + 1, + 'CLOUDFLARE_ACCOUNT_NOT_FOUND', + ); + } + else if (accounts.length === 1) { + discoveredCreds.accountId = accounts[0].id; + } else if (params.selectAccount) { + discoveredCreds.accountId = await params.selectAccount(accounts); + } else { + throw new CLIError( + `Cloudflare returned ${accounts.length} accounts. Re-run with --account-id .`, + 1, + 'CLOUDFLARE_ACCOUNT_SELECTION_REQUIRED', + ); + } + } + + const creds: CloudflareCredentials = discoveredCreds; + saveCloudflareCredentials(creds); + return creds; + } finally { + close(); + } +} + +function formatCloudflareErrors(errors: CloudflareApiResponse['errors']): string | null { + if (!errors || errors.length === 0) return null; + return errors + .map((err) => [err.code, err.message].filter(Boolean).join(': ')) + .filter(Boolean) + .join('; '); +} + +async function cloudflareFetch( + path: string, + options: RequestInit = {}, + creds?: CloudflareCredentials, +): Promise { + let activeCreds = creds ?? await requireCloudflareCredentials(); + const headers: Record = { + Authorization: `Bearer ${activeCreds.accessToken}`, + 'Content-Type': 'application/json', + ...(options.headers as Record | undefined), + }; + + let res = await fetch(`${CLOUDFLARE_API_BASE}${path}`, { ...options, headers }); + if (res.status === 401 && activeCreds.refreshToken) { + activeCreds = await refreshCloudflareCredentials(activeCreds); + res = await fetch(`${CLOUDFLARE_API_BASE}${path}`, { + ...options, + headers: { + ...headers, + Authorization: `Bearer ${activeCreds.accessToken}`, + }, + }); + } + const body = await res.json().catch(() => null) as CloudflareApiResponse | null; + if (!res.ok || !body?.success) { + const message = + formatCloudflareErrors(body?.errors) ?? + `Cloudflare request failed: HTTP ${res.status}`; + throw new CLIError(message, 1, 'CLOUDFLARE_API_ERROR', res.status); + } + return body.result; +} + +export async function searchCloudflareDomains( + query: string, + limit: number, +): Promise { + const creds = await requireCloudflareCredentials(); + const params = new URLSearchParams({ q: query, limit: String(limit) }); + const result = await cloudflareFetch<{ domains: CloudflareDomainCandidate[] }>( + `/accounts/${creds.accountId}/registrar/domain-search?${params.toString()}`, + {}, + creds, + ); + return result.domains; +} + +export async function checkCloudflareDomains( + domains: string[], +): Promise { + const creds = await requireCloudflareCredentials(); + const result = await cloudflareFetch<{ domains: CloudflareDomainCandidate[] }>( + `/accounts/${creds.accountId}/registrar/domain-check`, + { + method: 'POST', + body: JSON.stringify({ domains }), + }, + creds, + ); + return result.domains; +} + +export async function registerCloudflareDomain(domain: string): Promise { + const creds = await requireCloudflareCredentials(); + return cloudflareFetch( + `/accounts/${creds.accountId}/registrar/registrations`, + { + method: 'POST', + headers: { Prefer: 'respond-async' }, + body: JSON.stringify({ + domain_name: domain, + auto_renew: true, + privacy_mode: 'redaction', + }), + }, + creds, + ); +} + +export async function getCloudflareRegistrationStatus(domain: string): Promise { + const creds = await requireCloudflareCredentials(); + return cloudflareFetch( + `/accounts/${creds.accountId}/registrar/registrations/${encodeURIComponent(domain)}/registration-status`, + {}, + creds, + ); +} + +export async function getCloudflareRegistration(domain: string): Promise { + const creds = await requireCloudflareCredentials(); + return cloudflareFetch( + `/accounts/${creds.accountId}/registrar/registrations/${encodeURIComponent(domain)}`, + {}, + creds, + ); +} + +export async function findCloudflareZone(name: string): Promise { + const creds = await requireCloudflareCredentials(); + const params = new URLSearchParams({ + name, + 'account.id': creds.accountId, + per_page: '1', + }); + const result = await cloudflareFetch(`/zones?${params.toString()}`, {}, creds); + return result[0] ?? null; +} + +export async function createCloudflareZone(name: string): Promise { + const creds = await requireCloudflareCredentials(); + return cloudflareFetch( + '/zones', + { + method: 'POST', + body: JSON.stringify({ + account: { id: creds.accountId }, + name, + type: 'full', + }), + }, + creds, + ); +} + +export async function ensureCloudflareZone(name: string): Promise { + const existing = await findCloudflareZone(name); + if (existing) return existing; + return createCloudflareZone(name); +} + +export async function listCloudflareDnsRecords( + zoneId: string, + filters: { type?: string; name?: string } = {}, +): Promise { + const params = new URLSearchParams(); + if (filters.type) params.set('type', filters.type); + if (filters.name) params.set('name', filters.name); + const query = params.toString(); + const suffix = query ? `?${query}` : ''; + return cloudflareFetch(`/zones/${zoneId}/dns_records${suffix}`); +} + +export async function upsertCloudflareDnsRecord( + zoneId: string, + record: { type: string; name: string; content: string; ttl?: number; proxied?: boolean }, +): Promise { + const existing = await listCloudflareDnsRecords(zoneId, { + type: record.type, + name: record.name, + }); + const body = { + type: record.type, + name: record.name, + content: record.content, + ttl: record.ttl ?? 1, + proxied: record.proxied ?? false, + }; + const exact = existing.find((entry) => entry.content === record.content); + const current = record.type.toUpperCase() === 'TXT' + ? exact + : exact ?? existing[0]; + if (current) { + return cloudflareFetch( + `/zones/${zoneId}/dns_records/${current.id}`, + { + method: 'PUT', + body: JSON.stringify(body), + }, + ); + } + return cloudflareFetch( + `/zones/${zoneId}/dns_records`, + { + method: 'POST', + body: JSON.stringify(body), + }, + ); +}