@stacksjs/buddy 0.75.140 → 0.75.142

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.
@@ -442,6 +442,20 @@ export declare function resolvePersistedAttachTargetBox(tsCloudConfig: any, envi
442
442
  export declare function scrubLoopbackSitePortsForFirewall(tsCloudConfig: TsCloudConfig): TsCloudConfig;
443
443
  /** `resolveMailboxes`, plus the addresses left out for want of a password. */
444
444
  export declare function resolveMailboxesWithSkipped(mailboxes: unknown, domain: string, generatePassword?: boolean): { boxes: ResolvedMailbox[], skipped: string[] };
445
+ /**
446
+ * The recovery addresses declared in `config.email.mailboxes`.
447
+ *
448
+ * Independent of passwords on purpose: a mailbox that already exists on the
449
+ * server keeps its recovery address current even on a deploy that carries no
450
+ * password for it (`resolveMailboxesWithSkipped` skips such a mailbox for
451
+ * creation, and that must not also drop its recovery address).
452
+ *
453
+ * `invalid` names the entries left out: an address that is not an address,
454
+ * or that points back at the mailbox itself, which would send the reset link
455
+ * somewhere only the password can open. The mail server refuses both too;
456
+ * catching them here says so in the deploy log, with the config key.
457
+ */
458
+ export declare function resolveMailboxRecoveries(mailboxes: unknown, domain: string): { recoveries: ResolvedMailboxRecovery[], invalid: string[] };
445
459
  /**
446
460
  * Mail tenancy is an explicit deployment capability. The merged Stacks config
447
461
  * always contains framework email defaults, so checking `emailConfig` alone
@@ -496,6 +510,11 @@ export declare function resolveAttachTargetBox(owner: string, environment: strin
496
510
  * reason as `canPromptInteractively`.
497
511
  */
498
512
  export declare function canRevealSecrets(env?: Record<string, string | undefined>, stdout?: { isTTY?: boolean }): boolean;
513
+ /**
514
+ * Log what the recovery step (3a) did. Addresses only: the recovery address
515
+ * itself is personal data and stays out of CI logs.
516
+ */
517
+ export declare function reportRecoveryReconcile(logger: Pick<typeof log, 'info' | 'warn' | 'success'>, out: string, declared: Array<Pick<ResolvedMailboxRecovery, 'address'>>): void;
499
518
  /**
500
519
  * Say which mailboxes a reconcile created, and show a password only when that
501
520
  * is both necessary and safe.
@@ -798,6 +817,12 @@ declare interface ResolvedMailbox {
798
817
  generated: boolean
799
818
  passwordPolicy: 'initial' | 'managed'
800
819
  }
820
+ /** A declared recovery address for one mailbox (see MailboxConfig.recovery). */
821
+ export declare interface ResolvedMailboxRecovery {
822
+ address: string
823
+ recovery: string
824
+ policy: 'initial' | 'managed'
825
+ }
801
826
  /** What a mail-tenant reconcile resolved + provisioned, for the DNS step. */
802
827
  export declare interface MailTenantResult {
803
828
  domain: string
@@ -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,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
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 resolveMailboxRecoveries(mailboxes,domain){const recoveries=[],invalid=[];if(!Array.isArray(mailboxes))return{recoveries,invalid};for(const entry of mailboxes){if(!entry||typeof entry!=="object")continue;const raw=entry.email??entry.username,declared=entry.recovery;if(typeof raw!=="string"||declared===void 0||declared===null||declared==="")continue;const localPart=(raw.includes("@")?raw.split("@")[0]??"":raw).trim();if(!localPart)continue;const address=`${localPart}@${domain}`,recovery=typeof declared==="string"?declared.trim():"";if(!(/^[^\s@,;:"<>\\/'`$]+@[^\s@,;:"<>\\/'`$]+\.[^\s@,;:"<>\\/'`$]+$/.test(recovery)&&recovery.length<=254)||recovery.toLowerCase()===address.toLowerCase()){invalid.push(address);continue}const policy=entry.recoveryPolicy==="managed"?"managed":"initial";recoveries.push({address,recovery,policy})}return{recoveries,invalid}}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,13 +46,16 @@ 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} ${b.passwordPolicy}`).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)}export function reportRecoveryReconcile(logger,out,declared){const pick=(key)=>[...out.matchAll(new RegExp(`^${key}:([^\\n]+)`,"gm"))].flatMap((m)=>m[1]?[m[1].trim()]:[]),set=pick("RECOVERY_SET"),same=pick("RECOVERY_SAME"),kept=pick("RECOVERY_KEPT"),nobox=pick("RECOVERY_NOBOX"),old=pick("RECOVERY_OLDSERVER"),failed=pick("RECOVERY_FAIL");if(set.length)logger.success(`Mail: set the recovery address of ${set.join(", ")}`);if(kept.length)logger.info(`Mail: kept the recovery address the owner chose in webmail for ${kept.join(", ")} (recoveryPolicy: 'managed' overrides it)`);if(nobox.length)logger.warn(`Mail: no recovery address set for ${nobox.join(", ")}: the mailbox does not exist on the mail server yet`);if(old.length)logger.warn(`Mail: the mail server is too old for recovery addresses, so none was set for ${old.join(", ")}. Upgrade it (mail upgrade) and deploy again.`);if(failed.length)logger.warn(`Mail: the mail server refused the recovery address of ${failed.join(", ")}`);const seen=new Set([...set,...same,...kept,...nobox,...old,...failed]),missing=declared.map((r)=>r.address).filter((address)=>!seen.has(address));if(missing.length)logger.warn(`Mail: the recovery address of ${missing.join(", ")} was not reconciled`)}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.`)}const declaredRecoveries=domain?resolveMailboxRecoveries(cfg.mailboxes,domain):{recoveries:[],invalid:[]};if(declaredRecoveries.invalid.length>0)logger.warn(`Mail: ignored the recovery address of ${declaredRecoveries.invalid.join(", ")} in config/email.ts: it must be a valid address outside the mailbox itself`);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
+ `)}
51
+ `).toString("base64"):"",recoveries=declaredRecoveries.recoveries,recoveriesB64=recoveries.length?Buffer.from(`${recoveries.map((r)=>`${r.address} ${r.recovery} ${r.policy}`).join(`
50
52
  `)}
51
53
  `).toString("base64"):"",script=`set -e
52
54
  DOMAIN=${domain?`'${domain}'`:"''"}
53
55
  FWD_B64='${forwardsB64}'
54
56
  README_B64='${readmeB64}'
55
57
  BOXES_B64='${boxesB64}'
58
+ RECOVERY_B64='${recoveriesB64}'
56
59
  ENVF=/etc/mail/mail.env
57
60
  FJSON=/opt/mail/forwards.json
58
61
  DKIMDIR=/opt/mail/dkim
@@ -236,6 +239,27 @@ if [ -n "$BOXES_B64" ] && [ -x "$MS" ]; then
236
239
  fi
237
240
  done
238
241
  fi
242
+ # 3a) Recovery addresses: where the mail server's "Forgot password?" sends a
243
+ # mailbox's reset link. The CLI applies the policy itself: 'initial' leaves an
244
+ # address the owner set (or removed) in webmail alone, 'managed' always sets
245
+ # it. It prints one "Recovery: <outcome>" line. A server binary from before
246
+ # recovery addresses rejects the subcommand's arguments, which is reported as
247
+ # such rather than as a failure: the next mail server upgrade fixes it.
248
+ # The keys avoid MADE:/EXISTS:/UPDATED:, which the mailbox step above owns.
249
+ if [ -n "$RECOVERY_B64" ] && [ -x "$MS" ]; then
250
+ echo "$RECOVERY_B64" | base64 -d | while IFS=$' ' read -r addr recovery recovery_policy; do
251
+ [ -z "$addr" ] && continue
252
+ out=$(SMTP_DB_PATH="$MAIL_DB_PATH" "$MS" user:local recovery "$addr" "$recovery" --policy "$recovery_policy" 2>&1 || true)
253
+ case "$out" in
254
+ *"Recovery: updated"*) echo "RECOVERY_SET:$addr" ;;
255
+ *"Recovery: unchanged"*) echo "RECOVERY_SAME:$addr" ;;
256
+ *"Recovery: kept"*) echo "RECOVERY_KEPT:$addr" ;;
257
+ *"not found"*) echo "RECOVERY_NOBOX:$addr" ;;
258
+ *"Unknown option"*|*"Too many arguments"*|*"Unknown command"*|*"UnknownCommand"*) echo "RECOVERY_OLDSERVER:$addr" ;;
259
+ *) echo "RECOVERY_FAIL:$addr" ;;
260
+ esac
261
+ done
262
+ fi
239
263
  # 3b) Put the tenant's own mail hostname on the mail certificate.
240
264
  #
241
265
  # A person setting up Mail.app types mail.<their domain>, not the shared host.
@@ -437,6 +461,6 @@ EOF
437
461
  systemctl daemon-reload
438
462
  systemctl enable --now mail-health.timer >/dev/null 2>&1
439
463
  # 6) Restart only when the startup-read env actually changed (domain or DKIM key).
