@stacksjs/buddy 0.75.114 → 0.75.117

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.
@@ -796,6 +796,7 @@ declare interface ResolvedMailbox {
796
796
  localPart: string
797
797
  password: string
798
798
  generated: boolean
799
+ passwordPolicy: 'initial' | 'managed'
799
800
  }
800
801
  /** What a mail-tenant reconcile resolved + provisioned, for the DNS step. */
801
802
  export declare interface MailTenantResult {
@@ -35,7 +35,7 @@ console.log(JSON.stringify(ports))
35
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,args.envFile),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
- `))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)await reconcileR2ForDeploy(tsCloudConfig,log);if(ok&&dnsAllowed)await reconcileWorkersForDeploy(tsCloudConfig,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,type:"error"});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")}export 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,"_")}`,declaredPw=explicitPw||process.env[envKey],envPw=declaredPw&&/^enc(?:rypted)?:/.test(declaredPw)?void 0:declaredPw;if(declaredPw&&!envPw)log.warn(`Mail: ${envKey} is still encrypted (no key for it in this environment), so ${address} keeps its current password`);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
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)await reconcileR2ForDeploy(tsCloudConfig,log);if(ok&&dnsAllowed)await reconcileWorkersForDeploy(tsCloudConfig,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,type:"error"});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")}export function resolveMailboxesWithSkipped(mailboxes,domain,generatePassword=!1){if(!Array.isArray(mailboxes))return{boxes:[],skipped:[]};const out=[],skipped=[];for(const entry of mailboxes){let raw,explicitPw,passwordPolicy="initial";if(typeof entry==="string")raw=entry;else if(entry&&typeof entry==="object"){raw=entry.email??entry.username;explicitPw=entry.password;if(entry.passwordPolicy==="managed")passwordPolicy="managed";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,"_")}`,declaredPw=explicitPw||process.env[envKey],envPw=declaredPw&&/^enc(?:rypted)?:/.test(declaredPw)?void 0:declaredPw;if(declaredPw&&!envPw)log.warn(`Mail: ${envKey} is still encrypted (no key for it in this environment), so ${address} keeps its current password`);if(!envPw){if(generatePassword){out.push({address,localPart:localPart.toUpperCase(),password:generateMailboxPassword(),generated:!0,passwordPolicy});continue}skipped.push(address);continue}out.push({address,localPart:localPart.toUpperCase(),password:envPw,generated:!1,passwordPolicy})}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
39
39
  if systemctl list-unit-files --type=service --no-legend | awk '{print $1}' | grep -qx mail.service; then
40
40
  exit 0
41
41
  fi
@@ -46,7 +46,7 @@ rm -f /etc/systemd/system/mail-health.service /etc/systemd/system/mail-health.ti
46
46
  rm -f /usr/local/sbin/mail-health-check /etc/systemd/system/mail.service.d/reliability.conf
47
47
  rmdir /etc/systemd/system/mail.service.d 2>/dev/null || true
48
48
  systemctl daemon-reload
