Skip to content

Commit 1233450

Browse files
committed
chore: wip
1 parent 06aace0 commit 1233450

9 files changed

Lines changed: 890 additions & 18 deletions

File tree

README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,74 @@ await cloudfront.createInvalidation({
236236
})
237237
```
238238

239+
## DNS Providers
240+
241+
ts-cloud supports multiple DNS providers for domain management and SSL certificate validation:
242+
243+
### Cloudflare
244+
245+
1. Log in to your [Cloudflare Dashboard](https://dash.cloudflare.com/)
246+
2. Go to **My Profile****API Tokens** (or visit https://dash.cloudflare.com/profile/api-tokens)
247+
3. Click **Create Token**
248+
4. Use the **Edit zone DNS** template, or create a custom token with:
249+
- **Permissions**: Zone → DNS → Edit
250+
- **Zone Resources**: Include → All zones (or specific zones)
251+
5. Copy the generated token
252+
253+
```bash
254+
export CLOUDFLARE_API_TOKEN="your-api-token-here"
255+
```
256+
257+
### Porkbun
258+
259+
1. Log in to your [Porkbun Dashboard](https://porkbun.com/account/api)
260+
2. Enable API access for your domain(s)
261+
3. Generate an API key pair
262+
263+
```bash
264+
export PORKBUN_API_KEY="your-api-key"
265+
export PORKBUN_SECRET_KEY="your-secret-key"
266+
```
267+
268+
### GoDaddy
269+
270+
1. Log in to [GoDaddy Developer Portal](https://developer.godaddy.com/)
271+
2. Create a new API key
272+
3. Note both the key and secret
273+
274+
```bash
275+
export GODADDY_API_KEY="your-api-key"
276+
export GODADDY_API_SECRET="your-api-secret"
277+
export GODADDY_ENVIRONMENT="production" # or "ote" for testing
278+
```
279+
280+
### Route53
281+
282+
Uses AWS credentials from environment or ~/.aws/credentials:
283+
284+
```bash
285+
export AWS_ACCESS_KEY_ID="your-access-key"
286+
export AWS_SECRET_ACCESS_KEY="your-secret-key"
287+
export AWS_REGION="us-east-1"
288+
export AWS_HOSTED_ZONE_ID="Z1234567890" # Optional
289+
```
290+
291+
### CLI Usage
292+
293+
```bash
294+
# List domains
295+
cloud domain:list --provider cloudflare
296+
297+
# List DNS records
298+
cloud dns:records example.com --provider cloudflare
299+
300+
# Add a DNS record
301+
cloud dns:add example.com A 192.168.1.1 --name api --provider cloudflare
302+
303+
# Generate SSL certificate with DNS validation
304+
cloud domain:ssl example.com --provider cloudflare
305+
```
306+
239307
## Development
240308

241309
```bash

packages/ts-cloud/bin/commands/deploy.ts

Lines changed: 179 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,37 @@ import { CloudFrontClient } from '../../src/aws/cloudfront'
88
import { ECRClient } from '../../src/aws/ecr'
99
import { ECSClient } from '../../src/aws/ecs'
1010
import { validateTemplate, validateTemplateSize, validateResourceLimits } from '../../src/validation/template'
11-
import { loadValidatedConfig } from './shared'
11+
import { loadValidatedConfig, resolveDnsProviderConfig, getDnsProvider } from './shared'
12+
import { deployStaticSiteWithExternalDnsFull } from '../../src/deploy/static-site-external-dns'
13+
import type { DnsProviderConfig } from '../../src/dns/types'
1214