440
- 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()]:[])),updatedAddrs=new Set([...out.matchAll(/^UPDATED:([^\n]+)/gm)].flatMap((m)=>m[1]?[m[1].trim()]:[])),updateFailed=[...out.matchAll(/^UPDATEFAIL:([^\n]+)/gm)].flatMap((m)=>m[1]?[m[1].trim()]:[]),createdBoxes=boxes.filter((b)=>madeAddrs.has(b.address)),created=createdBoxes.map((b)=>({address:b.address,generated:b.generated}));logger.success(`Mail routing reconciled (${line.replace("MAILTENANT:","")})`);const failed=[...out.matchAll(/^FAIL:([^\n]+)/gm)].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,...updatedAddrs,...updateFailed,...[...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(", ")}`);reportMailboxCredentials(logger,createdBoxes,{interactive,persisted});if(updatedAddrs.size)logger.success(`Mail: set the declared password on ${updatedAddrs.size} existing mailbox(es): ${[...updatedAddrs].join(", ")}`);if(updateFailed.length)logger.warn(`Mail: could not set the declared password on ${updateFailed.length} existing mailbox(es) - they still accept the old one: ${updateFailed.join(", ")}`);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 resolvePostmasterRecord(value){if(typeof value!=="string")return;const token=value.trim().replace(/^"|"$/g,"").replace(/^google-site-verification=/i,"");return/^[\w-]{20,}$/.test(token)?`google-site-verification=${token}`:void 0}export function postmasterHint(domain,postmaster){if(postmaster===!1)return;if(postmaster&&typeof postmaster==="object"&&resolvePostmasterRecord(postmaster.google))return;return`Gmail Postmaster Tools: add ${domain} at https://postmaster.google.com/v2/u/0/add_domain, then set email.server.postmaster.google to the TXT value it shows; the next deploy publishes it (false silences this).`}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,postmasterCfg=cfg.server?.postmaster,postmaster=postmasterCfg?resolvePostmasterRecord(postmasterCfg.google):void 0;if(postmasterCfg&&postmasterCfg.google&&!postmaster)logger.warn(` Mail DNS: email.server.postmaster.google is not a Postmaster Tools token, so it was not published. Copy the TXT value Postmaster Tools shows for ${domain}.`);const envFile=envFileForEnvironment(process.env.APP_ENV||"production"),byHand=async(reason,knownProvider)=>{const ns=knownProvider?{nameservers:[]}:await resolveZoneNameservers(domain).catch(()=>({nameservers:[]})),provider=knownProvider??dnsProviderNameFromNameservers(ns.nameservers);logger.warn(`Mail DNS NOT published for ${domain}: ${reason}. Until these records exist, mail from ${domain} fails SPF, DKIM and DMARC and lands in spam or bounces.`);logger.warn(` Fix: ${dnsCredentialFix(provider,envFile,ns.nameservers)}`);logger.warn(" Or add these records by hand at the DNS host:");logger.warn(` MX @ 10 ${mailHost}`);logger.warn(` TXT @ ${spf}`);if(dkim)logger.warn(` TXT ${dkimName.padEnd(17)}${dkim}`);logger.warn(` TXT _dmarc ${dmarc}`);if(postmaster)logger.warn(` TXT @ ${postmaster}`);logger.warn(` 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`,declared)}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"));if(postmaster)await replaceTxt(apex,postmaster,(existing)=>existing===postmaster);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")},...postmaster?[{label:"Postmaster Tools",fqdn:apex,type:"TXT",owns:(content)=>content===postmaster}]:[]],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, ${postmaster?"Postmaster Tools, ":""}${mailFqdn}\u2192${ip})`);const hint=postmasterHint(domain,postmasterCfg);if(hint)logger.info(` ${hint}`)}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}`)}}async function reconcileR2ForDeploy(tsCloudConfig,logger){const buckets=tsCloudConfig?.infrastructure?.r2?.buckets;if(!buckets||Object.keys(buckets).length===0)return;let reconcileR2Buckets;try{({reconcileR2Buckets}=await import("@stacksjs/ts-cloud"))}catch{return}if(typeof reconcileR2Buckets!=="function"){logger.warn("R2: installed @stacksjs/ts-cloud is too old to manage buckets. Upgrade it to provision infrastructure.r2.");return}const apiToken=process.env.CLOUDFLARE_API_TOKEN,accountId=process.env.CLOUDFLARE_ACCOUNT_ID;if(!apiToken||!accountId){logger.warn("R2: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are both needed to provision buckets. Skipping.");return}logger.info(`R2: reconciling ${Object.keys(buckets).length} bucket(s)...`);try{const summary=await reconcileR2Buckets(tsCloudConfig,{apiToken,accountId,zoneId:process.env.CLOUDFLARE_ZONE_ID||void 0,log:(message)=>logger.info(` ${message}`)});for(const bucket of summary?.buckets??[]){const changes=bucket.changes?.length?bucket.changes.join(", "):"in sync";logger.success(` ${bucket.name}: ${changes}`);for(const domain of bucket.domains??[])logger.success(` https://${domain.domain}${domain.status?` (${domain.status})`:""}`)}for(const warning of summary?.warnings??[])logger.warn(` ${warning}`)}catch(err){logger.warn(`R2: ${err?.message||err}`)}}async function reconcileWorkersForDeploy(tsCloudConfig,logger){const workers=tsCloudConfig?.infrastructure?.workers;if(!workers||Object.keys(workers).length===0)return;let reconcileCloudflareWorkers;try{({reconcileCloudflareWorkers}=await import("@stacksjs/ts-cloud"))}catch{return}if(typeof reconcileCloudflareWorkers!=="function"){logger.warn("Workers: installed @stacksjs/ts-cloud is too old to deploy them. Upgrade it to deploy infrastructure.workers.");return}const apiToken=process.env.CLOUDFLARE_API_TOKEN,accountId=process.env.CLOUDFLARE_ACCOUNT_ID;if(!apiToken||!accountId){logger.warn("Workers: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are both needed to deploy them. Skipping.");return}logger.info(`Workers: reconciling ${Object.keys(workers).length} worker(s)...`);try{const summary=await reconcileCloudflareWorkers(tsCloudConfig,{apiToken,accountId,projectRoot:process.cwd(),zoneId:process.env.CLOUDFLARE_ZONE_ID||void 0,log:(message)=>logger.info(` ${message}`)});for(const worker of summary?.workers??[]){const changes=worker.changes?.length?worker.changes.join(", "):"in sync";logger.success(` ${worker.name}: ${changes}`);for(const domain of worker.domains??[])logger.success(` https://${domain.domain}${domain.status?` (${domain.status})`:""}`)}for(const warning of summary?.warnings??[])logger.warn(` ${warning}`)}catch(err){logger.warn(`Workers: ${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)}`)}}export async function delegateDeclaredZone(config,logger){const dns=config?.tsCloud?.infrastructure?.dns??config?.infrastructure?.dns;if(!dns?.registrar)return;try{const{applyDeclaredZoneSettings,delegateZoneFromConfig,describeDelegation,describeZoneSettings}=await import("@stacksjs/ts-cloud"),result=await delegateZoneFromConfig(dns);for(const line of describeDelegation(result))logger.info(line);if(result.status==="blocked")logger.warn("DNS: delegation did not complete; the domain still resolves through its registrar.");const settings=await applyDeclaredZoneSettings(dns);for(const line of describeZoneSettings(settings))logger.info(line);if(settings.status!=="skipped"&&settings.failed.length>0)logger.warn(`DNS: ${settings.failed.length} zone setting(s) could not be applied; the zone is serving with its previous values.`)}catch(error){logger.warn(`DNS: zone reconciliation could not run: ${error instanceof Error?error.message:String(error)}`)}}export 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 tsCloudConfig=await loadTsCloudConfig(process.env.APP_ENV||"production").catch(()=>{return});await delegateDeclaredZone(tsCloudConfig,logger);const declared=declaredDnsProvider(tsCloudConfig),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 runPreviewCommand(name,options,action){const base=resolveDeploymentEnvironment({option:options.base}),envFile=".env.preview";if(action==="deploy"&&!existsSync(p.projectPath(envFile))){log.error(`Previews read their secrets from ${envFile} only, and this project has none.`);log.info(`Create it with its own database, mail and API credentials (never production's): buddy env:set --file ${envFile} APP_KEY ...`);process.exit(ExitCode.FatalError)}if(existsSync(p.projectPath(envFile))){const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:"preview",keysFile:".env.keys",overload:!0,quiet:!0})}const appConfig=await loadTsCloudConfig(base);if(!appConfig){log.error("Previews need config/cloud.ts.");process.exit(ExitCode.FatalError)}const provider=resolveProvider(appConfig);if(!isSshPipelineProvider(provider)){log.error(`Previews run as a tenant on the app's box, which the '${provider}' provider does not have. They work with cloud.provider 'hetzner' or 'ssh'.`);process.exit(ExitCode.FatalError)}let target;try{target=previewTarget(appConfig,name,previewDomain(appConfig,process.env))}catch(error){log.error(getErrorMessage(error));process.exit(ExitCode.InvalidArgument)}const sshTarget=provider==="ssh"?resolveSshTarget(appConfig):null,lookup=provider==="ssh"?null:await resolveAttachTargetBox(target.owner,base,appConfig),host=sshTarget?.host??lookup?.box?.publicIp;if(!host){log.error(provider==="ssh"?"No SSH host configured for this project.":describeAttachLookupFailure(target.owner,base,lookup?.failure));process.exit(ExitCode.FatalError)}const remoteTarget=sshTarget?{...sshTarget,host}:hetznerTarget(host),{sshExecOrThrow}=await import("@stacksjs/ts-cloud"),runRemote=(script)=>{const encoded=Buffer.from(script).toString("base64"),privileged=remoteTarget.user==="root"?"bash":"sudo -n bash";return sshExecOrThrow(host,`echo ${encoded} | base64 -d | ${privileged}`,remoteExecOptions(remoteTarget,15))};if(action==="remove"){await removePreview(target,base,runRemote);return}const{buildHostSitePortsScript,occupiedHostPorts,parseHostSiteFragments}=await import("@stacksjs/ts-cloud"),owners=occupiedHostPorts(parseHostSiteFragments(await runRemote(buildHostSitePortsScript()))),port=previewPort(owners,target.slug,Number(appConfig.sites?.[target.site]?.port)||3000);log.info(`Deploying preview ${target.slug} to ${target.url} (port ${port} on ${host})`);await deployOverSsh(derivePreviewConfig(appConfig,target,port),base,{verbose:options.verbose===!0,yes:!0},envFile);log.success(`Preview deployed: ${target.url}`);if(process.env.GITHUB_OUTPUT)writeFileSync(process.env.GITHUB_OUTPUT,`url=${target.url}
464
+ 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()]:[])),updatedAddrs=new Set([...out.matchAll(/^UPDATED:([^\n]+)/gm)].flatMap((m)=>m[1]?[m[1].trim()]:[])),updateFailed=[...out.matchAll(/^UPDATEFAIL:([^\n]+)/gm)].flatMap((m)=>m[1]?[m[1].trim()]:[]),createdBoxes=boxes.filter((b)=>madeAddrs.has(b.address)),created=createdBoxes.map((b)=>({address:b.address,generated:b.generated}));logger.success(`Mail routing reconciled (${line.replace("MAILTENANT:","")})`);const failed=[...out.matchAll(/^FAIL:([^\n]+)/gm)].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,...updatedAddrs,...updateFailed,...[...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(", ")}`);reportMailboxCredentials(logger,createdBoxes,{interactive,persisted});if(updatedAddrs.size)logger.success(`Mail: set the declared password on ${updatedAddrs.size} existing mailbox(es): ${[...updatedAddrs].join(", ")}`);if(updateFailed.length)logger.warn(`Mail: could not set the declared password on ${updateFailed.length} existing mailbox(es) - they still accept the old one: ${updateFailed.join(", ")}`);if(migratedAddrs.size)logger.success(`Mail: migrated ${migratedAddrs.size} legacy mailbox username(s) to isolated full addresses`);reportRecoveryReconcile(logger,out,recoveries);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 resolvePostmasterRecord(value){if(typeof value!=="string")return;const token=value.trim().replace(/^"|"$/g,"").replace(/^google-site-verification=/i,"");return/^[\w-]{20,}$/.test(token)?`google-site-verification=${token}`:void 0}export function postmasterHint(domain,postmaster){if(postmaster===!1)return;if(postmaster&&typeof postmaster==="object"&&resolvePostmasterRecord(postmaster.google))return;return`Gmail Postmaster Tools: add ${domain} at https://postmaster.google.com/v2/u/0/add_domain, then set email.server.postmaster.google to the TXT value it shows; the next deploy publishes it (false silences this).`}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,postmasterCfg=cfg.server?.postmaster,postmaster=postmasterCfg?resolvePostmasterRecord(postmasterCfg.google):void 0;if(postmasterCfg&&postmasterCfg.google&&!postmaster)logger.warn(` Mail DNS: email.server.postmaster.google is not a Postmaster Tools token, so it was not published. Copy the TXT value Postmaster Tools shows for ${domain}.`);const envFile=envFileForEnvironment(process.env.APP_ENV||"production"),byHand=async(reason,knownProvider)=>{const ns=knownProvider?{nameservers:[]}:await resolveZoneNameservers(domain).catch(()=>({nameservers:[]})),provider=knownProvider??dnsProviderNameFromNameservers(ns.nameservers);logger.warn(`Mail DNS NOT published for ${domain}: ${reason}. Until these records exist, mail from ${domain} fails SPF, DKIM and DMARC and lands in spam or bounces.`);logger.warn(` Fix: ${dnsCredentialFix(provider,envFile,ns.nameservers)}`);logger.warn(" Or add these records by hand at the DNS host:");logger.warn(` MX @ 10 ${mailHost}`);logger.warn(` TXT @ ${spf}`);if(dkim)logger.warn(` TXT ${dkimName.padEnd(17)}${dkim}`);logger.warn(` TXT _dmarc ${dmarc}`);if(postmaster)logger.warn(` TXT @ ${postmaster}`);logger.warn(` 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`,declared)}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"));if(postmaster)await replaceTxt(apex,postmaster,(existing)=>existing===postmaster);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")},...postmaster?[{label:"Postmaster Tools",fqdn:apex,type:"TXT",owns:(content)=>content===postmaster}]:[]],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, ${postmaster?"Postmaster Tools, ":""}${mailFqdn}\u2192${ip})`);const hint=postmasterHint(domain,postmasterCfg);if(hint)logger.info(` ${hint}`)}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}`)}}async function reconcileR2ForDeploy(tsCloudConfig,logger){const buckets=tsCloudConfig?.infrastructure?.r2?.buckets;if(!buckets||Object.keys(buckets).length===0)return;let reconcileR2Buckets;try{({reconcileR2Buckets}=await import("@stacksjs/ts-cloud"))}catch{return}if(typeof reconcileR2Buckets!=="function"){logger.warn("R2: installed @stacksjs/ts-cloud is too old to manage buckets. Upgrade it to provision infrastructure.r2.");return}const apiToken=process.env.CLOUDFLARE_API_TOKEN,accountId=process.env.CLOUDFLARE_ACCOUNT_ID;if(!apiToken||!accountId){logger.warn("R2: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are both needed to provision buckets. Skipping.");return}logger.info(`R2: reconciling ${Object.keys(buckets).length} bucket(s)...`);try{const summary=await reconcileR2Buckets(tsCloudConfig,{apiToken,accountId,zoneId:process.env.CLOUDFLARE_ZONE_ID||void 0,log:(message)=>logger.info(` ${message}`)});for(const bucket of summary?.buckets??[]){const changes=bucket.changes?.length?bucket.changes.join(", "):"in sync";logger.success(` ${bucket.name}: ${changes}`);for(const domain of bucket.domains??[])logger.success(` https://${domain.domain}${domain.status?` (${domain.status})`:""}`)}for(const warning of summary?.warnings??[])logger.warn(` ${warning}`)}catch(err){logger.warn(`R2: ${err?.message||err}`)}}async function reconcileWorkersForDeploy(tsCloudConfig,logger){const workers=tsCloudConfig?.infrastructure?.workers;if(!workers||Object.keys(workers).length===0)return;let reconcileCloudflareWorkers;try{({reconcileCloudflareWorkers}=await import("@stacksjs/ts-cloud"))}catch{return}if(typeof reconcileCloudflareWorkers!=="function"){logger.warn("Workers: installed @stacksjs/ts-cloud is too old to deploy them. Upgrade it to deploy infrastructure.workers.");return}const apiToken=process.env.CLOUDFLARE_API_TOKEN,accountId=process.env.CLOUDFLARE_ACCOUNT_ID;if(!apiToken||!accountId){logger.warn("Workers: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are both needed to deploy them. Skipping.");return}logger.info(`Workers: reconciling ${Object.keys(workers).length} worker(s)...`);try{const summary=await reconcileCloudflareWorkers(tsCloudConfig,{apiToken,accountId,projectRoot:process.cwd(),zoneId:process.env.CLOUDFLARE_ZONE_ID||void 0,log:(message)=>logger.info(` ${message}`)});for(const worker of summary?.workers??[]){const changes=worker.changes?.length?worker.changes.join(", "):"in sync";logger.success(` ${worker.name}: ${changes}`);for(const domain of worker.domains??[])logger.success(` https://${domain.domain}${domain.status?` (${domain.status})`:""}`)}for(const warning of summary?.warnings??[])logger.warn(` ${warning}`)}catch(err){logger.warn(`Workers: ${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)}`)}}export async function delegateDeclaredZone(config,logger){const dns=config?.tsCloud?.infrastructure?.dns??config?.infrastructure?.dns;if(!dns?.registrar)return;try{const{applyDeclaredZoneSettings,delegateZoneFromConfig,describeDelegation,describeZoneSettings}=await import("@stacksjs/ts-cloud"),result=await delegateZoneFromConfig(dns);for(const line of describeDelegation(result))logger.info(line);if(result.status==="blocked")logger.warn("DNS: delegation did not complete; the domain still resolves through its registrar.");const settings=await applyDeclaredZoneSettings(dns);for(const line of describeZoneSettings(settings))logger.info(line);if(settings.status!=="skipped"&&settings.failed.length>0)logger.warn(`DNS: ${settings.failed.length} zone setting(s) could not be applied; the zone is serving with its previous values.`)}catch(error){logger.warn(`DNS: zone reconciliation could not run: ${error instanceof Error?error.message:String(error)}`)}}export 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 tsCloudConfig=await loadTsCloudConfig(process.env.APP_ENV||"production").catch(()=>{return});await delegateDeclaredZone(tsCloudConfig,logger);const declared=declaredDnsProvider(tsCloudConfig),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 runPreviewCommand(name,options,action){const base=resolveDeploymentEnvironment({option:options.base}),envFile=".env.preview";if(action==="deploy"&&!existsSync(p.projectPath(envFile))){log.error(`Previews read their secrets from ${envFile} only, and this project has none.`);log.info(`Create it with its own database, mail and API credentials (never production's): buddy env:set --file ${envFile} APP_KEY ...`);process.exit(ExitCode.FatalError)}if(existsSync(p.projectPath(envFile))){const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:"preview",keysFile:".env.keys",overload:!0,quiet:!0})}const appConfig=await loadTsCloudConfig(base);if(!appConfig){log.error("Previews need config/cloud.ts.");process.exit(ExitCode.FatalError)}const provider=resolveProvider(appConfig);if(!isSshPipelineProvider(provider)){log.error(`Previews run as a tenant on the app's box, which the '${provider}' provider does not have. They work with cloud.provider 'hetzner' or 'ssh'.`);process.exit(ExitCode.FatalError)}let target;try{target=previewTarget(appConfig,name,previewDomain(appConfig,process.env))}catch(error){log.error(getErrorMessage(error));process.exit(ExitCode.InvalidArgument)}const sshTarget=provider==="ssh"?resolveSshTarget(appConfig):null,lookup=provider==="ssh"?null:await resolveAttachTargetBox(target.owner,base,appConfig),host=sshTarget?.host??lookup?.box?.publicIp;if(!host){log.error(provider==="ssh"?"No SSH host configured for this project.":describeAttachLookupFailure(target.owner,base,lookup?.failure));process.exit(ExitCode.FatalError)}const remoteTarget=sshTarget?{...sshTarget,host}:hetznerTarget(host),{sshExecOrThrow}=await import("@stacksjs/ts-cloud"),runRemote=(script)=>{const encoded=Buffer.from(script).toString("base64"),privileged=remoteTarget.user==="root"?"bash":"sudo -n bash";return sshExecOrThrow(host,`echo ${encoded} | base64 -d | ${privileged}`,remoteExecOptions(remoteTarget,15))};if(action==="remove"){await removePreview(target,base,runRemote);return}const{buildHostSitePortsScript,occupiedHostPorts,parseHostSiteFragments}=await import("@stacksjs/ts-cloud"),owners=occupiedHostPorts(parseHostSiteFragments(await runRemote(buildHostSitePortsScript()))),port=previewPort(owners,target.slug,Number(appConfig.sites?.[target.site]?.port)||3000);log.info(`Deploying preview ${target.slug} to ${target.url} (port ${port} on ${host})`);await deployOverSsh(derivePreviewConfig(appConfig,target,port),base,{verbose:options.verbose===!0,yes:!0},envFile);log.success(`Preview deployed: ${target.url}`);if(process.env.GITHUB_OUTPUT)writeFileSync(process.env.GITHUB_OUTPUT,`url=${target.url}
441
465
  `,{flag:"a"})}async function removePreview(target,base,runRemote){log.info(`Removing preview ${target.slug} (${target.host})`);await runRemote(buildPreviewTeardownScript(target.slug,target.site));log.success(`Removed ${target.slug} from the box`);const tsCloudConfig=await loadTsCloudConfig(base).catch(()=>{return}),providerConfigs=dnsProviderConfigsFromEnv(declaredDnsProvider(tsCloudConfig)),zone=target.host,provider=providerConfigs.length>0?await resolveZoneDnsProvider(zone,providerConfigs,log):void 0;if(!provider)log.warn(`DNS: no configured provider manages ${zone}; remove the A/AAAA records for ${target.host} by hand.`);else{const{removeAddressRecords}=await import("@stacksjs/ts-cloud"),{removed,warnings}=await removeAddressRecords(provider,zone,target.host);for(const record of removed)log.success(`DNS: deleted ${record.type} ${target.host} (${provider.name})`);for(const warning of warnings)log.warn(`DNS: ${warning}`)}const pin=join(process.cwd(),"storage","cloud","state",`${target.slug}-${base}.json`);if(existsSync(pin)){const{rmSync}=await import("node:fs");rmSync(pin)}log.success(`Preview ${target.slug} removed`)}async function deployToFlyProvider(tsCloudConfig,environment,verbose){const{FlyDeployError,runFlyDeploy}=await import("./deploy-fly"),api=await loadTsCloudDeployApi();if(typeof api.deployToFly!=="function"){log.error("This @stacksjs/ts-cloud cannot deploy to Fly.io: upgrade it to 0.16.45 or newer.");process.exit(ExitCode.FatalError)}const{execSync}=await import("node:child_process");let release;try{release=execSync("git rev-parse --short=12 HEAD",{cwd:p.projectPath(),stdio:"pipe"}).toString().trim()}catch{release=`build-${Date.now()}`}const deployEnv=environment==="prod"?"production":environment;try{const result=await runFlyDeploy({config:tsCloudConfig,environment:deployEnv,token:process.env.FLY_API_TOKEN,release,envValues:await resolveDeployEnvValues(deployEnv,tsCloudConfig),projectRoot:p.projectPath()},{api,run:(command,options)=>{const child=Bun.spawnSync(command,{cwd:options.cwd,stdin:options.stdin===void 0?"inherit":new TextEncoder().encode(options.stdin),stdout:verbose?"inherit":"pipe",stderr:verbose?"inherit":"pipe"});return{exitCode:child.exitCode,output:`${child.stdout??""}${child.stderr??""}`}},exists:existsSync,log:(line)=>log.info(line)});log.success(`Deployed to ${result.url}`);if(result.untouched.length>0)log.warn(`Left ${result.untouched.length} Machine(s) the config no longer accounts for: ${result.untouched.join(", ")}. Remove them with \`fly machine destroy\` once nothing needs their data.`);for(const certificate of result.certificates){if(certificate.configured)continue;const needs=certificate.dns_requirements,target=needs?.cname?`a CNAME to ${needs.cname}`:[...needs?.a??[],...needs?.aaaa??[]].join(", ")||"Fly's addresses";log.info(`${certificate.hostname}: point it at ${target}; Fly issues the certificate once it resolves.`)}}catch(error){if(error instanceof FlyDeployError){log.error(error.message);if(error.hint)log.info(error.hint);process.exit(ExitCode.FatalError)}throw error}}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("--dry-run","Preview the deployment and change nothing",{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:tsCloudConfig?.mode||"server",fallbackRegion:process.env.AWS_REGION||"us-east-1",resolveSiteKind,applyEnvironmentToSites,warnings:unbacked.length>0?[unbackedDataMessage(unbacked)]:[]})}catch(error){log.error(getErrorMessage(error));process.exit(ExitCode.InvalidArgument)}if(options.json||process.argv.includes("--json"))console.log(`${deploymentPreviewJsonPrefix}${JSON.stringify(plan)}`);else process.stdout.write(formatDeploymentPreview(plan));return}await ensureDeployPrerequisites(options.verbose===!0,deployEnvName);process.env.APP_ENV=deployEnvName;{const envFile=deployEnvName==="production"?".env.production":`.env.${deployEnvName}`;if(existsSync(p.projectPath(envFile)))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:deployEnvName,keysFile:".env.keys",overload:!0,quiet:!0})}catch(err){log.warn(`Could not load ${envFile}: ${getErrorMessage(err)}`)}}const tsCloudConfig=await loadTsCloudConfig(deployEnvName);if(tsCloudConfig&&resolveProvider(tsCloudConfig)==="fly"){await deployToFlyProvider(tsCloudConfig,deployEnvName,options.verbose===!0);return}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("--all","Every configured site, instead of one",{default:!1}).option("--only-if-live <release>","Only roll back a site whose live release is this commit (what a failed deploy switched)",{default:void 0}).option("--verbose",descriptions.verbose,{default:!1}).action(async(site,options)=>{const exitCode=await runDeployRollback(site,options);if(exitCode!==ExitCode.Success)process.exit(exitCode)});buddy.command("deploy:preview <name>","Deploy a preview (e.g. pr-123) as its own tenant on the app's box, at <name>.<preview domain>").option("--base <environment>","The environment whose box the preview runs on",{default:"production"}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await runPreviewCommand(name,options,"deploy")});buddy.command("deploy:preview:remove <name>","Remove a preview from the app's box: its services, gateway route, certificate, files and DNS records").option("--base <environment>","The environment whose box the preview runs on",{default:"production"}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await runPreviewCommand(name,options,"remove")});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(`
442
466
  `);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,"-"),{stacksCloudName,stacksCloudRegion}=await import("@stacksjs/cloud"),stackName=await stacksCloudName(),{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),cfnClient=new AWSCloudFormationClient(await stacksCloudRegion());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,4 +1,7 @@