49
- 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)}`)}}export function resolveHetznerApiToken(tsCloudConfig){return tsCloudConfig?.hetzner?.apiToken||process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN}export function describeAttachLookupFailure(owner,environment,failure){if(failure?.kind==="no-token")return`Attach target '${owner}': no Hetzner API token to look it up with, so no lookup was attempted. Set HCLOUD_TOKEN (or HETZNER_API_TOKEN, or hetzner.apiToken in config/cloud.ts).`;if(failure?.kind==="request-failed"){const where=failure.status>0?`returned HTTP ${failure.status}`:"could not be reached";return`Attach target '${owner}': the Hetzner API ${where}, so whether the box exists is unknown.`+(failure.detail?` ${failure.detail}`:"")+(failure.status===401||failure.status===403?" That is an auth failure, not a missing server: check the token is valid for this Hetzner project.":"")}return`Attach target '${owner}' has no reachable box for '${environment}'. Nothing matched ts-cloud/project=${owner},ts-cloud/environment=${environment},ts-cloud/role=app, and no server is named '${owner}-${environment}-app'. Is it provisioned (by its owner)?`}export async function resolveAttachTargetBox(owner,environment,tsCloudConfig){const token=resolveHetznerApiToken(tsCloudConfig);if(!token)return{box:null,failure:{kind:"no-token"}};const pick=(servers)=>servers.find((s)=>s?.status!=="off"&&s?.public_net?.ipv4?.ip)||servers[0];let requestFailure;const req=async(qs)=>{try{const res=await fetch(`https://api.hetzner.cloud/v1/servers?${qs}`,{headers:{Authorization:`Bearer ${token}`}});if(!res.ok){const body=await res.text().catch(()=>"");requestFailure??={kind:"request-failed",status:res.status,detail:pollFailureDetail(body)};return[]}return(await res.json()).servers||[]}catch(err){requestFailure??={kind:"request-failed",status:0,detail:pollFailureDetail(getErrorMessage(err))};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{box:null,failure:requestFailure??{kind:"no-match"}};const{normalizePublicIpv6}=await import("@stacksjs/ts-cloud");if(typeof normalizePublicIpv6!=="function")log.warn("DNS: @stacksjs/ts-cloud does not export normalizePublicIpv6 - AAAA records will be skipped. Upgrade ts-cloud.");const reportedIpv6=chosen.public_net?.ipv6?.ip;return{box:{serverId:chosen.id,serverName:chosen.name,publicIp:chosen.public_net?.ipv4?.ip,publicIpv6:typeof normalizePublicIpv6==="function"?normalizePublicIpv6(reportedIpv6):void 0}}}export function canRevealSecrets(env=process.env,stdout=process.stdout){if(env.CI||env.CONTINUOUS_INTEGRATION||env.GITHUB_ACTIONS||env.BUILD_NUMBER||env.RUN_ID)return!1;return Boolean(stdout.isTTY)}function mailboxPasswordKey(box){return`MAIL_PASSWORD_${box.localPart.replace(/[^A-Z0-9]/g,"_")}`}export function reportMailboxCredentials(logger,created,options){const declared=created.filter((box)=>!box.generated);if(declared.length)logger.info(`Mail: created ${declared.length} mailbox(es) with their declared password (config/email.ts or MAIL_PASSWORD_<LOCALPART>): ${declared.map((box)=>box.address).join(", ")}`);const generated=created.filter((box)=>box.generated);if(!generated.length)return;if(options.interactive){logger.info(options.persisted?`Mail: created ${generated.length} mailbox(es) with generated passwords, saved encrypted to .env.production and shown once here:`:`Mail: created ${generated.length} mailbox(es) with generated passwords that were NOT saved - store them now, they are shown once:`);for(const box of generated)logger.info(` ${box.address} ${box.password}`);return}if(!options.persisted){logger.warn(`Mail: ${generated.length} mailbox(es) have generated passwords that were neither saved nor shown: ${generated.map((box)=>box.address).join(", ")}. Set ${generated.map(mailboxPasswordKey).join(", ")} and deploy again.`);return}logger.info(`Mail: created ${generated.length} mailbox(es) with generated passwords, saved encrypted to .env.production (not printed: this is not an interactive terminal):`);for(const box of generated)logger.info(` ${box.address} -> buddy env:get ${mailboxPasswordKey(box)} --file .env.production`)}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,generatePasswords=cfg.server?.generatePasswords===!0,resolved=domain?resolveMailboxesWithSkipped(cfg.mailboxes,domain,generatePasswords):{boxes:[],skipped:[]};let boxes=resolved.boxes;const interactive=canRevealSecrets(),generated=boxes.filter((box)=>box.generated);let persisted=!0;if(generated.length>0)try{const{setEnv}=await import("@stacksjs/env");for(const box of generated)await setEnv(mailboxPasswordKey(box),box.password,{file:".env.production"});logger.success(`Mail: generated and saved ${generated.length} mailbox password(s) to .env.production (encrypted)`)}catch(err){persisted=!1;if(interactive)logger.warn(`Mail: could not save the generated password(s) to .env.production - they are printed below and will otherwise be regenerated next deploy: ${getErrorMessage(err)}`);else{const unsaved=new Set(generated.map((box)=>box.address));boxes=boxes.filter((box)=>!unsaved.has(box.address));logger.warn(`Mail: could not save the generated password(s) to .env.production, and this is not an interactive terminal, so ${[...unsaved].join(", ")} were not created: ${getErrorMessage(err)}`)}}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 - 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(`
49
+ 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)}`)}}export function resolveHetznerApiToken(tsCloudConfig){return tsCloudConfig?.hetzner?.apiToken||process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN}export function describeAttachLookupFailure(owner,environment,failure){if(failure?.kind==="no-token")return`Attach target '${owner}': no Hetzner API token to look it up with, so no lookup was attempted. Set HCLOUD_TOKEN (or HETZNER_API_TOKEN, or hetzner.apiToken in config/cloud.ts).`;if(failure?.kind==="request-failed"){const where=failure.status>0?`returned HTTP ${failure.status}`:"could not be reached";return`Attach target '${owner}': the Hetzner API ${where}, so whether the box exists is unknown.`+(failure.detail?` ${failure.detail}`:"")+(failure.status===401||failure.status===403?" That is an auth failure, not a missing server: check the token is valid for this Hetzner project.":"")}return`Attach target '${owner}' has no reachable box for '${environment}'. Nothing matched ts-cloud/project=${owner},ts-cloud/environment=${environment},ts-cloud/role=app, and no server is named '${owner}-${environment}-app'. Is it provisioned (by its owner)?`}export async function resolveAttachTargetBox(owner,environment,tsCloudConfig){const token=resolveHetznerApiToken(tsCloudConfig);if(!token)return{box:null,failure:{kind:"no-token"}};const pick=(servers)=>servers.find((s)=>s?.status!=="off"&&s?.public_net?.ipv4?.ip)||servers[0];let requestFailure;const req=async(qs)=>{try{const res=await fetch(`https://api.hetzner.cloud/v1/servers?${qs}`,{headers:{Authorization:`Bearer ${token}`}});if(!res.ok){const body=await res.text().catch(()=>"");requestFailure??={kind:"request-failed",status:res.status,detail:pollFailureDetail(body)};return[]}return(await res.json()).servers||[]}catch(err){requestFailure??={kind:"request-failed",status:0,detail:pollFailureDetail(getErrorMessage(err))};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{box:null,failure:requestFailure??{kind:"no-match"}};const{normalizePublicIpv6}=await import("@stacksjs/ts-cloud");if(typeof normalizePublicIpv6!=="function")log.warn("DNS: @stacksjs/ts-cloud does not export normalizePublicIpv6 - AAAA records will be skipped. Upgrade ts-cloud.");const reportedIpv6=chosen.public_net?.ipv6?.ip;return{box:{serverId:chosen.id,serverName:chosen.name,publicIp:chosen.public_net?.ipv4?.ip,publicIpv6:typeof normalizePublicIpv6==="function"?normalizePublicIpv6(reportedIpv6):void 0}}}export function canRevealSecrets(env=process.env,stdout=process.stdout){if(env.CI||env.CONTINUOUS_INTEGRATION||env.GITHUB_ACTIONS||env.BUILD_NUMBER||env.RUN_ID)return!1;return Boolean(stdout.isTTY)}function mailboxPasswordKey(box){return`MAIL_PASSWORD_${box.localPart.replace(/[^A-Z0-9]/g,"_")}`}export function reportMailboxCredentials(logger,created,options){const declared=created.filter((box)=>!box.generated);if(declared.length)logger.info(`Mail: created ${declared.length} mailbox(es) with their declared password (config/email.ts or MAIL_PASSWORD_<LOCALPART>): ${declared.map((box)=>box.address).join(", ")}`);const generated=created.filter((box)=>box.generated);if(!generated.length)return;if(options.interactive){logger.info(options.persisted?`Mail: created ${generated.length} mailbox(es) with generated passwords, saved encrypted to .env.production and shown once here:`:`Mail: created ${generated.length} mailbox(es) with generated passwords that were NOT saved - store them now, they are shown once:`);for(const box of generated)logger.info(` ${box.address} ${box.password}`);return}if(!options.persisted){logger.warn(`Mail: ${generated.length} mailbox(es) have generated passwords that were neither saved nor shown: ${generated.map((box)=>box.address).join(", ")}. Set ${generated.map(mailboxPasswordKey).join(", ")} and deploy again.`);return}logger.info(`Mail: created ${generated.length} mailbox(es) with generated passwords, saved encrypted to .env.production (not printed: this is not an interactive terminal):`);for(const box of generated)logger.info(` ${box.address} -> buddy env:get ${mailboxPasswordKey(box)} --file .env.production`)}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,generatePasswords=cfg.server?.generatePasswords===!0,resolved=domain?resolveMailboxesWithSkipped(cfg.mailboxes,domain,generatePasswords):{boxes:[],skipped:[]};let boxes=resolved.boxes;const interactive=canRevealSecrets(),generated=boxes.filter((box)=>box.generated);let persisted=!0;if(generated.length>0)try{const{setEnv}=await import("@stacksjs/env");for(const box of generated)await setEnv(mailboxPasswordKey(box),box.password,{file:".env.production"});logger.success(`Mail: generated and saved ${generated.length} mailbox password(s) to .env.production (encrypted)`)}catch(err){persisted=!1;if(interactive)logger.warn(`Mail: could not save the generated password(s) to .env.production - they are printed below and will otherwise be regenerated next deploy: ${getErrorMessage(err)}`);else{const unsaved=new Set(generated.map((box)=>box.address));boxes=boxes.filter((box)=>!unsaved.has(box.address));logger.warn(`Mail: could not save the generated password(s) to .env.production, and this is not an interactive terminal, so ${[...unsaved].join(", ")} were not created: ${getErrorMessage(err)}`)}}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 - 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} ${b.passwordPolicy}`).join(`
50
50
  `)}
51
51
  `).toString("base64"):"",script=`set -e
52
52
  DOMAIN=${domain?`'${domain}'`:"''"}
@@ -162,10 +162,10 @@ if [ -n "$DOMAIN" ] && [ -f "$ENVF" ]; then
162
162
  echo "DKIMSEL:$SEL"
163
163
  echo "DKIMPUB:$(openssl rsa -in "$KEY" -pubout -outform DER 2>/dev/null | base64 -w0)"
164
164
  fi
165
- # 3) Create the configured mailboxes as per-domain isolated users, and bring an
166
- # existing one's password in line with the declared one.
165
+ # 3) Create the configured mailboxes as per-domain isolated users, and
166
+ # preserve existing user passwords unless management was explicitly requested.
167
167
  if [ -n "$BOXES_B64" ] && [ -x "$MS" ]; then
168
- echo "$BOXES_B64" | base64 -d | while IFS=$' ' read -r addr pw; do
168
+ echo "$BOXES_B64" | base64 -d | while IFS=$' ' read -r addr pw password_policy; do
169
169
  [ -z "$addr" ] && continue
170
170
  # NOTE: 'user:local info' exits 0 even when the user is absent (it only
171
171
  # prints "not found"), so existence is decided from the OUTPUT, not $?.
@@ -197,8 +197,10 @@ if [ -n "$BOXES_B64" ] && [ -x "$MS" ]; then
197
197
  mv -- "/opt/mail/mail/$legacy" "/opt/mail/mail/$addr"
198
198
  chown -R mail-server:mail-server "/opt/mail/mail/$addr"
199
199
  fi
200
- # The declared config password becomes authoritative after migration.
201
- SMTP_DB_PATH="$MAIL_DB_PATH" "$MS" user:local change-password "$addr" "$pw" >/dev/null 2>&1 || true
200
+ # A username migration does not rotate a person's password.
201
+ if [ "$password_policy" = managed ]; then
202
+ printf '%s\\n' "$pw" | SMTP_DB_PATH="$MAIL_DB_PATH" "$MS" user:local change-password "$addr" --password-stdin >/dev/null 2>&1 || true
203
+ fi
202
204
  else
203
205
  # The CLI action historically returned zero even when an internal
204
206
  # create failed. Pin the live DB for both calls and verify the row.
@@ -215,6 +217,9 @@ if [ -n "$BOXES_B64" ] && [ -x "$MS" ]; then
215
217
  # A disabled account never verifies, so it would be rewritten on every
216
218
  # deploy. Leave it alone: disabling it was somebody's decision.
217
219
  echo "EXISTS:$addr"
220
+ elif [ "$password_policy" != managed ]; then
221
+ # A user's password change must survive the next application deploy.
222
+ echo "EXISTS:$addr"
218
223
  elif SMTP_DB_PATH="$MAIL_DB_PATH" "$MS" user:local verify "$addr" "$pw" 2>&1 | grep -q '^Credentials valid'; then
219
224
  echo "EXISTS:$addr"
220
225
  else
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/buddy",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.114",
5
+ "version": "0.75.117",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -96,68 +96,68 @@
96
96
  },
97
97
  "dependencies": {
98
98
  "@stacksjs/bumpx": "^0.2.20",
99
- "@stacksjs/actions": "^0.75.114",
100
- "@stacksjs/ai": "^0.75.114",
101
- "@stacksjs/alias": "^0.75.114",
102
- "@stacksjs/analytics": "^0.75.114",
103
- "@stacksjs/api": "^0.75.114",
104
- "@stacksjs/arrays": "^0.75.114",
105
- "@stacksjs/auth": "^0.75.114",
106
- "@stacksjs/browser-extension": "^0.75.114",
107
- "@stacksjs/build": "^0.75.114",
108
- "@stacksjs/cache": "^0.75.114",
109
- "@stacksjs/chat": "^0.75.114",
99
+ "@stacksjs/actions": "^0.75.117",
100
+ "@stacksjs/ai": "^0.75.117",
101
+ "@stacksjs/alias": "^0.75.117",
102
+ "@stacksjs/analytics": "^0.75.117",
103
+ "@stacksjs/api": "^0.75.117",
104
+ "@stacksjs/arrays": "^0.75.117",
105
+ "@stacksjs/auth": "^0.75.117",
106
+ "@stacksjs/browser-extension": "^0.75.117",
107
+ "@stacksjs/build": "^0.75.117",
108
+ "@stacksjs/cache": "^0.75.117",
109
+ "@stacksjs/chat": "^0.75.117",
110
110
  "@stacksjs/clapp": "^0.2.16",
111
- "@stacksjs/cli": "^0.75.114",
112
- "@stacksjs/cloud": "^0.75.114",
113
- "@stacksjs/cms": "^0.75.114",
114
- "@stacksjs/collections": "^0.75.114",
115
- "@stacksjs/commerce": "^0.75.114",
116
- "@stacksjs/config": "^0.75.114",
117
- "@stacksjs/database": "^0.75.114",
118
- "@stacksjs/desktop-build": "^0.75.114",
119
- "@stacksjs/dns": "^0.75.114",
111
+ "@stacksjs/cli": "^0.75.117",
112
+ "@stacksjs/cloud": "^0.75.117",
113
+ "@stacksjs/cms": "^0.75.117",
114
+ "@stacksjs/collections": "^0.75.117",
115
+ "@stacksjs/commerce": "^0.75.117",
116
+ "@stacksjs/config": "^0.75.117",
117
+ "@stacksjs/database": "^0.75.117",
118
+ "@stacksjs/desktop-build": "^0.75.117",
119
+ "@stacksjs/dns": "^0.75.117",
120
120
  "@stacksjs/dnsx": "^0.2.3",
121
- "@stacksjs/email": "^0.75.114",
122
- "@stacksjs/enums": "^0.75.114",
123
- "@stacksjs/env": "^0.75.114",
124
- "@stacksjs/error-handling": "^0.75.114",
125
- "@stacksjs/events": "^0.75.114",
126
- "@stacksjs/features": "^0.75.114",
127
- "@stacksjs/git": "^0.75.114",
121
+ "@stacksjs/email": "^0.75.117",
122
+ "@stacksjs/enums": "^0.75.117",
123
+ "@stacksjs/env": "^0.75.117",
124
+ "@stacksjs/error-handling": "^0.75.117",
125
+ "@stacksjs/events": "^0.75.117",
126
+ "@stacksjs/features": "^0.75.117",
127
+ "@stacksjs/git": "^0.75.117",
128
128
  "@stacksjs/gitit": "^0.2.5",
129
- "@stacksjs/health": "^0.75.114",
129
+ "@stacksjs/health": "^0.75.117",
130
130
  "@stacksjs/httx": "^0.1.10",
131
- "@stacksjs/image": "^0.75.114",
132
- "@stacksjs/lint": "^0.75.114",
133
- "@stacksjs/logging": "^0.75.114",
134
- "@stacksjs/notifications": "^0.75.114",
135
- "@stacksjs/objects": "^0.75.114",
136
- "@stacksjs/orm": "^0.75.114",
137
- "@stacksjs/path": "^0.75.114",
138
- "@stacksjs/payments": "^0.75.114",
139
- "@stacksjs/realtime": "^0.75.114",
140
- "@stacksjs/router": "^0.75.114",
131
+ "@stacksjs/image": "^0.75.117",
132
+ "@stacksjs/lint": "^0.75.117",
133
+ "@stacksjs/logging": "^0.75.117",
134
+ "@stacksjs/notifications": "^0.75.117",
135
+ "@stacksjs/objects": "^0.75.117",
136
+ "@stacksjs/orm": "^0.75.117",
137
+ "@stacksjs/path": "^0.75.117",
138
+ "@stacksjs/payments": "^0.75.117",
139
+ "@stacksjs/realtime": "^0.75.117",
140
+ "@stacksjs/router": "^0.75.117",
141
141
  "@stacksjs/rpx": "^0.11.54",
142
- "@stacksjs/scheduler": "^0.75.114",
143
- "@stacksjs/search-engine": "^0.75.114",
144
- "@stacksjs/security": "^0.75.114",
145
- "@stacksjs/server": "^0.75.114",
146
- "@stacksjs/sites": "^0.75.114",
147
- "@stacksjs/skills": "^0.75.114",
148
- "@stacksjs/socials": "^0.75.114",
149
- "@stacksjs/storage": "^0.75.114",
150
- "@stacksjs/strings": "^0.75.114",
142
+ "@stacksjs/scheduler": "^0.75.117",
143
+ "@stacksjs/search-engine": "^0.75.117",
144
+ "@stacksjs/security": "^0.75.117",
145
+ "@stacksjs/server": "^0.75.117",
146
+ "@stacksjs/sites": "^0.75.117",
147
+ "@stacksjs/skills": "^0.75.117",
148
+ "@stacksjs/socials": "^0.75.117",
149
+ "@stacksjs/storage": "^0.75.117",
150
+ "@stacksjs/strings": "^0.75.117",
151
151
  "@stacksjs/stx": "^0.2.422",
152
- "@stacksjs/testing": "^0.75.114",
153
- "@stacksjs/tinker": "^0.75.114",
152
+ "@stacksjs/testing": "^0.75.117",
153
+ "@stacksjs/tinker": "^0.75.117",
154
154
  "@stacksjs/tlsx": "^0.13.19",
155
155
  "@stacksjs/ts-cloud": "^0.16.47",
156
- "@stacksjs/tunnel": "^0.75.114",
157
- "@stacksjs/types": "^0.75.114",
158
- "@stacksjs/ui": "^0.75.114",
159
- "@stacksjs/utils": "^0.75.114",
160
- "@stacksjs/validation": "^0.75.114",
156
+ "@stacksjs/tunnel": "^0.75.117",
157
+ "@stacksjs/types": "^0.75.117",
158
+ "@stacksjs/ui": "^0.75.117",
159
+ "@stacksjs/utils": "^0.75.117",
160
+ "@stacksjs/validation": "^0.75.117",
161
161
  "ajv": "^8.20.0",
162
162
  "ajv-formats": "^3.0.1",
163
163
  "bun-plugin-stx": "^0.2.422",