@stacksjs/buddy 0.74.11 → 0.74.12

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.
@@ -3,19 +3,19 @@ import{existsSync,mkdirSync,readFileSync,readdirSync,statSync,writeFileSync}from
3
3
  Last attempt: ${detail}`:"")+`
4
4
  A connection timeout means the box is probably still booting, so raise TS_CLOUD_SSH_WAIT_SECS and retry. "Permission denied" means the key is not authorized, and a refused or reset connection (especially after earlier attempts got further) usually means fail2ban banned this IP. Waiting longer fixes neither.`}export function bunRuntimeMissingMessage(opts){const detail=pollFailureDetail(opts.lastError);return`bun runtime did not appear at /usr/local/bin/bun within ${fmtDuration(opts.waitSecs)} (waited ${opts.elapsedSecs}s).`+(detail?`
5
5
  Last attempt: ${detail}`:"")+`
6
- cloud-init may have failed: SSH in and check /var/log/cloud-init-output.log. Raise TS_CLOUD_BOOT_WAIT_SECS for slow regions.`}export async function pollUntil(opts){log.info(`${opts.label} (up to ${fmtDuration(opts.timeoutSecs)})...`);const started=Date.now(),deadline=started+opts.timeoutSecs*1000;let lastHeartbeat=0,lastError;for(;;)try{await opts.check();return}catch(err){lastError=err;const elapsedSecs=Math.floor((Date.now()-started)/1000);if(Date.now()>deadline)throw Error(opts.timeoutMessage(elapsedSecs,lastError));if(elapsedSecs-lastHeartbeat>=30){log.info(` \u2026 still waiting (${elapsedSecs}s elapsed)`);lastHeartbeat=elapsedSecs}await new Promise((r)=>setTimeout(r,opts.intervalMs??5000))}}async function waitForRemoteReady(where){const{sshExecOrThrow}=await import("@stacksjs/ts-cloud"),target=toSshTarget(where),ip=target.host,run=(remote)=>sshExecOrThrow(ip,remote,remoteExecOptions(target,10)),sshWaitSecs=readWaitSecs("TS_CLOUD_SSH_WAIT_SECS",480);await pollUntil({label:"Waiting for SSH to come up",timeoutSecs:sshWaitSecs,check:()=>run("true"),timeoutMessage:(elapsed,lastError)=>sshUnreachableMessage({ip,waitSecs:sshWaitSecs,elapsedSecs:elapsed,lastError})});log.success("SSH is up");let hasCloudInit=!0;try{await run("command -v cloud-init >/dev/null 2>&1")}catch{hasCloudInit=!1}if(hasCloudInit){log.info("Waiting for cloud-init (installing bun + caddy)...");try{await run("cloud-init status --wait || true")}catch(err){log.debug("cloud-init status --wait returned non-zero (continuing):",err)}}else log.info("No cloud-init on this host; skipping the first-boot wait.");const bootWaitSecs=readWaitSecs("TS_CLOUD_BOOT_WAIT_SECS",720);await pollUntil({label:"Waiting for the bun runtime",timeoutSecs:bootWaitSecs,check:()=>run("test -x /usr/local/bin/bun"),timeoutMessage:(elapsed,lastError)=>bunRuntimeMissingMessage({waitSecs:bootWaitSecs,elapsedSecs:elapsed,lastError})});log.success("Server is ready (bun installed)")}async function resolveDeclaredTenants(){try{const{config}=await import("@stacksjs/config"),tenants=config.cloud?.tenants;return Array.isArray(tenants)?tenants.filter((slug)=>typeof slug==="string"):[]}catch{return[]}}export function normalizeDomains(domains){return domains.map((domain)=>String(domain??"").trim().toLowerCase()).filter(Boolean)}export function orphanedFragmentDomains(fragment,ours,retired=[]){const declared=new Set(normalizeDomains([...ours])),givenUp=new Set(normalizeDomains([...retired])),accountedFor=(domain,set)=>set.has(domain)||set.has(domain.replace(/^www\./,""));return[...new Set([...fragment.matchAll(/"(?:domain|to|from)"\s*:\s*"([a-z0-9.*-]+\.[a-z]{2,})"/gi)].map((match)=>String(match[1]).toLowerCase()).filter(Boolean))].filter((domain)=>!accountedFor(domain,declared)&&!accountedFor(domain,givenUp))}export async function assertFragmentIsOurs(where,tsCloudConfig,log){const slug=tsCloudConfig.project?.slug||"app",ours=new Set(Object.values(tsCloudConfig.sites??{}).map((site)=>String(site?.domain??"").toLowerCase()).filter(Boolean));let remote="";try{const{execSync}=await import("node:child_process"),args=sshCliArgs(toSshTarget(where));remote=execSync(`ssh ${args.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`cat /etc/rpx/sites.d/${slug}.json 2>/dev/null || true`,encoding:"utf8",stdio:["pipe","pipe","pipe"]})}catch{return}if(!remote.trim())return;const retired=normalizeDomains(Array.isArray(tsCloudConfig.cloud?.retiredDomains)?tsCloudConfig.cloud.retiredDomains:[]),orphaned=orphanedFragmentDomains(remote,ours,retired);if(orphaned.length===0){if(retired.length>0)log.info(`Retiring ${retired.length} domain(s) this project no longer serves: ${retired.join(", ")}`);return}log.error(`/etc/rpx/sites.d/${slug}.json on the box already serves ${orphaned.length} domain(s) this project does not declare:`);for(const domain of orphaned.slice(0,8))log.error(` ${domain}`);await log.error("Deploying would replace that fragment and take those domains down.");log.info(`Either the slug '${slug}' belongs to another project (pick a different project.slug), or those domains belong here and should be in config/cloud.ts sites.`);log.info("If you mean to stop serving them, list them in `cloud.retiredDomains` in config/cloud.ts.");process.exit(ExitCode.FatalError)}export async function assertPortsAreFree(where,tsCloudConfig,log){const slug=tsCloudConfig.project?.slug||"app",wanted=new Map;for(const[name,site]of Object.entries(tsCloudConfig.sites??{})){const port=Number(site?.port);if(Number.isFinite(port)&&port>0)wanted.set(port,name)}if(wanted.size===0)return;let listing="";try{const{execSync}=await import("node:child_process"),args=sshCliArgs(toSshTarget(where));listing=execSync(`ssh ${args.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`for p in ${[...wanted.keys()].join(" ")}; do
6
+ cloud-init may have failed: SSH in and check /var/log/cloud-init-output.log. Raise TS_CLOUD_BOOT_WAIT_SECS for slow regions.`}export async function pollUntil(opts){log.info(`${opts.label} (up to ${fmtDuration(opts.timeoutSecs)})...`);const started=Date.now(),deadline=started+opts.timeoutSecs*1000;let lastHeartbeat=0,lastError;for(;;)try{await opts.check();return}catch(err){lastError=err;const elapsedSecs=Math.floor((Date.now()-started)/1000);if(Date.now()>deadline)throw Error(opts.timeoutMessage(elapsedSecs,lastError));if(elapsedSecs-lastHeartbeat>=30){log.info(` \u2026 still waiting (${elapsedSecs}s elapsed)`);lastHeartbeat=elapsedSecs}await new Promise((r)=>setTimeout(r,opts.intervalMs??5000))}}async function waitForRemoteReady(where){const{sshExecOrThrow}=await import("@stacksjs/ts-cloud"),target=toSshTarget(where),ip=target.host,run=(remote)=>sshExecOrThrow(ip,remote,remoteExecOptions(target,10)),sshWaitSecs=readWaitSecs("TS_CLOUD_SSH_WAIT_SECS",480);await pollUntil({label:"Waiting for SSH to come up",timeoutSecs:sshWaitSecs,check:()=>run("true"),timeoutMessage:(elapsed,lastError)=>sshUnreachableMessage({ip,waitSecs:sshWaitSecs,elapsedSecs:elapsed,lastError})});log.success("SSH is up");let hasCloudInit=!0;try{await run("command -v cloud-init >/dev/null 2>&1")}catch{hasCloudInit=!1}if(hasCloudInit){log.info("Waiting for cloud-init (installing bun + caddy)...");try{await run("cloud-init status --wait || true")}catch(err){log.debug("cloud-init status --wait returned non-zero (continuing):",err)}}else log.info("No cloud-init on this host; skipping the first-boot wait.");const bootWaitSecs=readWaitSecs("TS_CLOUD_BOOT_WAIT_SECS",720);await pollUntil({label:"Waiting for the bun runtime",timeoutSecs:bootWaitSecs,check:()=>run("test -x /usr/local/bin/bun"),timeoutMessage:(elapsed,lastError)=>bunRuntimeMissingMessage({waitSecs:bootWaitSecs,elapsedSecs:elapsed,lastError})});log.success("Server is ready (bun installed)")}async function resolveDeclaredTenants(){try{const{config}=await import("@stacksjs/config"),tenants=config.cloud?.tenants;return Array.isArray(tenants)?tenants.filter((slug)=>typeof slug==="string"):[]}catch{return[]}}export function normalizeDomains(domains){return domains.map((domain)=>String(domain??"").trim().toLowerCase()).filter(Boolean)}export function orphanedFragmentDomains(fragment,ours,retired=[]){const declared=new Set(normalizeDomains([...ours])),givenUp=new Set(normalizeDomains([...retired])),accountedFor=(domain,set)=>set.has(domain)||set.has(domain.replace(/^www\./,""));return[...new Set([...fragment.matchAll(/"(?:domain|to|from)"\s*:\s*"([a-z0-9.*-]+\.[a-z]{2,})"/gi)].map((match)=>String(match[1]).toLowerCase()).filter(Boolean))].filter((domain)=>!accountedFor(domain,declared)&&!accountedFor(domain,givenUp))}export async function assertFragmentIsOurs(where,tsCloudConfig,log){const slug=tsCloudConfig.project?.slug||"app",ours=new Set(Object.values(tsCloudConfig.sites??{}).map((site)=>String(site?.domain??"").toLowerCase()).filter(Boolean));let remote="";try{const{execSync}=await import("node:child_process"),args=sshCliArgs(toSshTarget(where));remote=execSync(`ssh ${args.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`cat /etc/rpx/sites.d/${slug}.json 2>/dev/null || true`,encoding:"utf8",stdio:["pipe","pipe","pipe"]})}catch{return}if(!remote.trim())return;const retired=normalizeDomains(Array.isArray(tsCloudConfig.cloud?.retiredDomains)?tsCloudConfig.cloud.retiredDomains:[]),orphaned=orphanedFragmentDomains(remote,ours,retired);if(orphaned.length===0){if(retired.length>0)log.info(`Retiring ${retired.length} domain(s) this project no longer serves: ${retired.join(", ")}`);return}log.error(`/etc/rpx/sites.d/${slug}.json on the box already serves ${orphaned.length} domain(s) this project does not declare:`);for(const domain of orphaned.slice(0,8))log.error(` ${domain}`);log.error("Deploying would replace that fragment and take those domains down.");log.info(`Either the slug '${slug}' belongs to another project (pick a different project.slug), or those domains belong here and should be in config/cloud.ts sites.`);log.info("If you mean to stop serving them, list them in `cloud.retiredDomains` in config/cloud.ts.");process.exit(ExitCode.FatalError)}export async function assertPortsAreFree(where,tsCloudConfig,log){const slug=tsCloudConfig.project?.slug||"app",wanted=new Map;for(const[name,site]of Object.entries(tsCloudConfig.sites??{})){const port=Number(site?.port);if(Number.isFinite(port)&&port>0)wanted.set(port,name)}if(wanted.size===0)return;let listing="";try{const{execSync}=await import("node:child_process"),args=sshCliArgs(toSshTarget(where));listing=execSync(`ssh ${args.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`for p in ${[...wanted.keys()].join(" ")}; do
7
7
  pid=$(ss -lntpH "sport = :$p" 2>/dev/null | grep -oE 'pid=[0-9]+' | head -1 | cut -d= -f2)
8
8
  [ -n "$pid" ] || continue
9
9
  unit=$(systemctl status "$pid" 2>/dev/null | head -1 | grep -oE '[a-zA-Z0-9_.@-]+\\.service' | head -1)
10
10
  echo "$p \${unit:-unknown}"
11
11
  done`,encoding:"utf8",stdio:["pipe","pipe","pipe"]})}catch{return}const clashes=[];for(const line of listing.split(`
12
- `)){const[portText,unit="unknown"]=line.trim().split(/\s+/),port=Number(portText);if(!Number.isFinite(port)||!wanted.has(port))continue;if(unit.startsWith(`${slug}-`))continue;clashes.push(` ${port} (site '${wanted.get(port)}') is held by ${unit}`)}if(clashes.length===0)return;log.error("Another service on the box is already listening on a port this project wants:");for(const clash of clashes)await log.error(clash);await log.error("Two services on one port do not error: the kernel load-balances, and each domain serves the other's site about half the time.");log.info("Pick free ports in config/cloud.ts. `ss -lntp` on the box lists what is taken.");process.exit(ExitCode.FatalError)}export async function resolveDeployEnvValues(environment,tsCloudConfig){const fileName=environment==="production"?".env.production":environment==="staging"?".env.staging":existsSync(p.projectPath(".env.development"))?".env.development":".env",filePath=p.projectPath(fileName);if(!existsSync(filePath))return{};try{const{getEnv}=await import("@stacksjs/env"),result=getEnv(void 0,{file:fileName,format:"json"});if(!result.success||!result.output){log.debug(`[deploy] Could not read ${fileName} for site env merging: ${result.error??"unknown error"}`);return{}}const parsed=JSON.parse(result.output),values={},undecrypted=[];for(const[key,value]of Object.entries(parsed)){if(/^DOTENV_(PUBLIC|PRIVATE)_KEY/.test(key))continue;values[key]=String(value);if(/^(?:encrypted|enc):/.test(values[key]))undecrypted.push(key)}if(undecrypted.length>0){await log.error(`${undecrypted.length} value(s) in ${fileName} could not be decrypted: ${undecrypted.join(", ")}`);log.info(`The private key for this environment lives in .env.keys, which is not committed. Restore it, or set DOTENV_PRIVATE_KEY_${environment.toUpperCase()} in the environment running the deploy.`);process.exit(ExitCode.FatalError)}const{foreignTenantKeys,partitionTenantEnv}=await import("@stacksjs/env"),partition=partitionTenantEnv(values,{self:tsCloudConfig?.project?.slug,tenants:await resolveDeclaredTenants()});for(const{tenant,keys}of foreignTenantKeys(partition))log.warn(`[deploy] Skipping ${keys.length} '${tenant}' key(s) in ${fileName} - they belong to that tenant's own repository, and shipping them writes its secrets into this project's site .env files. Remove them with: buddy env:check --file ${fileName}. Keys: ${keys.join(", ")}`);return partition.own}catch(error){log.debug(`[deploy] Failed to resolve ${fileName} for site env merging:`,error);return{}}}export function mergeSiteDeployEnv(sites,resolvedDeployEnv){return Object.fromEntries(Object.entries(sites).map(([siteName,site])=>{if(!site)return[siteName,site];const base={...resolvedDeployEnv};if(site.port!==void 0)delete base.PORT;return[siteName,{...site,env:{...base,...site.env||{}}}]}))}function looksLikeSqliteFile(value){return typeof value==="string"&&/\.(?:sqlite3?|db)$/i.test(value.trim())}export function siteSqlitePath(siteEnv={}){if(String(siteEnv.DB_CONNECTION??"sqlite").trim().toLowerCase()!=="sqlite")return null;const configured=typeof siteEnv.DB_DATABASE_PATH==="string"&&siteEnv.DB_DATABASE_PATH.trim()?siteEnv.DB_DATABASE_PATH:looksLikeSqliteFile(siteEnv.DB_DATABASE)?siteEnv.DB_DATABASE:"database/stacks.sqlite",path=String(configured).trim().replace(/^\.\//,"");if(!path||path.startsWith("/")||path.startsWith("~")||path.split("/").includes(".."))return null;return path}export function tsCloudPersistentStateSupport(buildSiteDeployScript){const missing=[];if(typeof buildSiteDeployScript!=="function")return{ok:!1,missing:["buildSiteDeployScript"]};const target="/var/www/probe-shared/database/probe.sqlite";let script="";try{script=buildSiteDeployScript({siteName:"probe",slug:"probe",appDir:"/var/www/probe-probe",artifactFetch:[],releaseId:"probe",execStart:"/bin/true",envEntries:{},port:3000,sharedPaths:[{path:"database/probe.sqlite",target,seed:!0}]}).join(`
12
+ `)){const[portText,unit="unknown"]=line.trim().split(/\s+/),port=Number(portText);if(!Number.isFinite(port)||!wanted.has(port))continue;if(unit.startsWith(`${slug}-`))continue;clashes.push(` ${port} (site '${wanted.get(port)}') is held by ${unit}`)}if(clashes.length===0)return;log.error("Another service on the box is already listening on a port this project wants:");for(const clash of clashes)log.error(clash);log.error("Two services on one port do not error: the kernel load-balances, and each domain serves the other's site about half the time.");log.info("Pick free ports in config/cloud.ts. `ss -lntp` on the box lists what is taken.");process.exit(ExitCode.FatalError)}export async function resolveDeployEnvValues(environment,tsCloudConfig){const fileName=environment==="production"?".env.production":environment==="staging"?".env.staging":existsSync(p.projectPath(".env.development"))?".env.development":".env",filePath=p.projectPath(fileName);if(!existsSync(filePath))return{};try{const{getEnv}=await import("@stacksjs/env"),result=getEnv(void 0,{file:fileName,format:"json"});if(!result.success||!result.output){log.debug(`[deploy] Could not read ${fileName} for site env merging: ${result.error??"unknown error"}`);return{}}const parsed=JSON.parse(result.output),values={},undecrypted=[];for(const[key,value]of Object.entries(parsed)){if(/^DOTENV_(PUBLIC|PRIVATE)_KEY/.test(key))continue;values[key]=String(value);if(/^(?:encrypted|enc):/.test(values[key]))undecrypted.push(key)}if(undecrypted.length>0){log.error(`${undecrypted.length} value(s) in ${fileName} could not be decrypted: ${undecrypted.join(", ")}`);log.info(`The private key for this environment lives in .env.keys, which is not committed. Restore it, or set DOTENV_PRIVATE_KEY_${environment.toUpperCase()} in the environment running the deploy.`);process.exit(ExitCode.FatalError)}const{foreignTenantKeys,partitionTenantEnv}=await import("@stacksjs/env"),partition=partitionTenantEnv(values,{self:tsCloudConfig?.project?.slug,tenants:await resolveDeclaredTenants()});for(const{tenant,keys}of foreignTenantKeys(partition))log.warn(`[deploy] Skipping ${keys.length} '${tenant}' key(s) in ${fileName} - they belong to that tenant's own repository, and shipping them writes its secrets into this project's site .env files. Remove them with: buddy env:check --file ${fileName}. Keys: ${keys.join(", ")}`);return partition.own}catch(error){log.debug(`[deploy] Failed to resolve ${fileName} for site env merging:`,error);return{}}}export function mergeSiteDeployEnv(sites,resolvedDeployEnv){return Object.fromEntries(Object.entries(sites).map(([siteName,site])=>{if(!site)return[siteName,site];const base={...resolvedDeployEnv};if(site.port!==void 0)delete base.PORT;return[siteName,{...site,env:{...base,...site.env||{}}}]}))}function looksLikeSqliteFile(value){return typeof value==="string"&&/\.(?:sqlite3?|db)$/i.test(value.trim())}export function siteSqlitePath(siteEnv={}){if(String(siteEnv.DB_CONNECTION??"sqlite").trim().toLowerCase()!=="sqlite")return null;const configured=typeof siteEnv.DB_DATABASE_PATH==="string"&&siteEnv.DB_DATABASE_PATH.trim()?siteEnv.DB_DATABASE_PATH:looksLikeSqliteFile(siteEnv.DB_DATABASE)?siteEnv.DB_DATABASE:"database/stacks.sqlite",path=String(configured).trim().replace(/^\.\//,"");if(!path||path.startsWith("/")||path.startsWith("~")||path.split("/").includes(".."))return null;return path}export function tsCloudPersistentStateSupport(buildSiteDeployScript){const missing=[];if(typeof buildSiteDeployScript!=="function")return{ok:!1,missing:["buildSiteDeployScript"]};const target="/var/www/probe-shared/database/probe.sqlite";let script="";try{script=buildSiteDeployScript({siteName:"probe",slug:"probe",appDir:"/var/www/probe-probe",artifactFetch:[],releaseId:"probe",execStart:"/bin/true",envEntries:{},port:3000,sharedPaths:[{path:"database/probe.sqlite",target,seed:!0}]}).join(`
13
13
  `)}catch{return{ok:!1,missing:["shared paths with an explicit target"]}}if(!(script.includes("cp -a")&&script.includes("/current/")))missing.push("adoption of existing state into shared/");if(!script.includes(`ln -sfn ${target} `))missing.push("shared paths with an explicit target");return{ok:missing.length===0,missing}}export function projectDatabaseTarget(slug,relativePath){return`/var/www/${slug}-shared/${relativePath}`}function migrateIndex(site){if(!Array.isArray(site?.preStart))return-1;return site.preStart.findIndex((cmd)=>typeof cmd==="string"&&migratesDatabase(cmd))}function migratesDatabase(command){const withoutMessages=command.replace(/'[^']*'/g,"").replace(/"[^"]*"/g,"");if(/\bmigrate\b/.test(withoutMessages))return!0;return!/^\s*(?:echo|printf)\b/.test(command)&&/\bmigrate\b/.test(command)}export function applyAutomaticMigrations(sites){if(Object.values(sites).some((site)=>runsMigrations(site)))return sites;const isServerApp=(site)=>!!site&&typeof site.start==="string",owner=Object.entries(sites).filter(([,site])=>isServerApp(site)).find(([,site])=>site?.migrateOnDeploy!==!1)?.[0];if(!owner)return sites;const site=sites[owner],preStart=Array.isArray(site.preStart)?[...site.preStart]:[];preStart.push("./buddy migrate --no-generate");return{...sites,[owner]:{...site,preStart}}}function runsMigrations(site){return migrateIndex(site)!==-1}export function applyPersistentStatePaths(sites,slug){const isServerApp=(site)=>!!site&&typeof site.start==="string",appSites=Object.entries(sites).filter(([,site])=>isServerApp(site)),owner=(appSites.find(([,site])=>runsMigrations(site))??appSites[0])?.[0],out={};for(const[name,site]of Object.entries(sites)){if(!isServerApp(site)){out[name]=site;continue}const sqlite=siteSqlitePath(site.env||{}),framework=["storage/logs"];if(sqlite)framework.unshift({path:sqlite,target:projectDatabaseTarget(slug,sqlite),seed:name===owner});const declared=Array.isArray(site.sharedPaths)?site.sharedPaths.filter(Boolean):[],byPath=new Map;for(const entry of[...declared,...framework])byPath.set(typeof entry==="string"?entry:entry.path,entry);out[name]={...site,sharedPaths:[...byPath.values()]}}return out}export function encryptedEnvFileNames(projectRoot){try{return readdirSync(projectRoot).filter((name)=>/^\.env\.[\w-]+$/.test(name)&&name!==".env.example")}catch{return[]}}export function declaresScheduledWork(schedulerFile){if(!existsSync(schedulerFile))return!1;const source=readFileSync(schedulerFile,"utf8").replace(/\/\*[\s\S]*?\*\//g,"").replace(/(^|[^:])\/\/.*$/gm,"$1");return/\bschedule\s*\.\s*(?:job|action|command|call|exec)\b/.test(source)}export function applyScheduledWork(sites,schedulerFile){if(Object.values(sites).some((site)=>site?.scheduler!==void 0))return sites;if(!declaresScheduledWork(schedulerFile))return sites;const appSites=Object.entries(sites).filter(([,site])=>typeof site?.start==="string"),owner=(appSites.find(([,site])=>runsMigrations(site))??appSites[0])?.[0];if(!owner)return sites;return{...sites,[owner]:{...sites[owner],scheduler:!0}}}const apiStartPattern=/\bserve:api\b|(?:^|[\s/])serve\/api\.[cm]?[jt]s\b/;function servesApi(name,site){return name==="api"||typeof site?.start==="string"&&apiStartPattern.test(site.start)}function servesHttp(site){return Boolean(site?.port||site?.domain)}function describeSiteClassification(sites){const described=Object.entries(sites).filter(([,site])=>typeof site?.start==="string").map(([name,site])=>{if(servesApi(name,site))return`\`${name}\` (api)`;if(!servesHttp(site))return`\`${name}\` (headless, no HTTP surface)`;const env=site?.env??{},wiring=env.API_URL?"API_URL set":env.PORT_API?"PORT_API set":"no API_URL or PORT_API";return`\`${name}\` (page, ${wiring})`});return described.length>0?`Sites examined: ${described.join(", ")}.`:"No server-app sites were examined."}function isDashboardSite(name){return name==="dashboard"||name.startsWith("dashboard-")}export function apiDeploymentProblem(sites,hasApiRoutes){if(!hasApiRoutes)return;const entries=Object.entries(sites),appSites=entries.filter(([,site])=>typeof site?.start==="string");if(appSites.length===0)return;const api=entries.find(([name,site])=>servesApi(name,site)),pages=appSites.filter(([name,site])=>!servesApi(name,site)&&!isDashboardSite(name)&&servesHttp(site)),configured=(site)=>{const env=site?.env??{};return Boolean(env.API_URL||env.PORT_API)};if(!api){if(pages.every(([,site])=>configured(site)))return;return`This project declares API routes and no site serves them. \`/api/**\` will answer 502 on every request.
14
14
  ${describeSiteClassification(sites)}
15
15
  Add an \`api\` site to config/cloud.ts running \`buddy serve:api\` on its own port, and set \`PORT_API\` on the site that serves the pages so its proxy can find it.
16
16
  Set \`API_URL\` on the page site instead when the API lives on another host.`}const unwired=pages.filter(([,site])=>!configured(site));if(unwired.length===0)return;const port=api[1]?.port;return`The \`${api[0]}\` site serves the API, but ${unwired.map(([name])=>`\`${name}\``).join(", ")} will not proxy to it: neither \`PORT_API\` nor \`API_URL\` is set in its environment, so \`/api/**\` answers 502.
17
17
  ${describeSiteClassification(sites)}
18
- Set \`PORT_API: '${port??"<the api site's port>"}'\` in that site's \`env\` in config/cloud.ts.`}export function siteDatabaseDrivers(sites){const drivers=new Set;for(const site of Object.values(sites??{})){if(typeof site?.start!=="string")continue;if(!(Array.isArray(site?.preStart)?site.preStart:[]).some((step)=>typeof step==="string"&&step.trim().split(/\s+/).some((token)=>migrateToken.test(token))))continue;const env=site?.env??{};drivers.add(String(env.DB_CONNECTION||"sqlite").toLowerCase())}return[...drivers]}const buddyEntrypoint=/(?:^|\/)(?:cli\.[cm]?[jt]s|buddy|bud|stacks)$/,migrateToken=/(?:^|:)migrate(?::|$)/;export function buddyInvocationFrom(migrateCommand){if(typeof migrateCommand!=="string")return;const tokens=migrateCommand.trim().split(/\s+/),at=tokens.findIndex((token)=>migrateToken.test(token.replace(/[;&|]+$/,"")));if(at<1)return;let from=at;while(from>0&&!isShellToken(tokens[from-1]))from--;const invocation=tokens.slice(from,at),entrypoint=invocation[invocation.length-1];if(!entrypoint||!buddyEntrypoint.test(entrypoint))return;return invocation.join(" ")}function isShellToken(token){if(/[;&|<>()`]/.test(token))return!0;if(/^[A-Za-z_][\w]*=/.test(token))return!0;return SHELL_KEYWORDS.has(token)}const SHELL_KEYWORDS=new Set(["do","done","then","else","elif","fi","if","for","while","until","case","esac","in","function","{","}","!"]);export function preMigrationBackupCommand(backupsDir,migrateCommand){const invocation=buddyInvocationFrom(migrateCommand);if(!invocation)return;return`${invocation} db:backup --before-migrations --out ${backupsDir}`}export function applyPreMigrationBackup(sites,backupsDir){const out={};for(const[name,site]of Object.entries(sites)){const at=migrateIndex(site);if(at===-1){out[name]=site;continue}const preStart=[...site.preStart];if(preStart.some((cmd)=>typeof cmd==="string"&&/\bdb:backup\b/.test(cmd))){out[name]=site;continue}const backup=preMigrationBackupCommand(backupsDir,preStart[at]);if(!backup){log.warn(`No pre-migration backup for "${name}": its migrate step (${String(preStart[at])}) is not a buddy invocation this can reuse. Add a \`db:backup\` command to its preStart to take one.`);out[name]=site;continue}preStart.splice(at,0,backup);out[name]={...site,preStart}}return out}function prefixHostForEnv(host,prefix){if(host.startsWith(`${prefix}.`)||host.startsWith(`www.${prefix}.`))return host;if(host.startsWith("www."))return`www.${prefix}.${host.slice(4)}`;return`${prefix}.${host}`}export function applyEnvironmentToSites(sites,environment,config){const prefix=config?.environments?.[environment]?.domainPrefix;if(!prefix||environment==="production")return sites;const allHosts=[];for(const site of Object.values(sites)){const d=site?.domain;if(typeof d==="string")allHosts.push(d);else if(Array.isArray(d)){for(const x of d)if(typeof x==="string")allHosts.push(x)}}allHosts.sort((a,b)=>b.length-a.length);const rewrite=(val)=>{let r=val;for(const h of allHosts){const esc=h.replace(/[.]/g,"\\.");r=r.replace(new RegExp(`//${esc}(?=[/:?#]|$)`,"g"),`//${prefixHostForEnv(h,prefix)}`)}return r},out={};for(const[name,site]of Object.entries(sites)){if(!site){out[name]=site;continue}const s={...site};if(typeof s.domain==="string")s.domain=prefixHostForEnv(s.domain,prefix);else if(Array.isArray(s.domain))s.domain=s.domain.map((d)=>typeof d==="string"?prefixHostForEnv(d,prefix):d);if(typeof s.redirect==="string")s.redirect=rewrite(s.redirect);if(s.env&&typeof s.env==="object"){const e={...s.env};for(const k of Object.keys(e))if(typeof e[k]==="string")e[k]=rewrite(e[k]);s.env=e}out[name]=s}return out}async function deployOverSsh(tsCloudConfig,deployEnv,options){const verbose=options.verbose===!0,environment=deployEnv==="prod"?"production":deployEnv,provider=resolveProvider(tsCloudConfig),persistedAttachBox=resolvePersistedAttachTargetBox(tsCloudConfig,environment);let sshTarget;if(provider==="ssh"){const resolved=resolveSshTarget(tsCloudConfig);if(!resolved){await log.error("No SSH host configured for this deploy.");log.info("Add one to config/cloud.ts: ssh: { hosts: [{ host: 'pi-stacks.local', user: 'pi' }] }");log.info("Or set TS_CLOUD_SSH_HOST (with TS_CLOUD_SSH_USER / TS_CLOUD_SSH_PORT / TS_CLOUD_SSH_KEY).");process.exit(ExitCode.FatalError)}sshTarget=resolved;if(resolved.identityFile&&!existsSync(resolved.identityFile)){await log.error(`SSH private key not found at ${resolved.identityFile}.`);log.info("Fix ssh.hosts[].privateKeyPath in config/cloud.ts, or set TS_CLOUD_SSH_KEY.");process.exit(ExitCode.FatalError)}if(!resolved.identityFile)log.info(`Using ssh's own key selection for ${resolved.user}@${resolved.host} (agent or ~/.ssh/config).`)}else{if(!resolveHetznerApiToken(tsCloudConfig)&&!persistedAttachBox){await log.error("No Hetzner API token found. Set HCLOUD_TOKEN in your .env (or hetzner.apiToken in config/cloud.ts).");process.exit(ExitCode.FatalError)}const sshPubKey=join(homedir(),".ssh","id_ed25519.pub");if(!existsSync(sshPubKey)){await log.error(`SSH public key not found at ${sshPubKey}.`);log.info("ts-cloud deploys to Hetzner over SSH and registers this key on the server.");log.info("Generate one with: ssh-keygen -t ed25519");process.exit(ExitCode.FatalError)}}const{createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,buildSiteDeployScript}=await loadTsCloudDeployApi(),support=tsCloudPersistentStateSupport(buildSiteDeployScript);if(!support.ok){await log.error("This ts-cloud cannot keep your database across deploys, and deploying anyway would destroy it.");await log.error(`Missing: ${support.missing.join(", ")}.`);await log.error("Upgrade @stacksjs/ts-cloud (bun update @stacksjs/ts-cloud), or point TS_CLOUD_MODULE at a build that has it.");process.exit(ExitCode.FatalError)}const unbacked=findUnbackedManagedServices(tsCloudConfig,await hasOffsiteBackupDestination());if(unbacked.length>0)log.warn(`[deploy] ${unbackedDataMessage(unbacked)}`);try{await runSshDeploy({tsCloudConfig,environment,verbose,docker:options.docker===!0,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,onlySite:options.site||void 0,persistedAttachBox,provider,sshTarget})}catch(err){log.error(`${deployTargetLabel(provider,sshTarget?.profile)} deploy failed:`);console.error(err instanceof Error?err.stack||err.message:err);throw err}}export function resolveProjectTsCloudModule(projectRoot=process.cwd()){const manifestPath=join(projectRoot,"node_modules","@stacksjs","ts-cloud","package.json");if(!existsSync(manifestPath))return;const manifest=JSON.parse(readFileSync(manifestPath,"utf8")),rootExport=manifest.exports?.["."],entry=manifest.module??(typeof rootExport==="string"?rootExport:rootExport?.import);if(!entry)return;const modulePath=resolve(dirname(manifestPath),entry);return existsSync(modulePath)?modulePath:void 0}export async function loadTsCloudDeployApi(){const requested=process.env.TS_CLOUD_MODULE?.trim();if(!requested){const projectModule=resolveProjectTsCloudModule();return projectModule?import(pathToFileURL(projectModule).href):import("@stacksjs/ts-cloud")}const modulePath=isAbsolute(requested)?requested:resolve(process.cwd(),requested);if(!existsSync(modulePath))throw Error(`TS_CLOUD_MODULE points to a missing module: ${modulePath}`);return import(pathToFileURL(modulePath).href)}export function shouldInjectManagementDashboard(tsCloudConfig){return!tsCloudConfig.cloud?.attachTo}export function reconcilePartialDeployManagementDashboards(tsCloudConfig,livePorts){const sites=tsCloudConfig.sites,preserved=[],removed=[];if(!sites)return{preserved,removed};for(const[siteName,site]of Object.entries(sites)){if(siteName!=="dashboard"&&!siteName.startsWith("dashboard-"))continue;const livePort=livePorts[siteName];if(typeof livePort!=="number"||!Number.isInteger(livePort)||livePort<1||livePort>65535){delete sites[siteName];removed.push(siteName);continue}const next={...site,port:livePort};if(typeof next.start==="string")next.start=next.start.replace(/(--port(?:=|\s+))\d+/,`$1${livePort}`);sites[siteName]=next;preserved.push(siteName)}return{preserved,removed}}async function reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig,where){const slug=String(tsCloudConfig.project?.slug||"app").replace(/[^a-z0-9._-]+/gi,"-"),siteNames=Object.keys(tsCloudConfig.sites??{}).filter((name)=>name==="dashboard"||name.startsWith("dashboard-"));if(siteNames.length===0)return;const units=siteNames.map((siteName)=>({siteName,unit:`${slug}-${siteName}.service`})),probe=`
18
+ Set \`PORT_API: '${port??"<the api site's port>"}'\` in that site's \`env\` in config/cloud.ts.`}export function siteDatabaseDrivers(sites){const drivers=new Set;for(const site of Object.values(sites??{})){if(typeof site?.start!=="string")continue;if(!(Array.isArray(site?.preStart)?site.preStart:[]).some((step)=>typeof step==="string"&&step.trim().split(/\s+/).some((token)=>migrateToken.test(token))))continue;const env=site?.env??{};drivers.add(String(env.DB_CONNECTION||"sqlite").toLowerCase())}return[...drivers]}const buddyEntrypoint=/(?:^|\/)(?:cli\.[cm]?[jt]s|buddy|bud|stacks)$/,migrateToken=/(?:^|:)migrate(?::|$)/;export function buddyInvocationFrom(migrateCommand){if(typeof migrateCommand!=="string")return;const tokens=migrateCommand.trim().split(/\s+/),at=tokens.findIndex((token)=>migrateToken.test(token.replace(/[;&|]+$/,"")));if(at<1)return;let from=at;while(from>0&&!isShellToken(tokens[from-1]))from--;const invocation=tokens.slice(from,at),entrypoint=invocation[invocation.length-1];if(!entrypoint||!buddyEntrypoint.test(entrypoint))return;return invocation.join(" ")}function isShellToken(token){if(/[;&|<>()`]/.test(token))return!0;if(/^[A-Za-z_][\w]*=/.test(token))return!0;return SHELL_KEYWORDS.has(token)}const SHELL_KEYWORDS=new Set(["do","done","then","else","elif","fi","if","for","while","until","case","esac","in","function","{","}","!"]);export function preMigrationBackupCommand(backupsDir,migrateCommand){const invocation=buddyInvocationFrom(migrateCommand);if(!invocation)return;return`${invocation} db:backup --before-migrations --out ${backupsDir}`}export function applyPreMigrationBackup(sites,backupsDir){const out={};for(const[name,site]of Object.entries(sites)){const at=migrateIndex(site);if(at===-1){out[name]=site;continue}const preStart=[...site.preStart];if(preStart.some((cmd)=>typeof cmd==="string"&&/\bdb:backup\b/.test(cmd))){out[name]=site;continue}const backup=preMigrationBackupCommand(backupsDir,preStart[at]);if(!backup){log.warn(`No pre-migration backup for "${name}": its migrate step (${String(preStart[at])}) is not a buddy invocation this can reuse. Add a \`db:backup\` command to its preStart to take one.`);out[name]=site;continue}preStart.splice(at,0,backup);out[name]={...site,preStart}}return out}function prefixHostForEnv(host,prefix){if(host.startsWith(`${prefix}.`)||host.startsWith(`www.${prefix}.`))return host;if(host.startsWith("www."))return`www.${prefix}.${host.slice(4)}`;return`${prefix}.${host}`}export function applyEnvironmentToSites(sites,environment,config){const prefix=config?.environments?.[environment]?.domainPrefix;if(!prefix||environment==="production")return sites;const allHosts=[];for(const site of Object.values(sites)){const d=site?.domain;if(typeof d==="string")allHosts.push(d);else if(Array.isArray(d)){for(const x of d)if(typeof x==="string")allHosts.push(x)}}allHosts.sort((a,b)=>b.length-a.length);const rewrite=(val)=>{let r=val;for(const h of allHosts){const esc=h.replace(/[.]/g,"\\.");r=r.replace(new RegExp(`//${esc}(?=[/:?#]|$)`,"g"),`//${prefixHostForEnv(h,prefix)}`)}return r},out={};for(const[name,site]of Object.entries(sites)){if(!site){out[name]=site;continue}const s={...site};if(typeof s.domain==="string")s.domain=prefixHostForEnv(s.domain,prefix);else if(Array.isArray(s.domain))s.domain=s.domain.map((d)=>typeof d==="string"?prefixHostForEnv(d,prefix):d);if(typeof s.redirect==="string")s.redirect=rewrite(s.redirect);if(s.env&&typeof s.env==="object"){const e={...s.env};for(const k of Object.keys(e))if(typeof e[k]==="string")e[k]=rewrite(e[k]);s.env=e}out[name]=s}return out}async function deployOverSsh(tsCloudConfig,deployEnv,options){const verbose=options.verbose===!0,environment=deployEnv==="prod"?"production":deployEnv,provider=resolveProvider(tsCloudConfig),persistedAttachBox=resolvePersistedAttachTargetBox(tsCloudConfig,environment);let sshTarget;if(provider==="ssh"){const resolved=resolveSshTarget(tsCloudConfig);if(!resolved){log.error("No SSH host configured for this deploy.");log.info("Add one to config/cloud.ts: ssh: { hosts: [{ host: 'pi-stacks.local', user: 'pi' }] }");log.info("Or set TS_CLOUD_SSH_HOST (with TS_CLOUD_SSH_USER / TS_CLOUD_SSH_PORT / TS_CLOUD_SSH_KEY).");process.exit(ExitCode.FatalError)}sshTarget=resolved;if(resolved.identityFile&&!existsSync(resolved.identityFile)){log.error(`SSH private key not found at ${resolved.identityFile}.`);log.info("Fix ssh.hosts[].privateKeyPath in config/cloud.ts, or set TS_CLOUD_SSH_KEY.");process.exit(ExitCode.FatalError)}if(!resolved.identityFile)log.info(`Using ssh's own key selection for ${resolved.user}@${resolved.host} (agent or ~/.ssh/config).`)}else{if(!resolveHetznerApiToken(tsCloudConfig)&&!persistedAttachBox){log.error("No Hetzner API token found. Set HCLOUD_TOKEN in your .env (or hetzner.apiToken in config/cloud.ts).");process.exit(ExitCode.FatalError)}const sshPubKey=join(homedir(),".ssh","id_ed25519.pub");if(!existsSync(sshPubKey)){log.error(`SSH public key not found at ${sshPubKey}.`);log.info("ts-cloud deploys to Hetzner over SSH and registers this key on the server.");log.info("Generate one with: ssh-keygen -t ed25519");process.exit(ExitCode.FatalError)}}const{createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,buildSiteDeployScript}=await loadTsCloudDeployApi(),support=tsCloudPersistentStateSupport(buildSiteDeployScript);if(!support.ok){log.error("This ts-cloud cannot keep your database across deploys, and deploying anyway would destroy it.");log.error(`Missing: ${support.missing.join(", ")}.`);log.error("Upgrade @stacksjs/ts-cloud (bun update @stacksjs/ts-cloud), or point TS_CLOUD_MODULE at a build that has it.");process.exit(ExitCode.FatalError)}const unbacked=findUnbackedManagedServices(tsCloudConfig,await hasOffsiteBackupDestination());if(unbacked.length>0)log.warn(`[deploy] ${unbackedDataMessage(unbacked)}`);try{await runSshDeploy({tsCloudConfig,environment,verbose,docker:options.docker===!0,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,onlySite:options.site||void 0,persistedAttachBox,provider,sshTarget})}catch(err){log.error(`${deployTargetLabel(provider,sshTarget?.profile)} deploy failed:`);console.error(err instanceof Error?err.stack||err.message:err);throw err}}export function resolveProjectTsCloudModule(projectRoot=process.cwd()){const manifestPath=join(projectRoot,"node_modules","@stacksjs","ts-cloud","package.json");if(!existsSync(manifestPath))return;const manifest=JSON.parse(readFileSync(manifestPath,"utf8")),rootExport=manifest.exports?.["."],entry=manifest.module??(typeof rootExport==="string"?rootExport:rootExport?.import);if(!entry)return;const modulePath=resolve(dirname(manifestPath),entry);return existsSync(modulePath)?modulePath:void 0}export async function loadTsCloudDeployApi(){const requested=process.env.TS_CLOUD_MODULE?.trim();if(!requested){const projectModule=resolveProjectTsCloudModule();return projectModule?import(pathToFileURL(projectModule).href):import("@stacksjs/ts-cloud")}const modulePath=isAbsolute(requested)?requested:resolve(process.cwd(),requested);if(!existsSync(modulePath))throw Error(`TS_CLOUD_MODULE points to a missing module: ${modulePath}`);return import(pathToFileURL(modulePath).href)}export function shouldInjectManagementDashboard(tsCloudConfig){return!tsCloudConfig.cloud?.attachTo}export function reconcilePartialDeployManagementDashboards(tsCloudConfig,livePorts){const sites=tsCloudConfig.sites,preserved=[],removed=[];if(!sites)return{preserved,removed};for(const[siteName,site]of Object.entries(sites)){if(siteName!=="dashboard"&&!siteName.startsWith("dashboard-"))continue;const livePort=livePorts[siteName];if(typeof livePort!=="number"||!Number.isInteger(livePort)||livePort<1||livePort>65535){delete sites[siteName];removed.push(siteName);continue}const next={...site,port:livePort};if(typeof next.start==="string")next.start=next.start.replace(/(--port(?:=|\s+))\d+/,`$1${livePort}`);sites[siteName]=next;preserved.push(siteName)}return{preserved,removed}}async function reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig,where){const slug=String(tsCloudConfig.project?.slug||"app").replace(/[^a-z0-9._-]+/gi,"-"),siteNames=Object.keys(tsCloudConfig.sites??{}).filter((name)=>name==="dashboard"||name.startsWith("dashboard-"));if(siteNames.length===0)return;const units=siteNames.map((siteName)=>({siteName,unit:`${slug}-${siteName}.service`})),probe=`
19
19
  const units = ${JSON.stringify(units)}
20
20
  const text = bytes => new TextDecoder().decode(bytes).trim()
21
21
  const run = args => text(Bun.spawnSync(args).stdout)
@@ -30,9 +30,9 @@ for (const entry of units) {
30
30
  }
31
31
  console.log(JSON.stringify(ports))
32
32
  `.trim(),encoded=Buffer.from(probe).toString("base64"),{sshExecOrThrow}=await import("@stacksjs/ts-cloud"),target=toSshTarget(where),line=(await sshExecOrThrow(target.host,`/usr/local/bin/bun -e "eval(Buffer.from('${encoded}','base64').toString())"`,remoteExecOptions(target,10))).trim().split(`
33
- `).at(-1)||"{}",livePorts=JSON.parse(line),result=reconcilePartialDeployManagementDashboards(tsCloudConfig,livePorts);for(const siteName of result.preserved)log.info(`Partial deploy: preserving the live management dashboard service '${siteName}' on port ${livePorts[siteName]}`);for(const siteName of result.removed)log.info(`Partial deploy: omitting management dashboard route '${siteName}' because no active service owns it`)}export function resolvePersistedAttachTargetBox(tsCloudConfig,environment,cwd=process.cwd()){const owner=tsCloudConfig.cloud?.attachTo;if(!owner)return null;const stackName=tsCloudConfig.project?.stackName||`${tsCloudConfig.project?.slug||"app"}-${environment}`,statePath=join(cwd,"storage","cloud","state",`${stackName}.json`);if(!existsSync(statePath))return null;try{const state=JSON.parse(readFileSync(statePath,"utf8")),isSshPin=state.provider==="ssh";if(state.stackName!==stackName||!isSshPin&&typeof state.serverId!=="number"||typeof state.publicIp!=="string"||!state.publicIp.trim())return null;return{...typeof state.serverId==="number"?{serverId:state.serverId}:{},serverName:typeof state.serverName==="string"&&state.serverName?state.serverName:isSshPin?String(state.publicIp):`${owner}-${environment}-app`,publicIp:state.publicIp,publicIpv6:typeof state.publicIpv6==="string"?state.publicIpv6:void 0}}catch{return null}}export function scrubLoopbackSitePortsForFirewall(tsCloudConfig){const sites=tsCloudConfig?.sites;if(!sites)return tsCloudConfig;const loopbackHosts=new Set(["127.0.0.1","::1","localhost"]),scrubbed={};for(const[siteName,site]of Object.entries(sites)){const host=String(site?.env?.HOST??"").toLowerCase();if(site&&typeof site.port==="number"&&!site.domain&&loopbackHosts.has(host)){const rest={...site};delete rest.port;scrubbed[siteName]=rest}else scrubbed[siteName]=site}return{...tsCloudConfig,sites:scrubbed}}function githubDeploymentsEnabled(){return process.env.GITHUB_ACTIONS!=="true"&&process.env.TS_CLOUD_GITHUB_DEPLOYMENTS!=="0"}async function ghCliAvailable(){try{const{execSync}=await import("node:child_process");execSync("gh --version",{stdio:"ignore"});return!0}catch{return!1}}async function resolveSiteGithubSource(root){try{const{execSync}=await import("node:child_process"),run=(cmd)=>execSync(cmd,{cwd:root,stdio:["ignore","pipe","ignore"]}).toString().trim(),match=run("git config --get remote.origin.url").match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);if(!match?.[1])return null;return{repo:match[1],ref:run("git rev-parse HEAD")}}catch{return null}}async function setGithubDeploymentStatus(record,state){try{const{execSync}=await import("node:child_process"),body=JSON.stringify({state,environment:record.environment,...record.environmentUrl?{environment_url:record.environmentUrl}:{},description:state==="success"?"Deployed":state==="failure"?"Deploy failed":"Deploying"});execSync(`gh api -X POST repos/${record.repo}/deployments/${record.id}/statuses --input -`,{input:body,stdio:["pipe","ignore","ignore"]})}catch(err){log.warn(`GitHub deployment status (${state}) skipped for ${record.repo}: ${getErrorMessage(err)}`)}}async function startGithubDeployment(source,environment,environmentUrl){try{const{execSync}=await import("node:child_process"),body=JSON.stringify({ref:source.ref,environment,description:`buddy deploy (${environment})`,auto_merge:!1,required_contexts:[],production_environment:environment==="production"}),out=execSync(`gh api -X POST repos/${source.repo}/deployments --input - --jq '.id'`,{input:body,stdio:["pipe","pipe","ignore"]}).toString().trim(),id=Number(out);if(!out||!Number.isInteger(id)||id<=0)return null;const record={repo:source.repo,id,environment,environmentUrl};await setGithubDeploymentStatus(record,"in_progress");return record}catch(err){log.warn(`GitHub deployment record skipped for ${source.repo}: ${getErrorMessage(err)}`);return null}}async function startGithubDeployments(args){const{sites,onlySite,environment,resolveSiteKind}=args,records=[];if(!githubDeploymentsEnabled()||!await ghCliAvailable())return records;const seen=new Set;for(const[siteName,site]of Object.entries(sites)){if(!site||onlySite&&siteName!==onlySite)continue;const kind=resolveSiteKind(site);if(kind==="bucket"||kind==="redirect")continue;const source=await resolveSiteGithubSource(site.root||".");if(!source)continue;const key=`${source.repo}@${source.ref}`;if(seen.has(key))continue;seen.add(key);const record=await startGithubDeployment(source,environment,site.domain?`https://${site.domain}`:void 0);if(record){records.push(record);log.info(`GitHub deployment ${record.repo}#${record.id} \u2192 ${environment}`)}}return records}async function runSshDeploy(args){const{tsCloudConfig,environment,verbose,docker,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,onlySite,persistedAttachBox,provider,sshTarget}=args,startTime=performance.now(),targetLabel=deployTargetLabel(provider,sshTarget?.profile);console.log("");console.log(`\uD83D\uDE80 Deploy \u2192 ${targetLabel}`);console.log("");log.info(`Project: ${tsCloudConfig.project?.slug}`);log.info(`Environment: ${environment}`);if(sshTarget)log.info(`Host: ${sshTarget.user}@${sshTarget.host}${sshTarget.port===22?"":`:${sshTarget.port}`}`);else{log.info(`Location: ${tsCloudConfig.hetzner?.location||process.env.HCLOUD_LOCATION||"fsn1"}`);log.info(`Size: ${tsCloudConfig.infrastructure?.compute?.size||"small"}`)}try{if(shouldInjectManagementDashboard(tsCloudConfig)&&typeof ensureManagementDashboard==="function")ensureManagementDashboard(tsCloudConfig,{cwd:process.cwd(),logger:{info:(m)=>log.info(m),warn:(m)=>log.warn(m)}});else if(tsCloudConfig.cloud?.attachTo)log.info(`Management dashboard: using the '${tsCloudConfig.cloud.attachTo}' server owner's dashboard`)}catch(err){log.warn(`Management dashboard injection skipped: ${getErrorMessage(err)}`)}const driver=createCloudDriver({config:tsCloudConfig,provider});if(!driver.provisionComputeInfrastructure){await log.error(`The ${provider} driver does not support compute provisioning (update @stacksjs/ts-cloud).`);process.exit(ExitCode.FatalError)}const attachTo=tsCloudConfig.cloud?.attachTo;let ip,ipv6;if(attachTo){let lookup,box=persistedAttachBox;if(!box&&provider==="ssh"&&sshTarget)box={publicIp:sshTarget.host,serverName:sshTarget.host};if(!box){lookup=await resolveAttachTargetBox(attachTo,environment,tsCloudConfig);box=lookup.box}if(box&&!box.publicIpv6&&provider!=="ssh"){const resolved=await resolveAttachTargetBox(attachTo,environment,tsCloudConfig);if(resolved.box?.publicIpv6)box={...box,publicIpv6:resolved.box.publicIpv6}}if(!box?.publicIp){await log.error(describeAttachLookupFailure(attachTo,environment,lookup?.failure));process.exit(ExitCode.FatalError)}ip=box.publicIp;ipv6=box.publicIpv6;if((tsCloudConfig.project?.slug||"app")===attachTo){await log.error(`This project's slug is '${attachTo}', which is the slug of the box it attaches to.`);await log.error(`A tenant's deploy owns /etc/rpx/sites.d/<slug>.json, so deploying would overwrite '${attachTo}'s own gateway fragment and take its sites down.`);log.info(`Set a distinct project.slug in config/cloud.ts (e.g. '${p.projectPath().split("/").pop()}') and deploy again.`);process.exit(ExitCode.FatalError)}log.info(`Attaching to '${attachTo}' box '${box.serverName}' (${ip}) - skipping provisioning`);const guardTarget=sshTarget?{...sshTarget,host:ip}:ip;await assertFragmentIsOurs(guardTarget,tsCloudConfig,log);await assertPortsAreFree(guardTarget,tsCloudConfig,log);const compute=(tsCloudConfig.infrastructure??={}).compute??={};compute.webServer="rpx";const wantsPublicTls=dnsPublishingAllowed({provider,publicIp:ip,sites:tsCloudConfig.sites});compute.proxy={...wantsPublicTls?{onDemandTls:!0}:{},...compute.proxy??{},engine:"rpx"};const stackName=tsCloudConfig.project?.stackName||`${tsCloudConfig.project?.slug||"app"}-${environment}`,stateDir=join(process.cwd(),"storage","cloud","state");mkdirSync(stateDir,{recursive:!0});const attachPin=provider==="ssh"&&sshTarget?sshStatePin({stackName,target:{...sshTarget,host:ip},lanIp:ip}):{stackName,serverId:box.serverId,serverName:box.serverName,publicIp:ip,...ipv6?{publicIpv6:ipv6}:{},sshUser:"root",deployStoragePath:"/var/ts-cloud/staging"};writeFileSync(join(stateDir,`${stackName}.json`),`${JSON.stringify(attachPin,null,2)}
33
+ `).at(-1)||"{}",livePorts=JSON.parse(line),result=reconcilePartialDeployManagementDashboards(tsCloudConfig,livePorts);for(const siteName of result.preserved)log.info(`Partial deploy: preserving the live management dashboard service '${siteName}' on port ${livePorts[siteName]}`);for(const siteName of result.removed)log.info(`Partial deploy: omitting management dashboard route '${siteName}' because no active service owns it`)}export function resolvePersistedAttachTargetBox(tsCloudConfig,environment,cwd=process.cwd()){const owner=tsCloudConfig.cloud?.attachTo;if(!owner)return null;const stackName=tsCloudConfig.project?.stackName||`${tsCloudConfig.project?.slug||"app"}-${environment}`,statePath=join(cwd,"storage","cloud","state",`${stackName}.json`);if(!existsSync(statePath))return null;try{const state=JSON.parse(readFileSync(statePath,"utf8")),isSshPin=state.provider==="ssh";if(state.stackName!==stackName||!isSshPin&&typeof state.serverId!=="number"||typeof state.publicIp!=="string"||!state.publicIp.trim())return null;return{...typeof state.serverId==="number"?{serverId:state.serverId}:{},serverName:typeof state.serverName==="string"&&state.serverName?state.serverName:isSshPin?String(state.publicIp):`${owner}-${environment}-app`,publicIp:state.publicIp,publicIpv6:typeof state.publicIpv6==="string"?state.publicIpv6:void 0}}catch{return null}}export function scrubLoopbackSitePortsForFirewall(tsCloudConfig){const sites=tsCloudConfig?.sites;if(!sites)return tsCloudConfig;const loopbackHosts=new Set(["127.0.0.1","::1","localhost"]),scrubbed={};for(const[siteName,site]of Object.entries(sites)){const host=String(site?.env?.HOST??"").toLowerCase();if(site&&typeof site.port==="number"&&!site.domain&&loopbackHosts.has(host)){const rest={...site};delete rest.port;scrubbed[siteName]=rest}else scrubbed[siteName]=site}return{...tsCloudConfig,sites:scrubbed}}function githubDeploymentsEnabled(){return process.env.GITHUB_ACTIONS!=="true"&&process.env.TS_CLOUD_GITHUB_DEPLOYMENTS!=="0"}async function ghCliAvailable(){try{const{execSync}=await import("node:child_process");execSync("gh --version",{stdio:"ignore"});return!0}catch{return!1}}async function resolveSiteGithubSource(root){try{const{execSync}=await import("node:child_process"),run=(cmd)=>execSync(cmd,{cwd:root,stdio:["ignore","pipe","ignore"]}).toString().trim(),match=run("git config --get remote.origin.url").match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);if(!match?.[1])return null;return{repo:match[1],ref:run("git rev-parse HEAD")}}catch{return null}}async function setGithubDeploymentStatus(record,state){try{const{execSync}=await import("node:child_process"),body=JSON.stringify({state,environment:record.environment,...record.environmentUrl?{environment_url:record.environmentUrl}:{},description:state==="success"?"Deployed":state==="failure"?"Deploy failed":"Deploying"});execSync(`gh api -X POST repos/${record.repo}/deployments/${record.id}/statuses --input -`,{input:body,stdio:["pipe","ignore","ignore"]})}catch(err){log.warn(`GitHub deployment status (${state}) skipped for ${record.repo}: ${getErrorMessage(err)}`)}}async function startGithubDeployment(source,environment,environmentUrl){try{const{execSync}=await import("node:child_process"),body=JSON.stringify({ref:source.ref,environment,description:`buddy deploy (${environment})`,auto_merge:!1,required_contexts:[],production_environment:environment==="production"}),out=execSync(`gh api -X POST repos/${source.repo}/deployments --input - --jq '.id'`,{input:body,stdio:["pipe","pipe","ignore"]}).toString().trim(),id=Number(out);if(!out||!Number.isInteger(id)||id<=0)return null;const record={repo:source.repo,id,environment,environmentUrl};await setGithubDeploymentStatus(record,"in_progress");return record}catch(err){log.warn(`GitHub deployment record skipped for ${source.repo}: ${getErrorMessage(err)}`);return null}}async function startGithubDeployments(args){const{sites,onlySite,environment,resolveSiteKind}=args,records=[];if(!githubDeploymentsEnabled()||!await ghCliAvailable())return records;const seen=new Set;for(const[siteName,site]of Object.entries(sites)){if(!site||onlySite&&siteName!==onlySite)continue;const kind=resolveSiteKind(site);if(kind==="bucket"||kind==="redirect")continue;const source=await resolveSiteGithubSource(site.root||".");if(!source)continue;const key=`${source.repo}@${source.ref}`;if(seen.has(key))continue;seen.add(key);const record=await startGithubDeployment(source,environment,site.domain?`https://${site.domain}`:void 0);if(record){records.push(record);log.info(`GitHub deployment ${record.repo}#${record.id} \u2192 ${environment}`)}}return records}async function runSshDeploy(args){const{tsCloudConfig,environment,verbose,docker,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,onlySite,persistedAttachBox,provider,sshTarget}=args,startTime=performance.now(),targetLabel=deployTargetLabel(provider,sshTarget?.profile);console.log("");console.log(`\uD83D\uDE80 Deploy \u2192 ${targetLabel}`);console.log("");log.info(`Project: ${tsCloudConfig.project?.slug}`);log.info(`Environment: ${environment}`);if(sshTarget)log.info(`Host: ${sshTarget.user}@${sshTarget.host}${sshTarget.port===22?"":`:${sshTarget.port}`}`);else{log.info(`Location: ${tsCloudConfig.hetzner?.location||process.env.HCLOUD_LOCATION||"fsn1"}`);log.info(`Size: ${tsCloudConfig.infrastructure?.compute?.size||"small"}`)}try{if(shouldInjectManagementDashboard(tsCloudConfig)&&typeof ensureManagementDashboard==="function")ensureManagementDashboard(tsCloudConfig,{cwd:process.cwd(),logger:{info:(m)=>log.info(m),warn:(m)=>log.warn(m)}});else if(tsCloudConfig.cloud?.attachTo)log.info(`Management dashboard: using the '${tsCloudConfig.cloud.attachTo}' server owner's dashboard`)}catch(err){log.warn(`Management dashboard injection skipped: ${getErrorMessage(err)}`)}const driver=createCloudDriver({config:tsCloudConfig,provider});if(!driver.provisionComputeInfrastructure){log.error(`The ${provider} driver does not support compute provisioning (update @stacksjs/ts-cloud).`);process.exit(ExitCode.FatalError)}const attachTo=tsCloudConfig.cloud?.attachTo;let ip,ipv6;if(attachTo){let lookup,box=persistedAttachBox;if(!box&&provider==="ssh"&&sshTarget)box={publicIp:sshTarget.host,serverName:sshTarget.host};if(!box){lookup=await resolveAttachTargetBox(attachTo,environment,tsCloudConfig);box=lookup.box}if(box&&!box.publicIpv6&&provider!=="ssh"){const resolved=await resolveAttachTargetBox(attachTo,environment,tsCloudConfig);if(resolved.box?.publicIpv6)box={...box,publicIpv6:resolved.box.publicIpv6}}if(!box?.publicIp){log.error(describeAttachLookupFailure(attachTo,environment,lookup?.failure));process.exit(ExitCode.FatalError)}ip=box.publicIp;ipv6=box.publicIpv6;if((tsCloudConfig.project?.slug||"app")===attachTo){log.error(`This project's slug is '${attachTo}', which is the slug of the box it attaches to.`);log.error(`A tenant's deploy owns /etc/rpx/sites.d/<slug>.json, so deploying would overwrite '${attachTo}'s own gateway fragment and take its sites down.`);log.info(`Set a distinct project.slug in config/cloud.ts (e.g. '${p.projectPath().split("/").pop()}') and deploy again.`);process.exit(ExitCode.FatalError)}log.info(`Attaching to '${attachTo}' box '${box.serverName}' (${ip}) - skipping provisioning`);const guardTarget=sshTarget?{...sshTarget,host:ip}:ip;await assertFragmentIsOurs(guardTarget,tsCloudConfig,log);await assertPortsAreFree(guardTarget,tsCloudConfig,log);const compute=(tsCloudConfig.infrastructure??={}).compute??={};compute.webServer="rpx";const wantsPublicTls=dnsPublishingAllowed({provider,publicIp:ip,sites:tsCloudConfig.sites});compute.proxy={...wantsPublicTls?{onDemandTls:!0}:{},...compute.proxy??{},engine:"rpx"};const stackName=tsCloudConfig.project?.stackName||`${tsCloudConfig.project?.slug||"app"}-${environment}`,stateDir=join(process.cwd(),"storage","cloud","state");mkdirSync(stateDir,{recursive:!0});const attachPin=provider==="ssh"&&sshTarget?sshStatePin({stackName,target:{...sshTarget,host:ip},lanIp:ip}):{stackName,serverId:box.serverId,serverName:box.serverName,publicIp:ip,...ipv6?{publicIpv6:ipv6}:{},sshUser:"root",deployStoragePath:"/var/ts-cloud/staging"};writeFileSync(join(stateDir,`${stackName}.json`),`${JSON.stringify(attachPin,null,2)}
34
34
  `)}else{log.info(provider==="ssh"?`Adopting ${sshTarget?.host??"host"} (preflight, then bootstrap if needed)...`:"Provisioning Hetzner compute infrastructure...");const outputs=await driver.provisionComputeInfrastructure({config:scrubLoopbackSitePortsForFirewall(tsCloudConfig),environment});ip=outputs.appPublicIp;ipv6=outputs.appPublicIpv6;log.success(provider==="ssh"?"Host ready":"Hetzner compute infrastructure ready");if(outputs.appInstanceId&&provider!=="ssh")log.info(`Server ID: ${outputs.appInstanceId}`);if(provider==="ssh"&&sshTarget&&ip)try{const stackName=tsCloudConfig.project?.stackName||`${tsCloudConfig.project?.slug||"app"}-${environment}`,dir=join(process.cwd(),"storage","cloud","state"),statePath=join(dir,`${stackName}.json`);let existing=null;try{existing=existsSync(statePath)?JSON.parse(readFileSync(statePath,"utf8")):null}catch{existing=null}const pin=mergeSshStatePin(existing,sshStatePin({stackName,target:sshTarget,deployStoragePath:outputs.deployStoragePath}));mkdirSync(dir,{recursive:!0});writeFileSync(statePath,`${JSON.stringify(pin,null,2)}
35
- `)}catch(err){log.warn(`Could not record the ssh host pin: ${getErrorMessage(err)}`)}}if(ip)log.info(provider==="ssh"?`Host: ${ip}`:`Server IP: ${ip}`);if(!ip){await log.error("The deploy target has no reachable address - cannot deploy over SSH.");process.exit(ExitCode.FatalError)}await waitForRemoteReady(sshTarget?{...sshTarget,host:ip}:ip);if(onlySite&&!attachTo)await reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig,sshTarget?{...sshTarget,host:ip}:ip);const{execSync}=await import("node:child_process"),{tmpdir}=await import("node:os"),sites=applyEnvironmentToSites(tsCloudConfig.sites||{},environment,tsCloudConfig),slug=tsCloudConfig.project?.slug||"app";let sha;try{sha=execSync("git rev-parse --short HEAD",{stdio:["ignore","pipe","ignore"]}).toString().trim()}catch{sha=Date.now().toString(36)}const tarExcludes=["node_modules","pantry",".git",".github",".cache","bin","dist",relative(p.projectPath(),p.stxPath()).replace(/\/$/,""),".stx",relative(p.projectPath(),p.cloudStatePath()).replace(/\/$/,""),".ts-cloud",relative(p.projectPath(),p.frameworkRuntimePath()).replace(/\/$/,""),"tmp","temp",".DS_Store","*.log",".env",".env.local",".env.keys",".env.production.bak",".env.production.plain",...encryptedEnvFileNames(p.projectPath()),"*.sqlite","*.sqlite-wal","*.sqlite-shm"];if(onlySite&&!sites[onlySite]){await log.error(`--site '${onlySite}' is not a configured site. Available: ${Object.keys(sites).join(", ")}`);process.exit(ExitCode.FatalError)}const tarballs=new Map;for(const[siteName,site]of Object.entries(sites)){if(!site)continue;if(onlySite&&siteName!==onlySite)continue;const kind=resolveSiteKind(site);if(kind==="bucket")continue;if(kind==="redirect")continue;if(kind==="server-static"&&site.build){log.info(`Building static site '${siteName}': ${site.build}`);execSync(site.build,{stdio:verbose?"inherit":"pipe"})}const root=site.root||".",tarballPath=join(tmpdir(),`${slug}-${siteName}-${sha}.tar.gz`),siteExcludes=Array.isArray(site.exclude)?site.exclude.filter((entry)=>typeof entry==="string"&&entry.length>0):[],excludeArgs=[...tarExcludes,...siteExcludes].flatMap((pattern)=>[`--exclude='${pattern}'`,`--exclude='*/${pattern}'`]);if(siteExcludes.length>0)log.info(`Excluding server-owned paths: ${siteExcludes.join(", ")}`);log.info(`Packaging ${root} \u2192 ${tarballPath}...`);execSync(`tar czf "${tarballPath}" ${excludeArgs.join(" ")} -C "${root}" .`,{stdio:verbose?"inherit":"pipe",env:{...process.env,COPYFILE_DISABLE:"1"}});const sizeMb=Math.max(1,Math.round(statSync(tarballPath).size/1048576));log.info(`Release tarball: ~${sizeMb} MB`);tarballs.set(siteName,tarballPath)}if(docker)await buildContainerImageWithPantry({slug,sites,verbose});const resolvedDeployEnv=await resolveDeployEnvValues(environment,tsCloudConfig),sitesWithResolvedEnv=applyPreMigrationBackup(applyScheduledWork(applyPersistentStatePaths(applyAutomaticMigrations(mergeSiteDeployEnv(sites,resolvedDeployEnv)),slug),p.projectPath("app/Scheduler.ts")),projectDatabaseTarget(slug,"backups")),apiProblem=apiDeploymentProblem(sitesWithResolvedEnv,existsSync(p.projectPath("routes/api.ts")));if(apiProblem){log.error(apiProblem);throw Error("Refusing to deploy: the API would not be reachable.")}const{validateMigrationDialect}=await import("./migrate");for(const driver of siteDatabaseDrivers(sitesWithResolvedEnv)){const dialect=validateMigrationDialect(p.projectPath(),{driver});if(!dialect.valid){log.error(dialect.error??`The committed migrations cannot run on ${driver}.`);throw Error(`Refusing to deploy: the migrations cannot run on ${driver}.`)}}for(const[envKey,envValue]of Object.entries(resolvedDeployEnv))if(process.env[envKey]===void 0)process.env[envKey]=envValue;const githubDeployments=await startGithubDeployments({sites,onlySite,environment,resolveSiteKind});log.info(onlySite?`Shipping site '${onlySite}' to the server...`:"Shipping release to the server...");const deployConfig=onlySite?{...tsCloudConfig,sites:{[onlySite]:sitesWithResolvedEnv[onlySite]}}:{...tsCloudConfig,sites:sitesWithResolvedEnv},ok=await deployAllComputeSites({config:deployConfig,managementDashboard:!onlySite,rpxConfig:{...tsCloudConfig,sites:sitesWithResolvedEnv},environment,driver,sha,runtime:tsCloudConfig.infrastructure?.compute?.runtime||"bun",tarballForSite:(siteName)=>{const path=tarballs.get(siteName);if(!path)throw Error(`Missing tarball for site '${siteName}'`);return path},logger:{info:(m)=>log.info(m),warn:(m)=>log.warn(m),error:(m)=>log.error(m),step:(m)=>log.info(m),success:(m)=>log.success(m)}}),dnsAllowed=dnsPublishingAllowed({provider,publicIp:ip,sites});if(ok&&!dnsAllowed&&provider==="ssh"){const reachable=lanUrls(onlySite?{[onlySite]:sites[onlySite]}:sites,sshTarget??hetznerTarget(ip),tsCloudConfig.ssh?.lan?.hostname);log.info("Private host: skipping DNS, TLS issuance, CDN and mail reconciliation.");log.info(`Reachable on the local network at ${reachable.join(", ")}`);log.info("To publish a domain, give the host a routable address and set ssh.publicIp, or set TS_CLOUD_SSH_PUBLISH_DNS=1.")}let publishedDns=[];if(ok&&dnsAllowed){const autoWww=tsCloudConfig.infrastructure?.compute?.proxy?.autoWww;publishedDns=await reconcileHetznerDns(onlySite?{[onlySite]:sites[onlySite]}:sites,ip,log,ipv6,autoWww)}if(ok&&publishedDns.length>0){log.info(`Issuing TLS for ${publishedDns.length} newly published record(s)...`);try{const{execSync}=await import("node:child_process"),unit=`rpx-cert-renew-${tsCloudConfig.project?.slug||"app"}.service`,certSshArgs=sshCliArgs(sshTarget?{...sshTarget,host:ip}:hetznerTarget(ip),{connectTimeoutSec:20}),out=execSync(`ssh ${certSshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`systemctl start ${unit} 2>&1 || true
35
+ `)}catch(err){log.warn(`Could not record the ssh host pin: ${getErrorMessage(err)}`)}}if(ip)log.info(provider==="ssh"?`Host: ${ip}`:`Server IP: ${ip}`);if(!ip){log.error("The deploy target has no reachable address - cannot deploy over SSH.");process.exit(ExitCode.FatalError)}await waitForRemoteReady(sshTarget?{...sshTarget,host:ip}:ip);if(onlySite&&!attachTo)await reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig,sshTarget?{...sshTarget,host:ip}:ip);const{execSync}=await import("node:child_process"),{tmpdir}=await import("node:os"),sites=applyEnvironmentToSites(tsCloudConfig.sites||{},environment,tsCloudConfig),slug=tsCloudConfig.project?.slug||"app";let sha;try{sha=execSync("git rev-parse --short HEAD",{stdio:["ignore","pipe","ignore"]}).toString().trim()}catch{sha=Date.now().toString(36)}const tarExcludes=["node_modules","pantry",".git",".github",".cache","bin","dist",relative(p.projectPath(),p.stxPath()).replace(/\/$/,""),".stx",relative(p.projectPath(),p.cloudStatePath()).replace(/\/$/,""),".ts-cloud",relative(p.projectPath(),p.frameworkRuntimePath()).replace(/\/$/,""),"tmp","temp",".DS_Store","*.log",".env",".env.local",".env.keys",".env.production.bak",".env.production.plain",...encryptedEnvFileNames(p.projectPath()),"*.sqlite","*.sqlite-wal","*.sqlite-shm"];if(onlySite&&!sites[onlySite]){log.error(`--site '${onlySite}' is not a configured site. Available: ${Object.keys(sites).join(", ")}`);process.exit(ExitCode.FatalError)}const tarballs=new Map;for(const[siteName,site]of Object.entries(sites)){if(!site)continue;if(onlySite&&siteName!==onlySite)continue;const kind=resolveSiteKind(site);if(kind==="bucket")continue;if(kind==="redirect")continue;if(kind==="server-static"&&site.build){log.info(`Building static site '${siteName}': ${site.build}`);execSync(site.build,{stdio:verbose?"inherit":"pipe"})}const root=site.root||".",tarballPath=join(tmpdir(),`${slug}-${siteName}-${sha}.tar.gz`),siteExcludes=Array.isArray(site.exclude)?site.exclude.filter((entry)=>typeof entry==="string"&&entry.length>0):[],excludeArgs=[...tarExcludes,...siteExcludes].flatMap((pattern)=>[`--exclude='${pattern}'`,`--exclude='*/${pattern}'`]);if(siteExcludes.length>0)log.info(`Excluding server-owned paths: ${siteExcludes.join(", ")}`);log.info(`Packaging ${root} \u2192 ${tarballPath}...`);execSync(`tar czf "${tarballPath}" ${excludeArgs.join(" ")} -C "${root}" .`,{stdio:verbose?"inherit":"pipe",env:{...process.env,COPYFILE_DISABLE:"1"}});const sizeMb=Math.max(1,Math.round(statSync(tarballPath).size/1048576));log.info(`Release tarball: ~${sizeMb} MB`);tarballs.set(siteName,tarballPath)}if(docker)await buildContainerImageWithPantry({slug,sites,verbose});const resolvedDeployEnv=await resolveDeployEnvValues(environment,tsCloudConfig),sitesWithResolvedEnv=applyPreMigrationBackup(applyScheduledWork(applyPersistentStatePaths(applyAutomaticMigrations(mergeSiteDeployEnv(sites,resolvedDeployEnv)),slug),p.projectPath("app/Scheduler.ts")),projectDatabaseTarget(slug,"backups")),apiProblem=apiDeploymentProblem(sitesWithResolvedEnv,existsSync(p.projectPath("routes/api.ts")));if(apiProblem){log.error(apiProblem);throw Error("Refusing to deploy: the API would not be reachable.")}const{validateMigrationDialect}=await import("./migrate");for(const driver of siteDatabaseDrivers(sitesWithResolvedEnv)){const dialect=validateMigrationDialect(p.projectPath(),{driver});if(!dialect.valid){log.error(dialect.error??`The committed migrations cannot run on ${driver}.`);throw Error(`Refusing to deploy: the migrations cannot run on ${driver}.`)}}for(const[envKey,envValue]of Object.entries(resolvedDeployEnv))if(process.env[envKey]===void 0)process.env[envKey]=envValue;const githubDeployments=await startGithubDeployments({sites,onlySite,environment,resolveSiteKind});log.info(onlySite?`Shipping site '${onlySite}' to the server...`:"Shipping release to the server...");const deployConfig=onlySite?{...tsCloudConfig,sites:{[onlySite]:sitesWithResolvedEnv[onlySite]}}:{...tsCloudConfig,sites:sitesWithResolvedEnv},ok=await deployAllComputeSites({config:deployConfig,managementDashboard:!onlySite,rpxConfig:{...tsCloudConfig,sites:sitesWithResolvedEnv},environment,driver,sha,runtime:tsCloudConfig.infrastructure?.compute?.runtime||"bun",tarballForSite:(siteName)=>{const path=tarballs.get(siteName);if(!path)throw Error(`Missing tarball for site '${siteName}'`);return path},logger:{info:(m)=>log.info(m),warn:(m)=>log.warn(m),error:(m)=>log.error(m),step:(m)=>log.info(m),success:(m)=>log.success(m)}}),dnsAllowed=dnsPublishingAllowed({provider,publicIp:ip,sites});if(ok&&!dnsAllowed&&provider==="ssh"){const reachable=lanUrls(onlySite?{[onlySite]:sites[onlySite]}:sites,sshTarget??hetznerTarget(ip),tsCloudConfig.ssh?.lan?.hostname);log.info("Private host: skipping DNS, TLS issuance, CDN and mail reconciliation.");log.info(`Reachable on the local network at ${reachable.join(", ")}`);log.info("To publish a domain, give the host a routable address and set ssh.publicIp, or set TS_CLOUD_SSH_PUBLISH_DNS=1.")}let publishedDns=[];if(ok&&dnsAllowed){const autoWww=tsCloudConfig.infrastructure?.compute?.proxy?.autoWww;publishedDns=await reconcileHetznerDns(onlySite?{[onlySite]:sites[onlySite]}:sites,ip,log,ipv6,autoWww)}if(ok&&publishedDns.length>0){log.info(`Issuing TLS for ${publishedDns.length} newly published record(s)...`);try{const{execSync}=await import("node:child_process"),unit=`rpx-cert-renew-${tsCloudConfig.project?.slug||"app"}.service`,certSshArgs=sshCliArgs(sshTarget?{...sshTarget,host:ip}:hetznerTarget(ip),{connectTimeoutSec:20}),out=execSync(`ssh ${certSshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`systemctl start ${unit} 2>&1 || true
36
36
  systemctl is-active ${unit} >/dev/null 2>&1 && echo TLSUNIT:running || echo TLSUNIT:done
37
37
  journalctl -u ${unit} -n 20 --no-pager 2>/dev/null | grep -E 'Certificate written|Skipping|error|Error' | tail -5 || true`,encoding:"utf8",stdio:["pipe","pipe","pipe"]});for(const line of out.split(`
38
38
  `))if(/Certificate written|Skipping/.test(line))log.info(` ${line.replace(/^.*?\]:\s*/,"").trim()}`);log.success("TLS issued and gateway reloaded for the new record(s)")}catch(err){log.warn(`TLS issuance after DNS failed: ${err?.message||err}`);log.warn(` Until it succeeds, ${publishedDns.join(", ")} serves a fallback certificate.`)}}if(ok&&dnsAllowed)await reconcileConfigDns(onlySite?{[onlySite]:sites[onlySite]}:sites,log);if(ok&&dnsAllowed)await reconcileCloudflareCdnForDeploy(tsCloudConfig,ip,ipv6,log);if(ok&&dnsAllowed){const mailOwner=mailServerOwnerFromConfig(emailConfig);let mailIp=ip;if(mailOwner){const mailLookup=await resolveAttachTargetBox(mailOwner,environment,tsCloudConfig);if(mailLookup.box?.publicIp){mailIp=mailLookup.box.publicIp;log.info(`Mail: reconciling on '${mailOwner}' box '${mailLookup.box.serverName}' (${mailIp})`)}else{mailIp=void 0;log.warn(`Mail: ${describeAttachLookupFailure(mailOwner,environment,mailLookup.failure)}`);log.warn("Mail: skipping mail reconciliation; the application deploy remains live.")}}const mailRes=mailIp?await provisionMailTenant(mailIp,log):null;if(mailOwner&&mailIp&&mailIp!==ip)await cleanupDetachedMailHealth(ip,log);if(mailRes)await reconcileMailDns(mailRes,mailIp,log)}for(const record of githubDeployments)await setGithubDeploymentStatus(record,ok?"success":"failure");console.log("");if(ok){const liveAt=dnsAllowed?publishedDns[0]?`https://${publishedDns[0]}`:`http://${ip}:3000`:lanUrls(onlySite?{[onlySite]:sites[onlySite]}:sites,sshTarget??hetznerTarget(ip),tsCloudConfig.ssh?.lan?.hostname).join(", ");await outro(`Deployed to ${targetLabel}. Your site is live at ${liveAt}`,{startTime,useSeconds:!0});log.info(`Coming-soon page: http://${ip}:3000 (bypass with ?secret=\u2026)`)}else{await outro(`${targetLabel} deploy reported a failure - see the per-instance output above.`,{startTime,useSeconds:!0});process.exit(ExitCode.FatalError)}}function resolveMailboxes(mailboxes,domain,generatePassword=!1){return resolveMailboxesWithSkipped(mailboxes,domain,generatePassword).boxes}function generateMailboxPassword(){return Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString("base64url")}function resolveMailboxesWithSkipped(mailboxes,domain,generatePassword=!1){if(!Array.isArray(mailboxes))return{boxes:[],skipped:[]};const out=[],skipped=[];for(const entry of mailboxes){let raw,explicitPw;if(typeof entry==="string")raw=entry;else if(entry&&typeof entry==="object"){raw=entry.email??entry.username;explicitPw=entry.password;if(entry.generate===!0)generatePassword=!0}if(!raw||typeof raw!=="string")continue;const localPart=(raw.includes("@")?raw.split("@")[0]??"":raw).trim();if(!localPart)continue;const address=`${localPart}@${domain}`,envKey=`MAIL_PASSWORD_${localPart.toUpperCase().replace(/[^A-Z0-9]/g,"_")}`,envPw=explicitPw||process.env[envKey];if(!envPw){if(generatePassword){out.push({address,localPart:localPart.toUpperCase(),password:generateMailboxPassword(),generated:!0});continue}skipped.push(address);continue}out.push({address,localPart:localPart.toUpperCase(),password:envPw,generated:!1})}return{boxes:out,skipped}}export function hasExplicitEmailConfig(projectRoot=p.projectPath()){return existsSync(join(projectRoot,"config","email.ts"))}export function mailServerOwnerFromConfig(config){const owner=config?.server?.attachTo;return typeof owner==="string"&&owner.trim()?owner.trim():void 0}async function cleanupDetachedMailHealth(ip,logger){const{execSync}=await import("node:child_process"),sshArgs=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=20",`root@${ip}`],script=`set -e
@@ -385,5 +385,5 @@ EOF
385
385
  systemctl daemon-reload
386
386
  systemctl enable --now mail-health.timer >/dev/null 2>&1
387
387
  # 6) Restart only when the startup-read env actually changed (domain or DKIM key).
388
- 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}}const DNS_PROVIDER_CREDENTIALS={porkbun:["PORKBUN_API_KEY","PORKBUN_SECRET_KEY"],cloudflare:["CLOUDFLARE_API_TOKEN"],godaddy:["GODADDY_API_KEY","GODADDY_API_SECRET"],route53:["AWS_ACCESS_KEY_ID (or AWS_PROFILE)"]};export function declaredDnsProvider(config){const provider=config?.tsCloud?.infrastructure?.dns?.provider??config?.infrastructure?.dns?.provider;return typeof provider==="string"&&provider?provider.toLowerCase():void 0}export function dnsProviderConfigsFromEnv(declared){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"});if(!declared)return configs;return configs.filter((config)=>config.provider===declared)}export function declaredDnsProviderProblem(declared,configs){if(!declared||configs.length>0)return;const needed=DNS_PROVIDER_CREDENTIALS[declared];if(!needed)return`config/cloud.ts declares the DNS provider '${declared}', which is not one this deploy knows how to drive (${Object.keys(DNS_PROVIDER_CREDENTIALS).join(", ")}).`;return`config/cloud.ts declares '${declared}' as the DNS provider for this project, but ${needed.join(" and ")} ${needed.length>1?"are":"is"} not set in this environment. Set ${needed.length>1?"them":"it"} in .env.production (\`buddy env:set\`) so the records land at the registrar that actually administers the zone. Refusing to try another provider: writing DNS into the wrong zone is not better than writing none.`}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}`)},declared=declaredDnsProvider(await loadTsCloudConfig(process.env.APP_ENV||"production").catch(()=>{return})),providerConfigs=dnsProviderConfigsFromEnv(declared),declaredProblem=declaredDnsProviderProblem(declared,providerConfigs);if(declaredProblem){logger.warn(` DNS: ${declaredProblem}`);return byHand(`the declared provider '${declared}' has no credentials in this environment`)}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 declared=declaredDnsProvider(await loadTsCloudConfig(process.env.APP_ENV||"production").catch(()=>{return})),providerConfigs=dnsProviderConfigsFromEnv(declared),declaredProblem=declaredDnsProviderProblem(declared,providerConfigs);if(declaredProblem)logger.warn(`DNS: ${declaredProblem}`);if(providerConfigs.length===0){if(!declaredProblem)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){await 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,await hasOffsiteBackupDestination()):[];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){await 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&&isSshPipelineProvider(resolveProvider(tsCloudConfig))){await deployOverSsh(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"){productionUrl=(await resolveDeployEnvValues("production",tsCloudConfig)).APP_URL?.trim()||void 0;if(productionUrl)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){await 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"});await setEnv("AWS_SECRET_ACCESS_KEY",secretAccessKey,{file:".env.production"});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(`
388
+ 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}}const DNS_PROVIDER_CREDENTIALS={porkbun:["PORKBUN_API_KEY","PORKBUN_SECRET_KEY"],cloudflare:["CLOUDFLARE_API_TOKEN"],godaddy:["GODADDY_API_KEY","GODADDY_API_SECRET"],route53:["AWS_ACCESS_KEY_ID (or AWS_PROFILE)"]};export function declaredDnsProvider(config){const provider=config?.tsCloud?.infrastructure?.dns?.provider??config?.infrastructure?.dns?.provider;return typeof provider==="string"&&provider?provider.toLowerCase():void 0}export function dnsProviderConfigsFromEnv(declared){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"});if(!declared)return configs;return configs.filter((config)=>config.provider===declared)}export function declaredDnsProviderProblem(declared,configs){if(!declared||configs.length>0)return;const needed=DNS_PROVIDER_CREDENTIALS[declared];if(!needed)return`config/cloud.ts declares the DNS provider '${declared}', which is not one this deploy knows how to drive (${Object.keys(DNS_PROVIDER_CREDENTIALS).join(", ")}).`;return`config/cloud.ts declares '${declared}' as the DNS provider for this project, but ${needed.join(" and ")} ${needed.length>1?"are":"is"} not set in this environment. Set ${needed.length>1?"them":"it"} in .env.production (\`buddy env:set\`) so the records land at the registrar that actually administers the zone. Refusing to try another provider: writing DNS into the wrong zone is not better than writing none.`}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}`)},declared=declaredDnsProvider(await loadTsCloudConfig(process.env.APP_ENV||"production").catch(()=>{return})),providerConfigs=dnsProviderConfigsFromEnv(declared),declaredProblem=declaredDnsProviderProblem(declared,providerConfigs);if(declaredProblem){logger.warn(` DNS: ${declaredProblem}`);return byHand(`the declared provider '${declared}' has no credentials in this environment`)}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 declared=declaredDnsProvider(await loadTsCloudConfig(process.env.APP_ENV||"production").catch(()=>{return})),providerConfigs=dnsProviderConfigsFromEnv(declared),declaredProblem=declaredDnsProviderProblem(declared,providerConfigs);if(declaredProblem)logger.warn(`DNS: ${declaredProblem}`);if(providerConfigs.length===0){if(!declaredProblem)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,await hasOffsiteBackupDestination()):[];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&&isSshPipelineProvider(resolveProvider(tsCloudConfig))){await deployOverSsh(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"){productionUrl=(await resolveDeployEnvValues("production",tsCloudConfig)).APP_URL?.trim()||void 0;if(productionUrl)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"});await setEnv("AWS_SECRET_ACCESS_KEY",secretAccessKey,{file:".env.production"});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(`
389
389
  `);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){await 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){await 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(`
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)}`);await log.flush();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){await 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){await 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.`);await log.flush();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
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")}
@@ -1,4 +1,4 @@
1
- import process from"node:process";import{runAction}from"@stacksjs/actions";import{bgCyan,bold,intro,italic,log,onUnknownSubcommand,outro,prompts}from"@stacksjs/cli";import{config}from"@stacksjs/config";import{addDomain}from"@stacksjs/dns";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function domains(buddy){const descriptions={purchase:"Purchase a domain",add:"Add a domain to your cloud",remove:"Remove a domain from your cloud",skip:"Skip the confirmation prompt",project:"Target a specific project",verbose:"Enable verbose output"},c=config.dns.contactInfo;buddy.command("domains:purchase <domain>",descriptions.purchase).option("--years <years>","Number of years to purchase the domain for",{default:1}).option("--privacy","Enable privacy protection",{default:!0}).option("--auto-renew","Enable auto-renew",{default:!0}).option("--first-name <firstName>","Registrant first name",{default:c?.firstName}).option("--last-name <lastName>","Registrant last name",{default:c?.lastName}).option("--organization <organization>","Registrant organization name",{default:c?.organizationName}).option("--address-line1 <address>","Registrant address line 1",{default:c?.addressLine1}).option("--address-line2 <address>","Registrant address line 2",{default:c?.addressLine2}).option("--city <city>","Registrant city",{default:c?.city}).option("--state <state>","Registrant state",{default:c?.state}).option("--country <country>","Registrant country code",{default:c?.countryCode}).option("--zip <zip>","Registrant zip",{default:c?.zip}).option("--phone <phone>","Registrant phone",{default:c?.phoneNumber}).option("--email <email>","Registrant email",{default:c?.email}).option("--admin-first-name <firstName>","Admin first name",{default:c?.admin?.firstName||c?.firstName}).option("--admin-last-name <lastName>","Admin last name",{default:c?.admin?.lastName||c?.lastName}).option("--admin-organization <organization>","Admin organization",{default:c?.admin?.organizationName||c?.organizationName}).option("--admin-address-line1 <address>","Admin address line 1",{default:c?.admin?.addressLine1||c?.addressLine1}).option("--admin-address-line2 <address>","Admin address line 2",{default:c?.admin?.addressLine2||c?.addressLine2}).option("--admin-city <city>","Admin city",{default:c?.admin?.city||c?.city}).option("--admin-state <state>","Admin state",{default:c?.admin?.state||c?.state}).option("--admin-country <country>","Admin country code",{default:c?.admin?.countryCode||c?.countryCode}).option("--admin-zip <zip>","Admin zip",{default:c?.admin?.zip||c?.zip}).option("--admin-phone <phone>","Admin phone number",{default:c?.admin?.phoneNumber||c?.phoneNumber}).option("--admin-email <email>","Admin email",{default:c?.admin?.email||c?.email}).option("--tech-first-name <firstName>","Tech first name",{default:c?.tech?.firstName||c?.firstName}).option("--tech-last-name <lastName>","Tech last name",{default:c?.tech?.lastName||c?.lastName}).option("--tech-organization <organization>","Tech organization name",{default:c?.tech?.organizationName||c?.organizationName}).option("--tech-address-line1 <address>","Tech address line 1",{default:c?.tech?.addressLine1||c?.addressLine1}).option("--tech-address-line2 <address>","Tech address line 2",{default:c?.tech?.addressLine2||c?.addressLine2}).option("--tech-city <city>","Tech city",{default:c?.tech?.city||c?.city}).option("--tech-state <state>","Tech state",{default:c?.tech?.state||c?.state}).option("--tech-country <country>","Tech country",{default:c?.tech?.countryCode||c?.countryCode}).option("--tech-zip <zip>","Tech zip",{default:c?.tech?.zip||c?.zip}).option("--tech-phone <phone>","Tech phone",{default:c?.tech?.phoneNumber||c?.phoneNumber}).option("--tech-email <email>","Tech email",{default:c?.tech?.email||c?.email}).option("--privacy-admin","Enable privacy protection for admin",{default:c?.privacyAdmin||c?.privacy||!0}).option("--privacy-tech","Enable privacy protection for tech",{default:c?.privacyTech||c?.privacy||!0}).option("--privacy-registrant","Enable privacy protection for registrant",{default:c?.privacyRegistrant||c?.privacy||!0}).option("--contact-type <type>","Contact type",{default:"person"}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(domain,options)=>{log.debug("Running `buddy domains:purchase <domain>` ...",options);options.domain=domain;const startTime=await intro("buddy domains:purchase"),result=await runAction(Action.DomainsPurchase,options);if(resultFailed(result)){await outro("While running the domains:purchase command, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}if(!await prompts.confirm(`Would you like to set ${domain} as your APP_URL?`)){await outro(`Alrighty! ${italic(domain)} was added to your account.`,{startTime,useSeconds:!0,type:"success"});log.info(`Please note, you may need to validate your email address. Check your ${italic(options.registrantEmail)} inbox.`);process.exit(ExitCode.Success)}const{writeEnv}=await import("@stacksjs/env");writeEnv("APP_URL",domain);let message=`Great! ${italic(domain)} was added to your account.`;message+=`
1
+ import process from"node:process";import{runAction}from"@stacksjs/actions";import{bgCyan,bold,intro,italic,log,onUnknownSubcommand,outro,prompts}from"@stacksjs/cli";import{config}from"@stacksjs/config";import{addDomain}from"@stacksjs/dns";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function domains(buddy){const descriptions={purchase:"Purchase a domain",add:"Add a domain to your cloud",remove:"Remove a domain from your cloud",skip:"Skip the confirmation prompt",project:"Target a specific project",verbose:"Enable verbose output"},c=config.dns.contactInfo;buddy.command("domains:purchase <domain>",descriptions.purchase).option("--years <years>","Number of years to purchase the domain for",{default:1}).option("--privacy","Enable privacy protection",{default:!0}).option("--auto-renew","Enable auto-renew",{default:!0}).option("--first-name <firstName>","Registrant first name",{default:c?.firstName}).option("--last-name <lastName>","Registrant last name",{default:c?.lastName}).option("--organization <organization>","Registrant organization name",{default:c?.organizationName}).option("--address-line1 <address>","Registrant address line 1",{default:c?.addressLine1}).option("--address-line2 <address>","Registrant address line 2",{default:c?.addressLine2}).option("--city <city>","Registrant city",{default:c?.city}).option("--state <state>","Registrant state",{default:c?.state}).option("--country <country>","Registrant country code",{default:c?.countryCode}).option("--zip <zip>","Registrant zip",{default:c?.zip}).option("--phone <phone>","Registrant phone",{default:c?.phoneNumber}).option("--email <email>","Registrant email",{default:c?.email}).option("--admin-first-name <firstName>","Admin first name",{default:c?.admin?.firstName||c?.firstName}).option("--admin-last-name <lastName>","Admin last name",{default:c?.admin?.lastName||c?.lastName}).option("--admin-organization <organization>","Admin organization",{default:c?.admin?.organizationName||c?.organizationName}).option("--admin-address-line1 <address>","Admin address line 1",{default:c?.admin?.addressLine1||c?.addressLine1}).option("--admin-address-line2 <address>","Admin address line 2",{default:c?.admin?.addressLine2||c?.addressLine2}).option("--admin-city <city>","Admin city",{default:c?.admin?.city||c?.city}).option("--admin-state <state>","Admin state",{default:c?.admin?.state||c?.state}).option("--admin-country <country>","Admin country code",{default:c?.admin?.countryCode||c?.countryCode}).option("--admin-zip <zip>","Admin zip",{default:c?.admin?.zip||c?.zip}).option("--admin-phone <phone>","Admin phone number",{default:c?.admin?.phoneNumber||c?.phoneNumber}).option("--admin-email <email>","Admin email",{default:c?.admin?.email||c?.email}).option("--tech-first-name <firstName>","Tech first name",{default:c?.tech?.firstName||c?.firstName}).option("--tech-last-name <lastName>","Tech last name",{default:c?.tech?.lastName||c?.lastName}).option("--tech-organization <organization>","Tech organization name",{default:c?.tech?.organizationName||c?.organizationName}).option("--tech-address-line1 <address>","Tech address line 1",{default:c?.tech?.addressLine1||c?.addressLine1}).option("--tech-address-line2 <address>","Tech address line 2",{default:c?.tech?.addressLine2||c?.addressLine2}).option("--tech-city <city>","Tech city",{default:c?.tech?.city||c?.city}).option("--tech-state <state>","Tech state",{default:c?.tech?.state||c?.state}).option("--tech-country <country>","Tech country",{default:c?.tech?.countryCode||c?.countryCode}).option("--tech-zip <zip>","Tech zip",{default:c?.tech?.zip||c?.zip}).option("--tech-phone <phone>","Tech phone",{default:c?.tech?.phoneNumber||c?.phoneNumber}).option("--tech-email <email>","Tech email",{default:c?.tech?.email||c?.email}).option("--privacy-admin","Enable privacy protection for admin",{default:c?.privacyAdmin||c?.privacy||!0}).option("--privacy-tech","Enable privacy protection for tech",{default:c?.privacyTech||c?.privacy||!0}).option("--privacy-registrant","Enable privacy protection for registrant",{default:c?.privacyRegistrant||c?.privacy||!0}).option("--contact-type <type>","Contact type",{default:"person"}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(domain,options)=>{log.debug("Running `buddy domains:purchase <domain>` ...",options);options.domain=domain;const startTime=await intro("buddy domains:purchase"),result=await runAction(Action.DomainsPurchase,options);if(resultFailed(result)){await outro("While running the domains:purchase command, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}if(!await prompts.confirm(`Would you like to set ${domain} as your APP_URL?`)){await outro(`Alrighty! ${italic(domain)} was added to your account.`,{startTime,useSeconds:!0,type:"success"});log.info(`Please note, you may need to validate your email address. Check your ${italic(options.registrantEmail)} inbox.`);await log.flush();process.exit(ExitCode.Success)}const{writeEnv}=await import("@stacksjs/env");writeEnv("APP_URL",domain);let message=`Great! ${italic(domain)} was added to your account.`;message+=`
2
2
 
3
3
  And your APP_URL has been set to ${italic(domain)}.
4
4
 
@@ -1 +1 @@
1
- import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{hasTTY,isCI}from"@stacksjs/env";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function fresh(buddy){const descriptions={fresh:"Re-installs your npm dependencies",project:"Target a specific project",force:"Skip the confirmation prompt (required in CI/non-interactive shells)",verbose:"Enable verbose output"};buddy.command("fresh",descriptions.fresh).option("-p, --project [project]",descriptions.project,{default:!1}).option("-f, --force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy fresh` ...",options);const skipConfirm=options.force===!0||Boolean(buddy.isForce)||Boolean(buddy.isNoInteraction);if(!skipConfirm&&(isCI||!hasTTY||!process.stdin.isTTY)){log.syncError("Refusing to run `buddy fresh` from a non-interactive shell without confirmation.");log.fatal(" \u27A1\uFE0F Re-run with `--force` to proceed: `buddy fresh --force`")}if(!skipConfirm){const{confirm}=await import("@stacksjs/cli");if(!await confirm({message:"This will remove and reinstall all dependencies. Continue?",initial:!1})){log.info("Fresh install cancelled");process.exit(ExitCode.Success)}}const perf=await intro("buddy fresh"),result=await runAction(Action.Fresh,{...options,stdout:"inherit"});if(resultFailed(result)){await outro("While running `buddy fresh`, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Freshly reinstalled your dependencies",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"fresh")}
1
+ import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{hasTTY,isCI}from"@stacksjs/env";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function fresh(buddy){const descriptions={fresh:"Re-installs your npm dependencies",project:"Target a specific project",force:"Skip the confirmation prompt (required in CI/non-interactive shells)",verbose:"Enable verbose output"};buddy.command("fresh",descriptions.fresh).option("-p, --project [project]",descriptions.project,{default:!1}).option("-f, --force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy fresh` ...",options);const skipConfirm=options.force===!0||Boolean(buddy.isForce)||Boolean(buddy.isNoInteraction);if(!skipConfirm&&(isCI||!hasTTY||!process.stdin.isTTY)){log.syncError("Refusing to run `buddy fresh` from a non-interactive shell without confirmation.");log.fatal(" \u27A1\uFE0F Re-run with `--force` to proceed: `buddy fresh --force`")}if(!skipConfirm){const{confirm}=await import("@stacksjs/cli");if(!await confirm({message:"This will remove and reinstall all dependencies. Continue?",initial:!1})){log.info("Fresh install cancelled");await log.flush();process.exit(ExitCode.Success)}}const perf=await intro("buddy fresh"),result=await runAction(Action.Fresh,{...options,stdout:"inherit"});if(resultFailed(result)){await outro("While running `buddy fresh`, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Freshly reinstalled your dependencies",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"fresh")}
@@ -1,2 +1,2 @@
1
1
  import{existsSync,lstatSync,readdirSync,realpathSync}from"node:fs";import fs from"node:fs";import{homedir}from"node:os";import{join,resolve}from"node:path";import process from"node:process";import{italic,log}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";function recordPath(){return join(process.cwd(),"storage/framework/runtime/linked-core.json")}function readRecord(){try{return JSON.parse(fs.readFileSync(recordPath(),"utf-8"))}catch{return null}}function writeRecord(record){fs.mkdirSync(join(process.cwd(),"storage/framework/runtime"),{recursive:!0});fs.writeFileSync(recordPath(),`${JSON.stringify(record,null,2)}
2
- `)}function resolveFrameworkPath(explicit){const candidates=[explicit,process.env.STACKS_FRAMEWORK_PATH,resolve(process.cwd(),"../stacks"),join(homedir(),"Code/stacks")].filter(Boolean);for(const candidate of candidates){const full=resolve(candidate);if(existsSync(join(full,"storage/framework/core")))return full}return null}function corePackages(framework){const coreDir=join(framework,"storage/framework/core"),found=new Map;for(const entry of readdirSync(coreDir,{withFileTypes:!0})){if(!entry.isDirectory())continue;const dir=join(coreDir,entry.name),manifest=join(dir,"package.json");if(!existsSync(manifest))continue;try{const{name}=JSON.parse(fs.readFileSync(manifest,"utf-8"));if(name?.startsWith("@stacksjs/"))found.set(name,dir)}catch{}}return found}function normalize(name){return`@stacksjs/${name.replace(/^@stacksjs\//,"").replace(/^core\//,"")}`}function isSymlink(path){try{return lstatSync(path).isSymbolicLink()}catch{return!1}}export function link(buddy){buddy.command("link:core [...packages]","Point this app's @stacksjs/* at a local framework checkout").option("--path <path>","Framework checkout to link against").option("--all","Link every core package the app has installed",{default:!1}).example("buddy link:core auth router").example("buddy link:core --all --path ../stacks").action(async(packages,options)=>{const framework=resolveFrameworkPath(options?.path);if(!framework){await log.error("No framework checkout found.");log.info("Pass one with `--path <dir>` or set STACKS_FRAMEWORK_PATH.");process.exit(ExitCode.FatalError)}const available=corePackages(framework),modulesDir=join(process.cwd(),"node_modules"),wanted=(packages?.length??0)>0?packages.map(normalize):[...available.keys()].filter((name)=>existsSync(join(modulesDir,name))||options?.all);if(wanted.length===0){log.info("Nothing to link: no installed @stacksjs/* packages found in this project.");await log.flush();process.exit(ExitCode.Success)}const linked=[],unbuilt=[];for(const name of wanted){const source=available.get(name);if(!source){log.warn(`${name} is not in ${italic(framework)} - skipped.`);continue}const target=join(modulesDir,name);if(isSymlink(target)&&realpathSync(target)===realpathSync(source)){linked.push(name);continue}fs.rmSync(target,{recursive:!0,force:!0});fs.mkdirSync(join(target,".."),{recursive:!0});fs.symlinkSync(source,target,"dir");linked.push(name);if(!existsSync(join(source,"dist")))unbuilt.push(name)}writeRecord({framework,packages:linked});log.success(`Linked ${linked.length} package${linked.length===1?"":"s"} to ${italic(framework)}`);if(unbuilt.length>0){log.warn(`Not built yet: ${unbuilt.join(", ")}`);log.info("An app imports the build output, so each linked package needs `bun build.ts` in its directory.")}log.info("Undo with `buddy unlink:core`.");await log.flush();process.exit(ExitCode.Success)});buddy.command("unlink:core [...packages]","Go back to the installed @stacksjs/* packages").option("--all","Unlink everything that was linked",{default:!1}).example("buddy unlink:core").action(async(packages,_options)=>{const record=readRecord(),modulesDir=join(process.cwd(),"node_modules"),wanted=(packages?.length??0)>0?packages.map(normalize):record?.packages??readdirSync(join(modulesDir,"@stacksjs"),{withFileTypes:!0}).filter((entry)=>entry.isSymbolicLink()).map((entry)=>`@stacksjs/${entry.name}`);let removed=0;const unlinked=new Set;for(const name of wanted){const target=join(modulesDir,name);if(!isSymlink(target))continue;fs.rmSync(target,{force:!0});unlinked.add(name);removed++}const survivors=(record?.packages??[]).filter((name)=>!unlinked.has(name));if(survivors.length>0)writeRecord({framework:record.framework,packages:survivors});else fs.rmSync(recordPath(),{force:!0});if(removed===0){log.info("Nothing was linked.");await log.flush();process.exit(ExitCode.Success)}log.info(`Unlinked ${removed} package${removed===1?"":"s"}; reinstalling the published copies...`);if(await Bun.spawn(["bun","install","--force"],{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){await log.error("`bun install` failed. The links are gone; re-run the install once the failure is resolved.");process.exit(ExitCode.FatalError)}for(const name of survivors){const source=join(record.framework,"storage/framework/core",name.replace("@stacksjs/","")),target=join(modulesDir,name);if(!existsSync(source))continue;fs.rmSync(target,{recursive:!0,force:!0});fs.symlinkSync(source,target,"dir")}if(survivors.length>0)log.success(`Unlinked ${removed}; ${survivors.length} package${survivors.length===1?"":"s"} still linked.`);else log.success("This project is back on the published packages.");await log.flush();process.exit(ExitCode.Success)})}
2
+ `)}function resolveFrameworkPath(explicit){const candidates=[explicit,process.env.STACKS_FRAMEWORK_PATH,resolve(process.cwd(),"../stacks"),join(homedir(),"Code/stacks")].filter(Boolean);for(const candidate of candidates){const full=resolve(candidate);if(existsSync(join(full,"storage/framework/core")))return full}return null}function corePackages(framework){const coreDir=join(framework,"storage/framework/core"),found=new Map;for(const entry of readdirSync(coreDir,{withFileTypes:!0})){if(!entry.isDirectory())continue;const dir=join(coreDir,entry.name),manifest=join(dir,"package.json");if(!existsSync(manifest))continue;try{const{name}=JSON.parse(fs.readFileSync(manifest,"utf-8"));if(name?.startsWith("@stacksjs/"))found.set(name,dir)}catch{}}return found}function normalize(name){return`@stacksjs/${name.replace(/^@stacksjs\//,"").replace(/^core\//,"")}`}function isSymlink(path){try{return lstatSync(path).isSymbolicLink()}catch{return!1}}export function link(buddy){buddy.command("link:core [...packages]","Point this app's @stacksjs/* at a local framework checkout").option("--path <path>","Framework checkout to link against").option("--all","Link every core package the app has installed",{default:!1}).example("buddy link:core auth router").example("buddy link:core --all --path ../stacks").action(async(packages,options)=>{const framework=resolveFrameworkPath(options?.path);if(!framework){await log.error("No framework checkout found.");log.info("Pass one with `--path <dir>` or set STACKS_FRAMEWORK_PATH.");await log.flush();process.exit(ExitCode.FatalError)}const available=corePackages(framework),modulesDir=join(process.cwd(),"node_modules"),wanted=(packages?.length??0)>0?packages.map(normalize):[...available.keys()].filter((name)=>existsSync(join(modulesDir,name))||options?.all);if(wanted.length===0){log.info("Nothing to link: no installed @stacksjs/* packages found in this project.");await log.flush();process.exit(ExitCode.Success)}const linked=[],unbuilt=[];for(const name of wanted){const source=available.get(name);if(!source){log.warn(`${name} is not in ${italic(framework)} - skipped.`);continue}const target=join(modulesDir,name);if(isSymlink(target)&&realpathSync(target)===realpathSync(source)){linked.push(name);continue}fs.rmSync(target,{recursive:!0,force:!0});fs.mkdirSync(join(target,".."),{recursive:!0});fs.symlinkSync(source,target,"dir");linked.push(name);if(!existsSync(join(source,"dist")))unbuilt.push(name)}writeRecord({framework,packages:linked});log.success(`Linked ${linked.length} package${linked.length===1?"":"s"} to ${italic(framework)}`);if(unbuilt.length>0){log.warn(`Not built yet: ${unbuilt.join(", ")}`);log.info("An app imports the build output, so each linked package needs `bun build.ts` in its directory.")}log.info("Undo with `buddy unlink:core`.");await log.flush();await log.flush();process.exit(ExitCode.Success)});buddy.command("unlink:core [...packages]","Go back to the installed @stacksjs/* packages").option("--all","Unlink everything that was linked",{default:!1}).example("buddy unlink:core").action(async(packages,_options)=>{const record=readRecord(),modulesDir=join(process.cwd(),"node_modules"),wanted=(packages?.length??0)>0?packages.map(normalize):record?.packages??readdirSync(join(modulesDir,"@stacksjs"),{withFileTypes:!0}).filter((entry)=>entry.isSymbolicLink()).map((entry)=>`@stacksjs/${entry.name}`);let removed=0;const unlinked=new Set;for(const name of wanted){const target=join(modulesDir,name);if(!isSymlink(target))continue;fs.rmSync(target,{force:!0});unlinked.add(name);removed++}const survivors=(record?.packages??[]).filter((name)=>!unlinked.has(name));if(survivors.length>0)writeRecord({framework:record.framework,packages:survivors});else fs.rmSync(recordPath(),{force:!0});if(removed===0){log.info("Nothing was linked.");await log.flush();process.exit(ExitCode.Success)}log.info(`Unlinked ${removed} package${removed===1?"":"s"}; reinstalling the published copies...`);if(await Bun.spawn(["bun","install","--force"],{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){await log.error("`bun install` failed. The links are gone; re-run the install once the failure is resolved.");process.exit(ExitCode.FatalError)}for(const name of survivors){const source=join(record.framework,"storage/framework/core",name.replace("@stacksjs/","")),target=join(modulesDir,name);if(!existsSync(source))continue;fs.rmSync(target,{recursive:!0,force:!0});fs.symlinkSync(source,target,"dir")}if(survivors.length>0)log.success(`Unlinked ${removed}; ${survivors.length} package${survivors.length===1?"":"s"} still linked.`);else log.success("This project is back on the published packages.");await log.flush();process.exit(ExitCode.Success)})}