@stacksjs/buddy 0.72.53 → 0.72.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -501,9 +501,17 @@ export declare function planTxtReplacement<T extends { content?: unknown, value?
501
501
  export declare function reconcileMailDns(res: MailTenantResult, ip: string, logger: typeof log): Promise<void>;
502
502
  /**
503
503
  * Return the application zones that should receive config/dns.ts records.
504
+ *
504
505
  * Redirect-only domains still receive their managed apex/www A records through
505
506
  * reconcileHetznerDns, but must not inherit the primary app's MX, SPF, or site
506
507
  * verification records.
508
+ *
509
+ * Neither may a site that is a HOST inside a zone this app already owns.
510
+ * config/dns.ts describes one zone; applying it to every site hostname made
511
+ * the deploy try to create `www.mta-sts.stacksjs.com` from the scaffold's
512
+ * `{ name: 'www' }` entry, which is a record for a zone that does not exist
513
+ * and a host nobody asked for. A site whose domain sits under another site's
514
+ * domain gets its records from that parent zone's reconcile, not its own.
507
515
  */
508
516
  export declare function configDnsDomains(sites: Record<string, any>): string[];
509
517
  /**
@@ -76,47 +76,76 @@ if [ -n "$DOMAIN" ] && [ -f "$ENVF" ]; then
76
76
  ENV_CHANGED=1 ;;
77
77
  esac
78
78
  fi
79
- # 2) Per-domain DKIM key: generate on first sight, register in DKIM_EXTRA_KEYS.
79
+ # 2) DKIM: decide which key signs THIS domain, then register and publish that
80
+ # one key. Read the server's global signer first, because it wins.
81
+ #
82
+ # mail's \`configureDkim\` registers the DKIM_DOMAIN signer before any
83
+ # DKIM_EXTRA_KEYS entry and *silently drops* an entry for a domain that
84
+ # already has one. So for the domain that IS DKIM_DOMAIN, the global key
85
+ # signs and nothing this script generates is ever used.
86
+ #
87
+ # This used to generate a per-domain key anyway and register it anyway,
88
+ # leaving a dead 2048-bit key on disk, an inert env entry, and a warning on
89
+ # every deploy that no action could clear. Worse, the entry named a key
90
+ # whose public half was never published: the moment DKIM_DOMAIN moved to
91
+ # another domain, this entry would start signing with a key no record
92
+ # matched, and every message would fail DKIM with nothing in the logs.
93
+ # Registering the key that actually signs makes the handover a no-op.
80
94
  if [ -n "$DOMAIN" ] && [ -f "$ENVF" ]; then
81
95
  mkdir -p "$DKIMDIR"
82
- KEY="$DKIMDIR/$DOMAIN.private"
83
- if [ ! -f "$KEY" ]; then
84
- openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$KEY" 2>/dev/null
85
- chown mail-server:mail-server "$KEY" 2>/dev/null || true
86
- chmod 600 "$KEY" 2>/dev/null || true
96
+ gdom=$(grep -E '^DKIM_DOMAIN=' "$ENVF" | head -1 | cut -d= -f2- || true)
97
+ gkey=$(grep -E '^DKIM_PRIVATE_KEY_PATH=' "$ENVF" | head -1 | cut -d= -f2- || true)
98
+ gsel=$(grep -E '^DKIM_SELECTOR=' "$ENVF" | head -1 | cut -d= -f2- || true)
99
+ PERDOMAIN_KEY="$DKIMDIR/$DOMAIN.private"
100
+
101
+ if [ -n "$gdom" ] && [ "$gdom" = "$DOMAIN" ] && [ -n "$gkey" ] && [ -f "$gkey" ]; then
102
+ KEY="$gkey"
103
+ SEL="\${gsel:-mail}"
104
+ echo "DKIMGLOBAL:$KEY"
105
+ # An earlier deploy may have left the unused per-domain key behind. Say
106
+ # where it is; removing a private key is the operator's call, not ours.
107
+ [ -f "$PERDOMAIN_KEY" ] && echo "DKIMSTALE:$PERDOMAIN_KEY"
108
+ else
109
+ KEY="$PERDOMAIN_KEY"
110
+ SEL=mail
111
+ if [ ! -f "$KEY" ]; then
112
+ openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$KEY" 2>/dev/null
113
+ chown mail-server:mail-server "$KEY" 2>/dev/null || true
114
+ chmod 600 "$KEY" 2>/dev/null || true
115
+ fi
87
116
  fi
117
+
118
+ # Register the effective key, replacing any earlier entry for this domain
119
+ # (which may name the dead per-domain file). Rebuilt as a list rather than
120
+ # appended, so the domain can never hold two entries.
88
121
  ex=$(grep -E '^DKIM_EXTRA_KEYS=' "$ENVF" | head -1 | cut -d= -f2- || true)
89
- ENTRY="$DOMAIN:mail:$KEY"
90
- case ",$ex," in
91
- *"$DOMAIN:mail:"*) : ;;
92
- *) if grep -qE '^DKIM_EXTRA_KEYS=' "$ENVF"; then
93
- sed -i "s|^DKIM_EXTRA_KEYS=.*|DKIM_EXTRA_KEYS=\${ex:+$ex,}$ENTRY|" "$ENVF"
94
- else
95
- echo "DKIM_EXTRA_KEYS=$ENTRY" >> "$ENVF"
96
- fi
97
- ENV_CHANGED=1 ;;
98
- esac
99
- # Publish the key the server will actually sign with, which is not always the
100
- # one registered above. mail's \`configureDkim\` registers the global
101
- # DKIM_DOMAIN signer first and then *silently drops* any DKIM_EXTRA_KEYS entry
102
- # for a domain that already has a signer. So when this domain is DKIM_DOMAIN,
103
- # the effective key stays DKIM_PRIVATE_KEY_PATH under DKIM_SELECTOR, and the
104
- # per-domain file above is dead on disk. Advertising it would publish a key
105
- # the server never signs with \u2014 every message failing DKIM while the record
106
- # looks perfectly well-formed. Mirror the server's precedence instead of
107
- # assuming ours won.
108
- EFF_KEY="$KEY"
109
- EFF_SEL=mail
110
- gdom=$(grep -E '^DKIM_DOMAIN=' "$ENVF" | head -1 | cut -d= -f2- || true)
111
- if [ -n "$gdom" ] && [ "$gdom" = "$DOMAIN" ]; then
112
- gkey=$(grep -E '^DKIM_PRIVATE_KEY_PATH=' "$ENVF" | head -1 | cut -d= -f2- || true)
113
- gsel=$(grep -E '^DKIM_SELECTOR=' "$ENVF" | head -1 | cut -d= -f2- || true)
114
- [ -n "$gkey" ] && [ -f "$gkey" ] && EFF_KEY="$gkey"
115
- [ -n "$gsel" ] && EFF_SEL="$gsel"
116
- echo "DKIMGLOBAL:$EFF_KEY"
122
+ ENTRY="$DOMAIN:$SEL:$KEY"
123
+ # Split on commas with IFS and glob-match the domain. A tr/grep pipeline is
124
+ # the obvious way to write this and the wrong one here: the separator would
125
+ # have to be a newline written as \\n inside a JS template literal, and the
126
+ # domain would become a regex whose dots match anything.
127
+ OTHERS=""
128
+ OLDIFS="$IFS"
129
+ IFS=','
130
+ for entry in $ex; do
131
+ case "$entry" in
132
+ "$DOMAIN:"*|"") : ;;
133
+ *) OTHERS="\${OTHERS:+$OTHERS,}$entry" ;;
134
+ esac
135
+ done
136
+ IFS="$OLDIFS"
137
+ NEWEX="\${OTHERS:+$OTHERS,}$ENTRY"
138
+ if [ "$NEWEX" != "$ex" ]; then
139
+ if grep -qE '^DKIM_EXTRA_KEYS=' "$ENVF"; then
140
+ sed -i "s|^DKIM_EXTRA_KEYS=.*|DKIM_EXTRA_KEYS=$NEWEX|" "$ENVF"
141
+ else
142
+ echo "DKIM_EXTRA_KEYS=$NEWEX" >> "$ENVF"
143
+ fi
144
+ ENV_CHANGED=1
117
145
  fi
118
- echo "DKIMSEL:$EFF_SEL"
119
- echo "DKIMPUB:$(openssl rsa -in "$EFF_KEY" -pubout -outform DER 2>/dev/null | base64 -w0)"
146
+
147
+ echo "DKIMSEL:$SEL"
148
+ echo "DKIMPUB:$(openssl rsa -in "$KEY" -pubout -outform DER 2>/dev/null | base64 -w0)"
120
149
  fi
121
150
  # 3) Create the configured mailboxes as per-domain isolated users (skip existing).
122
151
  if [ -n "$BOXES_B64" ] && [ -x "$MS" ]; then
@@ -355,5 +384,5 @@ EOF
355
384
  systemctl daemon-reload
356
385
  systemctl enable --now mail-health.timer >/dev/null 2>&1
357
386
  # 6) Restart only when the startup-read env actually changed (domain or DKIM key).
358
- if [ "$ENV_CHANGED" = 1 ]; then systemctl restart mail 2>/dev/null || true; echo "MAILTENANT:env-changed+restarted,forwards=$FWD_STATE"; else echo "MAILTENANT:current,forwards=$FWD_STATE"; fi`;try{const out=execSync(`ssh ${sshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:script,encoding:"utf8",stdio:["pipe","pipe","pipe"]}),line=(out.match(/MAILTENANT:[^\n]*/)||[])[0]||"MAILTENANT:done",mailHostFromOut=(out.match(/MAILHOST:([^\n]*)/)||[])[1]?.trim(),mailHost=mailHostFromOut||`mail.${domain}`,dkimPubB64=(out.match(/DKIMPUB:([^\n]*)/)||[])[1]?.trim()||void 0,dkimSelector=(out.match(/DKIMSEL:([^\n]*)/)||[])[1]?.trim()||void 0,dkimGlobalKey=(out.match(/DKIMGLOBAL:([^\n]*)/)||[])[1]?.trim();if(dkimGlobalKey)logger.warn(`Mail: ${domain} is the mail server's global DKIM_DOMAIN, so it signs with ${dkimGlobalKey} and the per-domain key at /opt/mail/dkim/${domain}.private is unused. Rotate the global key, not that one.`);const madeAddrs=new Set([...out.matchAll(/MADE:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])),migratedAddrs=new Set([...out.matchAll(/MIGRATED:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])),created=boxes.filter((b)=>madeAddrs.has(b.address)).map((b)=>({address:b.address,password:b.password}));logger.success(`Mail routing reconciled (${line.replace("MAILTENANT:","")})`);const failed=[...out.matchAll(/FAIL:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[]);if(failed.length)logger.warn(`Mail: the server refused ${failed.length} mailbox(es): ${failed.join(", ")}`);const forwardError=(out.match(/FWDERR:([^\n]*)/)||[])[1]?.trim();if(forwardError)logger.warn(`Mail: the forward rules were not merged: ${forwardError}`);const certHost=(out.match(/CERTHOST:([^\n]*)/)||[])[1]?.trim();if(certHost)logger.success(`Mail: ${certHost} added to the mail certificate - clients can use it as the server name`);const certError=(out.match(/CERTFAIL:([^\n]*)/)||[])[1]?.trim();if(certError)logger.warn(`Mail: could not issue a certificate for mail.${domain} (clients should use ${mailHostFromOut||"the shared mail host"}): ${certError}`);const seen=new Set([...madeAddrs,...migratedAddrs,...[...out.matchAll(/EXISTS:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])]),unaccounted=boxes.filter((b)=>!seen.has(b.address)).map((b)=>b.address);if(unaccounted.length)logger.warn(`Mail: ${unaccounted.length} declared mailbox(es) were not reconciled: ${unaccounted.join(", ")}`);if(created.length){logger.info(`Mail: created ${created.length} mailbox(es) - credentials below (save them; shown once):`);for(const b of created)logger.info(` ${b.address} ${b.password}`)}if(migratedAddrs.size)logger.success(`Mail: migrated ${migratedAddrs.size} legacy mailbox username(s) to isolated full addresses`);return domain?{domain,mailHost,dkimPubB64,dkimSelector,created}:null}catch(err){logger.warn(`Mail routing reconcile skipped: ${getErrorMessage(err)}`);return null}}export function dnsProviderConfigsFromEnv(){const configs=[];if(process.env.PORKBUN_API_KEY&&process.env.PORKBUN_SECRET_KEY)configs.push({provider:"porkbun",apiKey:process.env.PORKBUN_API_KEY,secretKey:process.env.PORKBUN_SECRET_KEY});if(process.env.CLOUDFLARE_API_TOKEN)configs.push({provider:"cloudflare",apiToken:process.env.CLOUDFLARE_API_TOKEN});if(process.env.GODADDY_API_KEY&&process.env.GODADDY_API_SECRET)configs.push({provider:"godaddy",apiKey:process.env.GODADDY_API_KEY,apiSecret:process.env.GODADDY_API_SECRET,environment:process.env.GODADDY_ENVIRONMENT});if(process.env.AWS_ACCESS_KEY_ID||process.env.AWS_PROFILE)configs.push({provider:"route53"});return configs}async function resolveZoneDnsProvider(domain,providerConfigs,logger){if(providerConfigs.length===0)return;const{createDnsProvider,detectDnsProvider}=await import("@stacksjs/ts-cloud"),provider=await detectDnsProvider(domain,providerConfigs).catch((err)=>{logger.warn(` DNS: ignoring a configured provider for ${domain} - its credentials were rejected (${err?.message||err})`);return});if(provider)return provider;let nameservers=[];try{const{resolveNs}=await import("node:dns/promises");nameservers=await resolveNs(domain)}catch{}const providerName=dnsProviderNameFromNameservers(nameservers),providerConfig=providerConfigs.find((config)=>config.provider===providerName);return providerConfig?createDnsProvider(providerConfig):void 0}export function resolveDmarcPolicy(policy){return policy==="none"||policy==="quarantine"||policy==="reject"?policy:"quarantine"}export function zoneFqdn(name,zone){const apex=zone.replace(/\.$/,"").toLowerCase(),n=String(name??"").replace(/\.$/,"").toLowerCase();if(!n||n==="@")return apex;return n===apex||n.endsWith(`.${apex}`)?n:`${n}.${apex}`}export function selectRecordsAt(records,fqdn,type,zone){const target=zoneFqdn(fqdn,zone);return records.filter((r)=>String(r.type).toUpperCase()===type.toUpperCase()&&zoneFqdn(r.name,zone)===target)}export function findMailDnsAnomalies(records,expectations,zone){const problems=[];for(const expectation of expectations){const at=selectRecordsAt(records,expectation.fqdn,expectation.type,zone),ours=expectation.owns?at.filter((record)=>expectation.owns(txtContent(record))):at;if(ours.length===0)problems.push(`${expectation.label}: nothing published at ${expectation.fqdn}`);else if(ours.length>1)problems.push(`${expectation.label}: ${ours.length} records at ${expectation.fqdn}, expected 1 - receivers treat a duplicated ${expectation.type} here as unconfigured, so remove the stale one`)}return problems}export function txtContent(record){return String(record.content??record.value??"").replace(/^"|"$/g,"")}export function planTxtReplacement(existing,content,owns){const ours=existing.filter((record)=>owns(txtContent(record)));if(ours.length===1&&txtContent(ours[0])===content)return{remove:[],create:!1};return{remove:ours,create:!0}}export async function reconcileMailDns(res,ip,logger){const{domain,dkimPubB64}=res;let{mailHost}=res;const dkimName=`${res.dkimSelector||"mail"}._domainkey`;try{const dns=await import("node:dns"),own=`mail.${domain}`;if(own!==mailHost&&(await dns.promises.resolve4(own)).includes(ip))mailHost=own}catch{}const spf=`v=spf1 ip4:${ip} ~all`,cfg=emailConfig||{},firstBox=resolveMailboxes(cfg.mailboxes,domain)[0]?.address,fromAddress=typeof cfg.from?.address==="string"&&cfg.from.address.includes("@")?cfg.from.address:void 0,dmarcCfg=cfg.server?.dmarc||{},rua=dmarcCfg.reportTo||fromAddress||firstBox||`chris@${domain}`,dmarc=`v=DMARC1; p=${resolveDmarcPolicy(dmarcCfg.policy)}; rua=mailto:${rua}`,dkim=dkimPubB64?`v=DKIM1; k=rsa; p=${dkimPubB64}`:void 0,byHand=(reason)=>{logger.warn(`Mail DNS not published for ${domain}: ${reason}`);logger.info("Add these records by hand, or point a configured provider at the zone:");logger.info(` MX @ 10 ${mailHost}`);logger.info(` TXT @ ${spf}`);if(dkim)logger.info(` TXT ${dkimName.padEnd(17)}${dkim}`);logger.info(` TXT _dmarc ${dmarc}`);logger.info(` A mail ${ip}`)},providerConfigs=dnsProviderConfigsFromEnv();if(providerConfigs.length===0)return byHand("no DNS provider credentials are configured");const provider=await resolveZoneDnsProvider(domain,providerConfigs,logger);if(!provider)return byHand("no configured DNS provider administers this zone");const apex=domain.toLowerCase(),dkimFqdn=`${dkimName}.${domain}`,dmarcFqdn=`_dmarc.${domain}`,mailFqdn=`mail.${domain}`,recordsAt=async(fqdn,type)=>{const res=await provider.listRecords(domain);return selectRecordsAt(res?.success?res.records||[]:[],fqdn,type,domain)},replaceTxt=async(fqdn,content,owns)=>{const existing=await recordsAt(fqdn,"TXT"),{remove,create}=planTxtReplacement(existing,content,owns);if(!create)return;for(const record of remove){const removed=await provider.deleteRecord(domain,{...record,name:fqdn,type:"TXT"});if(removed&&removed.success===!1)throw Error(`TXT ${fqdn}: could not remove the record being replaced (${removed.message||"provider refused the delete"})`)}const created=await provider.createRecord(domain,{name:fqdn,type:"TXT",content,ttl:600});if(!created?.success)throw Error(`TXT ${fqdn}: ${created?.message||"provider rejected the record"}`)};try{const mxRecords=await recordsAt(apex,"MX"),staleMx=mxRecords.filter((r)=>String(r.content??r.value??"").replace(/\.$/,"").toLowerCase()!==mailHost.toLowerCase());for(const record of staleMx){logger.warn(` Mail DNS: replacing an existing MX for ${domain} \u2192 ${record.content??record.value}`);await provider.deleteRecord(domain,{...record,name:apex,type:"MX"})}if(mxRecords.length===staleMx.length){const created=await provider.createRecord(domain,{name:apex,type:"MX",content:mailHost,ttl:600,priority:10});if(!created?.success)throw Error(`MX ${domain}: ${created?.message||"provider rejected the record"}`)}await replaceTxt(apex,spf,(existing)=>existing.toLowerCase().startsWith("v=spf1"));if(dkim)await replaceTxt(dkimFqdn,dkim,()=>!0);await replaceTxt(dmarcFqdn,dmarc,(existing)=>existing.toLowerCase().startsWith("v=dmarc1"));const mailA=await provider.upsertRecord(domain,{name:mailFqdn,type:"A",content:ip,ttl:600});if(!mailA?.success)throw Error(`A ${mailFqdn}: ${mailA?.message||"provider rejected the record"}`);const verifyRes=await provider.listRecords(domain),anomalies=verifyRes?.success?findMailDnsAnomalies(verifyRes.records||[],[{label:"MX",fqdn:apex,type:"MX"},{label:"SPF",fqdn:apex,type:"TXT",owns:(content)=>content.toLowerCase().startsWith("v=spf1")},...dkim?[{label:"DKIM",fqdn:dkimFqdn,type:"TXT"}]:[],{label:"DMARC",fqdn:dmarcFqdn,type:"TXT",owns:(content)=>content.toLowerCase().startsWith("v=dmarc1")}],domain):[];for(const anomaly of anomalies)logger.warn(` Mail DNS: ${anomaly}`);logger.success(`Mail DNS published for ${domain} via ${provider.name} (MX\u2192${mailHost}, SPF, DKIM at ${dkimName}, DMARC, ${mailFqdn}\u2192${ip})`)}catch(err){logger.warn(`Mail DNS reconcile skipped for ${domain}: ${getErrorMessage(err)}`)}}export function configDnsDomains(sites){const domains=new Set;for(const site of Object.values(sites))if(!site?.redirect&&site?.domain&&typeof site.domain==="string")domains.add(site.domain.replace(/^www\./,""));return[...domains]}async function reconcileCloudflareCdnForDeploy(tsCloudConfig,ip,ipv6,logger){let resolveCloudflareCdnPlan,reconcileCloudflareCdn,CloudflareProvider,normalizePublicIpv6;try{({resolveCloudflareCdnPlan,reconcileCloudflareCdn,CloudflareProvider,normalizePublicIpv6}=await import("@stacksjs/ts-cloud"))}catch{return}if(typeof resolveCloudflareCdnPlan!=="function"||typeof reconcileCloudflareCdn!=="function"){if(tsCloudConfig?.infrastructure?.compute?.proxy?.cdn?.provider==="cloudflare")logger.warn("Cloudflare CDN: installed @stacksjs/ts-cloud is too old to manage it \u2014 upgrade to ^0.9.4.");return}const{plan,errors}=resolveCloudflareCdnPlan(tsCloudConfig);for(const error of errors)logger.warn(`Cloudflare CDN: ${error}`);if(!plan)return;if(!ip){logger.warn("Cloudflare CDN: no box IP resolved \u2014 skipping.");return}logger.info(`Cloudflare CDN: reconciling ${plan.hosts.length} host(s) on ${plan.zone}...`);try{const provider=new CloudflareProvider(plan.apiToken,{zoneId:plan.zoneId,accountId:plan.accountId}),report=await reconcileCloudflareCdn({provider,zone:plan.zone,hosts:plan.hosts,ipv4:ip,ipv6:typeof normalizePublicIpv6==="function"?normalizePublicIpv6(ipv6):ipv6,proxied:plan.proxied,settings:plan.settings,cache:plan.cache,originGuard:plan.originGuard,purge:plan.purge,skipOriginProbe:plan.skipOriginProbe});for(const record of report.records)logger.success(` ${record.host} ${record.type} \u2192 ${record.content}${record.proxied?" (proxied)":" (DNS-only)"}`);for(const setting of report.settingsChanged)logger.info(` ${setting.id}: ${JSON.stringify(setting.from)} \u2192 ${JSON.stringify(setting.to)}`);if(report.cacheRules>0)logger.success(` ${report.cacheRules} cache rule(s) applied`);if(report.originGuard)logger.success(" origin guard header applied");if(report.purged)logger.success(" edge cache purged");for(const deferred of report.deferredProxy||[])logger.warn(` ${deferred.host} left DNS-only: ${deferred.reason} \u2014 re-run the deploy once TLS is issued.`);for(const warning of report.warnings)logger.warn(` ${warning}`)}catch(err){logger.warn(`Cloudflare CDN: ${err?.message||err}`)}}export function dnsProviderNameFromNameservers(nameservers){const normalized=nameservers.map((name)=>name.toLowerCase().replace(/\.$/,""));if(normalized.some((name)=>name.endsWith(".porkbun.com")))return"porkbun";if(normalized.some((name)=>name.endsWith(".ns.cloudflare.com")))return"cloudflare";if(normalized.some((name)=>/(^|\.)awsdns-\d+\.(?:com|net|org|co\.uk)$/.test(name)))return"route53";if(normalized.some((name)=>name.endsWith(".domaincontrol.com")))return"godaddy";return null}async function reconcileConfigDns(sites,logger){const projectDnsConfig=await loadProjectDnsConfig(dnsConfig);if(["a","aaaa","cname","mx","txt"].reduce((total,key)=>total+(Array.isArray(projectDnsConfig?.[key])?projectDnsConfig[key].length:0),0)===0)return;for(const domain of configDnsDomains(sites))try{const result=await syncDnsConfig(domain,projectDnsConfig);if(!result.provider)continue;if(result.created||result.failed)logger.info(`DNS (config/dns.ts) ${domain}: ${result.created} created, ${result.kept} kept${result.failed?`, ${result.failed} failed`:""}`)}catch(err){logger.warn(`DNS (config/dns.ts) reconcile for ${domain} failed: ${err instanceof Error?err.message:String(err)}`)}}async function reconcileHetznerDns(sites,ip,logger,ipv6,autoWww){const published=[],{gatewayHostnames}=await import("@stacksjs/ts-cloud"),hostnames=gatewayHostnames(sites,{autoWww}),byBase=new Map;for(const fqdn of hostnames){const base=fqdn.replace(/^www\./,""),group=byBase.get(base)??new Set;group.add(fqdn);byBase.set(base,group)}if(byBase.size===0)return published;const providerConfigs=dnsProviderConfigsFromEnv();if(providerConfigs.length===0){logger.warn("DNS: no DNS provider credentials found (PORKBUN_API_KEY/\u2026); skipping DNS reconciliation.");for(const fqdn of hostnames)logger.info(` Point manually: A ${fqdn} \u2192 ${ip}`);return published}const{reconcileAddressRecords}=await import("@stacksjs/ts-cloud");logger.info("Reconciling DNS records...");const resolveA=async(fqdn)=>{try{const{resolve4}=await import("node:dns/promises");return await resolve4(fqdn)}catch{return[]}};for(const[domain,group]of byBase){const fqdns=[...group].sort();try{const provider=await resolveZoneDnsProvider(domain,providerConfigs,logger);if(!provider){for(const fqdn of fqdns){const current=await resolveA(fqdn);if(current.includes(ip))logger.success(` DNS: ${fqdn} \u2192 ${ip} (externally managed, already correct)`);else if(current.length===0)logger.warn(` DNS: ${fqdn} does not resolve and no configured provider manages ${domain} - create it manually: A ${fqdn} \u2192 ${ip}`);else logger.warn(` DNS: ${fqdn} resolves to ${current.join(", ")} but this deploy targets ${ip}, and no configured provider manages ${domain} - update it manually: A ${fqdn} \u2192 ${ip}`)}continue}for(const fqdn of fqdns){const report=await reconcileAddressRecords({provider,zone:domain,fqdn,ipv4:ip,ipv6});for(const record of report.published){logger.success(` DNS: ${record.fqdn} \u2192 ${record.content} (${provider.name})`);published.push(record.fqdn)}for(const warning of report.warnings)logger.warn(` DNS: ${warning}`)}}catch(err){logger.warn(` DNS: ${domain} reconciliation failed: ${err?.message||err}`)}}return published}async function buildContainerImageWithPantry(args){const{slug,sites,verbose}=args,{execSync}=await import("node:child_process");let cli;for(const candidate of["pantry","ts-pantry"])try{execSync(`command -v ${candidate}`,{stdio:"pipe"});cli=candidate;break}catch{}if(!cli){log.warn("pantry CLI not found on PATH - skipping container image build. Install pantry to enable `--docker`.");return}const dockerfile="storage/framework/Dockerfile",canPush=Boolean(process.env.PANTRY_REGISTRY_TOKEN||process.env.PANTRY_TOKEN);for(const[siteName,site]of Object.entries(sites)){if(!site?.start)continue;const tag=`${slug}-${siteName}:latest`;log.info(`[${siteName}] building image ${tag} with pantry (native, no Docker daemon)...`);const flags=["build",".","-t",tag,"-f",dockerfile,"--run-mode","skip"];if(canPush)flags.push("--push");execSync(`${cli} ${flags.map((f)=>f.includes(" ")?`'${f}'`:f).join(" ")}`,{stdio:verbose?"inherit":"pipe",maxBuffer:536870912});log.success(`[${siteName}] image built${canPush?" + pushed to the pantry registry":""}`)}}export function deploy(buddy){const descriptions={deploy:"Deploy your project",project:"Target a specific project",production:"Deploy to production",development:"Deploy to development",staging:"Deploy to staging",yes:"Confirm all prompts by default",domain:"Specify a domain to deploy to",verbose:"Enable verbose output"};buddy.command("deploy [env]",descriptions.deploy).option("--domain <domain>",descriptions.domain,{default:void 0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--prod",descriptions.production,{default:!1}).option("--dev",descriptions.development,{default:!1}).option("--yes",descriptions.yes,{default:!1}).option("--site <name>","Deploy only this one site to the existing server (multi-tenant surgical add)",{default:void 0}).option("--staging",descriptions.staging,{default:!1}).option("--docker","Also build an OCI image with pantry (native, no Docker daemon) and push it to the pantry registry",{default:!1}).option("-J, --json","Emit a machine-readable deployment preview",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(withDeployNotification(async(envArg,options)=>{log.debug("Running `buddy deploy` ...",options);const askedForDryRun=process.argv.includes("--dry-run")||options.dryRun===!0,optionEnvironment=options.env,deployEnvName=resolveDeploymentEnvironment({positional:envArg,option:optionEnvironment,staging:options.staging,development:options.dev}),deployEnv=deployEnvName;try{applyDeploymentDomainOverride({},options.domain)}catch(error){log.error(getErrorMessage(error));process.exit(ExitCode.InvalidArgument)}if(askedForDryRun){process.env.APP_ENV=deployEnvName;const envFile=deployEnvName==="production"?".env.production":`.env.${deployEnvName}`;if(existsSync(p.projectPath(envFile)))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:deployEnvName,keysFile:".env.keys",overload:!0,quiet:!0})}catch(error){log.debug(`Could not load ${envFile} for preview: ${getErrorMessage(error)}`)}const tsCloudConfig=await loadTsCloudConfig(deployEnvName),{resolveSiteKind}=await loadTsCloudDeployApi(),unbacked=tsCloudConfig?findUnbackedManagedServices(tsCloudConfig):[];let plan;try{plan=createDeploymentPreview({config:tsCloudConfig,environment:deployEnvName,site:options.site,domain:options.domain,docker:options.docker===!0,fallbackProjectName:app.name,fallbackProjectSlug:app.name?.toLowerCase().replace(/[^a-z0-9]+/g,"-")||"app",fallbackProvider:"aws",fallbackMode:cloudConfig.mode||"server",fallbackRegion:process.env.AWS_REGION||"us-east-1",resolveSiteKind,applyEnvironmentToSites,warnings:unbacked.length>0?[unbackedDataMessage(unbacked)]:[]})}catch(error){log.error(getErrorMessage(error));process.exit(ExitCode.InvalidArgument)}if(options.json||process.argv.includes("--json"))console.log(`${deploymentPreviewJsonPrefix}${JSON.stringify(plan)}`);else process.stdout.write(formatDeploymentPreview(plan));return}await ensureDeployPrerequisites(options.verbose===!0,deployEnvName);process.env.APP_ENV=deployEnvName;{const envFile=deployEnvName==="production"?".env.production":`.env.${deployEnvName}`;if(existsSync(p.projectPath(envFile)))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:deployEnvName,keysFile:".env.keys",overload:!0,quiet:!0})}catch(err){log.warn(`Could not load ${envFile}: ${getErrorMessage(err)}`)}}const tsCloudConfig=await loadTsCloudConfig(deployEnvName);if(tsCloudConfig&&resolveProvider(tsCloudConfig)==="hetzner"){await deployToHetzner(applyDeploymentDomainOverride(tsCloudConfig,options.domain),deployEnv,options);return}if(process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY)delete process.env.AWS_PROFILE;const startTime=performance.now();console.log("");console.log("\uD83D\uDE80 Deploy");console.log("");let productionUrl;if(deployEnv==="production"||deployEnv==="prod"){const prodEnvPath=p.projectPath(".env.production");if(existsSync(prodEnvPath)){const urlMatch=readFileSync(prodEnvPath,"utf-8").match(/^APP_URL=(.+)$/m);if(urlMatch?.[1]){productionUrl=urlMatch[1].trim();log.debug("Using APP_URL from .env.production:",productionUrl)}}}const envUrl=env.APP_URL,domain=options.domain||productionUrl||envUrl||app.url;if(deployEnvName==="production"&&!options.yes)await confirmProductionDeployment();if(!domain){log.info("No domain found in your .env.production or ./config/app.ts");log.info("Please ensure your domain is properly configured.");log.info("For more info, check out the docs or join our Discord.");process.exit(ExitCode.FatalError)}log.info(`Deploying to ${italic(domain)} (${deployEnv})`);await checkIfAwsIsBootstrapped(options);options.domain=await configureDomain(domain,options,startTime);const result=await runAction(Action.Deploy,options);if(resultFailed(result)){await outro("While running the `buddy deploy`, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Project deployed.",{startTime,useSeconds:!0})}));buddy.command("deploy:rollback [site]","Roll back a deployment to a preserved release").option("--env <environment>","Environment to roll back",{default:"production"}).option("--to <release>","Preserved release id to activate",{default:void 0}).option("--dry-run","Preview the rollback without changing the active release",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(site,options)=>{const exitCode=await runDeployRollback(site,options);if(exitCode!==ExitCode.Success)process.exit(exitCode)});onUnknownSubcommand(buddy,"deploy")}async function confirmProductionDeployment(){if(!process.stdin.isTTY){log.error("Refusing to deploy to production from a non-interactive shell without confirmation.");log.info(" \u27A1\uFE0F Re-run with `--yes` to confirm (e.g. in CI): `buddy deploy --prod --yes`");process.exit(ExitCode.InvalidArgument)}if(!await prompts.confirm({message:"Are you sure you want to deploy to production?",initial:!0})){log.info("Aborting deployment...");process.exit(ExitCode.InvalidArgument)}}async function configureDomain(domain,options,startTime){log.debug("Configuring domain...",domain);if(!domain){log.info("We could not identify a domain to deploy to.");log.warn("Please set your .env or ./config/app.ts properly.");log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");console.log("");log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");console.log("");process.exit(ExitCode.FatalError)}if(domain.includes("localhost")){log.info("You are deploying to a local environment.");log.warn("Please set your .env or ./config/app.ts properly. The domain we are deploying cannot be a `localhost` domain.");log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");console.log("");log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");console.log("");process.exit(ExitCode.FatalError)}if(await hasUserDomainBeenAddedToCloud(domain)){log.info("Domain is properly configured");log.info("Your cloud is deploying...");log.info(`${italic("This may take a while...")}`);return domain}console.log("");log.info(` \uD83D\uDC4B It appears to be your first ${italic(domain)} deployment.`);console.log("");log.info(italic("Let\u2019s ensure it is all connected properly."));log.info(italic("One moment..."));console.log("");const result=await addDomain({...options,deploy:!0,startTime});if(resultFailed(result)){await outro("While running the `buddy deploy`, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Added your domain.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}async function promptAndSaveCredentials(){const accessKeyId=await prompts.text({message:"AWS Access Key ID:",validate:(value)=>value.length>0?!0:"Access Key ID is required"});if(!accessKeyId){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const secretAccessKey=await prompts.password({message:"AWS Secret Access Key:",validate:(value)=>value.length>0?!0:"Secret Access Key is required"});if(!secretAccessKey){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const region=await prompts.text({message:"AWS Region:",initial:"us-east-1"});if(!region){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const{setEnv}=await import("@stacksjs/env");await setEnv("AWS_ACCESS_KEY_ID",accessKeyId,{file:".env.production",encrypt:!0});await setEnv("AWS_SECRET_ACCESS_KEY",secretAccessKey,{file:".env.production",encrypt:!0});await setEnv("AWS_REGION",region||"us-east-1",{file:".env.production"});process.env.AWS_ACCESS_KEY_ID=accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=secretAccessKey;process.env.AWS_REGION=region||"us-east-1";log.success("AWS credentials saved securely to .env.production");console.log("")}function loadAwsCredentialsFromEnv(){const environment=process.env.APP_ENV||process.env.NODE_ENV||"production",envFiles=[p.projectPath(`.env.${environment}`),p.projectPath(".env")];for(const envPath of envFiles){if(!existsSync(envPath))continue;try{const lines=readFileSync(envPath,"utf-8").split(`
387
+ if [ "$ENV_CHANGED" = 1 ]; then systemctl restart mail 2>/dev/null || true; echo "MAILTENANT:env-changed+restarted,forwards=$FWD_STATE"; else echo "MAILTENANT:current,forwards=$FWD_STATE"; fi`;try{const out=execSync(`ssh ${sshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:script,encoding:"utf8",stdio:["pipe","pipe","pipe"]}),line=(out.match(/MAILTENANT:[^\n]*/)||[])[0]||"MAILTENANT:done",mailHostFromOut=(out.match(/MAILHOST:([^\n]*)/)||[])[1]?.trim(),mailHost=mailHostFromOut||`mail.${domain}`,dkimPubB64=(out.match(/DKIMPUB:([^\n]*)/)||[])[1]?.trim()||void 0,dkimSelector=(out.match(/DKIMSEL:([^\n]*)/)||[])[1]?.trim()||void 0,dkimGlobalKey=(out.match(/DKIMGLOBAL:([^\n]*)/)||[])[1]?.trim();if(dkimGlobalKey)logger.info(`Mail: ${domain} signs with the server's global DKIM key (${dkimGlobalKey}) \u2014 rotate that one.`);const dkimStaleKey=(out.match(/DKIMSTALE:([^\n]*)/)||[])[1]?.trim();if(dkimStaleKey)logger.warn(`Mail: ${dkimStaleKey} is an unused DKIM key left by an earlier deploy \u2014 nothing signs with it. Remove it with: rm ${dkimStaleKey}`);const madeAddrs=new Set([...out.matchAll(/MADE:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])),migratedAddrs=new Set([...out.matchAll(/MIGRATED:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])),created=boxes.filter((b)=>madeAddrs.has(b.address)).map((b)=>({address:b.address,password:b.password}));logger.success(`Mail routing reconciled (${line.replace("MAILTENANT:","")})`);const failed=[...out.matchAll(/FAIL:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[]);if(failed.length)logger.warn(`Mail: the server refused ${failed.length} mailbox(es): ${failed.join(", ")}`);const forwardError=(out.match(/FWDERR:([^\n]*)/)||[])[1]?.trim();if(forwardError)logger.warn(`Mail: the forward rules were not merged: ${forwardError}`);const certHost=(out.match(/CERTHOST:([^\n]*)/)||[])[1]?.trim();if(certHost)logger.success(`Mail: ${certHost} added to the mail certificate - clients can use it as the server name`);const certError=(out.match(/CERTFAIL:([^\n]*)/)||[])[1]?.trim();if(certError)logger.warn(`Mail: could not issue a certificate for mail.${domain} (clients should use ${mailHostFromOut||"the shared mail host"}): ${certError}`);const seen=new Set([...madeAddrs,...migratedAddrs,...[...out.matchAll(/EXISTS:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])]),unaccounted=boxes.filter((b)=>!seen.has(b.address)).map((b)=>b.address);if(unaccounted.length)logger.warn(`Mail: ${unaccounted.length} declared mailbox(es) were not reconciled: ${unaccounted.join(", ")}`);if(created.length){logger.info(`Mail: created ${created.length} mailbox(es) - credentials below (save them; shown once):`);for(const b of created)logger.info(` ${b.address} ${b.password}`)}if(migratedAddrs.size)logger.success(`Mail: migrated ${migratedAddrs.size} legacy mailbox username(s) to isolated full addresses`);return domain?{domain,mailHost,dkimPubB64,dkimSelector,created}:null}catch(err){logger.warn(`Mail routing reconcile skipped: ${getErrorMessage(err)}`);return null}}export function dnsProviderConfigsFromEnv(){const configs=[];if(process.env.PORKBUN_API_KEY&&process.env.PORKBUN_SECRET_KEY)configs.push({provider:"porkbun",apiKey:process.env.PORKBUN_API_KEY,secretKey:process.env.PORKBUN_SECRET_KEY});if(process.env.CLOUDFLARE_API_TOKEN)configs.push({provider:"cloudflare",apiToken:process.env.CLOUDFLARE_API_TOKEN});if(process.env.GODADDY_API_KEY&&process.env.GODADDY_API_SECRET)configs.push({provider:"godaddy",apiKey:process.env.GODADDY_API_KEY,apiSecret:process.env.GODADDY_API_SECRET,environment:process.env.GODADDY_ENVIRONMENT});if(process.env.AWS_ACCESS_KEY_ID||process.env.AWS_PROFILE)configs.push({provider:"route53"});return configs}async function resolveZoneDnsProvider(domain,providerConfigs,logger){if(providerConfigs.length===0)return;const{createDnsProvider,detectDnsProvider}=await import("@stacksjs/ts-cloud"),provider=await detectDnsProvider(domain,providerConfigs).catch((err)=>{logger.warn(` DNS: ignoring a configured provider for ${domain} - its credentials were rejected (${err?.message||err})`);return});if(provider)return provider;let nameservers=[];try{const{resolveNs}=await import("node:dns/promises");nameservers=await resolveNs(domain)}catch{}const providerName=dnsProviderNameFromNameservers(nameservers),providerConfig=providerConfigs.find((config)=>config.provider===providerName);return providerConfig?createDnsProvider(providerConfig):void 0}export function resolveDmarcPolicy(policy){return policy==="none"||policy==="quarantine"||policy==="reject"?policy:"quarantine"}export function zoneFqdn(name,zone){const apex=zone.replace(/\.$/,"").toLowerCase(),n=String(name??"").replace(/\.$/,"").toLowerCase();if(!n||n==="@")return apex;return n===apex||n.endsWith(`.${apex}`)?n:`${n}.${apex}`}export function selectRecordsAt(records,fqdn,type,zone){const target=zoneFqdn(fqdn,zone);return records.filter((r)=>String(r.type).toUpperCase()===type.toUpperCase()&&zoneFqdn(r.name,zone)===target)}export function findMailDnsAnomalies(records,expectations,zone){const problems=[];for(const expectation of expectations){const at=selectRecordsAt(records,expectation.fqdn,expectation.type,zone),ours=expectation.owns?at.filter((record)=>expectation.owns(txtContent(record))):at;if(ours.length===0)problems.push(`${expectation.label}: nothing published at ${expectation.fqdn}`);else if(ours.length>1)problems.push(`${expectation.label}: ${ours.length} records at ${expectation.fqdn}, expected 1 - receivers treat a duplicated ${expectation.type} here as unconfigured, so remove the stale one`)}return problems}export function txtContent(record){return String(record.content??record.value??"").replace(/^"|"$/g,"")}export function planTxtReplacement(existing,content,owns){const ours=existing.filter((record)=>owns(txtContent(record)));if(ours.length===1&&txtContent(ours[0])===content)return{remove:[],create:!1};return{remove:ours,create:!0}}export async function reconcileMailDns(res,ip,logger){const{domain,dkimPubB64}=res;let{mailHost}=res;const dkimName=`${res.dkimSelector||"mail"}._domainkey`;try{const dns=await import("node:dns"),own=`mail.${domain}`;if(own!==mailHost&&(await dns.promises.resolve4(own)).includes(ip))mailHost=own}catch{}const spf=`v=spf1 ip4:${ip} ~all`,cfg=emailConfig||{},firstBox=resolveMailboxes(cfg.mailboxes,domain)[0]?.address,fromAddress=typeof cfg.from?.address==="string"&&cfg.from.address.includes("@")?cfg.from.address:void 0,dmarcCfg=cfg.server?.dmarc||{},rua=dmarcCfg.reportTo||fromAddress||firstBox||`chris@${domain}`,dmarc=`v=DMARC1; p=${resolveDmarcPolicy(dmarcCfg.policy)}; rua=mailto:${rua}`,dkim=dkimPubB64?`v=DKIM1; k=rsa; p=${dkimPubB64}`:void 0,byHand=(reason)=>{logger.warn(`Mail DNS not published for ${domain}: ${reason}`);logger.info("Add these records by hand, or point a configured provider at the zone:");logger.info(` MX @ 10 ${mailHost}`);logger.info(` TXT @ ${spf}`);if(dkim)logger.info(` TXT ${dkimName.padEnd(17)}${dkim}`);logger.info(` TXT _dmarc ${dmarc}`);logger.info(` A mail ${ip}`)},providerConfigs=dnsProviderConfigsFromEnv();if(providerConfigs.length===0)return byHand("no DNS provider credentials are configured");const provider=await resolveZoneDnsProvider(domain,providerConfigs,logger);if(!provider)return byHand("no configured DNS provider administers this zone");const apex=domain.toLowerCase(),dkimFqdn=`${dkimName}.${domain}`,dmarcFqdn=`_dmarc.${domain}`,mailFqdn=`mail.${domain}`,recordsAt=async(fqdn,type)=>{const res=await provider.listRecords(domain);return selectRecordsAt(res?.success?res.records||[]:[],fqdn,type,domain)},replaceTxt=async(fqdn,content,owns)=>{const existing=await recordsAt(fqdn,"TXT"),{remove,create}=planTxtReplacement(existing,content,owns);if(!create)return;for(const record of remove){const removed=await provider.deleteRecord(domain,{...record,name:fqdn,type:"TXT"});if(removed&&removed.success===!1)throw Error(`TXT ${fqdn}: could not remove the record being replaced (${removed.message||"provider refused the delete"})`)}const created=await provider.createRecord(domain,{name:fqdn,type:"TXT",content,ttl:600});if(!created?.success)throw Error(`TXT ${fqdn}: ${created?.message||"provider rejected the record"}`)};try{const mxRecords=await recordsAt(apex,"MX"),staleMx=mxRecords.filter((r)=>String(r.content??r.value??"").replace(/\.$/,"").toLowerCase()!==mailHost.toLowerCase());for(const record of staleMx){logger.warn(` Mail DNS: replacing an existing MX for ${domain} \u2192 ${record.content??record.value}`);await provider.deleteRecord(domain,{...record,name:apex,type:"MX"})}if(mxRecords.length===staleMx.length){const created=await provider.createRecord(domain,{name:apex,type:"MX",content:mailHost,ttl:600,priority:10});if(!created?.success)throw Error(`MX ${domain}: ${created?.message||"provider rejected the record"}`)}await replaceTxt(apex,spf,(existing)=>existing.toLowerCase().startsWith("v=spf1"));if(dkim)await replaceTxt(dkimFqdn,dkim,()=>!0);await replaceTxt(dmarcFqdn,dmarc,(existing)=>existing.toLowerCase().startsWith("v=dmarc1"));const mailA=await provider.upsertRecord(domain,{name:mailFqdn,type:"A",content:ip,ttl:600});if(!mailA?.success)throw Error(`A ${mailFqdn}: ${mailA?.message||"provider rejected the record"}`);const verifyRes=await provider.listRecords(domain),anomalies=verifyRes?.success?findMailDnsAnomalies(verifyRes.records||[],[{label:"MX",fqdn:apex,type:"MX"},{label:"SPF",fqdn:apex,type:"TXT",owns:(content)=>content.toLowerCase().startsWith("v=spf1")},...dkim?[{label:"DKIM",fqdn:dkimFqdn,type:"TXT"}]:[],{label:"DMARC",fqdn:dmarcFqdn,type:"TXT",owns:(content)=>content.toLowerCase().startsWith("v=dmarc1")}],domain):[];for(const anomaly of anomalies)logger.warn(` Mail DNS: ${anomaly}`);logger.success(`Mail DNS published for ${domain} via ${provider.name} (MX\u2192${mailHost}, SPF, DKIM at ${dkimName}, DMARC, ${mailFqdn}\u2192${ip})`)}catch(err){logger.warn(`Mail DNS reconcile skipped for ${domain}: ${getErrorMessage(err)}`)}}export function configDnsDomains(sites){const domains=new Set;for(const site of Object.values(sites))if(!site?.redirect&&site?.domain&&typeof site.domain==="string")domains.add(site.domain.replace(/^www\./,""));const all=[...domains];return all.filter((domain)=>!all.some((other)=>other!==domain&&domain.endsWith(`.${other}`)))}async function reconcileCloudflareCdnForDeploy(tsCloudConfig,ip,ipv6,logger){let resolveCloudflareCdnPlan,reconcileCloudflareCdn,CloudflareProvider,normalizePublicIpv6;try{({resolveCloudflareCdnPlan,reconcileCloudflareCdn,CloudflareProvider,normalizePublicIpv6}=await import("@stacksjs/ts-cloud"))}catch{return}if(typeof resolveCloudflareCdnPlan!=="function"||typeof reconcileCloudflareCdn!=="function"){if(tsCloudConfig?.infrastructure?.compute?.proxy?.cdn?.provider==="cloudflare")logger.warn("Cloudflare CDN: installed @stacksjs/ts-cloud is too old to manage it \u2014 upgrade to ^0.9.4.");return}const{plan,errors}=resolveCloudflareCdnPlan(tsCloudConfig);for(const error of errors)logger.warn(`Cloudflare CDN: ${error}`);if(!plan)return;if(!ip){logger.warn("Cloudflare CDN: no box IP resolved \u2014 skipping.");return}logger.info(`Cloudflare CDN: reconciling ${plan.hosts.length} host(s) on ${plan.zone}...`);try{const provider=new CloudflareProvider(plan.apiToken,{zoneId:plan.zoneId,accountId:plan.accountId}),report=await reconcileCloudflareCdn({provider,zone:plan.zone,hosts:plan.hosts,ipv4:ip,ipv6:typeof normalizePublicIpv6==="function"?normalizePublicIpv6(ipv6):ipv6,proxied:plan.proxied,settings:plan.settings,cache:plan.cache,originGuard:plan.originGuard,purge:plan.purge,skipOriginProbe:plan.skipOriginProbe});for(const record of report.records)logger.success(` ${record.host} ${record.type} \u2192 ${record.content}${record.proxied?" (proxied)":" (DNS-only)"}`);for(const setting of report.settingsChanged)logger.info(` ${setting.id}: ${JSON.stringify(setting.from)} \u2192 ${JSON.stringify(setting.to)}`);if(report.cacheRules>0)logger.success(` ${report.cacheRules} cache rule(s) applied`);if(report.originGuard)logger.success(" origin guard header applied");if(report.purged)logger.success(" edge cache purged");for(const deferred of report.deferredProxy||[])logger.warn(` ${deferred.host} left DNS-only: ${deferred.reason} \u2014 re-run the deploy once TLS is issued.`);for(const warning of report.warnings)logger.warn(` ${warning}`)}catch(err){logger.warn(`Cloudflare CDN: ${err?.message||err}`)}}export function dnsProviderNameFromNameservers(nameservers){const normalized=nameservers.map((name)=>name.toLowerCase().replace(/\.$/,""));if(normalized.some((name)=>name.endsWith(".porkbun.com")))return"porkbun";if(normalized.some((name)=>name.endsWith(".ns.cloudflare.com")))return"cloudflare";if(normalized.some((name)=>/(^|\.)awsdns-\d+\.(?:com|net|org|co\.uk)$/.test(name)))return"route53";if(normalized.some((name)=>name.endsWith(".domaincontrol.com")))return"godaddy";return null}async function reconcileConfigDns(sites,logger){const projectDnsConfig=await loadProjectDnsConfig(dnsConfig);if(["a","aaaa","cname","mx","txt"].reduce((total,key)=>total+(Array.isArray(projectDnsConfig?.[key])?projectDnsConfig[key].length:0),0)===0)return;for(const domain of configDnsDomains(sites))try{const result=await syncDnsConfig(domain,projectDnsConfig);if(!result.provider)continue;if(result.created||result.failed)logger.info(`DNS (config/dns.ts) ${domain}: ${result.created} created, ${result.kept} kept${result.failed?`, ${result.failed} failed`:""}`);for(const failure of result.failures)logger.warn(` ${failure.record.type} ${failure.record.name} \u2192 ${failure.record.content}: ${failure.reason}`);for(const skipped of result.skipped)logger.info(` skipped ${skipped.record.type} ${skipped.record.name}: ${skipped.reason}`)}catch(err){logger.warn(`DNS (config/dns.ts) reconcile for ${domain} failed: ${err instanceof Error?err.message:String(err)}`)}}async function reconcileHetznerDns(sites,ip,logger,ipv6,autoWww){const published=[],{gatewayHostnames}=await import("@stacksjs/ts-cloud"),hostnames=gatewayHostnames(sites,{autoWww}),byBase=new Map;for(const fqdn of hostnames){const base=fqdn.replace(/^www\./,""),group=byBase.get(base)??new Set;group.add(fqdn);byBase.set(base,group)}if(byBase.size===0)return published;const providerConfigs=dnsProviderConfigsFromEnv();if(providerConfigs.length===0){logger.warn("DNS: no DNS provider credentials found (PORKBUN_API_KEY/\u2026); skipping DNS reconciliation.");for(const fqdn of hostnames)logger.info(` Point manually: A ${fqdn} \u2192 ${ip}`);return published}const{reconcileAddressRecords}=await import("@stacksjs/ts-cloud");logger.info("Reconciling DNS records...");const resolveA=async(fqdn)=>{try{const{resolve4}=await import("node:dns/promises");return await resolve4(fqdn)}catch{return[]}};for(const[domain,group]of byBase){const fqdns=[...group].sort();try{const provider=await resolveZoneDnsProvider(domain,providerConfigs,logger);if(!provider){for(const fqdn of fqdns){const current=await resolveA(fqdn);if(current.includes(ip))logger.success(` DNS: ${fqdn} \u2192 ${ip} (externally managed, already correct)`);else if(current.length===0)logger.warn(` DNS: ${fqdn} does not resolve and no configured provider manages ${domain} - create it manually: A ${fqdn} \u2192 ${ip}`);else logger.warn(` DNS: ${fqdn} resolves to ${current.join(", ")} but this deploy targets ${ip}, and no configured provider manages ${domain} - update it manually: A ${fqdn} \u2192 ${ip}`)}continue}for(const fqdn of fqdns){const report=await reconcileAddressRecords({provider,zone:domain,fqdn,ipv4:ip,ipv6});for(const record of report.published){logger.success(` DNS: ${record.fqdn} \u2192 ${record.content} (${provider.name})`);published.push(record.fqdn)}for(const warning of report.warnings)logger.warn(` DNS: ${warning}`)}}catch(err){logger.warn(` DNS: ${domain} reconciliation failed: ${err?.message||err}`)}}return published}async function buildContainerImageWithPantry(args){const{slug,sites,verbose}=args,{execSync}=await import("node:child_process");let cli;for(const candidate of["pantry","ts-pantry"])try{execSync(`command -v ${candidate}`,{stdio:"pipe"});cli=candidate;break}catch{}if(!cli){log.warn("pantry CLI not found on PATH - skipping container image build. Install pantry to enable `--docker`.");return}const dockerfile="storage/framework/Dockerfile",canPush=Boolean(process.env.PANTRY_REGISTRY_TOKEN||process.env.PANTRY_TOKEN);for(const[siteName,site]of Object.entries(sites)){if(!site?.start)continue;const tag=`${slug}-${siteName}:latest`;log.info(`[${siteName}] building image ${tag} with pantry (native, no Docker daemon)...`);const flags=["build",".","-t",tag,"-f",dockerfile,"--run-mode","skip"];if(canPush)flags.push("--push");execSync(`${cli} ${flags.map((f)=>f.includes(" ")?`'${f}'`:f).join(" ")}`,{stdio:verbose?"inherit":"pipe",maxBuffer:536870912});log.success(`[${siteName}] image built${canPush?" + pushed to the pantry registry":""}`)}}export function deploy(buddy){const descriptions={deploy:"Deploy your project",project:"Target a specific project",production:"Deploy to production",development:"Deploy to development",staging:"Deploy to staging",yes:"Confirm all prompts by default",domain:"Specify a domain to deploy to",verbose:"Enable verbose output"};buddy.command("deploy [env]",descriptions.deploy).option("--domain <domain>",descriptions.domain,{default:void 0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--prod",descriptions.production,{default:!1}).option("--dev",descriptions.development,{default:!1}).option("--yes",descriptions.yes,{default:!1}).option("--site <name>","Deploy only this one site to the existing server (multi-tenant surgical add)",{default:void 0}).option("--staging",descriptions.staging,{default:!1}).option("--docker","Also build an OCI image with pantry (native, no Docker daemon) and push it to the pantry registry",{default:!1}).option("-J, --json","Emit a machine-readable deployment preview",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(withDeployNotification(async(envArg,options)=>{log.debug("Running `buddy deploy` ...",options);const askedForDryRun=process.argv.includes("--dry-run")||options.dryRun===!0,optionEnvironment=options.env,deployEnvName=resolveDeploymentEnvironment({positional:envArg,option:optionEnvironment,staging:options.staging,development:options.dev}),deployEnv=deployEnvName;try{applyDeploymentDomainOverride({},options.domain)}catch(error){log.error(getErrorMessage(error));process.exit(ExitCode.InvalidArgument)}if(askedForDryRun){process.env.APP_ENV=deployEnvName;const envFile=deployEnvName==="production"?".env.production":`.env.${deployEnvName}`;if(existsSync(p.projectPath(envFile)))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:deployEnvName,keysFile:".env.keys",overload:!0,quiet:!0})}catch(error){log.debug(`Could not load ${envFile} for preview: ${getErrorMessage(error)}`)}const tsCloudConfig=await loadTsCloudConfig(deployEnvName),{resolveSiteKind}=await loadTsCloudDeployApi(),unbacked=tsCloudConfig?findUnbackedManagedServices(tsCloudConfig):[];let plan;try{plan=createDeploymentPreview({config:tsCloudConfig,environment:deployEnvName,site:options.site,domain:options.domain,docker:options.docker===!0,fallbackProjectName:app.name,fallbackProjectSlug:app.name?.toLowerCase().replace(/[^a-z0-9]+/g,"-")||"app",fallbackProvider:"aws",fallbackMode:cloudConfig.mode||"server",fallbackRegion:process.env.AWS_REGION||"us-east-1",resolveSiteKind,applyEnvironmentToSites,warnings:unbacked.length>0?[unbackedDataMessage(unbacked)]:[]})}catch(error){log.error(getErrorMessage(error));process.exit(ExitCode.InvalidArgument)}if(options.json||process.argv.includes("--json"))console.log(`${deploymentPreviewJsonPrefix}${JSON.stringify(plan)}`);else process.stdout.write(formatDeploymentPreview(plan));return}await ensureDeployPrerequisites(options.verbose===!0,deployEnvName);process.env.APP_ENV=deployEnvName;{const envFile=deployEnvName==="production"?".env.production":`.env.${deployEnvName}`;if(existsSync(p.projectPath(envFile)))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:deployEnvName,keysFile:".env.keys",overload:!0,quiet:!0})}catch(err){log.warn(`Could not load ${envFile}: ${getErrorMessage(err)}`)}}const tsCloudConfig=await loadTsCloudConfig(deployEnvName);if(tsCloudConfig&&resolveProvider(tsCloudConfig)==="hetzner"){await deployToHetzner(applyDeploymentDomainOverride(tsCloudConfig,options.domain),deployEnv,options);return}if(process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY)delete process.env.AWS_PROFILE;const startTime=performance.now();console.log("");console.log("\uD83D\uDE80 Deploy");console.log("");let productionUrl;if(deployEnv==="production"||deployEnv==="prod"){const prodEnvPath=p.projectPath(".env.production");if(existsSync(prodEnvPath)){const urlMatch=readFileSync(prodEnvPath,"utf-8").match(/^APP_URL=(.+)$/m);if(urlMatch?.[1]){productionUrl=urlMatch[1].trim();log.debug("Using APP_URL from .env.production:",productionUrl)}}}const envUrl=env.APP_URL,domain=options.domain||productionUrl||envUrl||app.url;if(deployEnvName==="production"&&!options.yes)await confirmProductionDeployment();if(!domain){log.info("No domain found in your .env.production or ./config/app.ts");log.info("Please ensure your domain is properly configured.");log.info("For more info, check out the docs or join our Discord.");process.exit(ExitCode.FatalError)}log.info(`Deploying to ${italic(domain)} (${deployEnv})`);await checkIfAwsIsBootstrapped(options);options.domain=await configureDomain(domain,options,startTime);const result=await runAction(Action.Deploy,options);if(resultFailed(result)){await outro("While running the `buddy deploy`, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Project deployed.",{startTime,useSeconds:!0})}));buddy.command("deploy:rollback [site]","Roll back a deployment to a preserved release").option("--env <environment>","Environment to roll back",{default:"production"}).option("--to <release>","Preserved release id to activate",{default:void 0}).option("--dry-run","Preview the rollback without changing the active release",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(site,options)=>{const exitCode=await runDeployRollback(site,options);if(exitCode!==ExitCode.Success)process.exit(exitCode)});onUnknownSubcommand(buddy,"deploy")}async function confirmProductionDeployment(){if(!process.stdin.isTTY){log.error("Refusing to deploy to production from a non-interactive shell without confirmation.");log.info(" \u27A1\uFE0F Re-run with `--yes` to confirm (e.g. in CI): `buddy deploy --prod --yes`");process.exit(ExitCode.InvalidArgument)}if(!await prompts.confirm({message:"Are you sure you want to deploy to production?",initial:!0})){log.info("Aborting deployment...");process.exit(ExitCode.InvalidArgument)}}async function configureDomain(domain,options,startTime){log.debug("Configuring domain...",domain);if(!domain){log.info("We could not identify a domain to deploy to.");log.warn("Please set your .env or ./config/app.ts properly.");log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");console.log("");log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");console.log("");process.exit(ExitCode.FatalError)}if(domain.includes("localhost")){log.info("You are deploying to a local environment.");log.warn("Please set your .env or ./config/app.ts properly. The domain we are deploying cannot be a `localhost` domain.");log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");console.log("");log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");console.log("");process.exit(ExitCode.FatalError)}if(await hasUserDomainBeenAddedToCloud(domain)){log.info("Domain is properly configured");log.info("Your cloud is deploying...");log.info(`${italic("This may take a while...")}`);return domain}console.log("");log.info(` \uD83D\uDC4B It appears to be your first ${italic(domain)} deployment.`);console.log("");log.info(italic("Let\u2019s ensure it is all connected properly."));log.info(italic("One moment..."));console.log("");const result=await addDomain({...options,deploy:!0,startTime});if(resultFailed(result)){await outro("While running the `buddy deploy`, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Added your domain.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}async function promptAndSaveCredentials(){const accessKeyId=await prompts.text({message:"AWS Access Key ID:",validate:(value)=>value.length>0?!0:"Access Key ID is required"});if(!accessKeyId){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const secretAccessKey=await prompts.password({message:"AWS Secret Access Key:",validate:(value)=>value.length>0?!0:"Secret Access Key is required"});if(!secretAccessKey){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const region=await prompts.text({message:"AWS Region:",initial:"us-east-1"});if(!region){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const{setEnv}=await import("@stacksjs/env");await setEnv("AWS_ACCESS_KEY_ID",accessKeyId,{file:".env.production",encrypt:!0});await setEnv("AWS_SECRET_ACCESS_KEY",secretAccessKey,{file:".env.production",encrypt:!0});await setEnv("AWS_REGION",region||"us-east-1",{file:".env.production"});process.env.AWS_ACCESS_KEY_ID=accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=secretAccessKey;process.env.AWS_REGION=region||"us-east-1";log.success("AWS credentials saved securely to .env.production");console.log("")}function loadAwsCredentialsFromEnv(){const environment=process.env.APP_ENV||process.env.NODE_ENV||"production",envFiles=[p.projectPath(`.env.${environment}`),p.projectPath(".env")];for(const envPath of envFiles){if(!existsSync(envPath))continue;try{const lines=readFileSync(envPath,"utf-8").split(`
359
388
  `);let accessKeyId,secretAccessKey,region,accountId;for(const line of lines){const trimmed=line.trim();if(trimmed.startsWith("#")||!trimmed.includes("="))continue;const[key,...valueParts]=trimmed.split("="),value=valueParts.join("=").trim();if(key==="AWS_ACCESS_KEY_ID"&&value)accessKeyId=value;else if(key==="AWS_SECRET_ACCESS_KEY"&&value)secretAccessKey=value;else if(key==="AWS_REGION"&&value)region=value;else if(key==="AWS_ACCOUNT_ID"&&value)accountId=value}if(accessKeyId&&secretAccessKey){log.debug(`Found AWS credentials in ${envPath}`);return{accessKeyId,secretAccessKey,region,accountId}}}catch(error){log.debug(`Failed to read ${envPath} file:`,error)}}return{}}async function checkIfAwsIsBootstrapped(options){let handlingAlreadyExists=!1;try{log.info("Ensuring AWS cloud stack exists...");let hasCredentials=process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY;if(!hasCredentials){const envCredentials=loadAwsCredentialsFromEnv();if(envCredentials.accessKeyId&&envCredentials.secretAccessKey){process.env.AWS_ACCESS_KEY_ID=envCredentials.accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=envCredentials.secretAccessKey;if(envCredentials.region&&!process.env.AWS_REGION)process.env.AWS_REGION=envCredentials.region;if(envCredentials.accountId&&!process.env.AWS_ACCOUNT_ID)process.env.AWS_ACCOUNT_ID=envCredentials.accountId;hasCredentials=!0;const environment=process.env.APP_ENV||process.env.NODE_ENV||"production";log.success(`Using AWS credentials from .env.${environment}`)}}if(!hasCredentials){const fileCredentials=loadAwsCredentialsFromFile();if(fileCredentials.accessKeyId&&fileCredentials.secretAccessKey){process.env.AWS_ACCESS_KEY_ID=fileCredentials.accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=fileCredentials.secretAccessKey;if(fileCredentials.region&&!process.env.AWS_REGION)process.env.AWS_REGION=fileCredentials.region;hasCredentials=!0;log.success("Using AWS credentials from ~/.aws/credentials")}}if(!hasCredentials){log.info("AWS credentials not found in .env or ~/.aws/credentials.");log.info("You can either:");log.info(" 1. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in .env.production");log.info(" 2. Add credentials to ~/.aws/credentials");log.info(" 3. Configure them interactively below");console.log("");if(options?.yes){log.info("Skipping credential setup (--yes flag provided)");process.exit(ExitCode.FatalError)}const setupCredentials=await prompts.confirm({message:"Would you like to configure AWS credentials now?",initial:!0});log.debug("setupCredentials response:",setupCredentials,typeof setupCredentials);if(setupCredentials===void 0||setupCredentials===!1){if(setupCredentials===void 0){console.log("");log.info("Deployment cancelled");process.exit(ExitCode.Success)}console.log("");log.info("Skipping cloud infrastructure check");log.info("You can configure AWS credentials later by running: buddy configure:aws");return!0}await promptAndSaveCredentials()}else log.success("AWS credentials found");const appName=(process.env.APP_NAME||app.name||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),stackName=`${appName}-cloud`,{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),cfnClient=new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1");let stackExists=!1,needsEmailUpdate=!1;try{const stack=(await cfnClient.describeStacks({stackName})).Stacks?.[0];if(stack){stackExists=!0;log.success("Cloud stack exists");const{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),resources=await new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1").listStackResources(stackName),hasEmailBucket=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="EmailBucket"),hasOutboundLambda=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="OutboundEmailLambda"),hasConversionLambda=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="EmailConversionLambda"),hasNotificationTopic=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="EmailNotificationTopic"),hasMailApiLambda=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="MailApiLambda"),hasMailUsersTable=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="MailUsersTable"),hasMailServerInstance=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="MailServerInstance"),currentEmailDomain=stack.Outputs?.find((o)=>o.OutputKey==="EmailDomain")?.OutputValue,configuredDomain=(emailConfig?.from?.address?.includes("@")?emailConfig.from.address.split("@")[1]:void 0)||"stacksjs.com";if(!hasEmailBucket&&emailConfig?.server?.scan!==void 0){log.info("Email infrastructure not found in stack, will update...");needsEmailUpdate=!0}else if(currentEmailDomain&&currentEmailDomain!==configuredDomain){log.info(`Email domain changed: ${currentEmailDomain} -> ${configuredDomain}, will update...`);needsEmailUpdate=!0}else if(hasEmailBucket&&(!hasOutboundLambda||!hasConversionLambda||!hasNotificationTopic)){log.info("Email infrastructure incomplete, will update...");needsEmailUpdate=!0}else if(hasEmailBucket&&(!hasMailApiLambda||!hasMailUsersTable)){log.info("Mail API infrastructure missing, will update...");needsEmailUpdate=!0}else if(hasEmailBucket&&!hasMailServerInstance&&emailConfig?.server?.enabled){log.info("Mail server EC2 instance missing, will update...");needsEmailUpdate=!0}const currentMode=(stack.Outputs||[]).find((o)=>o.OutputKey==="MailServerMode")?.OutputValue,configuredMode=emailConfig?.server?.mode||"serverless";if(currentMode&&currentMode!==configuredMode){log.info(`Mail server mode changed: ${currentMode} -> ${configuredMode}, will update...`);needsEmailUpdate=!0}if(hasMailServerInstance&&emailConfig?.server?.enabled){if(process.env.FORCE_MAIL_UPDATE==="true"){log.info("Forcing mail server update...");needsEmailUpdate=!0}}if(!needsEmailUpdate)return!0}}catch(error){const caught=error&&typeof error==="object"?error:{message:String(error)};log.debug(`Stack not found: ${getErrorMessage(error)}`)}if(!stackExists)log.info("Cloud stack not found, will be created by deploy action");return!0}catch(err){if(!handlingAlreadyExists){log.error("Error checking cloud infrastructure");log.error(`Error: ${getErrorMessage(err)}`);if(options?.verbose)console.error(err)}process.exit(ExitCode.FatalError)}}
@@ -1,3 +1,3 @@
1
- import process from"node:process";import{log,onUnknownSubcommand}from"@stacksjs/cli";import{config}from"@stacksjs/config";import{renderDnsConfig,resolveLiveRecords,syncDnsConfig}from"@stacksjs/dns";import{ExitCode}from"@stacksjs/types";import{loadProjectDnsConfig}from"../config";export function dns(buddy){const descriptions={dns:"Lists the DNS records for a domain",query:"Host name or IP address to query",type:"Type of the DNS record being queried (A, MX, NS\u2026)",nameserver:"Address of the nameserver to send packets to",class:"Network class of the DNS record being queried (IN, CH, HS)",udp:"Use the DNS protocol over UDP",tcp:"Use the DNS protocol over TCP",tls:"Use the DNS-over-TLS protocol",https:"Use the DNS-over-HTTPS protocol",short:"Short mode: display nothing but the first result",json:"Display the output as JSON",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("dns [domain]",descriptions.dns).option("-q, --query <query>",descriptions.query).option("-t, --type <type>",descriptions.type,{default:"A"}).option("-n, --nameserver <nameserver>",descriptions.nameserver).option("--class <class>",descriptions.class).option("-U, --udp",descriptions.udp).option("-T, --tcp",descriptions.tcp).option("-S, --tls",descriptions.tls).option("-H, --https",descriptions.https).option("-1, --short",descriptions.short,{default:!1}).option("-J, --json",descriptions.json,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(domain,options)=>{log.debug("Running `buddy dns [domain]` ...",options);const targetDomain=domain||config.app.url;let DnsClient,formatOutput;try{const dnsx=await import("@stacksjs/dnsx");DnsClient=dnsx.DnsClient;formatOutput=dnsx.formatOutput}catch(err){log.error("`buddy dns` needs the @stacksjs/dnsx runtime, but only the type declarations are currently published. Install a build with the JS runtime (or wait for the next dnsx release) and re-run.");log.debug(`[dns] import failure: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}try{const client=new DnsClient({domains:[targetDomain],type:options.type,nameserver:options.nameserver,class:options.class,udp:options.udp,tcp:options.tcp,tls:options.tls,https:options.https,short:options.short,json:options.json,verbose:options.verbose}),startTime=performance.now(),responses=await client.query(),duration=performance.now()-startTime,output=formatOutput(responses,{json:options.json??!1,short:options.short??!1,showDuration:duration,colors:{enabled:!0},rawSeconds:!1});console.log(output)}catch(error){log.error(`DNS query failed: ${error instanceof Error?error.message:String(error)}`);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});const bareDomain=(input)=>(input||config.app.url||"").replace(/^[a-z]+:\/\//i,"").replace(/[/:].*$/,"");buddy.command("dns:pull [domain]","Print a domain's live DNS records as a config/dns.ts block").option("--verbose",descriptions.verbose,{default:!1}).action(async(domain,options)=>{const target=bareDomain(domain);log.debug(`Running \`buddy dns:pull ${target}\` ...`,options);const records=await resolveLiveRecords(target);if(!records.length){log.error(`No DNS records resolved for ${target}.`);process.exit(ExitCode.FatalError)}console.log(renderDnsConfig(target,records));process.exit(ExitCode.Success)});buddy.command("dns:diff [domain]","Show which config/dns.ts records are missing from the live zone").option("--verbose",descriptions.verbose,{default:!1}).action(async(domain,options)=>{const target=bareDomain(domain);log.debug(`Running \`buddy dns:diff ${target}\` ...`,options);const dnsConfig=await loadProjectDnsConfig(config.dns),{plan,provider}=await syncDnsConfig(target,dnsConfig,{dryRun:!0});for(const item of plan.items){const detail=item.record.type==="TXT"||item.record.type==="MX"?` ${item.record.content}`:` \u2192 ${item.record.content}`;console.log(` ${item.action==="create"?"+ create":" keep "} ${item.record.type.padEnd(5)} ${item.record.name}${detail}`)}console.log(`
2
- ${plan.create.length} to create, ${plan.keep.length} already present (${provider?`registrar: ${provider}`:"public DNS"})`);process.exit(ExitCode.Success)});buddy.command("dns:sync [domain]","Additively sync config/dns.ts to the registrar (creates missing records; never deletes or overwrites)").option("--dry-run","Show the plan without writing any records",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(domain,options)=>{const target=bareDomain(domain);log.debug(`Running \`buddy dns:sync ${target}\` ...`,options);const dnsConfig=await loadProjectDnsConfig(config.dns),result=await syncDnsConfig(target,dnsConfig,{dryRun:options.dryRun});if(!result.provider&&!options.dryRun){log.warn(`No DNS provider credentials found (e.g. PORKBUN_API_KEY / PORKBUN_SECRET_KEY) - nothing was synced. ${result.plan.create.length} record(s) would be created.`);process.exit(ExitCode.Success)}for(const record of result.plan.create)console.log(` ${result.applied?"created":"would create"} ${record.type.padEnd(5)} ${record.name} \u2192 ${record.content}`);const verb=result.applied?"created":"to create",count=result.applied?result.created:result.plan.create.length;console.log(`
3
- dns:sync ${target}: ${count} ${verb}, ${result.kept} kept${result.failed?`, ${result.failed} failed`:""}${result.provider?` (${result.provider})`:""}`);process.exit(result.failed>0?ExitCode.FatalError:ExitCode.Success)});onUnknownSubcommand(buddy,"dns")}
1
+ import process from"node:process";import{log,onUnknownSubcommand}from"@stacksjs/cli";import{config}from"@stacksjs/config";import{renderDnsConfig,resolveLiveRecords,syncDnsConfig}from"@stacksjs/dns";import{ExitCode}from"@stacksjs/types";import{loadProjectDnsConfig}from"../config";export function dns(buddy){const descriptions={dns:"Lists the DNS records for a domain",query:"Host name or IP address to query",type:"Type of the DNS record being queried (A, MX, NS\u2026)",nameserver:"Address of the nameserver to send packets to",class:"Network class of the DNS record being queried (IN, CH, HS)",udp:"Use the DNS protocol over UDP",tcp:"Use the DNS protocol over TCP",tls:"Use the DNS-over-TLS protocol",https:"Use the DNS-over-HTTPS protocol",short:"Short mode: display nothing but the first result",json:"Display the output as JSON",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("dns [domain]",descriptions.dns).option("-q, --query <query>",descriptions.query).option("-t, --type <type>",descriptions.type,{default:"A"}).option("-n, --nameserver <nameserver>",descriptions.nameserver).option("--class <class>",descriptions.class).option("-U, --udp",descriptions.udp).option("-T, --tcp",descriptions.tcp).option("-S, --tls",descriptions.tls).option("-H, --https",descriptions.https).option("-1, --short",descriptions.short,{default:!1}).option("-J, --json",descriptions.json,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(domain,options)=>{log.debug("Running `buddy dns [domain]` ...",options);const targetDomain=domain||config.app.url;let DnsClient,formatOutput;try{const dnsx=await import("@stacksjs/dnsx");DnsClient=dnsx.DnsClient;formatOutput=dnsx.formatOutput}catch(err){log.error("`buddy dns` needs the @stacksjs/dnsx runtime, but only the type declarations are currently published. Install a build with the JS runtime (or wait for the next dnsx release) and re-run.");log.debug(`[dns] import failure: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}try{const client=new DnsClient({domains:[targetDomain],type:options.type,nameserver:options.nameserver,class:options.class,udp:options.udp,tcp:options.tcp,tls:options.tls,https:options.https,short:options.short,json:options.json,verbose:options.verbose}),startTime=performance.now(),responses=await client.query(),duration=performance.now()-startTime,output=formatOutput(responses,{json:options.json??!1,short:options.short??!1,showDuration:duration,colors:{enabled:!0},rawSeconds:!1});console.log(output)}catch(error){log.error(`DNS query failed: ${error instanceof Error?error.message:String(error)}`);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});const bareDomain=(input)=>(input||config.app.url||"").replace(/^[a-z]+:\/\//i,"").replace(/[/:].*$/,"");buddy.command("dns:pull [domain]","Print a domain's live DNS records as a config/dns.ts block").option("--verbose",descriptions.verbose,{default:!1}).action(async(domain,options)=>{const target=bareDomain(domain);log.debug(`Running \`buddy dns:pull ${target}\` ...`,options);const records=await resolveLiveRecords(target);if(!records.length){log.error(`No DNS records resolved for ${target}.`);process.exit(ExitCode.FatalError)}console.log(renderDnsConfig(target,records));process.exit(ExitCode.Success)});buddy.command("dns:diff [domain]","Show which config/dns.ts records are missing from the live zone").option("--verbose",descriptions.verbose,{default:!1}).action(async(domain,options)=>{const target=bareDomain(domain);log.debug(`Running \`buddy dns:diff ${target}\` ...`,options);const dnsConfig=await loadProjectDnsConfig(config.dns),{plan,provider}=await syncDnsConfig(target,dnsConfig,{dryRun:!0});for(const item of plan.items){const detail=item.record.type==="TXT"||item.record.type==="MX"?` ${item.record.content}`:` \u2192 ${item.record.content}`,label=item.action==="create"?"+ create":item.action==="skip"?"- skip ":" keep ",why=item.action==="skip"?` (${item.reason})`:"";console.log(` ${label} ${item.record.type.padEnd(5)} ${item.record.name}${detail}${why}`)}const skipped=plan.skip.length?`, ${plan.skip.length} unpublishable`:"";console.log(`
2
+ ${plan.create.length} to create, ${plan.keep.length} already present${skipped} (${provider?`registrar: ${provider}`:"public DNS"})`);process.exit(ExitCode.Success)});buddy.command("dns:sync [domain]","Additively sync config/dns.ts to the registrar (creates missing records; never deletes or overwrites)").option("--dry-run","Show the plan without writing any records",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(domain,options)=>{const target=bareDomain(domain);log.debug(`Running \`buddy dns:sync ${target}\` ...`,options);const dnsConfig=await loadProjectDnsConfig(config.dns),result=await syncDnsConfig(target,dnsConfig,{dryRun:options.dryRun});if(!result.provider&&!options.dryRun){log.warn(`No DNS provider credentials found (e.g. PORKBUN_API_KEY / PORKBUN_SECRET_KEY) - nothing was synced. ${result.plan.create.length} record(s) would be created.`);process.exit(ExitCode.Success)}const failedNames=new Set(result.failures.map((failure)=>`${failure.record.type} ${failure.record.name}`));for(const record of result.plan.create){const verb=!result.applied?"would create":failedNames.has(`${record.type} ${record.name}`)?"FAILED ":"created";console.log(` ${verb} ${record.type.padEnd(5)} ${record.name} \u2192 ${record.content}`)}for(const failure of result.failures)console.log(` ${failure.record.type} ${failure.record.name}: ${failure.reason}`);for(const skipped of result.skipped)console.log(` skipped ${skipped.record.type.padEnd(5)} ${skipped.record.name}: ${skipped.reason}`);const verb=result.applied?"created":"to create",count=result.applied?result.created:result.plan.create.length,skippedNote=result.skipped.length?`, ${result.skipped.length} unpublishable`:"";console.log(`
3
+ dns:sync ${target}: ${count} ${verb}, ${result.kept} kept${result.failed?`, ${result.failed} failed`:""}${skippedNote}${result.provider?` (${result.provider})`:""}`);process.exit(result.failed>0?ExitCode.FatalError:ExitCode.Success)});onUnknownSubcommand(buddy,"dns")}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/buddy",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.72.53",
5
+ "version": "0.72.55",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -95,62 +95,62 @@
95
95
  "prepublishOnly": "bun run build"
96
96
  },
97
97
  "dependencies": {
98
- "@stacksjs/actions": "^0.72.53",
99
- "@stacksjs/ai": "^0.72.53",
100
- "@stacksjs/alias": "^0.72.53",
101
- "@stacksjs/analytics": "^0.72.53",
102
- "@stacksjs/arrays": "^0.72.53",
103
- "@stacksjs/auth": "^0.72.53",
104
- "@stacksjs/browser-extension": "^0.72.53",
105
- "@stacksjs/build": "^0.72.53",
106
- "@stacksjs/cache": "^0.72.53",
107
- "@stacksjs/chat": "^0.72.53",
98
+ "@stacksjs/actions": "^0.72.55",
99
+ "@stacksjs/ai": "^0.72.55",
100
+ "@stacksjs/alias": "^0.72.55",
101
+ "@stacksjs/analytics": "^0.72.55",
102
+ "@stacksjs/arrays": "^0.72.55",
103
+ "@stacksjs/auth": "^0.72.55",
104
+ "@stacksjs/browser-extension": "^0.72.55",
105
+ "@stacksjs/build": "^0.72.55",
106
+ "@stacksjs/cache": "^0.72.55",
107
+ "@stacksjs/chat": "^0.72.55",
108
108
  "@stacksjs/clapp": "^0.2.12",
109
- "@stacksjs/cli": "^0.72.53",
110
- "@stacksjs/cloud": "^0.72.53",
111
- "@stacksjs/cms": "^0.72.53",
112
- "@stacksjs/collections": "^0.72.53",
113
- "@stacksjs/config": "^0.72.53",
114
- "@stacksjs/database": "^0.72.53",
115
- "@stacksjs/desktop-build": "^0.72.53",
116
- "@stacksjs/dns": "^0.72.53",
109
+ "@stacksjs/cli": "^0.72.55",
110
+ "@stacksjs/cloud": "^0.72.55",
111
+ "@stacksjs/cms": "^0.72.55",
112
+ "@stacksjs/collections": "^0.72.55",
113
+ "@stacksjs/config": "^0.72.55",
114
+ "@stacksjs/database": "^0.72.55",
115
+ "@stacksjs/desktop-build": "^0.72.55",
116
+ "@stacksjs/dns": "^0.72.55",
117
117
  "@stacksjs/dnsx": "^0.2.3",
118
- "@stacksjs/email": "^0.72.53",
119
- "@stacksjs/enums": "^0.72.53",
120
- "@stacksjs/env": "^0.72.53",
121
- "@stacksjs/error-handling": "^0.72.53",
122
- "@stacksjs/events": "^0.72.53",
123
- "@stacksjs/git": "^0.72.53",
118
+ "@stacksjs/email": "^0.72.55",
119
+ "@stacksjs/enums": "^0.72.55",
120
+ "@stacksjs/env": "^0.72.55",
121
+ "@stacksjs/error-handling": "^0.72.55",
122
+ "@stacksjs/events": "^0.72.55",
123
+ "@stacksjs/git": "^0.72.55",
124
124
  "@stacksjs/gitit": "^0.2.5",
125
- "@stacksjs/health": "^0.72.53",
125
+ "@stacksjs/health": "^0.72.55",
126
126
  "@stacksjs/httx": "^0.1.10",
127
- "@stacksjs/image": "^0.72.53",
128
- "@stacksjs/lint": "^0.72.53",
129
- "@stacksjs/logging": "^0.72.53",
130
- "@stacksjs/notifications": "^0.72.53",
131
- "@stacksjs/objects": "^0.72.53",
132
- "@stacksjs/orm": "^0.72.53",
133
- "@stacksjs/path": "^0.72.53",
134
- "@stacksjs/payments": "^0.72.53",
135
- "@stacksjs/realtime": "^0.72.53",
136
- "@stacksjs/router": "^0.72.53",
127
+ "@stacksjs/image": "^0.72.55",
128
+ "@stacksjs/lint": "^0.72.55",
129
+ "@stacksjs/logging": "^0.72.55",
130
+ "@stacksjs/notifications": "^0.72.55",
131
+ "@stacksjs/objects": "^0.72.55",
132
+ "@stacksjs/orm": "^0.72.55",
133
+ "@stacksjs/path": "^0.72.55",
134
+ "@stacksjs/payments": "^0.72.55",
135
+ "@stacksjs/realtime": "^0.72.55",
136
+ "@stacksjs/router": "^0.72.55",
137
137
  "@stacksjs/rpx": "^0.11.42",
138
- "@stacksjs/scheduler": "^0.72.53",
139
- "@stacksjs/search-engine": "^0.72.53",
140
- "@stacksjs/security": "^0.72.53",
141
- "@stacksjs/server": "^0.72.53",
142
- "@stacksjs/sites": "^0.72.53",
143
- "@stacksjs/skills": "^0.72.53",
144
- "@stacksjs/storage": "^0.72.53",
145
- "@stacksjs/strings": "^0.72.53",
146
- "@stacksjs/testing": "^0.72.53",
147
- "@stacksjs/tinker": "^0.72.53",
138
+ "@stacksjs/scheduler": "^0.72.55",
139
+ "@stacksjs/search-engine": "^0.72.55",
140
+ "@stacksjs/security": "^0.72.55",
141
+ "@stacksjs/server": "^0.72.55",
142
+ "@stacksjs/sites": "^0.72.55",
143
+ "@stacksjs/skills": "^0.72.55",
144
+ "@stacksjs/storage": "^0.72.55",
145
+ "@stacksjs/strings": "^0.72.55",
146
+ "@stacksjs/testing": "^0.72.55",
147
+ "@stacksjs/tinker": "^0.72.55",
148
148
  "@stacksjs/ts-cloud": "^0.11.4",
149
- "@stacksjs/tunnel": "^0.72.53",
150
- "@stacksjs/types": "^0.72.53",
151
- "@stacksjs/ui": "^0.72.53",
152
- "@stacksjs/utils": "^0.72.53",
153
- "@stacksjs/validation": "^0.72.53",
149
+ "@stacksjs/tunnel": "^0.72.55",
150
+ "@stacksjs/types": "^0.72.55",
151
+ "@stacksjs/ui": "^0.72.55",
152
+ "@stacksjs/utils": "^0.72.55",
153
+ "@stacksjs/validation": "^0.72.55",
154
154
  "ajv": "^8.20.0",
155
155
  "ajv-formats": "^3.0.1",
156
156
  "ts-pantry": "^0.11.35"