1315
export function registerDeployCommands(app: CLI): void {
1416
app
1517
.command('deploy', 'Deploy infrastructure')
1618
.option('--stack <name>', 'Stack name')
1719
.option('--env <environment>', 'Environment to deploy to')
18-
.action(async (options?: { stack?: string, env?: string }) => {
20+
.option('--site <name>', 'Deploy specific site only')
21+
.action(async (options?: { stack?: string, env?: string, site?: string }) => {
1922
cli.header('Deploying Infrastructure')
2023

2124
try {
2225
// Load configuration
2326
const config = await loadValidatedConfig()
24-
const environment = (options?.env || 'production') as 'production' | 'staging' | 'development'
27+
const environment = (options?.env || 'staging') as 'production' | 'staging' | 'development'
2528
const stackName = options?.stack || `${config.project.slug}-${environment}`
2629
const region = config.project.region || 'us-east-1'
2730

31+
// Check if this is a static site deployment
32+
if (config.sites && Object.keys(config.sites).length > 0) {
33+
const dnsProvider = config.infrastructure?.dns?.provider
34+
35+
if (dnsProvider && dnsProvider !== 'route53') {
36+
// Deploy static sites with external DNS
37+
await deployStaticSitesWithExternalDns(config, options?.site, dnsProvider, region)
38+
return
39+
}
40+
}
41+
2842
cli.info(`Stack: ${stackName}`)
2943
cli.info(`Region: ${region}`)
3044
cli.info(`Environment: ${environment}`)
@@ -672,3 +686,165 @@ https://${bucket}.s3.${region}.amazonaws.com${prefix ? `/${prefix}` : ''}/index.
672686
}
673687
})
674688
}
689+
690+
/**
691+
* Deploy static sites with external DNS provider (Cloudflare, Porkbun, GoDaddy)
692+
* Handles detection of existing DNS records (like Netlify) and prompts for migration
693+
*/
694+
async function deployStaticSitesWithExternalDns(
695+
config: any,
696+
specificSite: string | undefined,
697+
dnsProviderName: string,
698+
region: string,
699+
): Promise<void> {
700+
const sites = config.sites || {}
701+
const siteNames = specificSite ? [specificSite] : Object.keys(sites)
702+
703+
if (siteNames.length === 0) {
704+
cli.warn('No sites configured in cloud.config.ts')
705+
return
706+
}
707+
708+
// Get DNS provider config from environment
709+
const dnsConfig = resolveDnsProviderConfig(dnsProviderName)
710+
if (!dnsConfig) {
711+
cli.error(`DNS provider '${dnsProviderName}' is not configured. Please set the required environment variables.`)
712+
cli.info('\nFor Cloudflare: CLOUDFLARE_API_TOKEN')
713+
cli.info('For Porkbun: PORKBUN_API_KEY, PORKBUN_SECRET_KEY')
714+
cli.info('For GoDaddy: GODADDY_API_KEY, GODADDY_API_SECRET')
715+
return
716+
}
717+
718+
const dnsProvider = getDnsProvider(dnsProviderName)
719+
720+
for (const siteName of siteNames) {
721+
const siteConfig = sites[siteName]
722+
if (!siteConfig) {
723+
cli.error(`Site '${siteName}' not found in configuration`)
724+
continue
725+
}
726+
727+
const domain = siteConfig.domain
728+
if (!domain) {
729+
cli.error(`Site '${siteName}' has no domain configured`)
730+
continue
731+
}
732+
733+
cli.header(`Deploying Site: ${siteName}`)
734+
cli.info(`Domain: ${domain}`)
735+
cli.info(`Source: ${siteConfig.root}`)
736+
cli.info(`DNS Provider: ${dnsProviderName}`)
737+
738+
// Check if source directory exists
739+
if (!existsSync(siteConfig.root)) {
740+
cli.error(`Source directory not found: ${siteConfig.root}`)
741+
cli.info('Run your build command first (e.g., bun run generate)')
742+
continue
743+
}
744+
745+
// Check for existing DNS records
746+
cli.step('Checking existing DNS records...')
747+
const existingRecords = await dnsProvider.listRecords(domain)
748+
749+
if (existingRecords.success && existingRecords.records.length > 0) {
750+
// Look for existing CNAME records that might be pointing to Netlify or other providers
751+
const domainParts = domain.split('.')
752+
const subdomain = domainParts.length > 2 ? domainParts[0] : '@'
753+
const rootDomain = domainParts.slice(-2).join('.')
754+
755+
const existingCname = existingRecords.records.find(r =>
756+
r.type === 'CNAME' &&
757+
(r.name === domain || r.name === subdomain || r.name === `${subdomain}.${rootDomain}`)
758+
)
759+
760+
if (existingCname) {
761+
const isNetlify = existingCname.content.includes('netlify')
762+
const isVercel = existingCname.content.includes('vercel')
763+
const isCloudFront = existingCname.content.includes('cloudfront.net')
764+
765+
// Skip if already pointing to CloudFront (our infrastructure)
766+
if (isCloudFront) {
767+
cli.info(`Domain already points to CloudFront: ${existingCname.content}`)
768+
cli.info('Proceeding with file upload...')
769+
} else {
770+
const providerName = isNetlify ? 'Netlify' : isVercel ? 'Vercel' : 'another provider'
771+
772+
cli.warn(`\nExisting CNAME record detected:`)
773+
cli.info(` ${existingCname.name} -> ${existingCname.content}`)
774+
775+
if (isNetlify || isVercel) {
776+
cli.info(`\nThis domain is currently pointing to ${providerName}.`)
777+
cli.info('Deploying will update this record to point to AWS CloudFront.')
778+
}
779+
780+
const proceed = await cli.confirm(`\nUpdate DNS record to point to AWS CloudFront?`, true)
781+
if (!proceed) {
782+
cli.info('Deployment cancelled')
783+
continue
784+
}
785+
786+
// Delete the old CNAME record before deploying
787+
cli.step(`Removing old ${providerName} CNAME record...`)
788+
const deleteResult = await dnsProvider.deleteRecord(domain, {
789+
name: existingCname.name,
790+
type: 'CNAME',
791+
content: existingCname.content,
792+
})
793+
794+
if (deleteResult.success) {
795+
cli.success(`Removed CNAME: ${existingCname.name} -> ${existingCname.content}`)
796+
} else {
797+
cli.warn(`Could not remove old record: ${deleteResult.message}`)
798+
cli.info('The deployment will attempt to update it instead.')
799+
}
800+
}
801+
}
802+
}
803+
804+
// Deploy the static site
805+
cli.step('Deploying to AWS (S3 + CloudFront)...')
806+
807+
const result = await deployStaticSiteWithExternalDnsFull({
808+
siteName: `${config.project.slug}-${siteName}`,
809+
domain,
810+
region,
811+
sourceDir: siteConfig.root,
812+
certificateArn: siteConfig.certificateArn,
813+
dnsProvider: dnsConfig,
814+
onProgress: (stage, detail) => {
815+
if (stage === 'infrastructure') {
816+
cli.step(detail || 'Setting up infrastructure...')
817+
} else if (stage === 'upload') {
818+
// Show upload progress without spamming
819+
if (detail?.includes('/') && !detail.includes('1/')) {
820+
const match = detail.match(/(\d+)\/(\d+)/)
821+
if (match) {
822+
const [, current, total] = match
823+
if (Number(current) % 10 === 0 || current === total) {
824+
cli.info(` Uploaded ${current}/${total} files`)
825+
}
826+
}
827+
}
828+
} else if (stage === 'invalidate') {
829+
cli.step('Invalidating CDN cache...')
830+
} else if (stage === 'complete') {
831+
// Handled below
832+
}
833+
},
834+
})
835+
836+
if (result.success) {
837+
cli.success('\nDeployment successful!')
838+
cli.box(`Site Deployed!
839+
840+
Domain: https://${result.domain}
841+
CloudFront: ${result.distributionDomain}
842+
Bucket: ${result.bucket}
843+
Files: ${result.filesUploaded}
844+
845+
Your site is now live at https://${result.domain}`, 'green')
846+
} else {
847+
cli.error(`\nDeployment failed: ${result.message}`)
848+
}
849+
}
850+
}

packages/ts-cloud/bin/commands/domain.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { getDnsProvider, resolveDnsProviderConfig } from './shared'
77
export function registerDomainCommands(app: CLI): void {
88
app
99
.command('domain:list', 'List all domains')
10-
.option('--provider <provider>', 'DNS provider: porkbun, godaddy, or route53')
10+
.option('--provider <provider>', 'DNS provider: porkbun, godaddy, cloudflare, or route53')
1111
.action(async (options?: { provider?: string }) => {
1212
cli.header('Domains')
1313

@@ -46,7 +46,7 @@ export function registerDomainCommands(app: CLI): void {
4646

4747
app
4848
.command('domain:add <domain>', 'Add a new domain')
49-
.option('--provider <provider>', 'DNS provider: porkbun, godaddy, or route53')
49+
.option('--provider <provider>', 'DNS provider: porkbun, godaddy, cloudflare, or route53')
5050
.action(async (domain: string, options?: { provider?: string }) => {
5151
cli.header(`Adding Domain: ${domain}`)
5252

@@ -149,7 +149,7 @@ export function registerDomainCommands(app: CLI): void {
149149

150150
app
151151
.command('domain:verify <domain>', 'Verify domain ownership and SSL status')
152-
.option('--provider <provider>', 'DNS provider: porkbun, godaddy, or route53')
152+
.option('--provider <provider>', 'DNS provider: porkbun, godaddy, cloudflare, or route53')
153153
.action(async (domain: string, options?: { provider?: string }) => {
154154
cli.header(`Verifying Domain: ${domain}`)
155155

@@ -234,7 +234,7 @@ export function registerDomainCommands(app: CLI): void {
234234

235235
app
236236
.command('dns:records <domain>', 'List DNS records for a domain')
237-
.option('--provider <provider>', 'DNS provider: porkbun, godaddy, or route53')
237+
.option('--provider <provider>', 'DNS provider: porkbun, godaddy, cloudflare, or route53')
238238
.option('--type <type>', 'Filter by record type (A, AAAA, CNAME, TXT, MX, etc.)')
239239
.action(async (domain: string, options?: { provider?: string, type?: string }) => {
240240
cli.header(`DNS Records for ${domain}`)
@@ -282,7 +282,7 @@ export function registerDomainCommands(app: CLI): void {
282282

283283
app
284284
.command('dns:add <domain> <type> <value>', 'Add DNS record')
285-
.option('--provider <provider>', 'DNS provider: porkbun, godaddy, or route53')
285+
.option('--provider <provider>', 'DNS provider: porkbun, godaddy, cloudflare, or route53')
286286
.option('--name <name>', 'Record name (subdomain)', { default: '@' })
287287
.option('--ttl <seconds>', 'Time to live in seconds', { default: '300' })
288288
.action(async (domain: string, type: string, value: string, options?: { provider?: string, name?: string, ttl?: string }) => {
@@ -325,7 +325,7 @@ export function registerDomainCommands(app: CLI): void {
325325

326326
app
327327
.command('dns:delete <domain> <type>', 'Delete DNS record')
328-
.option('--provider <provider>', 'DNS provider: porkbun, godaddy, or route53')
328+
.option('--provider <provider>', 'DNS provider: porkbun, godaddy, cloudflare, or route53')
329329
.option('--name <name>', 'Record name (subdomain)', { default: '@' })
330330
.option('--value <value>', 'Record value (required for multi-value records)')
331331
.action(async (domain: string, type: string, options?: { provider?: string, name?: string, value?: string }) => {

packages/ts-cloud/bin/commands/shared.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,15 @@ export function resolveDnsProviderConfig(providerName?: string): DnsProviderConf
4545
const hostedZoneId = process.env.AWS_HOSTED_ZONE_ID
4646
return { provider: 'route53', region, hostedZoneId }
4747
}
48+
case 'cloudflare': {
49+
const apiToken = process.env.CLOUDFLARE_API_TOKEN
50+
if (!apiToken) {
51+
throw new Error('CLOUDFLARE_API_TOKEN environment variable is required for Cloudflare provider')
52+
}
53+
return { provider: 'cloudflare', apiToken }
54+
}
4855
default:
49-
throw new Error(`Unknown DNS provider: ${providerName}. Supported: porkbun, godaddy, route53`)
56+
throw new Error(`Unknown DNS provider: ${providerName}. Supported: porkbun, godaddy, route53, cloudflare`)
5057
}
5158
}
5259

@@ -81,6 +88,12 @@ export function resolveDnsProviderConfig(providerName?: string): DnsProviderConf
8188
hostedZoneId: process.env.AWS_HOSTED_ZONE_ID,
8289
}
8390
}
91+
if (process.env.CLOUDFLARE_API_TOKEN) {
92+
return {
93+
provider: 'cloudflare',
94+
apiToken: process.env.CLOUDFLARE_API_TOKEN,
95+
}
96+
}
8497

8598
return null
8699
}
@@ -91,7 +104,7 @@ export function resolveDnsProviderConfig(providerName?: string): DnsProviderConf
91104
export function getDnsProvider(providerName?: string): DnsProvider {
92105
const config = resolveDnsProviderConfig(providerName)
93106
if (!config) {
94-
throw new Error('No DNS provider configured. Set environment variables for Porkbun (PORKBUN_API_KEY, PORKBUN_SECRET_KEY), GoDaddy (GODADDY_API_KEY, GODADDY_API_SECRET), or Route53 (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)')
107+
throw new Error('No DNS provider configured. Set environment variables for Porkbun (PORKBUN_API_KEY, PORKBUN_SECRET_KEY), GoDaddy (GODADDY_API_KEY, GODADDY_API_SECRET), Cloudflare (CLOUDFLARE_API_TOKEN), or Route53 (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)')
95108
}
96109
return createDnsProvider(config)
97110
}

0 commit comments

Comments
 (0)