@stacksjs/buddy 0.71.2 → 0.71.10

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.
@@ -21,7 +21,7 @@ for (const entry of units) {
21
21
  }
22
22
  console.log(JSON.stringify(ports))
23
23
  `.trim(),encoded=Buffer.from(probe).toString("base64"),{sshExecOrThrow}=await import("@stacksjs/ts-cloud"),line=(await sshExecOrThrow(ip,`/usr/local/bin/bun -e "eval(Buffer.from('${encoded}','base64').toString())"`,{user:"root",connectTimeoutSec:10})).trim().split(`
24
- `).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"));if(state.stackName!==stackName||typeof state.serverId!=="number"||typeof state.publicIp!=="string"||!state.publicIp.trim())return null;return{serverId:state.serverId,serverName:typeof state.serverName==="string"&&state.serverName?state.serverName:`${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 runHetznerDeploy(args){const{tsCloudConfig,environment,verbose,docker,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,onlySite,persistedAttachBox}=args,startTime=performance.now();console.log("");console.log("\uD83D\uDE80 Deploy \u2192 Hetzner Cloud");console.log("");log.info(`Project: ${tsCloudConfig.project?.slug}`);log.info(`Environment: ${environment}`);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:"hetzner"});if(!driver.provisionComputeInfrastructure){log.error("Hetzner driver does not support compute provisioning (update @stacksjs/ts-cloud).");process.exit(ExitCode.FatalError)}const attachTo=tsCloudConfig.cloud?.attachTo;let ip,ipv6;if(attachTo){const box=persistedAttachBox??await resolveAttachTargetBox(attachTo,environment);if(!box?.publicIp){log.error(`Attach target '${attachTo}' has no reachable box for '${environment}'. Is '${attachTo}-${environment}-app' provisioned (by its owner)?`);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}) \u2014 skipping provisioning`);await assertFragmentIsOurs(ip,tsCloudConfig,log);await assertPortsAreFree(ip,tsCloudConfig,log);const compute=(tsCloudConfig.infrastructure??={}).compute??={};compute.webServer="rpx";compute.proxy={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});writeFileSync(join(stateDir,`${stackName}.json`),`${JSON.stringify({stackName,serverId:box.serverId,serverName:box.serverName,publicIp:ip,...ipv6?{publicIpv6:ipv6}:{},sshUser:"root",deployStoragePath:"/var/ts-cloud/staging"},null,2)}
24
+ `).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"));if(state.stackName!==stackName||typeof state.serverId!=="number"||typeof state.publicIp!=="string"||!state.publicIp.trim())return null;return{serverId:state.serverId,serverName:typeof state.serverName==="string"&&state.serverName?state.serverName:`${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 runHetznerDeploy(args){const{tsCloudConfig,environment,verbose,docker,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,onlySite,persistedAttachBox}=args,startTime=performance.now();console.log("");console.log("\uD83D\uDE80 Deploy \u2192 Hetzner Cloud");console.log("");log.info(`Project: ${tsCloudConfig.project?.slug}`);log.info(`Environment: ${environment}`);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:"hetzner"});if(!driver.provisionComputeInfrastructure){log.error("Hetzner 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 box=persistedAttachBox??await resolveAttachTargetBox(attachTo,environment);if(box&&!box.publicIpv6){const resolved=await resolveAttachTargetBox(attachTo,environment);if(resolved?.publicIpv6)box={...box,publicIpv6:resolved.publicIpv6}}if(!box?.publicIp){log.error(`Attach target '${attachTo}' has no reachable box for '${environment}'. Is '${attachTo}-${environment}-app' provisioned (by its owner)?`);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}) \u2014 skipping provisioning`);await assertFragmentIsOurs(ip,tsCloudConfig,log);await assertPortsAreFree(ip,tsCloudConfig,log);const compute=(tsCloudConfig.infrastructure??={}).compute??={};compute.webServer="rpx";compute.proxy={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});writeFileSync(join(stateDir,`${stackName}.json`),`${JSON.stringify({stackName,serverId:box.serverId,serverName:box.serverName,publicIp:ip,...ipv6?{publicIpv6:ipv6}:{},sshUser:"root",deployStoragePath:"/var/ts-cloud/staging"},null,2)}
25
25
  `)}else{log.info("Provisioning Hetzner compute infrastructure...");const outputs=await driver.provisionComputeInfrastructure({config:scrubLoopbackSitePortsForFirewall(tsCloudConfig),environment});ip=outputs.appPublicIp;ipv6=outputs.appPublicIpv6;log.success("Hetzner compute infrastructure ready");if(outputs.appInstanceId)log.info(`Server ID: ${outputs.appInstanceId}`)}if(ip)log.info(`Server IP: ${ip}`);if(!ip){log.error("Provisioned server has no public IP \u2014 cannot deploy over SSH.");process.exit(ExitCode.FatalError)}await waitForRemoteReady(ip);if(onlySite&&!attachTo)await reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig,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(mergeSiteDeployEnv(sites,resolvedDeployEnv),slug),p.projectPath("app/Scheduler.ts")),projectDatabaseTarget(slug,"backups"));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)}});let publishedDns=[];if(ok){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=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=20",`root@${ip}`],out=execSync(`ssh ${certSshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`systemctl start ${unit} 2>&1 || true
26
26
  systemctl is-active ${unit} >/dev/null 2>&1 && echo TLSUNIT:running || echo TLSUNIT:done
27
27
  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(`
@@ -36,7 +36,7 @@ rm -f /etc/systemd/system/mail-health.service /etc/systemd/system/mail-health.ti
36
36
  rm -f /usr/local/sbin/mail-health-check /etc/systemd/system/mail.service.d/reliability.conf
37
37
  rmdir /etc/systemd/system/mail.service.d 2>/dev/null || true
38
38
  systemctl daemon-reload
39
- systemctl reset-failed`;try{execSync(`ssh ${sshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:script,encoding:"utf8",stdio:["pipe","pipe","pipe"]});logger.success("Mail: removed the detached health timer from the application box")}catch(err){logger.warn(`Mail: could not remove the detached health timer: ${getErrorMessage(err)}`)}}async function resolveAttachTargetBox(owner,environment){const token=process.env.HCLOUD_TOKEN;if(!token)return null;const pick=(servers)=>servers.find((s)=>s?.status!=="off"&&s?.public_net?.ipv4?.ip)||servers[0],req=async(qs)=>{try{const res=await fetch(`https://api.hetzner.cloud/v1/servers?${qs}`,{headers:{Authorization:`Bearer ${token}`}});if(!res.ok)return[];return(await res.json()).servers||[]}catch{return[]}},byLabel=await req(`label_selector=${encodeURIComponent(`ts-cloud/project=${owner},ts-cloud/environment=${environment},ts-cloud/role=app`)}`),chosen=pick(byLabel)||pick(await req(`name=${encodeURIComponent(`${owner}-${environment}-app`)}`));if(!chosen)return null;const{hetznerBoxIpv6}=await import("@stacksjs/ts-cloud");return{serverId:chosen.id,serverName:chosen.name,publicIp:chosen.public_net?.ipv4?.ip,publicIpv6:hetznerBoxIpv6?.(chosen.public_net?.ipv6?.ip)}}export async function provisionMailTenant(ip,logger){if(!hasExplicitEmailConfig())return null;const cfg=emailConfig||{};if(cfg.server?.enabled===!1)return null;const domain=cfg.domain||(typeof cfg.from?.address==="string"&&cfg.from.address.includes("@")?cfg.from.address.split("@")[1]:void 0),declaredForwards=cfg.forwards&&typeof cfg.forwards==="object"?cfg.forwards:{},forwards={...declaredForwards},declaredBoxes=new Set(domain?resolveMailboxes(cfg.mailboxes,domain).map((b)=>b.address):[]);for(const[key,targets]of Object.entries(declaredForwards)){const at=key.indexOf("@");if(at===-1||declaredBoxes.has(key))continue;const localPart=key.slice(0,at);if(key.slice(at+1)!==domain||forwards[localPart])continue;forwards[localPart]=targets}const hasForwards=Object.keys(forwards).length>0,resolved=domain?resolveMailboxesWithSkipped(cfg.mailboxes,domain):{boxes:[],skipped:[]},boxes=resolved.boxes;if(resolved.skipped.length>0){logger.warn(`Mail: ${resolved.skipped.length} declared mailbox(es) were not created because no password was supplied: ${resolved.skipped.join(", ")}`);logger.info(`Set MAIL_PASSWORD_<LOCALPART> in the target environment (e.g. ${resolved.skipped[0]?.split("@")[0]?.toUpperCase().replace(/[^A-Z0-9]/g,"_")}) and run this again.`)}if(!domain&&!hasForwards)return null;const{execSync}=await import("node:child_process"),sshArgs=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=20",`root@${ip}`],forwardsB64=hasForwards?Buffer.from(JSON.stringify(forwards)).toString("base64"):"",readme="Auto-forwarding rules, re-read on every message (edits take effect immediately, no restart). KEY = the delivered mailbox: the FULL address for per-domain isolated mailboxes (e.g. no-reply@app.com), or a bare local-part for legacy role mailboxes. VALUE = list of destination addresses; targets on a local domain are written straight to that mailbox Maildir, external targets are relayed. Managed by buddy deploy "+"from config/email.ts (merge-based \u2014 hand edits to other keys are preserved).",readmeB64=Buffer.from(readme).toString("base64"),boxesB64=boxes.length?Buffer.from(`${boxes.map((b)=>`${b.address} ${b.password}`).join(`
39
+ systemctl reset-failed`;try{execSync(`ssh ${sshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:script,encoding:"utf8",stdio:["pipe","pipe","pipe"]});logger.success("Mail: removed the detached health timer from the application box")}catch(err){logger.warn(`Mail: could not remove the detached health timer: ${getErrorMessage(err)}`)}}async function resolveAttachTargetBox(owner,environment){const token=process.env.HCLOUD_TOKEN;if(!token)return null;const pick=(servers)=>servers.find((s)=>s?.status!=="off"&&s?.public_net?.ipv4?.ip)||servers[0],req=async(qs)=>{try{const res=await fetch(`https://api.hetzner.cloud/v1/servers?${qs}`,{headers:{Authorization:`Bearer ${token}`}});if(!res.ok)return[];return(await res.json()).servers||[]}catch{return[]}},byLabel=await req(`label_selector=${encodeURIComponent(`ts-cloud/project=${owner},ts-cloud/environment=${environment},ts-cloud/role=app`)}`),chosen=pick(byLabel)||pick(await req(`name=${encodeURIComponent(`${owner}-${environment}-app`)}`));if(!chosen)return null;const{normalizePublicIpv6}=await import("@stacksjs/ts-cloud");if(typeof normalizePublicIpv6!=="function")log.warn("DNS: @stacksjs/ts-cloud does not export normalizePublicIpv6 \u2014 AAAA records will be skipped. Upgrade ts-cloud.");const reportedIpv6=chosen.public_net?.ipv6?.ip;return{serverId:chosen.id,serverName:chosen.name,publicIp:chosen.public_net?.ipv4?.ip,publicIpv6:typeof normalizePublicIpv6==="function"?normalizePublicIpv6(reportedIpv6):void 0}}export async function provisionMailTenant(ip,logger){if(!hasExplicitEmailConfig())return null;const cfg=emailConfig||{};if(cfg.server?.enabled===!1)return null;const domain=cfg.domain||(typeof cfg.from?.address==="string"&&cfg.from.address.includes("@")?cfg.from.address.split("@")[1]:void 0),declaredForwards=cfg.forwards&&typeof cfg.forwards==="object"?cfg.forwards:{},forwards={...declaredForwards},declaredBoxes=new Set(domain?resolveMailboxes(cfg.mailboxes,domain).map((b)=>b.address):[]);for(const[key,targets]of Object.entries(declaredForwards)){const at=key.indexOf("@");if(at===-1||declaredBoxes.has(key))continue;const localPart=key.slice(0,at);if(key.slice(at+1)!==domain||forwards[localPart])continue;forwards[localPart]=targets}const hasForwards=Object.keys(forwards).length>0,resolved=domain?resolveMailboxesWithSkipped(cfg.mailboxes,domain):{boxes:[],skipped:[]},boxes=resolved.boxes;if(resolved.skipped.length>0){logger.warn(`Mail: ${resolved.skipped.length} declared mailbox(es) were not created because no password was supplied: ${resolved.skipped.join(", ")}`);logger.info(`Set MAIL_PASSWORD_<LOCALPART> in the target environment (e.g. ${resolved.skipped[0]?.split("@")[0]?.toUpperCase().replace(/[^A-Z0-9]/g,"_")}) and run this again.`)}if(!domain&&!hasForwards)return null;const{execSync}=await import("node:child_process"),sshArgs=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=20",`root@${ip}`],forwardsB64=hasForwards?Buffer.from(JSON.stringify(forwards)).toString("base64"):"",readme="Auto-forwarding rules, re-read on every message (edits take effect immediately, no restart). KEY = the delivered mailbox: the FULL address for per-domain isolated mailboxes (e.g. no-reply@app.com), or a bare local-part for legacy role mailboxes. VALUE = list of destination addresses; targets on a local domain are written straight to that mailbox Maildir, external targets are relayed. Managed by buddy deploy "+"from config/email.ts (merge-based \u2014 hand edits to other keys are preserved).",readmeB64=Buffer.from(readme).toString("base64"),boxesB64=boxes.length?Buffer.from(`${boxes.map((b)=>`${b.address} ${b.password}`).join(`
40
40
  `)}
41
41
  `).toString("base64"):"",script=`set -e
42
42
  DOMAIN=${domain?`'${domain}'`:"''"}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/buddy",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.71.2",
5
+ "version": "0.71.10",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -95,55 +95,55 @@
95
95
  "prepublishOnly": "bun run build"
96
96
  },
97
97
  "dependencies": {
98
- "@stacksjs/actions": "^0.71.2",
99
- "@stacksjs/ai": "^0.71.2",
100
- "@stacksjs/alias": "^0.71.2",
101
- "@stacksjs/arrays": "^0.71.2",
102
- "@stacksjs/auth": "^0.71.2",
103
- "@stacksjs/build": "^0.71.2",
104
- "@stacksjs/cache": "^0.71.2",
105
- "@stacksjs/cli": "^0.71.2",
98
+ "@stacksjs/actions": "^0.71.10",
99
+ "@stacksjs/ai": "^0.71.10",
100
+ "@stacksjs/alias": "^0.71.10",
101
+ "@stacksjs/arrays": "^0.71.10",
102
+ "@stacksjs/auth": "^0.71.10",
103
+ "@stacksjs/build": "^0.71.10",
104
+ "@stacksjs/cache": "^0.71.10",
105
+ "@stacksjs/cli": "^0.71.10",
106
106
  "@stacksjs/clapp": "^0.2.12",
107
- "@stacksjs/cloud": "^0.71.2",
108
- "@stacksjs/collections": "^0.71.2",
109
- "@stacksjs/config": "^0.71.2",
110
- "@stacksjs/database": "^0.71.2",
111
- "@stacksjs/desktop-build": "^0.71.2",
112
- "@stacksjs/dns": "^0.71.2",
113
- "@stacksjs/email": "^0.71.2",
114
- "@stacksjs/enums": "^0.71.2",
115
- "@stacksjs/error-handling": "^0.71.2",
116
- "@stacksjs/events": "^0.71.2",
117
- "@stacksjs/git": "^0.71.2",
107
+ "@stacksjs/cloud": "^0.71.10",
108
+ "@stacksjs/collections": "^0.71.10",
109
+ "@stacksjs/config": "^0.71.10",
110
+ "@stacksjs/database": "^0.71.10",
111
+ "@stacksjs/desktop-build": "^0.71.10",
112
+ "@stacksjs/dns": "^0.71.10",
113
+ "@stacksjs/email": "^0.71.10",
114
+ "@stacksjs/enums": "^0.71.10",
115
+ "@stacksjs/error-handling": "^0.71.10",
116
+ "@stacksjs/events": "^0.71.10",
117
+ "@stacksjs/git": "^0.71.10",
118
118
  "@stacksjs/gitit": "^0.2.5",
119
- "@stacksjs/health": "^0.71.2",
119
+ "@stacksjs/health": "^0.71.10",
120
120
  "@stacksjs/dnsx": "^0.2.3",
121
121
  "@stacksjs/httx": "^0.1.10",
122
- "@stacksjs/image": "^0.71.2",
123
- "@stacksjs/lint": "^0.71.2",
124
- "@stacksjs/logging": "^0.71.2",
125
- "@stacksjs/notifications": "^0.71.2",
126
- "@stacksjs/objects": "^0.71.2",
127
- "@stacksjs/orm": "^0.71.2",
128
- "@stacksjs/path": "^0.71.2",
129
- "@stacksjs/skills": "^0.71.2",
130
- "@stacksjs/payments": "^0.71.2",
131
- "@stacksjs/realtime": "^0.71.2",
132
- "@stacksjs/router": "^0.71.2",
122
+ "@stacksjs/image": "^0.71.10",
123
+ "@stacksjs/lint": "^0.71.10",
124
+ "@stacksjs/logging": "^0.71.10",
125
+ "@stacksjs/notifications": "^0.71.10",
126
+ "@stacksjs/objects": "^0.71.10",
127
+ "@stacksjs/orm": "^0.71.10",
128
+ "@stacksjs/path": "^0.71.10",
129
+ "@stacksjs/skills": "^0.71.10",
130
+ "@stacksjs/payments": "^0.71.10",
131
+ "@stacksjs/realtime": "^0.71.10",
132
+ "@stacksjs/router": "^0.71.10",
133
133
  "@stacksjs/rpx": "^0.11.42",
134
- "@stacksjs/search-engine": "^0.71.2",
135
- "@stacksjs/security": "^0.71.2",
136
- "@stacksjs/server": "^0.71.2",
137
- "@stacksjs/cms": "^0.71.2",
138
- "@stacksjs/sites": "^0.71.2",
139
- "@stacksjs/storage": "^0.71.2",
140
- "@stacksjs/strings": "^0.71.2",
141
- "@stacksjs/testing": "^0.71.2",
142
- "@stacksjs/tunnel": "^0.71.2",
143
- "@stacksjs/types": "^0.71.2",
144
- "@stacksjs/ui": "^0.71.2",
145
- "@stacksjs/utils": "^0.71.2",
146
- "@stacksjs/validation": "^0.71.2",
134
+ "@stacksjs/search-engine": "^0.71.10",
135
+ "@stacksjs/security": "^0.71.10",
136
+ "@stacksjs/server": "^0.71.10",
137
+ "@stacksjs/cms": "^0.71.10",
138
+ "@stacksjs/sites": "^0.71.10",
139
+ "@stacksjs/storage": "^0.71.10",
140
+ "@stacksjs/strings": "^0.71.10",
141
+ "@stacksjs/testing": "^0.71.10",
142
+ "@stacksjs/tunnel": "^0.71.10",
143
+ "@stacksjs/types": "^0.71.10",
144
+ "@stacksjs/ui": "^0.71.10",
145
+ "@stacksjs/utils": "^0.71.10",
146
+ "@stacksjs/validation": "^0.71.10",
147
147
  "@stacksjs/ts-cloud": "^0.8.3",
148
148
  "ajv": "^8.20.0",
149
149
  "ajv-formats": "^3.0.1",