1
- import{closeSync,existsSync,mkdirSync,openSync,readdirSync,readFileSync,rmSync}from"node:fs";import{relative}from"node:path";import process from"node:process";import{confirm,intro,log,onUnknownSubcommand,outro,text}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{hasTTY,isCI}from"@stacksjs/env";import{appPath,frameworkPath,frameworkRuntimePath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{preflightDatabase}from"../database-preflight";import{DDL_CONSTRAINT_OVERRIDE_ENV,DIALECT_OVERRIDE_ENV,auditDdlConstraints,auditMigrationCorpus,dialectCapabilities,formatDdlConstraintError,formatMigrationDialectError,isLibsqlDriver,relativeMigrationDirectory,resolveMigrationDirectory,stripSqlNoise}from"@stacksjs/database";import{resultFailed}from"../result";let _runAction;async function runAction(...args){if(!_runAction)_runAction=(await import("@stacksjs/actions")).runAction;return _runAction(...args)}function acquireMigrationLock(){const lockDir=frameworkRuntimePath(),lockFile=`${lockDir}/migrations.lock`;try{if(!existsSync(lockDir))mkdirSync(lockDir,{recursive:!0});const fd=openSync(lockFile,"wx");try{closeSync(fd)}catch{}return{acquired:!0,release:()=>{try{rmSync(lockFile,{force:!0})}catch{}}}}catch{return{acquired:!1,release:()=>{}}}}async function ensureDatabaseOrExit(){try{const{ensureDatabaseReady}=await import("@stacksjs/database");await ensureDatabaseReady()}catch(error){log.syncError(error instanceof Error?error.message:String(error));process.exit(ExitCode.FatalError)}}function readMigrateMarker(){const file=frameworkRuntimePath("last-migrate-result.json");if(!existsSync(file))return null;try{const raw=readFileSync(file,"utf8"),parsed=JSON.parse(raw),n=typeof parsed.appliedCount==="number"?parsed.appliedCount:null;if(n===null||!Number.isFinite(n))return null;return{appliedCount:Math.max(0,Math.floor(n))}}catch{return null}finally{try{rmSync(file,{force:!0})}catch{}}}function countModelFiles(dir){if(!existsSync(dir))return 0;let count=0;const entries=readdirSync(dir,{withFileTypes:!0});for(const entry of entries)if(entry.isDirectory())count+=countModelFiles(`${dir}/${entry.name}`);else if(entry.name.endsWith(".ts")&&!entry.name.startsWith(".")&&!entry.name.startsWith("index"))count++;return count}function validateModelsExist(){const userModelsPath=appPath("Models"),defaultModelsPath=frameworkPath("defaults/app/Models"),userModelCount=countModelFiles(userModelsPath),defaultModelCount=countModelFiles(defaultModelsPath);if(userModelCount===0&&defaultModelCount===0)return{valid:!1,error:"No models found. Please create models in app/Models or ensure framework defaults exist."};return{valid:!0}}export function validateMigrationDialect(cwd=process.cwd(),options={}){const driver=String(options.driver||process.env.DB_CONNECTION||"sqlite").toLowerCase(),dir=resolveMigrationDirectory(driver,{cwd}),relativeDir=relativeMigrationDirectory(dir,cwd);if(process.env[DIALECT_OVERRIDE_ENV]!=="1"){const caps=dialectCapabilities(driver),target=caps.wire==="mysql"?"mysql":caps.wire==="postgres"?"postgres":"sqlite";if(driver==="sqlite"||driver==="postgres"||caps.wire==="mysql"||isLibsqlDriver(driver)){const audit=auditMigrationCorpus({dir,target});if(!audit.empty&&audit.incompatible.length>0)return{valid:!1,error:formatMigrationDialectError(audit,target,relativeDir)}}}if(process.env[DDL_CONSTRAINT_OVERRIDE_ENV]!=="1"){const constraints=auditDdlConstraints({dir,dialect:driver});if(!constraints.empty&&constraints.violations.length>0)return{valid:!1,error:formatDdlConstraintError(constraints,driver,relativeDir)}}return{valid:!0}}async function reportSchemaDrift(){try{const{auditSchemaDrift,formatSchemaDrift}=await import("@stacksjs/database"),drift=await auditSchemaDrift();if(drift.skipped||drift.clean)return!0;log.warn(await formatSchemaDrift(drift));return!1}catch(err){log.debug(`[migrate] schema drift check skipped: ${err instanceof Error?err.message:String(err)}`);return!0}}async function reportMissingRecordedTables(){try{const{missingRecordedTables}=await import("@stacksjs/database"),missing=await missingRecordedTables();if(missing.length===0)return!0;log.warn(`${missing.length} table${missing.length===1?"":"s"} the migrations table says ${missing.length===1?"was":"were"} created ${missing.length===1?"is":"are"} missing from the database: ${missing.slice(0,10).join(", ")}${missing.length>10?`, +${missing.length-10} more`:""}.`);return!1}catch(err){log.debug(`[migrate] recorded-table check skipped: ${err instanceof Error?err.message:String(err)}`);return!0}}function logDriftRemedy(){log.info("`buddy migrate` only runs files the migrations table does not list, so it will not repair this on its own.");log.info(" A table that is missing although its migration is recorded: `./buddy migrate:status --reconcile --requeue-reverted`, then `./buddy migrate`.");log.info(" Anything else: `./buddy migrate:status` compares the corpus, the ledger and the live schema. Pass --strict to fail on drift in CI.")}async function reportIgnoredMigrations(){try{const{findIgnoredMigrations,formatIgnoredMigrations}=await import("@stacksjs/database"),ignored=findIgnoredMigrations();if(!ignored.length)return!0;log.warn(formatIgnoredMigrations(ignored));return!1}catch(err){log.debug(`[migrate] ignored-migration check skipped: ${err instanceof Error?err.message:String(err)}`);return!0}}function strictMode(options){return options.strict===!0||process.env.STACKS_MIGRATE_STRICT==="1"}async function reportMissingForeignKeys(){try{const{auditForeignKeys}=await import("@stacksjs/database"),result=await auditForeignKeys();if(result.absentTable.length>0)log.debug(`[migrate] ${result.absentTable.length} declared foreign key(s) belong to tables that are not in this database (${[...new Set(result.absentTable.map((fk)=>fk.fromTable))].join(", ")}); not reported as missing.`);if(result.missing.length===0)return;const sample=result.missing.slice(0,5).map((fk)=>` \u2022 ${fk.fromTable}.${fk.fromColumn} \u2192 ${fk.toTable}.${fk.toColumn} (${fk.model})`).join(`
1
+ import{closeSync,existsSync,mkdirSync,openSync,readdirSync,readFileSync,rmSync}from"node:fs";import{relative}from"node:path";import process from"node:process";import{confirm,intro,log,onUnknownSubcommand,outro,text}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{hasTTY,isCI}from"@stacksjs/env";import{appPath,frameworkPath,frameworkRuntimePath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{preflightDatabase}from"../database-preflight";import{DDL_CONSTRAINT_OVERRIDE_ENV,DIALECT_OVERRIDE_ENV,auditDdlConstraints,auditMigrationCorpus,dialectCapabilities,formatDdlConstraintError,formatMigrationDialectError,isLibsqlDriver,relativeMigrationDirectory,resolveMigrationDirectory,stripSqlNoise}from"@stacksjs/database";import{resultFailed}from"../result";let _runAction;async function runAction(...args){if(!_runAction)_runAction=(await import("@stacksjs/actions")).runAction;return _runAction(...args)}function acquireMigrationLock(){const lockDir=frameworkRuntimePath(),lockFile=`${lockDir}/migrations.lock`;try{if(!existsSync(lockDir))mkdirSync(lockDir,{recursive:!0});const fd=openSync(lockFile,"wx");try{closeSync(fd)}catch{}return{acquired:!0,release:()=>{try{rmSync(lockFile,{force:!0})}catch{}}}}catch{return{acquired:!1,release:()=>{}}}}async function ensureDatabaseOrExit(){try{const{ensureDatabaseReady}=await import("@stacksjs/database");await ensureDatabaseReady()}catch(error){log.syncError(error instanceof Error?error.message:String(error));process.exit(ExitCode.FatalError)}}function readMigrateMarker(){const file=frameworkRuntimePath("last-migrate-result.json");if(!existsSync(file))return null;try{const raw=readFileSync(file,"utf8"),parsed=JSON.parse(raw),n=typeof parsed.appliedCount==="number"?parsed.appliedCount:null;if(n===null||!Number.isFinite(n))return null;return{appliedCount:Math.max(0,Math.floor(n))}}catch{return null}finally{try{rmSync(file,{force:!0})}catch{}}}function countModelFiles(dir){if(!existsSync(dir))return 0;let count=0;const entries=readdirSync(dir,{withFileTypes:!0});for(const entry of entries)if(entry.isDirectory())count+=countModelFiles(`${dir}/${entry.name}`);else if(entry.name.endsWith(".ts")&&!entry.name.startsWith(".")&&!entry.name.startsWith("index"))count++;return count}function validateModelsExist(){const userModelsPath=appPath("Models"),defaultModelsPath=frameworkPath("defaults/app/Models"),userModelCount=countModelFiles(userModelsPath),defaultModelCount=countModelFiles(defaultModelsPath);if(userModelCount===0&&defaultModelCount===0)return{valid:!1,error:"No models found. Please create models in app/Models or ensure framework defaults exist."};return{valid:!0}}export function validateMigrationDialect(cwd=process.cwd(),options={}){const driver=String(options.driver||process.env.DB_CONNECTION||"sqlite").toLowerCase(),dir=resolveMigrationDirectory(driver,{cwd}),relativeDir=relativeMigrationDirectory(dir,cwd);if(process.env[DIALECT_OVERRIDE_ENV]!=="1"){const caps=dialectCapabilities(driver),target=caps.wire==="mysql"?"mysql":caps.wire==="postgres"?"postgres":"sqlite";if(driver==="sqlite"||driver==="postgres"||caps.wire==="mysql"||isLibsqlDriver(driver)){const audit=auditMigrationCorpus({dir,target});if(!audit.empty&&audit.incompatible.length>0)return{valid:!1,error:formatMigrationDialectError(audit,target,relativeDir)}}}if(process.env[DDL_CONSTRAINT_OVERRIDE_ENV]!=="1"){const constraints=auditDdlConstraints({dir,dialect:driver});if(!constraints.empty&&constraints.violations.length>0)return{valid:!1,error:formatDdlConstraintError(constraints,driver,relativeDir)}}return{valid:!0}}async function reportSchemaDrift(){try{const{auditSchemaDrift,formatSchemaDrift}=await import("@stacksjs/database"),drift=await auditSchemaDrift();if(drift.skipped||drift.clean)return!0;log.warn(await formatSchemaDrift(drift));return!1}catch(err){log.debug(`[migrate] schema drift check skipped: ${err instanceof Error?err.message:String(err)}`);return!0}}async function reportMissingRecordedTables(){try{const{missingRecordedTables}=await import("@stacksjs/database"),missing=await missingRecordedTables();if(missing.length===0)return!0;log.warn(`${missing.length} table${missing.length===1?"":"s"} the migrations table says ${missing.length===1?"was":"were"} created ${missing.length===1?"is":"are"} missing from the database: ${missing.slice(0,10).join(", ")}${missing.length>10?`, +${missing.length-10} more`:""}.`);return!1}catch(err){log.debug(`[migrate] recorded-table check skipped: ${err instanceof Error?err.message:String(err)}`);return!0}}function logDriftRemedy(){log.info("`buddy migrate` only runs files the migrations table does not list, so it will not repair this on its own.");log.info(" A table that is missing although its migration is recorded: `./buddy migrate:status --reconcile --requeue-reverted`, then `./buddy migrate`.");log.info(" Anything else: `./buddy migrate:status` compares the corpus, the ledger and the live schema. Pass --strict to fail on drift in CI.")}async function reportIgnoredMigrations(){try{const{findIgnoredMigrations,formatIgnoredMigrations}=await import("@stacksjs/database"),ignored=findIgnoredMigrations();if(!ignored.length)return!0;log.warn(formatIgnoredMigrations(ignored));return!1}catch(err){log.debug(`[migrate] ignored-migration check skipped: ${err instanceof Error?err.message:String(err)}`);return!0}}async function reportHandWrittenMigrations(options={}){try{const{handWrittenMigrations}=await import("@stacksjs/database"),{files}=await handWrittenMigrations();if(files.length===0)return!0;log.warn(`${files.length} hand-written migration${files.length===1?"":"s"} - no \`-- qb:generated\` marker, so the models do not describe ${files.length===1?"it":"them"}${options.list?`:
2
+ ${files.slice(0,20).map((file)=>` \u2022 ${file}`).join(`
3
+ `)}${files.length>20?`
4
+ \u2022 \u2026 +${files.length-20} more`:""}`:" (`./buddy migrate:status` lists them)."}`);log.info(" Move the change into app/Models and regenerate (`./buddy migrate:regenerate`), or allow the file in database.safety.allowHandWrittenMigrations.");return!1}catch(err){log.debug(`[migrate] hand-written migration check skipped: ${err instanceof Error?err.message:String(err)}`);return!0}}function strictMode(options){return options.strict===!0||process.env.STACKS_MIGRATE_STRICT==="1"}async function reportMissingForeignKeys(){try{const{auditForeignKeys}=await import("@stacksjs/database"),result=await auditForeignKeys();if(result.absentTable.length>0)log.debug(`[migrate] ${result.absentTable.length} declared foreign key(s) belong to tables that are not in this database (${[...new Set(result.absentTable.map((fk)=>fk.fromTable))].join(", ")}); not reported as missing.`);if(result.missing.length===0)return;const sample=result.missing.slice(0,5).map((fk)=>` \u2022 ${fk.fromTable}.${fk.fromColumn} \u2192 ${fk.toTable}.${fk.toColumn} (${fk.model})`).join(`
2
5
  `),more=result.missing.length>5?`
3
6
  + ${result.missing.length-5} more - run \`./buddy doctor\` for the full list.`:"";log.warn(`${result.missing.length} of ${result.declared.length} declared foreign keys are missing from the live schema:
4
7
  ${sample}${more}
@@ -6,12 +9,12 @@ The model-backed create migrations may be stale for this database - run \`./budd
6
9
  `),more=result.orphans.length>5?`
7
10
  + ${result.orphans.length-5} more - run \`./buddy doctor\` for the full list.`:"",first=result.orphans[0];log.warn(`${result.total} row${result.total===1?"":"s"} violate foreign keys (orphaned parents), now that SQLite enforces \`foreign_keys = ON\`:
8
11
  ${sample}${more}
9
- These were written under the old \`foreign_keys = OFF\` default (#1951). Review and clean up manually - e.g. DELETE FROM ${first.table} WHERE ${first.column} IS NOT NULL AND ${first.column} NOT IN (SELECT id FROM ${first.parent}). migrate never deletes data.`)}catch(err){log.debug(`[migrate] FK orphan scan skipped: ${err instanceof Error?err.message:String(err)}`)}}function describeOp(op){switch(op.kind){case"drop_table":return`drop table "${op.table}" (all rows lost)`;case"drop_column":return`drop column "${op.table}"."${op.column}" (column data lost)`;case"modify_column":return`change type of "${op.table}"."${op.column}" (possible data loss)`;case"rebuild_table":return`rebuild table "${op.table}" (SQLite rebuilds in place; compare the SQL below against the live schema)`;case"rename_column":return`rename "${op.table}"."${op.from}" \u2192 "${op.to}"`;case"rename_table":return`rename table "${op.from}" \u2192 "${op.to}"`;default:return`${op.kind} on "${op.table}"${op.column?`."${op.column}"`:""}`}}async function missingBaselineNote(){try{const{resolveSnapshotDirectory,snapshotDirectoryIsShared}=await import("@stacksjs/database"),dir=resolveSnapshotDirectory();if(existsSync(dir)&&readdirSync(dir).some((f)=>/^model-snapshot\.\w+\.json$/.test(f)))return;return`No model snapshot in ${dir}, so this change was derived without a baseline and will be proposed again on the next run.${snapshotDirectoryIsShared()?" DB_SNAPSHOT_PATH points there; check the directory exists and is writable.":" If each deploy runs from a new directory, point DB_SNAPSHOT_PATH at a shared one that survives the release."}`}catch(error){log.debug(`[migrate] baseline check skipped: ${error instanceof Error?error.message:String(error)}`);return}}async function confirmDestructiveMigrations(opts){let operations=[];try{const{previewPendingMigrations}=await import("@stacksjs/database");operations=await previewPendingMigrations({fromDb:opts.fromDb,applyRenames:opts.applyRenames})}catch(error){log.debug(`Migration preview unavailable: ${error instanceof Error?error.message:String(error)}`);return!0}const renames=operations.filter((o)=>o.kind==="rename_column"||o.kind==="rename_table");for(const r of renames)log.info(`Detected ${describeOp(r)} - applying as a rename (data preserved). Use --no-rename to drop + add instead.`);const destructive=operations.filter((o)=>o.destructive);if(destructive.length===0)return!0;log.warn(`This migration includes ${destructive.length} potentially destructive change${destructive.length===1?"":"s"}:`);for(const op of destructive)log.warn(` \u2022 ${describeOp(op)}`);const baseline=await missingBaselineNote();if(baseline)log.warn(baseline);if(opts.force)return!0;if(isCI||!hasTTY){log.error("Refusing to apply destructive changes in a non-interactive environment. Re-run with --force to proceed.");return!1}return confirm({message:"Apply these destructive changes?",initial:!1})}export function isMigratePreview(options,argv=process.argv){return options.diff===!0||options.dryRun===!0||options.pretend===!0||argv.includes("--dry-run")||argv.includes("--pretend")}function parseGuardBool(raw){if(raw==null||raw==="")return;const v=raw.toLowerCase().trim();if(v==="1"||v==="true"||v==="yes"||v==="on")return!0;if(v==="0"||v==="false"||v==="no"||v==="off")return!1;return}function parseFreshGuard(raw){const v=raw?.toLowerCase().trim();return v==="allow"||v==="confirm"||v==="disabled"?v:void 0}async function resolveMigrationGuards(){const isProd=/^prod/i.test(process.env.APP_ENV||"local");let cfg={};try{const{awaitConfig}=await import("@stacksjs/config");cfg=(await awaitConfig()).database?.safety??{}}catch(error){log.debug(`[migrate] safety config unavailable, using defaults: ${error instanceof Error?error.message:String(error)}`)}const confirmMigrate=parseGuardBool(process.env.DB_MIGRATE_CONFIRM)??cfg.confirmMigrate??!0,migrateFresh=parseFreshGuard(process.env.DB_MIGRATE_FRESH)??cfg.migrateFresh??(isProd?"disabled":"allow");return{confirmMigrate,migrateFresh}}function currentDatabaseLabel(){const driver=(process.env.DB_CONNECTION||"sqlite").toLowerCase();if(driver==="sqlite")return process.env.DB_DATABASE_PATH||"database/stacks.sqlite";if(isLibsqlDriver(driver))return process.env.TURSO_DATABASE_URL||"the Turso database";return process.env.DB_DATABASE||"stacks"}export function migrate(buddy){const descriptions={migrate:"Migrates your database",fresh:"Drop all tables and re-run every migration (destroys all data)",project:"Target a specific project",verbose:"Enable verbose output",auth:"Also migrate auth tables (oauth_clients, oauth_access_tokens, oauth_refresh_tokens, password_resets)",force:"Apply destructive changes (drop column/table, lossy type change) without confirmation",fromDb:"Diff against the live database schema instead of the snapshot (self-heal drift)",noRename:"Treat renamed columns as drop + add instead of a data-preserving rename",noGenerate:"Apply committed migration files only; do not generate new ones from your models"};buddy.command("migrate",descriptions.migrate).alias("db:migrate").option("-d, --diff","Show the SQL that would be run",{default:!1}).option("--pretend","Same as --diff (and --dry-run): preview, change nothing",{default:!1}).option("--dry-run","Same as --diff: preview, change nothing",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("-a, --auth",descriptions.auth,{default:!0}).option("--no-auth","Skip auth/oauth table migrations").option("-f, --force",descriptions.force,{default:!1}).option("--create-database","Create the database if it does not exist, without asking",{default:!1}).option("--from-db",descriptions.fromDb,{default:!1}).option("--no-rename",descriptions.noRename).option("--no-generate",descriptions.noGenerate).option("--strict","Fail when the schema has drifted from the models or a migration is git-ignored (for CI)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy migrate` ...",options);const perf=await intro("buddy migrate"),validation=validateModelsExist();if(!validation.valid){console.error(`
12
+ These were written under the old \`foreign_keys = OFF\` default (#1951). Review and clean up manually - e.g. DELETE FROM ${first.table} WHERE ${first.column} IS NOT NULL AND ${first.column} NOT IN (SELECT id FROM ${first.parent}). migrate never deletes data.`)}catch(err){log.debug(`[migrate] FK orphan scan skipped: ${err instanceof Error?err.message:String(err)}`)}}function describeOp(op){switch(op.kind){case"drop_table":return`drop table "${op.table}" (all rows lost)`;case"drop_column":return`drop column "${op.table}"."${op.column}" (column data lost)`;case"modify_column":return`change type of "${op.table}"."${op.column}" (possible data loss)`;case"rebuild_table":return`rebuild table "${op.table}" (SQLite rebuilds in place; compare the SQL below against the live schema)`;case"rename_column":return`rename "${op.table}"."${op.from}" \u2192 "${op.to}"`;case"rename_table":return`rename table "${op.from}" \u2192 "${op.to}"`;case"add_enum_value":return`add value(s) to the enum type of "${op.table}"."${op.column}"`;case"create_enum":return`create the enum type for "${op.table}"${op.column?`."${op.column}"`:""}`;default:return`${op.kind} on "${op.table}"${op.column?`."${op.column}"`:""}`}}async function missingBaselineNote(){try{const{resolveSnapshotDirectory,snapshotDirectoryIsShared}=await import("@stacksjs/database"),dir=resolveSnapshotDirectory();if(existsSync(dir)&&readdirSync(dir).some((f)=>/^model-snapshot\.\w+\.json$/.test(f)))return;return`No model snapshot in ${dir}, so this change was derived without a baseline and will be proposed again on the next run.${snapshotDirectoryIsShared()?" DB_SNAPSHOT_PATH points there; check the directory exists and is writable.":" If each deploy runs from a new directory, point DB_SNAPSHOT_PATH at a shared one that survives the release."}`}catch(error){log.debug(`[migrate] baseline check skipped: ${error instanceof Error?error.message:String(error)}`);return}}async function confirmDestructiveMigrations(opts){let operations=[];try{const{previewPendingMigrations}=await import("@stacksjs/database");operations=await previewPendingMigrations({fromDb:opts.fromDb,applyRenames:opts.applyRenames})}catch(error){log.debug(`Migration preview unavailable: ${error instanceof Error?error.message:String(error)}`);return!0}const renames=operations.filter((o)=>o.kind==="rename_column"||o.kind==="rename_table");for(const r of renames)log.info(`Detected ${describeOp(r)} - applying as a rename (data preserved). Use --no-rename to drop + add instead.`);for(const op of operations.filter((o)=>o.warning))log.warn(`${describeOp(op)}: ${op.warning}`);const destructive=operations.filter((o)=>o.destructive);if(destructive.length===0)return!0;log.warn(`This migration includes ${destructive.length} potentially destructive change${destructive.length===1?"":"s"}:`);for(const op of destructive)log.warn(` \u2022 ${describeOp(op)}`);const baseline=await missingBaselineNote();if(baseline)log.warn(baseline);if(opts.force)return!0;if(isCI||!hasTTY){log.error("Refusing to apply destructive changes in a non-interactive environment. Re-run with --force to proceed.");return!1}return confirm({message:"Apply these destructive changes?",initial:!1})}export function isMigratePreview(options,argv=process.argv){return options.diff===!0||options.dryRun===!0||options.pretend===!0||argv.includes("--dry-run")||argv.includes("--pretend")}function parseGuardBool(raw){if(raw==null||raw==="")return;const v=raw.toLowerCase().trim();if(v==="1"||v==="true"||v==="yes"||v==="on")return!0;if(v==="0"||v==="false"||v==="no"||v==="off")return!1;return}function parseFreshGuard(raw){const v=raw?.toLowerCase().trim();return v==="allow"||v==="confirm"||v==="disabled"?v:void 0}async function resolveMigrationGuards(){const isProd=/^prod/i.test(process.env.APP_ENV||"local");let cfg={};try{const{awaitConfig}=await import("@stacksjs/config");cfg=(await awaitConfig()).database?.safety??{}}catch(error){log.debug(`[migrate] safety config unavailable, using defaults: ${error instanceof Error?error.message:String(error)}`)}const confirmMigrate=parseGuardBool(process.env.DB_MIGRATE_CONFIRM)??cfg.confirmMigrate??!0,migrateFresh=parseFreshGuard(process.env.DB_MIGRATE_FRESH)??cfg.migrateFresh??(isProd?"disabled":"allow");return{confirmMigrate,migrateFresh}}function currentDatabaseLabel(){const driver=(process.env.DB_CONNECTION||"sqlite").toLowerCase();if(driver==="sqlite")return process.env.DB_DATABASE_PATH||"database/stacks.sqlite";if(isLibsqlDriver(driver))return process.env.TURSO_DATABASE_URL||"the Turso database";return process.env.DB_DATABASE||"stacks"}export function migrate(buddy){const descriptions={migrate:"Migrates your database",fresh:"Drop all tables and re-run every migration (destroys all data)",project:"Target a specific project",verbose:"Enable verbose output",auth:"Also migrate auth tables (oauth_clients, oauth_access_tokens, oauth_refresh_tokens, password_resets)",force:"Apply destructive changes (drop column/table, lossy type change) without confirmation",fromDb:"Diff against the live database schema instead of the snapshot (self-heal drift)",noRename:"Treat renamed columns as drop + add instead of a data-preserving rename",noGenerate:"Apply committed migration files only; do not generate new ones from your models"};buddy.command("migrate",descriptions.migrate).alias("db:migrate").option("-d, --diff","Show the SQL that would be run",{default:!1}).option("--pretend","Same as --diff (and --dry-run): preview, change nothing",{default:!1}).option("--dry-run","Same as --diff: preview, change nothing",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("-a, --auth",descriptions.auth,{default:!0}).option("--no-auth","Skip auth/oauth table migrations").option("-f, --force",descriptions.force,{default:!1}).option("--create-database","Create the database if it does not exist, without asking",{default:!1}).option("--from-db",descriptions.fromDb,{default:!1}).option("--no-rename",descriptions.noRename).option("--no-generate",descriptions.noGenerate).option("--strict","Fail when the schema has drifted from the models or a migration is git-ignored (for CI)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy migrate` ...",options);const perf=await intro("buddy migrate"),validation=validateModelsExist();if(!validation.valid){console.error(`
10
13
  \u274C Error: ${validation.error}
11
14
  `);process.exit(ExitCode.FatalError)}const dialectCheck=validateMigrationDialect();if(!dialectCheck.valid){console.error(`
12
15
  \u274C Error: ${dialectCheck.error}
13
- `);process.exit(ExitCode.FatalError)}const applyRenames=options.rename===!1?!1:void 0;if(options.fromDb)process.env.STACKS_MIGRATE_FROM_DB="1";if(applyRenames===!1)process.env.STACKS_MIGRATE_NO_RENAME="1";if(options.generate===!1)process.env.STACKS_MIGRATE_NO_GENERATE="1";if(isMigratePreview(options)){const{auditSchemaDrift,formatSchemaDrift,pendingMigrationFiles,pendingMigrationOperations}=await import("@stacksjs/database");let failed=!1,files=[];try{files=await pendingMigrationFiles()}catch(error){failed=!0;await log.error("Could not read the migrations table:",error)}if(files.length>0){log.info(`${files.length} committed migration file${files.length===1?"":"s"} not applied yet - \`buddy migrate\` will run ${files.length===1?"it":"them"}:`);for(const file of files.slice(0,10))log.info(` \u2022 ${file}`);if(files.length>10)log.info(` \u2022 \u2026 +${files.length-10} more`)}let ops=[];try{ops=await pendingMigrationOperations({fromDb:options.fromDb,applyRenames})}catch(error){failed=!0;await log.error("Failed to diff the models against the migration snapshot:",error)}if(ops.length>0){log.info(`${ops.length} model change${ops.length===1?"":"s"} not in a migration file yet:`);for(const op of ops)log.info(` \u2022 ${describeOp(op)}${op.destructive?" [destructive]":""}`)}const drift=await auditSchemaDrift(),modelsDrifted=!drift.skipped&&!drift.clean;if(modelsDrifted)log.warn(await formatSchemaDrift(drift));const drifted=modelsDrifted||!await reportMissingRecordedTables();if(failed){await outro("Diff incomplete - see the errors above. No changes applied.",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}if(files.length===0&&ops.length===0&&!drifted){log.info("No pending schema changes - your models match the database.");await outro("Diff complete - no changes applied.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(drifted&&files.length===0&&ops.length===0)logDriftRemedy();await outro("Diff complete - no changes applied.",{startTime:perf,useSeconds:!0,type:drifted?"warning":"success"});process.exit(drifted&&strictMode(options)?ExitCode.FatalError:ExitCode.Success)}await preflightDatabase({createDatabase:options.createDatabase,command:"migrate"});if((await resolveMigrationGuards()).confirmMigrate&&!options.force)if(isCI||!hasTTY)log.debug("[migrate] confirmMigrate guard skipped - non-interactive environment.");else{const APP_ENV=process.env.APP_ENV||"local";await log.flush();if(!await confirm({message:`Run migrations against the ${APP_ENV} database "${currentDatabaseLabel()}"?`,initial:!0})){await outro("Migration cancelled - no changes applied.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}await ensureDatabaseOrExit();const lock=acquireMigrationLock();if(!lock.acquired){log.syncError("Another migration is already running (storage/framework/runtime/migrations.lock exists). Wait for it to finish, or remove the lockfile if it is stale.");process.exit(ExitCode.FatalError)}try{const{catchUpFrameworkRenames}=await import("@stacksjs/database");await catchUpFrameworkRenames()}catch(error){lock.release();log.syncError(error instanceof Error?error.message:String(error));process.exit(ExitCode.FatalError)}if(options.generate!==!1){if(!await confirmDestructiveMigrations({force:options.force,fromDb:options.fromDb,applyRenames})){lock.release();await outro("Migration cancelled - no changes applied.",{startTime:perf,useSeconds:!0});process.exit(isCI||!hasTTY?ExitCode.FatalError:ExitCode.Success)}}if(options.auth!==!1){log.debug("Migrating auth tables...");try{const{migrateAuthTables}=await import("@stacksjs/database"),authResult=await migrateAuthTables({verbose:options.verbose});if(!authResult.success)log.error(`Failed to migrate auth tables: ${authResult.error}`)}catch(error){log.error("Failed to migrate auth tables:",error)}}const result=await runAction(Action.Migrate,options).finally(()=>lock.release());if(resultFailed(result))log.error("Model migrations failed - applying notification/RBAC table guarantees before exiting.");if(options.auth!==!1)try{const{ensureUtcDatetimeColumns,ensureUtcTimestampDefaults,migrateNotificationTables,migrateRbacTables,migrateTraitTables}=await import("@stacksjs/database"),notifResult=await migrateNotificationTables({verbose:options.verbose});if(!notifResult.success)log.error(`Failed to migrate notification tables: ${notifResult.error}`);const rbacResult=await migrateRbacTables({verbose:options.verbose});if(!rbacResult.success)log.error(`Failed to migrate RBAC tables: ${rbacResult.error}`);const traitResult=await migrateTraitTables({verbose:options.verbose});if(!traitResult.success)log.error(`Failed to migrate polymorphic trait tables: ${traitResult.error}`);const datetimeResult=await ensureUtcDatetimeColumns({verbose:options.verbose});if(!datetimeResult.success)log.error(`Failed to convert TIMESTAMP columns to DATETIME: ${datetimeResult.error}`);const defaultsResult=await ensureUtcTimestampDefaults({verbose:options.verbose});if(!defaultsResult.success)log.error(`Failed to pin timestamp defaults to UTC: ${defaultsResult.error}`)}catch(error){log.error("Failed to migrate notification/RBAC/trait tables:",error)}if(resultFailed(result)){await outro("While running the migrate command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}if(options.auth!==!1)try{const{ensureUsersAuthColumns,sqlHelpers}=await import("@stacksjs/database"),driver=process.env.DB_CONNECTION||"sqlite";await ensureUsersAuthColumns(sqlHelpers(driver),{verbose:options.verbose})}catch(error){log.error("Failed to ensure users auth columns post-migration:",error)}try{const{ensureUuidColumns,sqlHelpers}=await import("@stacksjs/database"),driver=process.env.DB_CONNECTION||"sqlite";await ensureUuidColumns(sqlHelpers(driver),{verbose:options.verbose})}catch(error){log.error("Failed to ensure uuid columns post-migration:",error)}await reportMissingForeignKeys();const modelsMatch=await reportSchemaDrift(),tablesPresent=await reportMissingRecordedTables(),schemaMatches=modelsMatch&&tablesPresent,allCommittable=await reportIgnoredMigrations();if(strictMode(options)&&(!schemaMatches||!allCommittable)){console.error(`
14
- \u274C --strict: ${!allCommittable?"a migration is git-ignored":"the live schema does not match the models"} (see above).
16
+ `);process.exit(ExitCode.FatalError)}const applyRenames=options.rename===!1?!1:void 0;if(options.fromDb)process.env.STACKS_MIGRATE_FROM_DB="1";if(applyRenames===!1)process.env.STACKS_MIGRATE_NO_RENAME="1";if(options.generate===!1)process.env.STACKS_MIGRATE_NO_GENERATE="1";if(options.strict)process.env.STACKS_MIGRATE_STRICT="1";if(isMigratePreview(options)){const{auditSchemaDrift,formatSchemaDrift,pendingMigrationFiles,pendingMigrationOperations}=await import("@stacksjs/database");let failed=!1,files=[];try{files=await pendingMigrationFiles()}catch(error){failed=!0;await log.error("Could not read the migrations table:",error)}if(files.length>0){log.info(`${files.length} committed migration file${files.length===1?"":"s"} not applied yet - \`buddy migrate\` will run ${files.length===1?"it":"them"}:`);for(const file of files.slice(0,10))log.info(` \u2022 ${file}`);if(files.length>10)log.info(` \u2022 \u2026 +${files.length-10} more`)}let ops=[];try{ops=await pendingMigrationOperations({fromDb:options.fromDb,applyRenames})}catch(error){failed=!0;await log.error("Failed to diff the models against the migration snapshot:",error)}if(ops.length>0){log.info(`${ops.length} model change${ops.length===1?"":"s"} not in a migration file yet:`);for(const op of ops){log.info(` \u2022 ${describeOp(op)}${op.destructive?" [destructive]":""}`);if(op.warning)log.warn(` ${op.warning}`)}}const drift=await auditSchemaDrift(),modelsDrifted=!drift.skipped&&!drift.clean;if(modelsDrifted)log.warn(await formatSchemaDrift(drift));const drifted=modelsDrifted||!await reportMissingRecordedTables();if(failed){await outro("Diff incomplete - see the errors above. No changes applied.",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}if(files.length===0&&ops.length===0&&!drifted){log.info("No pending schema changes - your models match the database.");await outro("Diff complete - no changes applied.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(drifted&&files.length===0&&ops.length===0)logDriftRemedy();await outro("Diff complete - no changes applied.",{startTime:perf,useSeconds:!0,type:drifted?"warning":"success"});process.exit(drifted&&strictMode(options)?ExitCode.FatalError:ExitCode.Success)}await preflightDatabase({createDatabase:options.createDatabase,command:"migrate"});if((await resolveMigrationGuards()).confirmMigrate&&!options.force)if(isCI||!hasTTY)log.debug("[migrate] confirmMigrate guard skipped - non-interactive environment.");else{const APP_ENV=process.env.APP_ENV||"local";await log.flush();if(!await confirm({message:`Run migrations against the ${APP_ENV} database "${currentDatabaseLabel()}"?`,initial:!0})){await outro("Migration cancelled - no changes applied.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}await ensureDatabaseOrExit();const lock=acquireMigrationLock();if(!lock.acquired){log.syncError("Another migration is already running (storage/framework/runtime/migrations.lock exists). Wait for it to finish, or remove the lockfile if it is stale.");process.exit(ExitCode.FatalError)}try{const{catchUpFrameworkRenames}=await import("@stacksjs/database");await catchUpFrameworkRenames()}catch(error){lock.release();log.syncError(error instanceof Error?error.message:String(error));process.exit(ExitCode.FatalError)}if(options.generate!==!1){if(!await confirmDestructiveMigrations({force:options.force,fromDb:options.fromDb,applyRenames})){lock.release();await outro("Migration cancelled - no changes applied.",{startTime:perf,useSeconds:!0});process.exit(isCI||!hasTTY?ExitCode.FatalError:ExitCode.Success)}}if(options.auth!==!1){log.debug("Migrating auth tables...");try{const{migrateAuthTables}=await import("@stacksjs/database"),authResult=await migrateAuthTables({verbose:options.verbose});if(!authResult.success)log.error(`Failed to migrate auth tables: ${authResult.error}`)}catch(error){log.error("Failed to migrate auth tables:",error)}}const result=await runAction(Action.Migrate,options).finally(()=>lock.release());if(resultFailed(result))log.error("Model migrations failed - applying notification/RBAC table guarantees before exiting.");if(options.auth!==!1)try{const{ensureUtcDatetimeColumns,ensureUtcTimestampDefaults,migrateNotificationTables,migrateRbacTables,migrateTraitTables}=await import("@stacksjs/database"),notifResult=await migrateNotificationTables({verbose:options.verbose});if(!notifResult.success)log.error(`Failed to migrate notification tables: ${notifResult.error}`);const rbacResult=await migrateRbacTables({verbose:options.verbose});if(!rbacResult.success)log.error(`Failed to migrate RBAC tables: ${rbacResult.error}`);const traitResult=await migrateTraitTables({verbose:options.verbose});if(!traitResult.success)log.error(`Failed to migrate polymorphic trait tables: ${traitResult.error}`);const datetimeResult=await ensureUtcDatetimeColumns({verbose:options.verbose});if(!datetimeResult.success)log.error(`Failed to convert TIMESTAMP columns to DATETIME: ${datetimeResult.error}`);const defaultsResult=await ensureUtcTimestampDefaults({verbose:options.verbose});if(!defaultsResult.success)log.error(`Failed to pin timestamp defaults to UTC: ${defaultsResult.error}`)}catch(error){log.error("Failed to migrate notification/RBAC/trait tables:",error)}if(resultFailed(result)){await outro("While running the migrate command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}if(options.auth!==!1)try{const{ensureUsersAuthColumns,sqlHelpers}=await import("@stacksjs/database"),driver=process.env.DB_CONNECTION||"sqlite";await ensureUsersAuthColumns(sqlHelpers(driver),{verbose:options.verbose})}catch(error){log.error("Failed to ensure users auth columns post-migration:",error)}try{const{ensureUuidColumns,sqlHelpers}=await import("@stacksjs/database"),driver=process.env.DB_CONNECTION||"sqlite";await ensureUuidColumns(sqlHelpers(driver),{verbose:options.verbose})}catch(error){log.error("Failed to ensure uuid columns post-migration:",error)}await reportMissingForeignKeys();const modelsMatch=await reportSchemaDrift(),tablesPresent=await reportMissingRecordedTables(),schemaMatches=modelsMatch&&tablesPresent,allCommittable=await reportIgnoredMigrations(),allGenerated=await reportHandWrittenMigrations({list:strictMode(options)});if(strictMode(options)&&(!schemaMatches||!allCommittable||!allGenerated)){console.error(`
17
+ \u274C --strict: ${!allCommittable?"a migration is git-ignored":!allGenerated?"a migration was written by hand rather than generated from the models":"the live schema does not match the models"} (see above).
15
18
  `);process.exit(ExitCode.FatalError)}await reportFkOrphans();const APP_ENV=process.env.APP_ENV||"local",marker=readMigrateMarker(),authSuffix=options.auth!==!1?" (including auth tables)":"",applied=marker==null?`Migrated your ${APP_ENV} database.${authSuffix}`:marker.appliedCount===0?schemaMatches?`Nothing to migrate - your ${APP_ENV} database is already up to date.${authSuffix}`:`Nothing to migrate${authSuffix}.`:`Applied ${marker.appliedCount} migration${marker.appliedCount===1?"":"s"} to your ${APP_ENV} database.${authSuffix}`,outroMessage=schemaMatches?applied:`${applied} The database is still not in the state its migrations and models describe - see above.`;if(!schemaMatches)logDriftRemedy();await outro(outroMessage,{startTime:perf,useSeconds:!0,type:schemaMatches?"success":"warning"});process.exit(ExitCode.Success)});buddy.command("migrate:fresh",descriptions.fresh).alias("db:fresh").option("-d, --diff","Show what would be dropped and replayed, and change nothing",{default:!1}).option("--pretend","Same as --diff (and --dry-run): preview, change nothing",{default:!1}).option("--dry-run","Same as --diff: preview, change nothing",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("-s, --seed","Run database seeders after migration",{default:!1}).option("-a, --auth",descriptions.auth,{default:!0}).option("--no-auth","Skip auth/oauth table migrations").option("--create-database","Create the database if it does not exist, without asking",{default:!1}).option("-f, --force",'Skip the drop-database confirmation (only honored when the migrateFresh guard is "allow")',{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy migrate:fresh` ...",options);const perf=await intro("buddy migrate:fresh"),validation=validateModelsExist();if(!validation.valid){console.error(`
16
19
  \u274C Error: ${validation.error}
17
20
  `);process.exit(ExitCode.FatalError)}const dialectCheck=validateMigrationDialect();if(!dialectCheck.valid){console.error(`
@@ -43,15 +46,29 @@ These were written under the old \`foreign_keys = OFF\` default (#1951). Review
43
46
  2. (Optional) Export data from the current ${current} database.
44
47
  3. ${switchBlocked?`Regenerate the migration files for ${target} FIRST - \`./buddy migrate\` will refuse until then.`:"Run `./buddy migrate` (or `migrate:fresh` to start clean)."}
45
48
  4. The post-migrate FK audit will report any constraints that didn't replay.
46
- `);await outro("Plan rendered. Re-run after updating .env to actually switch.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:regenerate [dialect]","Rebuild database/migrations from your models for a given dialect").option("--dry-run","Show what would change without writing anything",{default:!1}).option("-f, --force","Regenerate even though the database already has migrations recorded",{default:!1}).option("--replace-unmarked","Also delete migrations carrying no @generated marker (pre-marker corpora only)",{default:!1}).option("--only-existing-tables","Rebuild exactly the tables already in the corpus, for changing dialect without adopting the framework schema",{default:!1}).action(async(dialect,options)=>{const perf=await intro("buddy migrate:regenerate"),target=(dialect||process.env.DB_CONNECTION||"sqlite").toLowerCase();if(!new Set(["sqlite","mysql","vitess","postgres","singlestore"]).has(target)){log.syncError(`Unknown dialect "${target}". Allowed: sqlite, mysql, vitess, postgres, singlestore.`);process.exit(ExitCode.FatalError)}const{countAppliedMigrations,regenerateMigrationCorpus}=await import("@stacksjs/database");let applied=0;try{applied=await countAppliedMigrations()}catch{applied=0}if(applied>0&&!options.force&&!options.dryRun){log.syncError(`This database already has ${applied} migration(s) recorded.`);log.syncError("Regenerating renumbers every file, and the migrations table keys on the filename,");log.syncError("so already-applied migrations would look pending and run a second time.");log.syncError(" Point at an empty database, or re-run with --force if you know it is safe.");process.exit(ExitCode.FatalError)}const plan=await regenerateMigrationCorpus({dialect:target,dryRun:!0,replaceUnmarked:options.replaceUnmarked,onlyExistingTables:options.onlyExistingTables});if(resultFailed(plan)){log.syncError(plan.error.message);process.exit(ExitCode.FatalError)}const{files,removed,preserved,preservedOutOfScope,models,modelRoots,unrebuildable}=plan.value,outOfScope=new Set(preservedOutOfScope),protectedHistory=preserved.filter((f)=>!outOfScope.has(f)),protectedHistoryBlock=protectedHistory.length===0?"":`
49
+ `);await outro("Plan rendered. Re-run after updating .env to actually switch.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:regenerate [dialect]","Rebuild database/migrations from your models for a given dialect").option("--dry-run","Show what would change without writing anything",{default:!1}).option("-f, --force","Regenerate even though the database already has migrations recorded",{default:!1}).option("--replace-unmarked","Also delete migrations carrying no @generated marker (pre-marker corpora only)",{default:!1}).option("--only-existing-tables","Rebuild exactly the tables already in the corpus, for changing dialect without adopting the framework schema",{default:!1}).action(async(dialect,options)=>{const perf=await intro("buddy migrate:regenerate"),target=(dialect||process.env.DB_CONNECTION||"sqlite").toLowerCase();if(!new Set(["sqlite","mysql","vitess","postgres","singlestore"]).has(target)){log.syncError(`Unknown dialect "${target}". Allowed: sqlite, mysql, vitess, postgres, singlestore.`);process.exit(ExitCode.FatalError)}const{countAppliedMigrations,regenerateMigrationCorpus}=await import("@stacksjs/database");let applied=0;try{applied=await countAppliedMigrations()}catch{applied=0}if(applied>0&&!options.force&&!options.dryRun){log.syncError(`This database already has ${applied} migration(s) recorded.`);log.syncError("Regenerating renumbers every file, and the migrations table keys on the filename,");log.syncError("so already-applied migrations would look pending and run a second time.");log.syncError(" Point at an empty database, or re-run with --force if you know it is safe.");process.exit(ExitCode.FatalError)}const plan=await regenerateMigrationCorpus({dialect:target,dryRun:!0,replaceUnmarked:options.replaceUnmarked,onlyExistingTables:options.onlyExistingTables});if(resultFailed(plan)){log.syncError(plan.error.message);process.exit(ExitCode.FatalError)}const{files,removed,removedObsolete,removedDataOnly,preserved,preservedOutOfScope,preservedData,relocated,models,modelRoots,unrebuildable}=plan.value,dataKept=new Set(preservedData),outOfScope=new Set(preservedOutOfScope),protectedHistory=preserved.filter((f)=>!outOfScope.has(f)&&!dataKept.has(f)),outOfScopeKept=preservedOutOfScope.filter((f)=>!dataKept.has(f)),movedTo=new Map(relocated.map((r)=>[r.from,r.to])),protectedHistoryBlock=protectedHistory.length===0?"":`
47
50
  \u2022 ${protectedHistory.length} protected history file(s) will be KEPT:
48
51
  ${protectedHistory.map((f)=>` ${f}`).join(`
49
52
  `)}
50
53
  These files either cannot be regenerated safely or follow a preserved
51
54
  CREATE migration whose chronology must remain stable. --replace-unmarked
52
- only replaces files that genuinely lack the @generated marker.`,OUT_OF_SCOPE_SHOWN=20,outOfScopeMore=preservedOutOfScope.length-OUT_OF_SCOPE_SHOWN,outOfScopeBlock=preservedOutOfScope.length===0?"":`
53
- \u2022 ${preservedOutOfScope.length} file(s) describe tables this corpus does not rebuild, and will be KEPT:
54
- ${preservedOutOfScope.slice(0,OUT_OF_SCOPE_SHOWN).map((f)=>` ${f}`).join(`
55
+ only replaces files that genuinely lack the @generated marker.`,obsoleteBlock=removedObsolete.length===0?"":`
56
+ \u2022 ${removedObsolete.length} of those describe nothing the regenerated corpus lacks (enum types it declares, drops of indexes it never builds):
57
+ ${removedObsolete.map((f)=>` ${f}`).join(`
58
+ `)}`,dataOnlyBlock=removedDataOnly.length===0?"":`
59
+ \u2022 ${removedDataOnly.length} of those are data-only migrations, no-ops on a fresh database:
60
+ ${removedDataOnly.map((f)=>` ${f}`).join(`
61
+ `)}
62
+ They only DELETE or UPDATE rows, and every table the corpus builds starts
63
+ empty. A database that already ran them keeps their effect.`,dataBlock=preservedData.length===0?"":`
64
+ \u2022 ${preservedData.length} data migration(s) insert rows (INSERT ... VALUES / COPY) and will be KEPT:
65
+ ${preservedData.map((f)=>` ${f}${movedTo.has(f)?` -> renumbered to ${movedTo.get(f)}`:""}`).join(`
66
+ `)}
67
+ That can be reference data no model describes, so no flag removes it.${relocated.length>0?`
68
+ Renumbered ones fill a table the corpus now creates, so they move after it;
69
+ their logical name is unchanged, so a database that ran them will not again.`:""}`,OUT_OF_SCOPE_SHOWN=20,outOfScopeMore=outOfScopeKept.length-OUT_OF_SCOPE_SHOWN,outOfScopeBlock=outOfScopeKept.length===0?"":`
70
+ \u2022 ${outOfScopeKept.length} file(s) describe tables this corpus does not rebuild, and will be KEPT:
71
+ ${outOfScopeKept.slice(0,OUT_OF_SCOPE_SHOWN).map((f)=>` ${f}`).join(`
55
72
  `)}${outOfScopeMore>0?`
56
73
  ... and ${outOfScopeMore} more`:""}
57
74
  Nothing in scope regenerates these, so removing them would leave the app
@@ -72,11 +89,12 @@ ${unrebuildable.map((t)=>` ${t}`).join(`
72
89
  \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
73
90
  \u2022 ${models} model(s) read from ${rootList}
74
91
  \u2022 ${files.length} migration file(s) will be written
75
- \u2022 ${removed.length} existing file(s) will be removed${protectedHistoryBlock}${outOfScopeBlock}${unrebuildableBlock}
92
+ \u2022 ${removed.length} existing file(s) will be removed${obsoleteBlock}${dataOnlyBlock}${dataBlock}${protectedHistoryBlock}${outOfScopeBlock}${unrebuildableBlock}
76
93
  \u2022 These files are tracked in git, so review with \`git diff\` afterwards
77
94
  \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
78
- `);if(options.dryRun){await outro("Dry run. Nothing was written.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(!isCI&&hasTTY&&process.stdin.isTTY){await log.flush();if(!await confirm({message:`Replace ${removed.length} migration file(s) with ${files.length} generated for ${target}?`,initial:!1})){await outro("Cancelled. Nothing was written.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}const result=await regenerateMigrationCorpus({dialect:target,replaceUnmarked:options.replaceUnmarked});if(resultFailed(result)){log.syncError(result.error.message);process.exit(ExitCode.FatalError)}log.success(`Wrote ${result.value.files.length} ${target} migration file(s) to database/migrations.`);if(applied>0){const{reconcileMigrationLedger}=await import("@stacksjs/database"),fixed=await reconcileMigrationLedger();if(fixed.remapped.length>0)log.info(`Repointed ${fixed.remapped.length} ledger row(s) at their renumbered file.`);if(fixed.recorded.length>0)log.info(`Recorded ${fixed.recorded.length} migration(s) already present in the schema.`);if(fixed.pruned.length>0)log.info(`Pruned ${fixed.pruned.length} duplicate ledger row(s).`);if(fixed.skipped.length>0)log.warn(`${fixed.skipped.length} ledger entr(ies) need a look - run \`./buddy migrate:status\`.`)}log.info("Review the change with `git diff`, then run `./buddy migrate`.");await outro("Regenerated.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:status","Compare database/migrations, the migrations ledger, and the live schema").option("--reconcile","Repair the ledger where the schema proves what happened",{default:!1}).option("--include-partial","With --reconcile, also record half-applied migrations",{default:!1}).option("--requeue-reverted","With --reconcile, un-record the migrations of a table that is missing so the next migrate rebuilds it",{default:!1}).option("--json","Emit the audit as JSON",{default:!1}).action(async(options)=>{const perf=options.json?void 0:await intro("buddy migrate:status"),{auditMigrationLedger,reconcileMigrationLedger}=await import("@stacksjs/database"),audit=await auditMigrationLedger();if(options.json){console.log(JSON.stringify(audit,(_k,v)=>v instanceof Set?[...v]:v,2));process.exit(audit.drift?ExitCode.FatalError:ExitCode.Success)}if(!audit.supported){log.info(`Dialect "${audit.dialect}" is not audited. Nothing to compare.`);await outro("Skipped.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const{counts,entries,orphans}=audit,list=(status)=>entries.filter((e)=>e.status===status).map((e)=>e.file),report=[],section=(heading,files)=>{if(files.length===0)return;if(heading)report.push(` ${heading}`);for(const file of files.slice(0,8))report.push(` ${file}`);if(files.length>8)report.push(` \u2026 +${files.length-8} more`);report.push("")};report.push("");report.push(` Migration status: ${audit.dialect}`);report.push(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");report.push(` ${entries.length+audit.gated.length} file(s) on disk \xB7 ${audit.recordedCount} recorded in the ledger`);report.push("");if(counts.applied>0)report.push(` ${counts.applied} applied - recorded, and present in the schema.`,"");if(counts.unverifiable>0)report.push(` ${counts.unverifiable} unverifiable - data migrations with no schema trace to check.`,"");if(audit.gated.length>0)report.push(` ${audit.gated.length} skipped - owned by a disabled feature, so \`buddy migrate\` does not run them until it is enabled.`,"");section(`${counts.pending} pending - not applied yet, will run on the next \`buddy migrate\`:`,list("pending"));if(counts.stranded>0){report.push(` ${counts.stranded} STRANDED - already applied to the schema, but missing from the ledger.`);report.push(" These re-run on the next `buddy migrate`, which is unsafe for anything not idempotent.");section("",list("stranded"))}section(`${counts.partial} PARTIAL - some effects present, some missing. Needs a human:`,list("partial"));section(`${counts.reverted} REVERTED - recorded as applied, but the effects are gone from the schema:`,list("reverted"));if(orphans.length>0)section(`${orphans.length} orphaned ledger row(s) - recorded, but no such file on disk:`,orphans.map((o)=>{if(o.renamedTo)return`${o.migration} -> renumbered to ${o.renamedTo}`;if(audit.remapPlan.superseded.includes(o.migration))return`${o.migration} (duplicate; already recorded under its current name)`;return`${o.migration} (no counterpart; migration deleted?)`}));console.log(report.join(`
79
- `));if(!audit.drift){await outro("Ledger matches the corpus and the schema.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(!options.reconcile){console.log(`
95
+ `);if(options.dryRun){await outro("Dry run. Nothing was written.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(!isCI&&hasTTY&&process.stdin.isTTY){await log.flush();if(!await confirm({message:`Replace ${removed.length} migration file(s) with ${files.length} generated for ${target}?`,initial:!1})){await outro("Cancelled. Nothing was written.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}const result=await regenerateMigrationCorpus({dialect:target,replaceUnmarked:options.replaceUnmarked,onlyExistingTables:options.onlyExistingTables});if(resultFailed(result)){log.syncError(result.error.message);process.exit(ExitCode.FatalError)}log.success(`Wrote ${result.value.files.length} ${target} migration file(s) to database/migrations.`);if((applied>0?applied:await countAppliedMigrations().catch(()=>0))>0){const{reconcileMigrationLedger}=await import("@stacksjs/database"),fixed=await reconcileMigrationLedger({regenerated:{removed:result.value.removed,written:result.value.files.map((file)=>file.name)}});if(fixed.remapped.length>0)log.info(`Repointed ${fixed.remapped.length} ledger row(s) at their renumbered file.`);if(fixed.recorded.length>0)log.info(`Recorded ${fixed.recorded.length} regenerated migration(s) whose every table, index and type the schema already has - they will not run.`);if(fixed.retired.length>0)log.info(`Retired ${fixed.retired.length} ledger row(s) for migrations the regenerated corpus replaced.`);if(fixed.pruned.length>0)log.info(`Pruned ${fixed.pruned.length} duplicate ledger row(s).`);if(fixed.skipped.length>0){log.warn(`${fixed.skipped.length} ledger entr(ies) need a look:`);for(const entry of fixed.skipped.slice(0,10))log.info(` ${entry.file} - ${entry.reason}`);if(fixed.skipped.length>10)log.info(` \u2026 +${fixed.skipped.length-10} more - run \`./buddy migrate:status\``)}log.info("This database has history. Before `./buddy migrate`, bring its schema in line with the models:");log.info(" ./buddy migrate:align # what differs, and what can be repaired safely");log.info(" ./buddy migrate:align --apply # rename legacy indexes, add missing indexes and foreign keys");log.info(" ./buddy migrate:status --reconcile # record the regenerated files the schema now satisfies")}log.info("Review the change with `git diff`, then run `./buddy migrate`.");await outro("Regenerated.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:align","Repair a live database that drifted from the models, without writing migrations (PostgreSQL)").option("--apply","Run the safe repairs, in one transaction",{default:!1}).option("--widen-integers","Also widen integer columns the models declare as bigint (rewrites those tables)",{default:!1}).option("-f, --force","With --apply, skip the confirmation",{default:!1}).option("--json","Emit the plan as JSON",{default:!1}).action(async(options)=>{const perf=options.json?void 0:await intro("buddy migrate:align"),{alignSchema}=await import("@stacksjs/database");let planned;try{planned=await alignSchema({widenIntegers:options.widenIntegers})}catch(error){log.syncError(`Could not compare the database with the models: ${error instanceof Error?error.message:String(error)}`);process.exit(ExitCode.FatalError)}if(options.json){console.log(JSON.stringify(planned,null,2));process.exit(ExitCode.Success)}if(!planned.supported){await log.info(`migrate:align is not supported for "${planned.dialect}" yet - only PostgreSQL. Nothing was compared.`);await outro("Skipped.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const{actions,reports,refused}=planned.plan,report=["",` Align plan: ${planned.dialect}`," \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"];if(actions.length>0){report.push(` ${actions.length} safe repair(s)${options.apply?"":" - run with --apply to make them, in one transaction"}:`);for(const action of actions)report.push(` \u2022 ${action.detail}`,` ${action.sql};`);report.push("")}if(refused.length>0){report.push(` ${refused.length} repair(s) REFUSED - the data would violate them:`);for(const action of refused)report.push(` \u2022 ${action.detail}: ${action.reason}`);report.push("")}if(reports.length>0){report.push(` ${reports.length} difference(s) left for you to decide - destructive or ambiguous, never applied:`);for(const item of reports.slice(0,40))report.push(` \u2022 [${item.kind}] ${item.detail}`);if(reports.length>40)report.push(` \u2026 +${reports.length-40} more (--json lists all)`);report.push("")}if(actions.length===0&&refused.length===0&&reports.length===0)report.push(" The database matches the models.","");console.log(report.join(`
96
+ `));if(!options.apply||actions.length===0){await outro(options.apply?"Nothing to repair.":"Plan only. Nothing was changed.",{startTime:perf,useSeconds:!0});process.exit(refused.length>0?ExitCode.FatalError:ExitCode.Success)}if(!options.force&&!isCI&&hasTTY&&process.stdin.isTTY){await log.flush();if(!await confirm({message:`Apply ${actions.length} repair(s) to the ${process.env.APP_ENV||"local"} database "${currentDatabaseLabel()}"?`,initial:!1})){await outro("Cancelled. Nothing was changed.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}try{const applied=await alignSchema({apply:!0,widenIntegers:options.widenIntegers});await log.success(`Applied ${applied.applied.length} repair(s).`);for(const action of applied.plan.refused)await log.warn(`Refused: ${action.detail}: ${action.reason}`)}catch(error){log.syncError(`Nothing was changed - the repairs run as one transaction and it failed: ${error instanceof Error?error.message:String(error)}`);process.exit(ExitCode.FatalError)}await log.info("Migrations whose every table, index and type now exists can be recorded without running:");await log.info(" ./buddy migrate:status --reconcile");await outro("Aligned.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:status","Compare database/migrations, the migrations ledger, and the live schema").option("--reconcile","Repair the ledger where the schema proves what happened",{default:!1}).option("--include-partial","With --reconcile, also record half-applied migrations",{default:!1}).option("--requeue-reverted","With --reconcile, un-record the migrations of a table that is missing so the next migrate rebuilds it",{default:!1}).option("--json","Emit the audit as JSON",{default:!1}).option("--strict","Also fail when a migration was written by hand rather than generated from the models",{default:!1}).action(async(options)=>{const perf=options.json?void 0:await intro("buddy migrate:status"),{auditMigrationLedger,handWrittenMigrations,reconcileMigrationLedger}=await import("@stacksjs/database"),audit=await auditMigrationLedger(),handWritten=(await handWrittenMigrations()).files,strict=strictMode(options),handWrittenFails=strict&&handWritten.length>0;if(options.json){console.log(JSON.stringify({...audit,handWritten},(_k,v)=>v instanceof Set?[...v]:v,2));process.exit(audit.drift||handWrittenFails?ExitCode.FatalError:ExitCode.Success)}if(!audit.supported){log.info(`Dialect "${audit.dialect}" is not audited. Nothing to compare.`);await outro("Skipped.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const{counts,entries,orphans}=audit,list=(status)=>entries.filter((e)=>e.status===status).map((e)=>e.file),report=[],section=(heading,files)=>{if(files.length===0)return;if(heading)report.push(` ${heading}`);for(const file of files.slice(0,8))report.push(` ${file}`);if(files.length>8)report.push(` \u2026 +${files.length-8} more`);report.push("")};report.push("");report.push(` Migration status: ${audit.dialect}`);report.push(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");report.push(` ${entries.length+audit.gated.length} file(s) on disk \xB7 ${audit.recordedCount} recorded in the ledger`);report.push("");if(counts.applied>0)report.push(` ${counts.applied} applied - recorded, and present in the schema.`,"");if(counts.unverifiable>0)report.push(` ${counts.unverifiable} unverifiable - data migrations with no schema trace to check.`,"");if(audit.gated.length>0)report.push(` ${audit.gated.length} skipped - owned by a disabled feature, so \`buddy migrate\` does not run them until it is enabled.`,"");section(`${counts.pending} pending - not applied yet, will run on the next \`buddy migrate\`:`,list("pending"));if(counts.stranded>0){report.push(` ${counts.stranded} STRANDED - already applied to the schema, but missing from the ledger.`);report.push(" These re-run on the next `buddy migrate`, which is unsafe for anything not idempotent.");section("",list("stranded"))}section(`${counts.partial} PARTIAL - some effects present, some missing. Needs a human:`,list("partial"));section(`${counts.reverted} REVERTED - recorded as applied, but the effects are gone from the schema:`,list("reverted"));if(orphans.length>0)section(`${orphans.length} orphaned ledger row(s) - recorded, but no such file on disk:`,orphans.map((o)=>{if(o.renamedTo)return`${o.migration} -> renumbered to ${o.renamedTo}`;if(audit.remapPlan.superseded.includes(o.migration))return`${o.migration} (duplicate; already recorded under its current name)`;return`${o.migration} (no counterpart; migration deleted?)`}));if(handWritten.length>0){section(`${handWritten.length} hand-written - no \`-- qb:generated\` marker, so no model describes ${handWritten.length===1?"it":"them"}${strict?" (fails --strict)":""}:`,handWritten);report.push(" Move each change into app/Models and regenerate, or allow the file in");report.push(" database.safety.allowHandWrittenMigrations (reference data no model can describe).","")}console.log(report.join(`
97
+ `));if(!audit.drift){if(handWrittenFails){await outro("--strict: hand-written migrations (see above).",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}await outro("Ledger matches the corpus and the schema.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(!options.reconcile){console.log(`
80
98
  Repair with: ./buddy migrate:status --reconcile
81
99
  That repoints renumbered ledger rows and records migrations the schema
82
100
  already proves. It never runs SQL from a migration file.
@@ -85,4 +103,4 @@ ${unrebuildable.map((t)=>` ${t}`).join(`
85
103
  ./buddy migrate:status --reconcile --requeue-reverted
86
104
  That un-records the migrations that built it, only when none of them
87
105
  touches a table that is still there, so the next migrate rebuilds it.
88
- `);await outro("Drift detected.",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}const fixed=await reconcileMigrationLedger({includePartial:options.includePartial,requeueReverted:options.requeueReverted});if(fixed.remapped.length>0)log.success(`Repointed ${fixed.remapped.length} ledger row(s) at their renumbered file.`);if(fixed.requeued.length>0){log.success(`Un-recorded ${fixed.requeued.length} migration(s) so the next \`./buddy migrate\` rebuilds the missing table(s):`);for(const file of fixed.requeued.slice(0,8))log.info(` ${file}`);if(fixed.requeued.length>8)log.info(` \u2026 +${fixed.requeued.length-8} more`)}if(fixed.recorded.length>0)log.success(`Recorded ${fixed.recorded.length} migration(s) the schema already reflects.`);if(fixed.pruned.length)log.success(`Pruned ${fixed.pruned.length} duplicate ledger row(s) left by a renumbering.`);if(fixed.skipped.length>0){log.warn(`Left ${fixed.skipped.length} entr(ies) alone:`);for(const entry of fixed.skipped.slice(0,8))log.info(` ${entry.file} - ${entry.reason}`);if(fixed.skipped.length>8)log.info(` \u2026 +${fixed.skipped.length-8} more`)}if(fixed.remapped.length===0&&fixed.recorded.length===0&&fixed.pruned.length===0&&fixed.requeued.length===0)log.info("Nothing could be repaired automatically.");if(fixed.skipped.some((entry)=>entry.reason.includes("no longer exists on disk"))){log.info("A recorded migration with no file is not repaired automatically: removing the row would let it run again.");log.info(" Restore the file if it went missing by accident - a bad merge, a partial checkout.");log.info(" Delete the row by hand if the migration is genuinely retired: DELETE FROM migrations WHERE migration = '<file>'.")}await outro("Reconciled.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"migrate")}
106
+ `);await outro("Drift detected.",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}const fixed=await reconcileMigrationLedger({includePartial:options.includePartial,requeueReverted:options.requeueReverted});if(fixed.retired.length>0)log.success(`Retired ${fixed.retired.length} ledger row(s) for migrations a regeneration replaced.`);if(fixed.remapped.length>0)log.success(`Repointed ${fixed.remapped.length} ledger row(s) at their renumbered file.`);if(fixed.requeued.length>0){log.success(`Un-recorded ${fixed.requeued.length} migration(s) so the next \`./buddy migrate\` rebuilds the missing table(s):`);for(const file of fixed.requeued.slice(0,8))log.info(` ${file}`);if(fixed.requeued.length>8)log.info(` \u2026 +${fixed.requeued.length-8} more`)}if(fixed.recorded.length>0)log.success(`Recorded ${fixed.recorded.length} migration(s) the schema already reflects.`);if(fixed.pruned.length)log.success(`Pruned ${fixed.pruned.length} duplicate ledger row(s) left by a renumbering.`);if(fixed.skipped.length>0){log.warn(`Left ${fixed.skipped.length} entr(ies) alone:`);for(const entry of fixed.skipped.slice(0,8))log.info(` ${entry.file} - ${entry.reason}`);if(fixed.skipped.length>8)log.info(` \u2026 +${fixed.skipped.length-8} more`)}if(fixed.remapped.length===0&&fixed.recorded.length===0&&fixed.pruned.length===0&&fixed.requeued.length===0)log.info("Nothing could be repaired automatically.");if(fixed.skipped.some((entry)=>entry.reason.includes("no longer exists on disk"))){log.info("A recorded migration with no file is not repaired automatically: removing the row would let it run again.");log.info(" Restore the file if it went missing by accident - a bad merge, a partial checkout.");log.info(" Delete the row by hand if the migration is genuinely retired: DELETE FROM migrations WHERE migration = '<file>'.")}await outro("Reconciled.",{startTime:perf,useSeconds:!0});process.exit(handWrittenFails?ExitCode.FatalError:ExitCode.Success)});onUnknownSubcommand(buddy,"migrate")}
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.140",
5
+ "version": "0.75.142",
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.21",
99
- "@stacksjs/actions": "0.75.140",
100
- "@stacksjs/ai": "0.75.140",
101
- "@stacksjs/alias": "0.75.140",
102
- "@stacksjs/analytics": "0.75.140",
103
- "@stacksjs/api": "0.75.140",
104
- "@stacksjs/arrays": "0.75.140",
105
- "@stacksjs/auth": "0.75.140",
106
- "@stacksjs/browser-extension": "0.75.140",
107
- "@stacksjs/build": "0.75.140",
108
- "@stacksjs/cache": "0.75.140",
109
- "@stacksjs/chat": "0.75.140",
99
+ "@stacksjs/actions": "0.75.142",
100
+ "@stacksjs/ai": "0.75.142",
101
+ "@stacksjs/alias": "0.75.142",
102
+ "@stacksjs/analytics": "0.75.142",
103
+ "@stacksjs/api": "0.75.142",
104
+ "@stacksjs/arrays": "0.75.142",
105
+ "@stacksjs/auth": "0.75.142",
106
+ "@stacksjs/browser-extension": "0.75.142",
107
+ "@stacksjs/build": "0.75.142",
108
+ "@stacksjs/cache": "0.75.142",
109
+ "@stacksjs/chat": "0.75.142",
110
110
  "@stacksjs/clapp": "^0.2.16",
111
- "@stacksjs/cli": "0.75.140",
112
- "@stacksjs/cloud": "0.75.140",
113
- "@stacksjs/cms": "0.75.140",
114
- "@stacksjs/collections": "0.75.140",
115
- "@stacksjs/commerce": "0.75.140",
116
- "@stacksjs/config": "0.75.140",
117
- "@stacksjs/database": "0.75.140",
118
- "@stacksjs/desktop-build": "0.75.140",
119
- "@stacksjs/dns": "0.75.140",
111
+ "@stacksjs/cli": "0.75.142",
112
+ "@stacksjs/cloud": "0.75.142",
113
+ "@stacksjs/cms": "0.75.142",
114
+ "@stacksjs/collections": "0.75.142",
115
+ "@stacksjs/commerce": "0.75.142",
116
+ "@stacksjs/config": "0.75.142",
117
+ "@stacksjs/database": "0.75.142",
118
+ "@stacksjs/desktop-build": "0.75.142",
119
+ "@stacksjs/dns": "0.75.142",
120
120
  "@stacksjs/dnsx": "^0.2.3",
121
- "@stacksjs/email": "0.75.140",
122
- "@stacksjs/enums": "0.75.140",
123
- "@stacksjs/env": "0.75.140",
124
- "@stacksjs/error-handling": "0.75.140",
125
- "@stacksjs/events": "0.75.140",
126
- "@stacksjs/features": "0.75.140",
127
- "@stacksjs/git": "0.75.140",
121
+ "@stacksjs/email": "0.75.142",
122
+ "@stacksjs/enums": "0.75.142",
123
+ "@stacksjs/env": "0.75.142",
124
+ "@stacksjs/error-handling": "0.75.142",
125
+ "@stacksjs/events": "0.75.142",
126
+ "@stacksjs/features": "0.75.142",
127
+ "@stacksjs/git": "0.75.142",
128
128
  "@stacksjs/gitit": "^0.2.5",
129
- "@stacksjs/health": "0.75.140",
129
+ "@stacksjs/health": "0.75.142",
130
130
  "@stacksjs/httx": "^0.1.10",
131
- "@stacksjs/image": "0.75.140",
132
- "@stacksjs/lint": "0.75.140",
133
- "@stacksjs/logging": "0.75.140",
134
- "@stacksjs/notifications": "0.75.140",
135
- "@stacksjs/objects": "0.75.140",
136
- "@stacksjs/orm": "0.75.140",
137
- "@stacksjs/path": "0.75.140",
138
- "@stacksjs/payments": "0.75.140",
139
- "@stacksjs/realtime": "0.75.140",
140
- "@stacksjs/router": "0.75.140",
131
+ "@stacksjs/image": "0.75.142",
132
+ "@stacksjs/lint": "0.75.142",
133
+ "@stacksjs/logging": "0.75.142",
134
+ "@stacksjs/notifications": "0.75.142",
135
+ "@stacksjs/objects": "0.75.142",
136
+ "@stacksjs/orm": "0.75.142",
137
+ "@stacksjs/path": "0.75.142",
138
+ "@stacksjs/payments": "0.75.142",
139
+ "@stacksjs/realtime": "0.75.142",
140
+ "@stacksjs/router": "0.75.142",
141
141
  "@stacksjs/rpx": "^0.11.54",
142
- "@stacksjs/scheduler": "0.75.140",
143
- "@stacksjs/search-engine": "0.75.140",
144
- "@stacksjs/security": "0.75.140",
145
- "@stacksjs/server": "0.75.140",
146
- "@stacksjs/sites": "0.75.140",
147
- "@stacksjs/skills": "0.75.140",
148
- "@stacksjs/socials": "0.75.140",
149
- "@stacksjs/storage": "0.75.140",
150
- "@stacksjs/strings": "0.75.140",
142
+ "@stacksjs/scheduler": "0.75.142",
143
+ "@stacksjs/search-engine": "0.75.142",
144
+ "@stacksjs/security": "0.75.142",
145
+ "@stacksjs/server": "0.75.142",
146
+ "@stacksjs/sites": "0.75.142",
147
+ "@stacksjs/skills": "0.75.142",
148
+ "@stacksjs/socials": "0.75.142",
149
+ "@stacksjs/storage": "0.75.142",
150
+ "@stacksjs/strings": "0.75.142",
151
151
  "@stacksjs/stx": "^0.2.445",
152
- "@stacksjs/testing": "0.75.140",
153
- "@stacksjs/tinker": "0.75.140",
152
+ "@stacksjs/testing": "0.75.142",
153
+ "@stacksjs/tinker": "0.75.142",
154
154
  "@stacksjs/tlsx": "^0.13.19",
155
155
  "@stacksjs/ts-cloud": "^0.16.52",
156
- "@stacksjs/tunnel": "0.75.140",
157
- "@stacksjs/types": "0.75.140",
158
- "@stacksjs/ui": "0.75.140",
159
- "@stacksjs/utils": "0.75.140",
160
- "@stacksjs/validation": "0.75.140",
156
+ "@stacksjs/tunnel": "0.75.142",
157
+ "@stacksjs/types": "0.75.142",
158
+ "@stacksjs/ui": "0.75.142",
159
+ "@stacksjs/utils": "0.75.142",
160
+ "@stacksjs/validation": "0.75.142",
161
161
  "ajv": "^8.20.0",
162
162
  "ajv-formats": "^3.0.1",
163
163
  "bun-plugin-stx": "^0.2.445",