@stacksjs/buddy 0.74.26 → 0.74.27

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.
@@ -350,13 +350,30 @@ cat > /usr/local/sbin/mail-health-check <<'EOF'
350
350
  set -eu
351
351
  exec 9>/run/mail-health-check.lock
352
352
  flock -n 9 || exit 0
353
- systemctl is-active --quiet mail || { systemctl restart mail; exit 0; }
353
+ restart_mail() {
354
+ reason="$1"
355
+ logger -t mail-health "$reason; restarting mail"
356
+ if ! systemctl restart mail; then
357
+ logger -t mail-health "mail restart failed"
358
+ return 1
359
+ fi
360
+ if ! systemctl is-active --quiet mail; then
361
+ logger -t mail-health "mail remained inactive after restart"
362
+ return 1
363
+ fi
364
+ sleep 2
365
+ }
366
+ if ! systemctl is-active --quiet mail; then
367
+ restart_mail "mail service is inactive"
368
+ fi
354
369
  for port in 25 143 587 993; do
355
- ss -H -ltn "sport = :$port" | grep -q . || {
356
- logger -t mail-health "required TCP port $port is not listening; restarting mail"
357
- systemctl restart mail
358
- exit 0
359
- }
370
+ if ! ss -H -ltn "sport = :$port" | grep -q .; then
371
+ restart_mail "required TCP port $port is not listening"
372
+ ss -H -ltn "sport = :$port" | grep -q . || {
373
+ logger -t mail-health "required TCP port $port is still not listening after restart"
374
+ exit 1
375
+ }
376
+ fi
360
377
  done
361
378
  EOF
362
379
  chmod 755 /usr/local/sbin/mail-health-check
@@ -1,6 +1,14 @@
1
1
  import { CLI } from '@stacksjs/clapp';
2
2
  export declare function resolveDirectMailHost(env?: NodeJS.ProcessEnv): string | undefined;
3
3
  export declare function sanitizeLineCount(value?: string): string;
4
+ /**
5
+ * Install the externally escrowed key as a systemd machine-bound credential.
6
+ *
7
+ * The raw key reaches systemd-creds only through stdin. The host never puts it
8
+ * in argv, an environment variable, or a plaintext file. Keeping this command
9
+ * separate also makes the security properties testable without a live server.
10
+ */
11
+ export declare function machineBindMailStorageCommand(): string;
4
12
  export declare function mailCommands(buddy: CLI): void;
5
13
  /**
6
14
  * Find the `mail` binary.
@@ -1,4 +1,4 @@
1
- var {require}=import.meta;import{log,runCommand}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{getErrorMessage}from"@stacksjs/utils";import{CLI}from"@stacksjs/clapp";import{createHash,randomBytes,timingSafeEqual}from"crypto";import{readFileSync,existsSync}from"node:fs";import{execSync,spawnSync}from"node:child_process";export function resolveDirectMailHost(env=process.env){let configured=env.MAIL_SERVER_HOST||(env.HCLOUD_TOKEN||env.HETZNER_API_TOKEN?env.MAIL_HOST:void 0);if(configured&&["127.0.0.1","localhost","mailpit"].includes(configured))configured=env.MAIL_DOMAIN?`mail.${env.MAIL_DOMAIN}`:void 0;if(!configured||configured==="127.0.0.1"||configured==="localhost")return;return configured.replace(/^https?:\/\//,"").split("/")[0]?.split(":")[0]}export function sanitizeLineCount(value){const count=Number.parseInt(value||"50",10);return String(Number.isFinite(count)?Math.min(5000,Math.max(1,count)):50)}function shellQuote(value){return`'${value.replace(/'/g,"'\\''")}'`}async function resolveOperationalMailHost(){if(process.env.MAIL_SERVER_HOST)return resolveDirectMailHost();const token=process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN;if(!token)return;const appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");try{const ip=(await(await fetch(`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent(`${appName}-production-app`)}`,{headers:{Authorization:`Bearer ${token}`}})).json()).servers?.[0]?.public_net?.ipv4?.ip;if(ip)return ip}catch{}const configured=resolveDirectMailHost();if(configured)return configured;const{email}=await import("@stacksjs/config");return email?.domain?`mail.${email.domain}`:void 0}const mailStorageRemote="/usr/local/sbin/mail-storage-external";function loadMailStorageAws(options){const profile=options.profile||process.env.AWS_PROFILE||"default",region=options.region||process.env.AWS_REGION||"us-east-1";process.env.AWS_PROFILE=profile;loadEnvFiles();if(existsSync(".env.production"))try{const{loadEnv}=require("@stacksjs/env");loadEnv({path:".env.production",env:"production",keysFile:".env.keys",overload:!0,quiet:!0})}catch{}const credentialsPath=`${process.env.HOME||process.env.USERPROFILE||""}/.aws/credentials`;if((!process.env.AWS_ACCESS_KEY_ID||!process.env.AWS_SECRET_ACCESS_KEY)&&existsSync(credentialsPath)){const values={};let inProfile=!1;for(const line of readFileSync(credentialsPath,"utf8").split(`
1
+ var {require}=import.meta;import{log,runCommand}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{getErrorMessage}from"@stacksjs/utils";import{CLI}from"@stacksjs/clapp";import{createHash,randomBytes,timingSafeEqual}from"crypto";import{readFileSync,existsSync}from"node:fs";import{execSync,spawnSync}from"node:child_process";export function resolveDirectMailHost(env=process.env){let configured=env.MAIL_SERVER_HOST||(env.HCLOUD_TOKEN||env.HETZNER_API_TOKEN?env.MAIL_HOST:void 0);if(configured&&["127.0.0.1","localhost","mailpit"].includes(configured))configured=env.MAIL_DOMAIN?`mail.${env.MAIL_DOMAIN}`:void 0;if(!configured||configured==="127.0.0.1"||configured==="localhost")return;return configured.replace(/^https?:\/\//,"").split("/")[0]?.split(":")[0]}export function sanitizeLineCount(value){const count=Number.parseInt(value||"50",10);return String(Number.isFinite(count)?Math.min(5000,Math.max(1,count)):50)}function shellQuote(value){return`'${value.replace(/'/g,"'\\''")}'`}async function resolveOperationalMailHost(){if(process.env.MAIL_SERVER_HOST)return resolveDirectMailHost();const token=process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN;if(!token)return;const appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");try{const ip=(await(await fetch(`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent(`${appName}-production-app`)}`,{headers:{Authorization:`Bearer ${token}`}})).json()).servers?.[0]?.public_net?.ipv4?.ip;if(ip)return ip}catch{}const configured=resolveDirectMailHost();if(configured)return configured;const{email}=await import("@stacksjs/config");return email?.domain?`mail.${email.domain}`:void 0}const mailStorageRemote="/usr/local/sbin/mail-storage-external",mailStorageCredential="/etc/credstore.encrypted/mail-storage-key.cred",mailStorageMachineUnit="/etc/systemd/system/mail-storage.service.machine-bound-backup";export function machineBindMailStorageCommand(){return["set -eu",`test -f ${mailStorageMachineUnit}`,"command -v systemd-creds >/dev/null",`install -d -m 0700 ${mailStorageCredential.slice(0,mailStorageCredential.lastIndexOf("/"))}`,`credential_tmp=$(mktemp ${mailStorageCredential}.XXXXXX)`,'cleanup() { rm -f "$credential_tmp"; }',"trap cleanup EXIT HUP INT TERM",'chmod 0600 "$credential_tmp"','systemd-creds encrypt --name=mail-storage-key - "$credential_tmp" >/dev/null','test "$(systemd-creds decrypt --name=mail-storage-key "$credential_tmp" - | wc -c)" -ge 32',`mv "$credential_tmp" ${mailStorageCredential}`,"trap - EXIT HUP INT TERM",`${mailStorageRemote} rollback-machine`,"systemctl is-enabled --quiet mail-storage.service","systemctl is-active --quiet mail-storage.service","mountpoint -q /var/lib/mail-storage","systemctl is-active --quiet mail.service"].join("; ")}function loadMailStorageAws(options){const profile=options.profile||process.env.AWS_PROFILE||"default",region=options.region||process.env.AWS_REGION||"us-east-1";process.env.AWS_PROFILE=profile;loadEnvFiles();if(existsSync(".env.production"))try{const{loadEnv}=require("@stacksjs/env");loadEnv({path:".env.production",env:"production",keysFile:".env.keys",overload:!0,quiet:!0})}catch{}const credentialsPath=`${process.env.HOME||process.env.USERPROFILE||""}/.aws/credentials`;if((!process.env.AWS_ACCESS_KEY_ID||!process.env.AWS_SECRET_ACCESS_KEY)&&existsSync(credentialsPath)){const values={};let inProfile=!1;for(const line of readFileSync(credentialsPath,"utf8").split(`
2
2
  `)){const trimmed=line.trim();if(trimmed.startsWith("[")){inProfile=trimmed===`[${profile}]`;continue}if(!inProfile)continue;const separator=trimmed.indexOf("=");if(separator>0)values[trimmed.slice(0,separator).trim()]=trimmed.slice(separator+1).trim()}if(values.aws_access_key_id&&values.aws_secret_access_key){process.env.AWS_ACCESS_KEY_ID=values.aws_access_key_id;process.env.AWS_SECRET_ACCESS_KEY=values.aws_secret_access_key;if(values.aws_session_token)process.env.AWS_SESSION_TOKEN=values.aws_session_token;else delete process.env.AWS_SESSION_TOKEN}}return{profile,region}}function mailStorageSecretId(options){if(options.secret)return options.secret;if(process.env.MAIL_STORAGE_SECRET_ID)return process.env.MAIL_STORAGE_SECRET_ID;const app=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),environment=(process.env.APP_ENV||"production").toLowerCase().replace(/[^a-z0-9-]/g,"-");return`${app}/${environment}/mail-storage-key`}async function mailStorageHost(options){const host=options.host||await resolveOperationalMailHost();if(!host)throw Error("Could not resolve the mail host. Pass --host <hostname-or-ip>.");if(!/^[a-zA-Z0-9._:-]+$/.test(host))throw Error(`Unsafe mail host: ${host}`);return host}function runMailStorageSsh(host,command,input){const result=spawnSync("ssh",["-o","BatchMode=yes","-o","ConnectTimeout=15",`root@${host}`,command],{input,maxBuffer:33554432});if(result.status!==0){const stderr=Buffer.from(result.stderr||[]).toString("utf8").trim();throw Error(stderr||`SSH command failed with status ${result.status}`)}return Buffer.from(result.stdout||[])}function readMailRecoveryKey(){const result=spawnSync("/usr/bin/security",["find-generic-password","-a","mail-storage-recovery","-s","com.stacks.production.mail.luks","-w"],{maxBuffer:1048576,timeout:30000});if(result.status!==0)throw Error("macOS Keychain recovery-key read failed");const raw=Buffer.from(result.stdout||[]),text=raw.toString("ascii").trim(),key=/^[0-9a-f]{128}$/i.test(text)?Buffer.from(text,"hex"):raw.length===65&&raw[64]===10?Buffer.from(raw.subarray(0,64)):Buffer.from(raw);raw.fill(0);if(key.length!==64)throw Error("macOS Keychain returned an invalid recovery key");return key}async function waitForHetznerAction(token,actionId){const deadline=Date.now()+120000;while(Date.now()<deadline){const response=await fetch(`https://api.hetzner.cloud/v1/actions/${actionId}`,{headers:{Authorization:`Bearer ${token}`}}),data=await response.json();if(!response.ok)throw Error(data?.error?.message||`Hetzner action ${actionId} lookup failed`);if(data.action?.status==="success")return;if(data.action?.status==="error")throw Error(data.action?.error?.message||`Hetzner action ${actionId} failed`);await new Promise((resolve)=>setTimeout(resolve,1000))}throw Error(`Hetzner action ${actionId} timed out`)}async function ensureMailStorageVolume(options){loadEnvFiles();const token=process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN;if(!token)throw Error("HCLOUD_TOKEN or HETZNER_API_TOKEN is required.");const headers={Authorization:`Bearer ${token}`,"Content-Type":"application/json"},host=await mailStorageHost(options),server=(await(await fetch(`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent("stacks-production-app")}`,{headers})).json()).servers?.[0];if(!server)throw Error("Hetzner server stacks-production-app was not found.");const app=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),name=`${app}-production-mail`,requestedSize=Number.parseInt(options.size||"20",10);if(!Number.isFinite(requestedSize)||requestedSize<10||requestedSize>1024)throw Error("Mail volume size must be between 10 and 1024 GiB.");let volume=(await(await fetch(`https://api.hetzner.cloud/v1/volumes?name=${encodeURIComponent(name)}`,{headers})).json()).volumes?.[0];if(!volume){const createResponse=await fetch("https://api.hetzner.cloud/v1/volumes",{method:"POST",headers,body:JSON.stringify({name,size:requestedSize,server:server.id,automount:!1,labels:{application:app,environment:"production",purpose:"mail-storage"}})}),createData=await createResponse.json();if(!createResponse.ok)throw Error(createData?.error?.message||"Hetzner volume creation failed.");volume=createData.volume;if(createData.action?.id)await waitForHetznerAction(token,createData.action.id)}else if(!volume.server){const attachResponse=await fetch(`https://api.hetzner.cloud/v1/volumes/${volume.id}/actions/attach`,{method:"POST",headers,body:JSON.stringify({server:server.id,automount:!1})}),attachData=await attachResponse.json();if(!attachResponse.ok)throw Error(attachData?.error?.message||"Hetzner volume attach failed.");await waitForHetznerAction(token,attachData.action.id)}else if(volume.server!==server.id)throw Error(`Hetzner volume ${name} is attached to a different server.`);volume=(await(await fetch(`https://api.hetzner.cloud/v1/volumes/${volume.id}`,{headers})).json()).volume||volume;if(!volume.linux_device)throw Error(`Hetzner volume ${name} has no Linux device path yet.`);return{device:volume.linux_device,host,id:volume.id,name,size:volume.size}}async function ensureMailKmsKey(options){const{region}=loadMailStorageAws(options),{AWSClient}=await import("@stacksjs/cloud"),{S3Client}=await import("@stacksjs/cloud"),{SecretsManagerClient}=await import("@stacksjs/cloud"),client=new AWSClient,request=async(target,body)=>client.request({service:"kms",region,method:"POST",path:"/",headers:{"Content-Type":"application/x-amz-json-1.1","X-Amz-Target":`TrentService.${target}`},body:JSON.stringify(body)}),alias="alias/stacks-production-mail";let keyId=(await request("ListAliases",{Limit:100})).Aliases?.find((item)=>item.AliasName===alias)?.TargetKeyId;if(!keyId){keyId=(await request("CreateKey",{Description:"Customer-managed key for production mail secrets and backups",KeyUsage:"ENCRYPT_DECRYPT",Origin:"AWS_KMS",Tags:[{TagKey:"Application",TagValue:"stacks"},{TagKey:"Environment",TagValue:"production"},{TagKey:"Purpose",TagValue:"mail-storage"}]})).KeyMetadata?.KeyId;if(!keyId)throw Error("AWS KMS did not return a key ID.");await request("CreateAlias",{AliasName:alias,TargetKeyId:keyId});await request("EnableKeyRotation",{KeyId:keyId,RotationPeriodInDays:90})}const arn=(await request("DescribeKey",{KeyId:keyId})).KeyMetadata?.Arn;if(!arn)throw Error("AWS KMS did not return a key ARN.");const secrets=new SecretsManagerClient(region);for(const secretId of["stacks/production/mail-storage-key","stacks/production/mail-restic"])try{await secrets.updateSecret({SecretId:secretId,KmsKeyId:arn})}catch(error){if(error?.code!=="ResourceNotFoundException")throw Error(`Could not re-encrypt secret ${secretId}: ${getErrorMessage(error)}`)}const s3=new S3Client(region);for(const bucket of["stacks-production-s3-backups","stacks-production-s3-email","stacks-production-email"])try{await s3.putBucketEncryption(bucket,"aws:kms",arn)}catch(error){throw Error(`Could not apply KMS encryption to ${bucket}: ${getErrorMessage(error)}`)}return{alias,arn}}async function configureMailRestic(options){const{region}=loadMailStorageAws(options),host=await mailStorageHost(options),bucket=options.bucket||"stacks-production-s3-backups";if(!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket))throw Error(`Unsafe S3 bucket name: ${bucket}`);const kms=await ensureMailKmsKey(options),{AWSClient}=await import("@stacksjs/cloud"),{SecretsManagerClient}=await import("@stacksjs/cloud"),iam=new AWSClient,iamRequest=async(action,params={})=>iam.request({service:"iam",region:"us-east-1",method:"POST",path:"/",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({Action:action,Version:"2010-05-08",...params}).toString()}),userName="stacks-production-mail-backup";try{await iamRequest("GetUser",{UserName:userName})}catch(error){if(error?.code!=="NoSuchEntity")throw error;await iamRequest("CreateUser",{UserName:userName,"Tags.member.1.Key":"Application","Tags.member.1.Value":"stacks","Tags.member.2.Key":"Environment","Tags.member.2.Value":"production","Tags.member.3.Key":"Purpose","Tags.member.3.Value":"mail-backup"})}await iamRequest("PutUserPolicy",{UserName:userName,PolicyName:"mail-restic-backup",PolicyDocument:JSON.stringify({Version:"2012-10-17",Statement:[{Effect:"Allow",Action:["s3:ListBucket","s3:GetBucketLocation"],Resource:`arn:aws:s3:::${bucket}`,Condition:{StringLike:{"s3:prefix":["restic-mail","restic-mail/*"]}}},{Effect:"Allow",Action:["s3:GetObject","s3:PutObject","s3:DeleteObject","s3:AbortMultipartUpload","s3:ListMultipartUploadParts"],Resource:`arn:aws:s3:::${bucket}/restic-mail/*`},{Effect:"Allow",Action:["kms:Decrypt","kms:Encrypt","kms:GenerateDataKey","kms:DescribeKey"],Resource:kms.arn}]})});const listedMembers=(await iamRequest("ListAccessKeys",{UserName:userName})).ListAccessKeysResult?.AccessKeyMetadata?.member,oldKeys=listedMembers?Array.isArray(listedMembers)?listedMembers:[listedMembers]:[];if(oldKeys.length>0&&!options.force)throw Error(`IAM user ${userName} already has a key. Pass --force to rotate it.`);if(oldKeys.length>=2)for(const old of oldKeys)await iamRequest("DeleteAccessKey",{UserName:userName,AccessKeyId:old.AccessKeyId});const access=(await iamRequest("CreateAccessKey",{UserName:userName})).CreateAccessKeyResult?.AccessKey;if(!access?.AccessKeyId||!access?.SecretAccessKey)throw Error("AWS IAM did not return the Restic access key.");const password=randomBytes(48).toString("base64url"),repository=`s3:s3.amazonaws.com/${bucket}/restic-mail`,secretId="stacks/production/mail-restic",secretPayload=JSON.stringify({version:1,repository,password,accessKeyId:access.AccessKeyId,secretAccessKey:access.SecretAccessKey,kmsKeyArn:kms.arn,createdAt:new Date().toISOString()}),secrets=new SecretsManagerClient(region);try{await secrets.createSecret({Name:secretId,Description:"Restic credentials for production mail backups",KmsKeyId:kms.arn,SecretString:secretPayload,Tags:[{Key:"Application",Value:"stacks"},{Key:"Environment",Value:"production"},{Key:"Purpose",Value:"mail-backup"}]})}catch(error){if(error?.code!=="ResourceExistsException")throw error;await secrets.updateSecret({SecretId:secretId,KmsKeyId:kms.arn,SecretString:secretPayload})}const configDir="/var/lib/mail-storage/data/restic",passwordPath=`${configDir}/password`,envPayload=[`RESTIC_REPOSITORY=${repository}`,`RESTIC_PASSWORD_FILE=${passwordPath}`,`AWS_ACCESS_KEY_ID=${access.AccessKeyId}`,`AWS_SECRET_ACCESS_KEY=${access.SecretAccessKey}`,`AWS_DEFAULT_REGION=${region}`,""].join(`
3
3
  `);runMailStorageSsh(host,`install -d -m 0700 ${configDir}; umask 077; dd of=${configDir}/restic.env status=none`,Buffer.from(envPayload));runMailStorageSsh(host,`umask 077; dd of=${passwordPath} status=none`,Buffer.from(`${password}
4
4
  `));runMailStorageSsh(host,`set -a; . ${configDir}/restic.env; set +a; restic snapshots >/dev/null 2>&1 || restic init`);runMailStorageSsh(host,"systemctl enable --now mail-restic-backup.timer mail-storage-health.timer");runMailStorageSsh(host,"systemctl start mail-restic-backup.service");runMailStorageSsh(host,"/usr/local/sbin/mail-restic-restore-check");for(const old of oldKeys)if(old.AccessKeyId!==access.AccessKeyId)await iamRequest("DeleteAccessKey",{UserName:userName,AccessKeyId:old.AccessKeyId});return{repository,secretId}}function decodeMailStorageSecret(secretString){if(!secretString)throw Error("The mail storage secret has no SecretString value.");let parsed;try{parsed=JSON.parse(secretString)}catch{throw Error("The mail storage secret is not valid JSON.")}if(parsed.version!==1||parsed.encoding!=="base64"||typeof parsed.key!=="string")throw Error("The mail storage secret uses an unsupported format.");const key=Buffer.from(parsed.key,"base64");if(key.length<32)throw Error("The mail storage key is unexpectedly short.");return key}async function getMailStorageSecret(options){const{profile,region}=loadMailStorageAws(options),secretId=mailStorageSecretId(options),{SecretsManagerClient}=await import("@stacksjs/cloud"),value=await new SecretsManagerClient(region,profile).getSecretValue({SecretId:secretId});return{key:decodeMailStorageSecret(value.SecretString),secretId}}async function escrowMailStorageKey(options){const{profile,region}=loadMailStorageAws(options),host=await mailStorageHost(options),secretId=mailStorageSecretId(options),{SecretsManagerClient}=await import("@stacksjs/cloud"),secrets=new SecretsManagerClient(region,profile),key=runMailStorageSsh(host,"systemd-creds decrypt --name=mail-storage-key /etc/credstore.encrypted/mail-storage-key.cred -");if(key.length<32)throw Error("The machine-bound mail storage key is unexpectedly short.");let existing;try{const value=await secrets.getSecretValue({SecretId:secretId});existing=decodeMailStorageSecret(value.SecretString)}catch(error){if(error?.code!=="ResourceNotFoundException")throw error}if(existing&&!timingSafeEqual(existing,key)){existing.fill(0);if(!options.force){key.fill(0);throw Error(`Secret ${secretId} already contains a different key. Pass --force to replace it.`)}}existing?.fill(0);const payload={version:1,encoding:"base64",key:key.toString("base64"),image:"/var/lib/mail-storage.luks",mapper:"mail-storage",createdAt:new Date().toISOString()};if(existing)await secrets.putSecretValue({SecretId:secretId,SecretString:JSON.stringify(payload)});else try{await secrets.createSecret({Name:secretId,Description:`Externally escrowed LUKS2 key for ${host}`,SecretString:JSON.stringify(payload),Tags:[{Key:"Application",Value:process.env.APP_NAME||"stacks"},{Key:"Environment",Value:process.env.APP_ENV||"production"},{Key:"Purpose",Value:"mail-storage"}]})}catch(error){if(error?.code!=="ResourceExistsException")throw error;await secrets.putSecretValue({SecretId:secretId,SecretString:JSON.stringify(payload)})}const verify=await secrets.getSecretValue({SecretId:secretId}),verifiedKey=decodeMailStorageSecret(verify.SecretString),matches=verifiedKey.length===key.length&&timingSafeEqual(verifiedKey,key);verifiedKey.fill(0);key.fill(0);if(!matches)throw Error(`Secret ${secretId} did not verify after writing.`);return{secretId,host}}function addMailStorageOptions(command){return command.option("--host <host>","Mail server hostname or IP").option("--secret <id>","AWS Secrets Manager secret ID").option("--region <region>","AWS region",{default:"us-east-1"}).option("--profile <profile>","AWS credential profile",{default:"default"})}export function mailCommands(buddy){buddy.command("mail:provision","Provision this app's mail from config/email.ts onto the shared mail server (domain + DKIM + mailboxes + MX/SPF/DKIM/DMARC DNS). Reusable + idempotent; the same reconcile buddy deploy runs.").option("--ip <ip>","Mail server IP (defaults to the A record of config.email.domain)").action(async(options)=>{const environment=options.env||process.env.APP_ENV||"production";process.env.APP_ENV=environment;const envFile=`.env.${environment}`;if(existsSync(envFile))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:environment,keysFile:".env.keys",overload:!0,quiet:!0})}catch(err){log.warn(`Could not load ${envFile}: ${getErrorMessage(err)}`)}const{email:emailConfig}=await import("@stacksjs/config"),domain=emailConfig?.domain;if(!domain){await log.error("config/email.ts has no `domain` - set it (e.g. domain: 'bughq.org') and add `mailboxes`.");process.exit(ExitCode.FatalError)}let ip=options.ip;if(!ip)try{ip=(await(await import("node:dns")).promises.resolve4(domain))[0]}catch{await log.error(`Could not resolve an IP for ${domain}. Deploy the site first, or pass --ip <mail-server-ip>.`);process.exit(ExitCode.FatalError)}const{provisionMailTenant,reconcileMailDns}=await import("./deploy");log.info(`Provisioning mail for ${domain} on ${ip}...`);const res=await provisionMailTenant(ip,log);if(res)await reconcileMailDns(res,ip,log);log.success(`Mail provisioned for ${domain}. Add MAIL_PASSWORD_<LOCALPART> env vars to pin mailbox passwords.`);await log.flush();process.exit(ExitCode.Success)});addMailStorageOptions(buddy.command("mail:storage:status","Show encrypted mail storage and external-key status")).action(async(options)=>{loadEnvFiles();try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,`${mailStorageRemote} status`).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to read mail storage status: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:kms:ensure","Create and apply the customer-managed production mail KMS key")).action(async(options)=>{try{const kms=await ensureMailKmsKey(options);console.log(`alias=${kms.alias}`);console.log(`arn=${kms.arn}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to configure mail KMS: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:volume:migrate","Create a dedicated Hetzner volume and migrate encrypted mail storage")).option("--size <gib>","Hetzner volume size in GiB",{default:"20"}).action(async(options)=>{let key,prepareAttempted=!1;try{const volume=await ensureMailStorageVolume(options);key=(await getMailStorageSecret(options)).key;runMailStorageSsh(volume.host,`for i in $(seq 1 60); do test -b ${shellQuote(volume.device)} && exit 0; sleep 1; done; exit 1`);prepareAttempted=!0;runMailStorageSsh(volume.host,["umask 077","key_file=$(mktemp /run/mail-volume-migrate-key.XXXXXX)",`trap 'shred -u "$key_file" 2>/dev/null || rm -f "$key_file"' EXIT`,'dd of="$key_file" bs=64 count=1 iflag=fullblock status=none',`MAIL_ENCRYPTED_IMAGE_SIZE=16G /usr/local/sbin/mail-volume-migrate prepare ${shellQuote(volume.device)}`,`${mailStorageRemote} unlock < "$key_file"`,"/usr/local/sbin/mail-volume-migrate finalize"].join(" && "),key);const status=runMailStorageSsh(volume.host,`${mailStorageRemote} status`).toString("utf8").trim();console.log(`volume=${volume.name}`);console.log(`volume_id=${volume.id}`);console.log(`volume_size_gib=${volume.size}`);console.log(status);process.exit(ExitCode.Success)}catch(error){if(prepareAttempted&&key){const host=await mailStorageHost(options);try{runMailStorageSsh(host,"/usr/local/sbin/mail-volume-migrate rollback")}catch(rollbackError){console.error(`Dedicated-volume rollback step failed: ${getErrorMessage(rollbackError)}`)}try{runMailStorageSsh(host,`${mailStorageRemote} unlock`,key)}catch(unlockError){console.error(`Mail storage re-unlock failed: ${getErrorMessage(unlockError)}`)}}console.error(`Failed to migrate mail storage volume: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:key:recovery:add","Add an independent LUKS recovery key to macOS Keychain")).action(async(options)=>{let currentKey,recoveryKey;try{const host=await mailStorageHost(options);currentKey=(await getMailStorageSecret(options)).key;recoveryKey=randomBytes(64);runMailStorageSsh(host,`${mailStorageRemote} add-recovery-key`,Buffer.concat([currentKey,recoveryKey]));const account="mail-storage-recovery",service="com.stacks.production.mail.luks",encoded=recoveryKey.toString("base64"),keychainWriter=`
@@ -56,7 +56,7 @@ var {require}=import.meta;import{log,runCommand}from"@stacksjs/cli";import{ExitC
56
56
  guard status == errSecSuccess, let stored = result as? Data, stored == secret
57
57
  else { exit(3) }
58
58
  `;if(spawnSync("/usr/bin/swift",["-e",keychainWriter],{input:`${encoded}
59
- `,maxBuffer:1048576}).status!==0)throw Error("macOS Keychain write failed");console.log(`keychain_service=${service}`);console.log("recovery_keyslot=verified");process.exit(ExitCode.Success)}catch(error){console.error(`Failed to add recovery key: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{currentKey?.fill(0);recoveryKey?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:key:recovery:verify","Verify the Keychain recovery key against the live LUKS header")).action(async(options)=>{let key;try{const host=await mailStorageHost(options);key=readMailRecoveryKey();runMailStorageSsh(host,"cryptsetup open --test-passphrase --type luks2 --key-file - /var/lib/mail-storage.luks",key);console.log("Keychain recovery key unlocks the live mail volume.");process.exit(ExitCode.Success)}catch(error){console.error(`Recovery key verification failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:key:slot:remove","Remove an unusable non-primary LUKS keyslot")).option("--slot <number>","LUKS keyslot number to remove").action(async(options)=>{let key;try{const slot=Number.parseInt(options.slot||"",10);if(!Number.isInteger(slot)||slot<1||slot>31)throw Error("Pass a non-primary keyslot from 1 through 31.");const host=await mailStorageHost(options);key=(await getMailStorageSecret(options)).key;const output=runMailStorageSsh(host,`${mailStorageRemote} remove-keyslot ${slot}`,key).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to remove LUKS keyslot: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:restic:configure","Configure client-encrypted Restic backups to S3")).option("--force","Rotate an existing dedicated IAM access key",{default:!1}).action(async(options)=>{try{const result=await configureMailRestic(options);console.log(`repository=${result.repository}`);console.log(`recovery_secret=${result.secretId}`);console.log("restore_check=ok");process.exit(ExitCode.Success)}catch(error){console.error(`Failed to configure Restic mail backups: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:restore:check","Restore and validate the newest off-host mail snapshot")).action(async(options)=>{try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,"/usr/local/sbin/mail-restic-restore-check").toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Mail restore check failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:maildir:migrate","Migrate live mailboxes to canonical Maildir++ layout")).action(async(options)=>{try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,"/usr/local/sbin/maildir-migrate migrate").toString("utf8").trim();console.log(output);const status=runMailStorageSsh(host,"/usr/local/sbin/maildir-migrate status").toString("utf8").trim();console.log(status);process.exit(ExitCode.Success)}catch(error){console.error(`Maildir++ migration failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:forward:compile","Compile and validate the live RFC 5228 forwarding script")).action(async(options)=>{try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,'/usr/local/sbin/mail-forward-compile /opt/mail/forwards.json /opt/mail/forwards.sieve && sed -n "1,160p" /opt/mail/forwards.sieve').toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Forwarding script compilation failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:escrow","Copy the machine-bound LUKS key to AWS Secrets Manager")).option("--force","Replace a different existing secret",{default:!1}).action(async(options)=>{try{const result=await escrowMailStorageKey(options);console.log(`Mail storage key verified in AWS Secrets Manager: ${result.secretId}`);console.log(`Host: ${result.host}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to escrow mail storage key: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:key:verify","Verify the external key against the LUKS2 header")).action(async(options)=>{let key;try{const host=await mailStorageHost(options),secret=await getMailStorageSecret(options);key=secret.key;runMailStorageSsh(host,"cryptsetup open --test-passphrase --type luks2 --key-file - /var/lib/mail-storage.luks",key);console.log(`External key ${secret.secretId} unlocks the mail volume.`);process.exit(ExitCode.Success)}catch(error){console.error(`Mail storage key verification failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:unlock","Unlock external mail storage and start mail")).action(async(options)=>{let key;try{const host=await mailStorageHost(options),secret=await getMailStorageSecret(options);key=secret.key;const output=runMailStorageSsh(host,`${mailStorageRemote} unlock`,key).toString("utf8").trim();console.log(output);console.log(`External key: ${secret.secretId}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to unlock mail storage: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:lock","Stop mail and lock external mail storage")).action(async(options)=>{loadEnvFiles();try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,`${mailStorageRemote} lock`).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to lock mail storage: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:backup","Create and verify an encrypted local mail backup")).action(async(options)=>{loadEnvFiles();try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,["set -eu","systemctl reset-failed mail-backup.service 2>/dev/null || true","systemctl start mail-backup.service",'latest=$(find -L /opt/mail/backups -maxdepth 1 -type f -name "mail-backup-*.tgz" -printf "%T@ %p\\n" | sort -nr | head -1 | cut -d" " -f2-)','test -n "$latest"','tar -tzf "$latest" >/dev/null','printf "backup=%s\\n" "$latest"','stat -c "mode=%a owner=%U:%G size=%s" "$latest"'].join("; ")).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to create mail backup: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:header:backup","Stream a LUKS header backup to S3 with KMS encryption")).option("--bucket <bucket>","Recovery bucket (defaults to <app>-<env>-s3-backups)").action(async(options)=>{try{const{region}=loadMailStorageAws(options),host=await mailStorageHost(options),app=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),environment=(process.env.APP_ENV||"production").toLowerCase().replace(/[^a-z0-9-]/g,"-"),bucket=options.bucket||`${app}-${environment}-s3-backups`;if(!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket))throw Error(`Unsafe S3 bucket name: ${bucket}`);const objectKey=`luks-headers/mail-storage-${new Date().toISOString().replace(/[-:]/g,"").replace(/\.\d{3}Z$/,"Z")}.header`,{AWSClient}=await import("@stacksjs/cloud"),{S3Client}=await import("@stacksjs/cloud"),kms=await ensureMailKmsKey(options),s3=new S3Client(region);await s3.putBucketEncryption(bucket,"aws:kms",kms.arn);const uploadUrl=s3.generatePresignedPutUrl(bucket,objectKey,"application/octet-stream",600),remoteCommand=["set -eu","tmp_dir=$(mktemp -d /run/mail-storage-header.XXXXXX)",'tmp="$tmp_dir/header"',`trap 'shred -u "$tmp" 2>/dev/null || rm -f "$tmp"; rmdir "$tmp_dir"' EXIT`,'cryptsetup luksHeaderBackup /var/lib/mail-storage.luks --header-backup-file "$tmp"',`curl --fail-with-body --silent --show-error -H 'Content-Type: application/octet-stream' --upload-file "$tmp" ${shellQuote(uploadUrl)} 1>&2`].join("; ");runMailStorageSsh(host,remoteCommand);const object=await s3.headObject(bucket,objectKey);if(!object?.ContentLength||object.ContentLength<1048576)throw Error("The uploaded LUKS header size did not verify.");const head=await new AWSClient().request({service:"s3",region,method:"HEAD",path:`/${bucket}/${objectKey}`,returnHeaders:!0}),encryption=head?.headers?.["x-amz-server-side-encryption"];if(encryption!=="aws:kms")throw Error(`The LUKS header object reported unexpected encryption: ${encryption||"none"}`);const keyId=head?.headers?.["x-amz-server-side-encryption-aws-kms-key-id"];if(keyId!==kms.arn)throw Error(`The LUKS header object used an unexpected KMS key: ${keyId||"none"}`);console.log(`header=s3://${bucket}/${objectKey}`);console.log("encryption=aws:kms");console.log(`kms_key=${kms.arn}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to back up the LUKS header: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:externalize","Move the LUKS key off-host and require external unlocks")).option("--force","Replace a different existing secret",{default:!1}).action(async(options)=>{let configured=!1,key;try{const escrow=await escrowMailStorageKey(options);key=(await getMailStorageSecret(options)).key;runMailStorageSsh(escrow.host,`${mailStorageRemote} configure-external`);configured=!0;runMailStorageSsh(escrow.host,`${mailStorageRemote} unlock`,key);runMailStorageSsh(escrow.host,`${mailStorageRemote} finalize-external`);const status=runMailStorageSsh(escrow.host,`${mailStorageRemote} status`).toString("utf8").trim();console.log(status);console.log(`External key: ${escrow.secretId}`);process.exit(ExitCode.Success)}catch(error){if(configured)try{const host=await mailStorageHost(options);runMailStorageSsh(host,`${mailStorageRemote} rollback-machine`)}catch(rollbackError){console.error(`Automatic rollback also failed: ${getErrorMessage(rollbackError)}`)}console.error(`Failed to externalize mail storage: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});buddy.command("mail:user:add <email>","Add a mail user").option("--password <password>","User password (generated if not provided)").action(async(email,options)=>{log.info(`Adding mail user: ${email}`);try{const password=options.password||randomBytes(16).toString("base64").replace(/[+/=]/g,"").substring(0,16),passwordHash=createHash("sha256").update(password).digest("hex"),tableName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-mail-users`,{DynamoDBClient}=await import("@stacksjs/ts-cloud");await new DynamoDBClient(process.env.AWS_REGION||"us-east-1").putItem({TableName:tableName,Item:{email:{S:email.toLowerCase()},passwordHash:{S:passwordHash},createdAt:{S:new Date().toISOString()}}});log.success(`User ${email} added successfully!`);console.log("");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log(" MAIL CREDENTIALS");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");console.log(` Email: ${email}`);console.log(` Password: ${password}`);console.log("");console.log(" To connect with Mail.app, run:");console.log(" buddy mail:proxy");console.log("");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");if(!options.password)log.warn("Save this password! It will not be shown again.");await log.flush();process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to add user: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:user:list","List mail users").action(async()=>{try{const tableName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-mail-users`,{DynamoDBClient}=await import("@stacksjs/ts-cloud"),result=await new DynamoDBClient(process.env.AWS_REGION||"us-east-1").scan({TableName:tableName});console.log("");console.log("Mail Users:");console.log("\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(!result.Items||result.Items.length===0)console.log(" No users found");else for(const item of result.Items){const email=item.email?.S||"unknown",createdAt=item.createdAt?.S||"unknown";console.log(` \uD83D\uDCE7 ${email}`);console.log(` Created: ${createdAt}`)}console.log("");process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to list users: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:user:delete <email>","Delete a mail user").action(async(email)=>{try{const tableName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-mail-users`,{DynamoDBClient}=await import("@stacksjs/ts-cloud");await new DynamoDBClient(process.env.AWS_REGION||"us-east-1").deleteItem({TableName:tableName,Key:{email:{S:email.toLowerCase()}}});log.success(`User ${email} deleted`);await log.flush();process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to delete user: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:proxy","Start local IMAP proxy for Mail.app").option("--port <port>","IMAP proxy port",{default:"1993"}).option("--api <url>","Mail API URL").action(async(options)=>{log.info("Starting IMAP proxy...");try{let apiUrl=options.api;if(!apiUrl){const{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),cfn=new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1"),stackName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-cloud`;try{apiUrl=((await cfn.describeStacks({stackName})).Stacks?.[0]?.Outputs||[]).find((o)=>o.OutputKey==="MailApiUrl")?.OutputValue}catch(e){}}if(!apiUrl){await log.error("Mail API URL not found. Run `buddy deploy` first to create the mail infrastructure.");process.exit(ExitCode.FatalError)}console.log("");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log(" IMAP PROXY");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");console.log(` API URL: ${apiUrl}`);console.log(` Port: ${options.port}`);console.log("");console.log(" Configure Mail.app with:");console.log(" \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");console.log(" Account Type: IMAP");console.log(" Incoming Server: localhost");console.log(` Incoming Port: ${options.port}`);console.log(" SSL: No (local connection)");console.log(" Outgoing Server: localhost");console.log(` Outgoing Port: ${options.port}`);console.log(" Username: your-email@stacksjs.com");console.log(" Password: your-password");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");process.env.MAIL_API_URL=apiUrl;process.env.IMAP_PORT=options.port;const{ImapProxy}=await import("../../mail-server/src/proxy/imap-proxy"),proxy=new ImapProxy(Number.parseInt(options.port,10),apiUrl);await proxy.start();process.on("SIGINT",()=>{console.log(`
59
+ `,maxBuffer:1048576}).status!==0)throw Error("macOS Keychain write failed");console.log(`keychain_service=${service}`);console.log("recovery_keyslot=verified");process.exit(ExitCode.Success)}catch(error){console.error(`Failed to add recovery key: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{currentKey?.fill(0);recoveryKey?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:key:recovery:verify","Verify the Keychain recovery key against the live LUKS header")).action(async(options)=>{let key;try{const host=await mailStorageHost(options);key=readMailRecoveryKey();runMailStorageSsh(host,"cryptsetup open --test-passphrase --type luks2 --key-file - /var/lib/mail-storage.luks",key);console.log("Keychain recovery key unlocks the live mail volume.");process.exit(ExitCode.Success)}catch(error){console.error(`Recovery key verification failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:key:slot:remove","Remove an unusable non-primary LUKS keyslot")).option("--slot <number>","LUKS keyslot number to remove").action(async(options)=>{let key;try{const slot=Number.parseInt(options.slot||"",10);if(!Number.isInteger(slot)||slot<1||slot>31)throw Error("Pass a non-primary keyslot from 1 through 31.");const host=await mailStorageHost(options);key=(await getMailStorageSecret(options)).key;const output=runMailStorageSsh(host,`${mailStorageRemote} remove-keyslot ${slot}`,key).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to remove LUKS keyslot: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:restic:configure","Configure client-encrypted Restic backups to S3")).option("--force","Rotate an existing dedicated IAM access key",{default:!1}).action(async(options)=>{try{const result=await configureMailRestic(options);console.log(`repository=${result.repository}`);console.log(`recovery_secret=${result.secretId}`);console.log("restore_check=ok");process.exit(ExitCode.Success)}catch(error){console.error(`Failed to configure Restic mail backups: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:restore:check","Restore and validate the newest off-host mail snapshot")).action(async(options)=>{try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,"/usr/local/sbin/mail-restic-restore-check").toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Mail restore check failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:maildir:migrate","Migrate live mailboxes to canonical Maildir++ layout")).action(async(options)=>{try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,"/usr/local/sbin/maildir-migrate migrate").toString("utf8").trim();console.log(output);const status=runMailStorageSsh(host,"/usr/local/sbin/maildir-migrate status").toString("utf8").trim();console.log(status);process.exit(ExitCode.Success)}catch(error){console.error(`Maildir++ migration failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:forward:compile","Compile and validate the live RFC 5228 forwarding script")).action(async(options)=>{try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,'/usr/local/sbin/mail-forward-compile /opt/mail/forwards.json /opt/mail/forwards.sieve && sed -n "1,160p" /opt/mail/forwards.sieve').toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Forwarding script compilation failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:escrow","Copy the machine-bound LUKS key to AWS Secrets Manager")).option("--force","Replace a different existing secret",{default:!1}).action(async(options)=>{try{const result=await escrowMailStorageKey(options);console.log(`Mail storage key verified in AWS Secrets Manager: ${result.secretId}`);console.log(`Host: ${result.host}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to escrow mail storage key: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:key:verify","Verify the external key against the LUKS2 header")).action(async(options)=>{let key;try{const host=await mailStorageHost(options),secret=await getMailStorageSecret(options);key=secret.key;runMailStorageSsh(host,"cryptsetup open --test-passphrase --type luks2 --key-file - /var/lib/mail-storage.luks",key);console.log(`External key ${secret.secretId} unlocks the mail volume.`);process.exit(ExitCode.Success)}catch(error){console.error(`Mail storage key verification failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:unlock","Unlock external mail storage and start mail")).action(async(options)=>{let key;try{const host=await mailStorageHost(options),secret=await getMailStorageSecret(options);key=secret.key;const output=runMailStorageSsh(host,`${mailStorageRemote} unlock`,key).toString("utf8").trim();console.log(output);console.log(`External key: ${secret.secretId}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to unlock mail storage: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:machine-bind","Enable unattended reboot recovery with a machine-bound encrypted LUKS credential")).action(async(options)=>{let key;try{const host=await mailStorageHost(options),secret=await getMailStorageSecret(options);key=secret.key;const output=runMailStorageSsh(host,machineBindMailStorageCommand(),key).toString("utf8").trim();if(output)console.log(output);const status=runMailStorageSsh(host,`${mailStorageRemote} status`).toString("utf8").trim();console.log(status);console.log(`Recovery secret retained: ${secret.secretId}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to machine-bind mail storage: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:lock","Stop mail and lock external mail storage")).action(async(options)=>{loadEnvFiles();try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,`${mailStorageRemote} lock`).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to lock mail storage: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:backup","Create and verify an encrypted local mail backup")).action(async(options)=>{loadEnvFiles();try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,["set -eu","systemctl reset-failed mail-backup.service 2>/dev/null || true","systemctl start mail-backup.service",'latest=$(find -L /opt/mail/backups -maxdepth 1 -type f -name "mail-backup-*.tgz" -printf "%T@ %p\\n" | sort -nr | head -1 | cut -d" " -f2-)','test -n "$latest"','tar -tzf "$latest" >/dev/null','printf "backup=%s\\n" "$latest"','stat -c "mode=%a owner=%U:%G size=%s" "$latest"'].join("; ")).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to create mail backup: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:header:backup","Stream a LUKS header backup to S3 with KMS encryption")).option("--bucket <bucket>","Recovery bucket (defaults to <app>-<env>-s3-backups)").action(async(options)=>{try{const{region}=loadMailStorageAws(options),host=await mailStorageHost(options),app=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),environment=(process.env.APP_ENV||"production").toLowerCase().replace(/[^a-z0-9-]/g,"-"),bucket=options.bucket||`${app}-${environment}-s3-backups`;if(!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket))throw Error(`Unsafe S3 bucket name: ${bucket}`);const objectKey=`luks-headers/mail-storage-${new Date().toISOString().replace(/[-:]/g,"").replace(/\.\d{3}Z$/,"Z")}.header`,{AWSClient}=await import("@stacksjs/cloud"),{S3Client}=await import("@stacksjs/cloud"),kms=await ensureMailKmsKey(options),s3=new S3Client(region);await s3.putBucketEncryption(bucket,"aws:kms",kms.arn);const uploadUrl=s3.generatePresignedPutUrl(bucket,objectKey,"application/octet-stream",600),remoteCommand=["set -eu","tmp_dir=$(mktemp -d /run/mail-storage-header.XXXXXX)",'tmp="$tmp_dir/header"',`trap 'shred -u "$tmp" 2>/dev/null || rm -f "$tmp"; rmdir "$tmp_dir"' EXIT`,'cryptsetup luksHeaderBackup /var/lib/mail-storage.luks --header-backup-file "$tmp"',`curl --fail-with-body --silent --show-error -H 'Content-Type: application/octet-stream' --upload-file "$tmp" ${shellQuote(uploadUrl)} 1>&2`].join("; ");runMailStorageSsh(host,remoteCommand);const object=await s3.headObject(bucket,objectKey);if(!object?.ContentLength||object.ContentLength<1048576)throw Error("The uploaded LUKS header size did not verify.");const head=await new AWSClient().request({service:"s3",region,method:"HEAD",path:`/${bucket}/${objectKey}`,returnHeaders:!0}),encryption=head?.headers?.["x-amz-server-side-encryption"];if(encryption!=="aws:kms")throw Error(`The LUKS header object reported unexpected encryption: ${encryption||"none"}`);const keyId=head?.headers?.["x-amz-server-side-encryption-aws-kms-key-id"];if(keyId!==kms.arn)throw Error(`The LUKS header object used an unexpected KMS key: ${keyId||"none"}`);console.log(`header=s3://${bucket}/${objectKey}`);console.log("encryption=aws:kms");console.log(`kms_key=${kms.arn}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to back up the LUKS header: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:externalize","Move the LUKS key off-host and require external unlocks")).option("--force","Replace a different existing secret",{default:!1}).action(async(options)=>{let configured=!1,key;try{const escrow=await escrowMailStorageKey(options);key=(await getMailStorageSecret(options)).key;runMailStorageSsh(escrow.host,`${mailStorageRemote} configure-external`);configured=!0;runMailStorageSsh(escrow.host,`${mailStorageRemote} unlock`,key);runMailStorageSsh(escrow.host,`${mailStorageRemote} finalize-external`);const status=runMailStorageSsh(escrow.host,`${mailStorageRemote} status`).toString("utf8").trim();console.log(status);console.log(`External key: ${escrow.secretId}`);process.exit(ExitCode.Success)}catch(error){if(configured)try{const host=await mailStorageHost(options);runMailStorageSsh(host,`${mailStorageRemote} rollback-machine`)}catch(rollbackError){console.error(`Automatic rollback also failed: ${getErrorMessage(rollbackError)}`)}console.error(`Failed to externalize mail storage: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});buddy.command("mail:user:add <email>","Add a mail user").option("--password <password>","User password (generated if not provided)").action(async(email,options)=>{log.info(`Adding mail user: ${email}`);try{const password=options.password||randomBytes(16).toString("base64").replace(/[+/=]/g,"").substring(0,16),passwordHash=createHash("sha256").update(password).digest("hex"),tableName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-mail-users`,{DynamoDBClient}=await import("@stacksjs/ts-cloud");await new DynamoDBClient(process.env.AWS_REGION||"us-east-1").putItem({TableName:tableName,Item:{email:{S:email.toLowerCase()},passwordHash:{S:passwordHash},createdAt:{S:new Date().toISOString()}}});log.success(`User ${email} added successfully!`);console.log("");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log(" MAIL CREDENTIALS");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");console.log(` Email: ${email}`);console.log(` Password: ${password}`);console.log("");console.log(" To connect with Mail.app, run:");console.log(" buddy mail:proxy");console.log("");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");if(!options.password)log.warn("Save this password! It will not be shown again.");await log.flush();process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to add user: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:user:list","List mail users").action(async()=>{try{const tableName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-mail-users`,{DynamoDBClient}=await import("@stacksjs/ts-cloud"),result=await new DynamoDBClient(process.env.AWS_REGION||"us-east-1").scan({TableName:tableName});console.log("");console.log("Mail Users:");console.log("\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(!result.Items||result.Items.length===0)console.log(" No users found");else for(const item of result.Items){const email=item.email?.S||"unknown",createdAt=item.createdAt?.S||"unknown";console.log(` \uD83D\uDCE7 ${email}`);console.log(` Created: ${createdAt}`)}console.log("");process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to list users: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:user:delete <email>","Delete a mail user").action(async(email)=>{try{const tableName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-mail-users`,{DynamoDBClient}=await import("@stacksjs/ts-cloud");await new DynamoDBClient(process.env.AWS_REGION||"us-east-1").deleteItem({TableName:tableName,Key:{email:{S:email.toLowerCase()}}});log.success(`User ${email} deleted`);await log.flush();process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to delete user: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:proxy","Start local IMAP proxy for Mail.app").option("--port <port>","IMAP proxy port",{default:"1993"}).option("--api <url>","Mail API URL").action(async(options)=>{log.info("Starting IMAP proxy...");try{let apiUrl=options.api;if(!apiUrl){const{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),cfn=new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1"),stackName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-cloud`;try{apiUrl=((await cfn.describeStacks({stackName})).Stacks?.[0]?.Outputs||[]).find((o)=>o.OutputKey==="MailApiUrl")?.OutputValue}catch(e){}}if(!apiUrl){await log.error("Mail API URL not found. Run `buddy deploy` first to create the mail infrastructure.");process.exit(ExitCode.FatalError)}console.log("");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log(" IMAP PROXY");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");console.log(` API URL: ${apiUrl}`);console.log(` Port: ${options.port}`);console.log("");console.log(" Configure Mail.app with:");console.log(" \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");console.log(" Account Type: IMAP");console.log(" Incoming Server: localhost");console.log(` Incoming Port: ${options.port}`);console.log(" SSL: No (local connection)");console.log(" Outgoing Server: localhost");console.log(` Outgoing Port: ${options.port}`);console.log(" Username: your-email@stacksjs.com");console.log(" Password: your-password");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");process.env.MAIL_API_URL=apiUrl;process.env.IMAP_PORT=options.port;const{ImapProxy}=await import("../../mail-server/src/proxy/imap-proxy"),proxy=new ImapProxy(Number.parseInt(options.port,10),apiUrl);await proxy.start();process.on("SIGINT",()=>{console.log(`
60
60
  Shutting down proxy...`);proxy.stop();process.exit(0)})}catch(error){await log.error(`Failed to start proxy: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:test","Test mail API connection").action(async()=>{try{const{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),cfn=new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1"),stackName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-cloud`,mailApiOutput=((await cfn.describeStacks({stackName})).Stacks?.[0]?.Outputs||[]).find((o)=>o.OutputKey==="MailApiUrl");if(!mailApiOutput?.OutputValue){await log.error("Mail API not deployed. Run `buddy deploy` first.");process.exit(ExitCode.FatalError)}const apiUrl=mailApiOutput.OutputValue;log.info(`Testing Mail API at ${apiUrl}`);const response=await fetch(`${apiUrl}/mailboxes`,{headers:{Authorization:`Basic ${Buffer.from("test@stacksjs.com:test").toString("base64")}`}});if(response.status===401)log.success("Mail API is responding (auth required)");else if(response.ok){log.success("Mail API is working!");const data=await response.json();console.log("Response:",JSON.stringify(data,null,2))}else log.warn(`Mail API returned status ${response.status}`);await log.flush();process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to test API: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:credentials [email]","Show SMTP credentials for email access").action(async(emailAddress)=>{loadEnvFiles();const domain=process.env.APP_DOMAIN||process.env.MAIL_DOMAIN||"stacksjs.com",mailHost=`mail.${domain}`,email=emailAddress||`chris@${domain}`,localPart=email.includes("@")?email.split("@")[0]:email,username=email,envKey=`MAIL_PASSWORD_${localPart.toUpperCase().replace(/[^A-Z0-9]/g,"_")}`,rawPassword=process.env[envKey],isCiphertext=typeof rawPassword==="string"&&rawPassword.replace(/^["']/,"").startsWith("encrypted:"),password=isCiphertext?void 0:rawPassword;console.log("");console.log("==========================================================");console.log(` EMAIL CREDENTIALS - ${email}`);console.log("==========================================================");console.log("");console.log(` Email: ${email}`);console.log(` Host: ${mailHost}`);console.log(" Port: 587");console.log(" Security: STARTTLS");console.log(` Username: ${username}`);if(password)console.log(` Password: ${password}`);else if(isCiphertext)console.log(" Password: (encrypted - this machine has no private key for .env.production)");else console.log(` Password: (not set - add ${envKey} to .env.production)`);console.log("");console.log("==========================================================");console.log("");process.exit(ExitCode.Success)});buddy.command("mail:logs","Show mail server logs from production").option("-n, --lines <count>","Number of log lines to show",{default:"50"}).option("-f, --follow","Follow log output (poll every 5s)").option("--filter <pattern>","Filter logs by pattern (e.g. AUTH, LOGIN, error)").action(async(options)=>{loadEnvFiles();const directHost=await resolveOperationalMailHost();if(directHost){const lines=sanitizeLineCount(options.lines),filter=options.filter?options.filter.replace(/[^a-zA-Z0-9_.@\s-]/g,""):"",command=`journalctl -u mail --no-pager -n ${lines}${filter?` | grep -iE ${shellQuote(filter)}`:""}`;try{const output=execSync(`ssh -o BatchMode=yes -o ConnectTimeout=10 root@${shellQuote(directHost)} ${shellQuote(command)}`,{encoding:"utf8"});console.log(output);process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to fetch logs from ${directHost}: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}}const region=process.env.AWS_REGION||"us-east-1",appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");try{const{EC2Client,SSMClient}=await import("@stacksjs/ts-cloud"),ec2=new EC2Client(region),ssm=new SSMClient(region);log.info("Finding mail server...");const instanceId=(await ec2.describeInstances({Filters:[{Name:"tag:Name",Values:[`${appName}-mail-server`]},{Name:"instance-state-name",Values:["running"]}]})).Reservations?.[0]?.Instances?.[0]?.InstanceId;if(!instanceId){await log.error("No running mail server found.");log.info(`Looked for instances tagged: ${appName}-mail-server`);await log.flush();process.exit(ExitCode.FatalError)}log.info(`Found instance: ${instanceId}`);const lines=sanitizeLineCount(options.lines),sanitizedFilter=options.filter?options.filter.replace(/[^a-zA-Z0-9_.@\s-]/g,""):"",filterCmd=sanitizedFilter?` | grep -iE '${sanitizedFilter}'`:"",fetchLogs=async()=>{const cmd=`journalctl -u smtp-server -u mail-server --no-pager -n ${lines} --since "10 minutes ago" 2>&1${filterCmd}`,result=await ssm.runShellCommand(instanceId,[cmd],{timeoutSeconds:30,maxWaitMs:30000,pollIntervalMs:2000});if(!result.success){log.error(`Failed to fetch logs: ${result.error||"Unknown error"}`);return!1}if(result.output){const logLines=result.output.trim().split(`
61
61
  `);for(const line of logLines)if(line.includes("error")||line.includes("Error")||line.includes("AUTH failed"))console.log(` \x1B[31m${line}\x1B[0m`);else if(line.includes("warning")||line.includes("Warning"))console.log(` \x1B[33m${line}\x1B[0m`);else if(line.includes("AUTH success")||line.includes("LOGIN completed")||line.includes("authenticated"))console.log(` \x1B[32m${line}\x1B[0m`);else console.log(` ${line}`)}else console.log(" No log entries found.");return!0};console.log("");console.log("==========================================================");console.log(" MAIL SERVER LOGS");console.log("==========================================================");console.log("");await fetchLogs();if(options.follow){console.log("");log.info("Following logs (Ctrl+C to stop)...");let polling=!1;const poll=setInterval(()=>{if(polling)return;polling=!0;console.log("");fetchLogs().catch((e)=>log.error("Log polling failed:",e)).finally(()=>{polling=!1})},5000);await new Promise((resolve)=>{process.on("SIGINT",()=>{clearInterval(poll);resolve()})})}console.log("");process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to fetch logs: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:status","Show mail server status").action(async()=>{loadEnvFiles();const directHost=await resolveOperationalMailHost();if(directHost){const command='systemctl is-active mail; systemctl show mail -p NRestarts -p ActiveEnterTimestamp --value; ss -H -ltn | grep -E "(:(25|143|465|587|993))\\b"; systemctl is-enabled mail-health.timer; systemctl is-active mail-health.timer';try{const output=execSync(`ssh -o BatchMode=yes -o ConnectTimeout=10 root@${shellQuote(directHost)} ${shellQuote(command)}`,{encoding:"utf8"});console.log(output);process.exit(ExitCode.Success)}catch(error){await log.error(`Mail server ${directHost} is unhealthy: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}}const region=process.env.AWS_REGION||"us-east-1",appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");try{const{EC2Client,SSMClient}=await import("@stacksjs/ts-cloud"),ec2=new EC2Client(region),ssm=new SSMClient(region),instanceId=(await ec2.describeInstances({Filters:[{Name:"tag:Name",Values:[`${appName}-mail-server`]},{Name:"instance-state-name",Values:["running"]}]})).Reservations?.[0]?.Instances?.[0]?.InstanceId;if(!instanceId){await log.error("No running mail server found.");log.info(`Looked for instances tagged: ${appName}-mail-server`);await log.flush();process.exit(ExitCode.FatalError)}const result=await ssm.runShellCommand(instanceId,["echo === Service ===","(systemctl status smtp-server --no-pager 2>&1 || systemctl status mail-server --no-pager 2>&1) | head -15","echo === Ports ===",'ss -tlnp 2>&1 | grep -E "(25|587|465|993|143|110|995)" || echo "No mail ports listening"',"echo === Memory ===",`ps aux | grep -E "(smtp-server|bun|mail)" | grep -v grep | awk '{print $6/1024 "MB", $11}'`],{timeoutSeconds:30,maxWaitMs:30000,pollIntervalMs:2000});console.log("");console.log("==========================================================");console.log(" MAIL SERVER STATUS");console.log("==========================================================");console.log("");if(result.success&&result.output)for(const line of result.output.trim().split(`
62
62
  `))console.log(` ${line}`);else log.error(`Failed: ${result.error}`);console.log("");await log.flush();process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to get status: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:port25:request","Request port 25 unblock for direct SMTP delivery").option("--provider <provider>","Cloud provider: aws or hetzner",{default:"aws"}).option("--instance-id <id>","AWS EC2 instance ID (auto-detected if not provided)").option("--server-id <id>","Hetzner server ID (auto-detected if not provided)").option("--elastic-ip <ip>","AWS Elastic IP address").option("--rdns <hostname>","Reverse DNS hostname (e.g. mail.stacksjs.com)").option("--use-case <text>","Description of email use case").action(async(options)=>{loadEnvFiles();const domain=process.env.APP_DOMAIN||process.env.MAIL_DOMAIN||"stacksjs.com",provider=options.provider.toLowerCase();if(provider==="aws")await requestAwsPort25(options,domain);else if(provider==="hetzner")await requestHetznerPort25(options,domain);else{await log.error(`Unknown provider: ${provider}. Use 'aws' or 'hetzner'.`);process.exit(ExitCode.FatalError)}});buddy.command("mail:port25:status","Check outbound port 25 status on the mail server").option("--provider <provider>","Cloud provider: aws or hetzner",{default:"aws"}).action(async(options)=>{loadEnvFiles();const region=process.env.AWS_REGION||"us-east-1",appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");if(options.provider==="hetzner"){const hetznerToken=process.env.HETZNER_API_TOKEN;if(!hetznerToken){await log.error("HETZNER_API_TOKEN not set");process.exit(ExitCode.FatalError)}try{const ip=(await(await fetch("https://api.hetzner.cloud/v1/servers?label_selector=service=mail",{headers:{Authorization:`Bearer ${hetznerToken}`}})).json()).servers?.[0]?.public_net?.ipv4?.ip;if(!ip){const mailServer=(await(await fetch("https://api.hetzner.cloud/v1/servers",{headers:{Authorization:`Bearer ${hetznerToken}`}})).json()).servers?.find((s)=>s.name?.includes("mail"));if(mailServer){const serverIp=mailServer.public_net?.ipv4?.ip;log.info(`Found Hetzner server: ${mailServer.name} (${serverIp})`);const result=execSync(`ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@${serverIp} "timeout 5 bash -c 'echo QUIT > /dev/tcp/gmail-smtp-in.l.google.com/25' 2>&1 && echo PORT_25_OPEN || echo PORT_25_BLOCKED"`,{encoding:"utf-8"});printPort25Status(result.trim(),"Hetzner",serverIp)}else log.error("No mail server found on Hetzner")}else{const result=execSync(`ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@${ip} "timeout 5 bash -c 'echo QUIT > /dev/tcp/gmail-smtp-in.l.google.com/25' 2>&1 && echo PORT_25_OPEN || echo PORT_25_BLOCKED"`,{encoding:"utf-8"});printPort25Status(result.trim(),"Hetzner",ip)}}catch(error){await log.error(`Failed: ${getErrorMessage(error)}`)}process.exit(ExitCode.Success)}try{const{EC2Client,SSMClient}=await import("@stacksjs/ts-cloud"),ec2=new EC2Client(region),ssm=new SSMClient(region),instance=(await ec2.describeInstances({Filters:[{Name:"tag:Name",Values:[`${appName}-mail-server`]},{Name:"instance-state-name",Values:["running"]}]})).Reservations?.[0]?.Instances?.[0],instanceId=instance?.InstanceId;if(!instanceId){await log.error("No running mail server found.");process.exit(ExitCode.FatalError)}const publicIp=instance?.PublicIpAddress||"unknown";log.info(`Testing port 25 on ${instanceId} (${publicIp})...`);const result=await ssm.runShellCommand(instanceId,['timeout 5 bash -c "echo QUIT > /dev/tcp/gmail-smtp-in.l.google.com/25" 2>&1 && echo PORT_25_OPEN || echo PORT_25_BLOCKED'],{timeoutSeconds:15,maxWaitMs:20000,pollIntervalMs:2000});printPort25Status(result.output?.trim()||"PORT_25_BLOCKED","AWS",publicIp);process.exit(ExitCode.Success)}catch(error){await log.error(`Failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:server","Start SMTP relay server").option("--port <port>","SMTP port",{default:"587"}).action(async(options)=>{loadEnvFiles();const domain=process.env.APP_DOMAIN||process.env.MAIL_DOMAIN||"stacksjs.com",port=Number.parseInt(options.port,10),mailboxes=["chris","blake","glenn"],users={},generated=[];for(const name of mailboxes){const envKey=`MAIL_PASSWORD_${name.toUpperCase()}`;let password=process.env[envKey];if(!password){password=randomBytes(16).toString("base64url");generated.push(`${envKey}=${password}`)}users[name]={password,email:`${name}@${domain}`}}if(generated.length>0){console.log("");log.warn("Generated missing passwords. Add these to .env.production:");for(const line of generated)console.log(` ${line}`)}console.log("");console.log("==========================================================");console.log(" SMTP RELAY SERVER");console.log("==========================================================");console.log("");console.log(` Host: mail.${domain}`);console.log(` Port: ${port}`);console.log(` Domain: ${domain}`);console.log("");console.log(" Mailboxes:");for(const[name,user]of Object.entries(users))console.log(` ${name} (${user.email})`);console.log("");console.log(" Relaying through AWS SES");console.log("==========================================================");console.log("");try{const{SmtpServer}=await import("@stacksjs/cloud"),server=new SmtpServer({port,host:"0.0.0.0",domain,users,sentBucket:"stacks-production-email",sentPrefix:"sent/"});await server.start();await new Promise((resolve)=>{process.on("SIGINT",async()=>{console.log(`
@@ -5,14 +5,6 @@
5
5
  * a dangling one; only the second is ours to clean up.
6
6
  */
7
7
  export declare function isDanglingLink(target: string): boolean;
8
- /**
9
- * The package manager this project actually installs with.
10
- *
11
- * An app installed with pantry has no node_modules at all, so running `bun
12
- * install` at the end of an unvendor writes a second, competing dependency
13
- * tree that the `./buddy` shim does not read — the install "succeeds" and the
14
- * project still cannot resolve a single @stacksjs package.
15
- */
16
8
  export declare function detectInstaller(cwd: string): string[];
17
9
  /**
18
10
  * Repoint package manifests that remain under `storage/framework` after core
@@ -1,3 +1,3 @@
1
- import{existsSync,lstatSync}from"node:fs";import{readdir}from"node:fs/promises";import{join,resolve}from"node:path";import{log}from"@stacksjs/cli";import{fs}from"@stacksjs/storage";export function isDanglingLink(target){try{return lstatSync(target).isSymbolicLink()&&!existsSync(target)}catch{return!1}}export function detectInstaller(cwd){const has=(rel)=>existsSync(resolve(cwd,rel)),usesPantry=has("pantry")||has("pantry.lock")&&!has("node_modules");if(usesPantry&&Bun.which("pantry"))return["pantry","install"];if(usesPantry)log.warn("This project installs with pantry, but no `pantry` binary is on PATH \u2014 falling back to `bun install`.");return["bun","install"]}export async function rewriteSurvivingFrameworkManifests(cwd,provided,range){const framework=resolve(cwd,"storage/framework"),files=[];let ranges=0;const walk=async(dir)=>{let entries;try{entries=await readdir(dir,{withFileTypes:!0})}catch{return}for(const entry of entries){if(entry.name==="core"||entry.name==="node_modules"||entry.name==="dist")continue;const full=join(dir,entry.name);if(entry.isDirectory()){await walk(full);continue}if(!entry.isFile()||entry.name!=="package.json")continue;const raw=await fs.promises.readFile(full,"utf-8"),pkg=JSON.parse(raw);let touched=!1;for(const field of["dependencies","devDependencies","peerDependencies"]){const dependencies=pkg[field];if(!dependencies)continue;for(const[name,spec]of Object.entries(dependencies)){if(!spec.startsWith("workspace:")||!provided.has(name))continue;dependencies[name]=range;ranges++;touched=!0}}if(!touched)continue;await fs.promises.writeFile(full,`${JSON.stringify(pkg,null,2)}
1
+ import{existsSync,lstatSync,readFileSync}from"node:fs";import{readdir}from"node:fs/promises";import{join,resolve}from"node:path";import{log}from"@stacksjs/cli";import{fs}from"@stacksjs/storage";export function isDanglingLink(target){try{return lstatSync(target).isSymbolicLink()&&!existsSync(target)}catch{return!1}}function hoistedLinker(cwd){try{const bunfig=readFileSync(resolve(cwd,"bunfig.toml"),"utf-8");return/^\s*linker\s*=\s*["']hoisted["']/m.test(bunfig)}catch{return!1}}export function detectInstaller(cwd){const has=(rel)=>existsSync(resolve(cwd,rel)),usesPantry=has("pantry")||has("pantry.lock")&&!has("node_modules");if(usesPantry&&Bun.which("pantry")){if(hoistedLinker(cwd))return["pantry","install","--linker","hoisted"];return["pantry","install"]}if(usesPantry)log.warn("This project installs with pantry, but no `pantry` binary is on PATH \u2014 falling back to `bun install`.");return["bun","install"]}export async function rewriteSurvivingFrameworkManifests(cwd,provided,range){const framework=resolve(cwd,"storage/framework"),files=[];let ranges=0;const walk=async(dir)=>{let entries;try{entries=await readdir(dir,{withFileTypes:!0})}catch{return}for(const entry of entries){if(entry.name==="core"||entry.name==="node_modules"||entry.name==="dist")continue;const full=join(dir,entry.name);if(entry.isDirectory()){await walk(full);continue}if(!entry.isFile()||entry.name!=="package.json")continue;const raw=await fs.promises.readFile(full,"utf-8"),pkg=JSON.parse(raw);let touched=!1;for(const field of["dependencies","devDependencies","peerDependencies"]){const dependencies=pkg[field];if(!dependencies)continue;for(const[name,spec]of Object.entries(dependencies)){if(!spec.startsWith("workspace:")||!provided.has(name))continue;dependencies[name]=range;ranges++;touched=!0}}if(!touched)continue;await fs.promises.writeFile(full,`${JSON.stringify(pkg,null,2)}
2
2
  `);files.push(full.replace(`${cwd}/`,""))}};await walk(framework);return{files,ranges}}const SCANNED_EXTENSIONS=new Set([".ts",".tsx",".js",".mjs",".cjs",".json",".sh",".yml",".yaml",".toml"]),SKIPPED_DIRECTORIES=new Set(["node_modules","pantry","dist","storage","public","coverage","temp","tmp",".git",".cache",".claude",".idea",".stx",".vscode"]);async function*projectFiles(cwd){const walk=async function*(dir){let entries;try{entries=await readdir(dir,{withFileTypes:!0})}catch{return}for(const entry of entries){const full=join(dir,entry.name);if(entry.isDirectory()){if(SKIPPED_DIRECTORIES.has(entry.name))continue;yield*walk(full);continue}if(!entry.isFile())continue;const dot=entry.name.lastIndexOf("."),ext=dot===-1?"":entry.name.slice(dot);if(SCANNED_EXTENSIONS.has(ext)||entry.name==="Dockerfile"||entry.name.startsWith("Dockerfile."))yield full}};yield*walk(cwd)}const CORE_SOURCE_IMPORT=/(\bfrom\s+|\bimport\s*\(\s*|\brequire\s*\(\s*)(['"])(?:\.{1,2}\/)*storage\/framework\/core\/([\w.-]+)\/src\/([^'"]+?)(?:\.ts)?\2/g;function toPackageSpecifier(pkg,rest){const subpath=rest.replace(/\/index$/,"");return subpath==="index"?`@stacksjs/${pkg}`:`@stacksjs/${pkg}/${subpath}`}const CORE_ACTION_COMMANDS={"actions/src/migrate/database":"migrate","actions/src/migrate/fresh":"migrate:fresh","actions/src/database/seed":"seed","actions/src/auth/setup":"auth:setup","actions/src/dev/api":"dev:api","actions/src/key-generate":"key:generate"},CORE_COMMAND_PATH=/\bbunx?\s+(?:-{1,2}[\w=.-]+\s+)*(?:\.\/)?storage\/framework\/core\/([\w/.-]+?)\.ts\b/g;export async function rewriteCoreCommandPaths(cwd){const touched=[];for await(const file of projectFiles(cwd)){const raw=await fs.promises.readFile(file,"utf-8");if(!raw.includes("storage/framework/core/"))continue;const next=raw.replace(CORE_COMMAND_PATH,(match,entry)=>{if(entry==="buddy/src/cli")return"./buddy";const command=CORE_ACTION_COMMANDS[entry];return command?`./buddy ${command}`:match});if(next!==raw){await fs.promises.writeFile(file,next);touched.push(file.replace(`${cwd}/`,""))}}return touched}export async function rewriteCoreSourceImports(cwd){const touched=[];for await(const file of projectFiles(cwd)){const raw=await fs.promises.readFile(file,"utf-8");if(!raw.includes("storage/framework/core/"))continue;const next=raw.replace(CORE_SOURCE_IMPORT,(_match,lead,quote,pkg,rest)=>`${lead}${quote}${toPackageSpecifier(pkg,rest)}${quote}`);if(next!==raw){await fs.promises.writeFile(file,next);touched.push(file.replace(`${cwd}/`,""))}}return touched}export async function findCoreReferences(cwd){const hits=[];for await(const file of projectFiles(cwd)){const raw=await fs.promises.readFile(file,"utf-8");if(!raw.includes("storage/framework/core"))continue;raw.split(`
3
3
  `).forEach((text,index)=>{if(!text.includes("storage/framework/core"))return;const trimmed=text.trim();if(/^(?:\/\/|\*|#|<!--)/.test(trimmed))return;hits.push({file:file.replace(`${cwd}/`,""),line:index+1,text:trimmed.slice(0,120)})})}return hits}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/buddy",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.74.26",
5
+ "version": "0.74.27",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -95,66 +95,66 @@
95
95
  "prepublishOnly": "bun run build"
96
96
  },
97
97
  "dependencies": {
98
- "@stacksjs/actions": "^0.74.26",
99
- "@stacksjs/ai": "^0.74.26",
100
- "@stacksjs/alias": "^0.74.26",
101
- "@stacksjs/analytics": "^0.74.26",
102
- "@stacksjs/api": "^0.74.26",
103
- "@stacksjs/arrays": "^0.74.26",
104
- "@stacksjs/auth": "^0.74.26",
105
- "@stacksjs/browser-extension": "^0.74.26",
106
- "@stacksjs/build": "^0.74.26",
107
- "@stacksjs/cache": "^0.74.26",
108
- "@stacksjs/chat": "^0.74.26",
98
+ "@stacksjs/actions": "^0.74.27",
99
+ "@stacksjs/ai": "^0.74.27",
100
+ "@stacksjs/alias": "^0.74.27",
101
+ "@stacksjs/analytics": "^0.74.27",
102
+ "@stacksjs/api": "^0.74.27",
103
+ "@stacksjs/arrays": "^0.74.27",
104
+ "@stacksjs/auth": "^0.74.27",
105
+ "@stacksjs/browser-extension": "^0.74.27",
106
+ "@stacksjs/build": "^0.74.27",
107
+ "@stacksjs/cache": "^0.74.27",
108
+ "@stacksjs/chat": "^0.74.27",
109
109
  "@stacksjs/clapp": "^0.2.12",
110
- "@stacksjs/cli": "^0.74.26",
111
- "@stacksjs/cloud": "^0.74.26",
112
- "@stacksjs/cms": "^0.74.26",
113
- "@stacksjs/collections": "^0.74.26",
114
- "@stacksjs/config": "^0.74.26",
115
- "@stacksjs/database": "^0.74.26",
116
- "@stacksjs/desktop-build": "^0.74.26",
117
- "@stacksjs/dns": "^0.74.26",
110
+ "@stacksjs/cli": "^0.74.27",
111
+ "@stacksjs/cloud": "^0.74.27",
112
+ "@stacksjs/cms": "^0.74.27",
113
+ "@stacksjs/collections": "^0.74.27",
114
+ "@stacksjs/config": "^0.74.27",
115
+ "@stacksjs/database": "^0.74.27",
116
+ "@stacksjs/desktop-build": "^0.74.27",
117
+ "@stacksjs/dns": "^0.74.27",
118
118
  "@stacksjs/dnsx": "^0.2.3",
119
- "@stacksjs/email": "^0.74.26",
120
- "@stacksjs/enums": "^0.74.26",
121
- "@stacksjs/env": "^0.74.26",
122
- "@stacksjs/error-handling": "^0.74.26",
123
- "@stacksjs/events": "^0.74.26",
124
- "@stacksjs/features": "^0.74.26",
125
- "@stacksjs/git": "^0.74.26",
119
+ "@stacksjs/email": "^0.74.27",
120
+ "@stacksjs/enums": "^0.74.27",
121
+ "@stacksjs/env": "^0.74.27",
122
+ "@stacksjs/error-handling": "^0.74.27",
123
+ "@stacksjs/events": "^0.74.27",
124
+ "@stacksjs/features": "^0.74.27",
125
+ "@stacksjs/git": "^0.74.27",
126
126
  "@stacksjs/gitit": "^0.2.5",
127
- "@stacksjs/health": "^0.74.26",
127
+ "@stacksjs/health": "^0.74.27",
128
128
  "@stacksjs/httx": "^0.1.10",
129
- "@stacksjs/image": "^0.74.26",
130
- "@stacksjs/lint": "^0.74.26",
131
- "@stacksjs/logging": "^0.74.26",
132
- "@stacksjs/notifications": "^0.74.26",
133
- "@stacksjs/objects": "^0.74.26",
134
- "@stacksjs/orm": "^0.74.26",
135
- "@stacksjs/path": "^0.74.26",
136
- "@stacksjs/payments": "^0.74.26",
137
- "@stacksjs/realtime": "^0.74.26",
138
- "@stacksjs/router": "^0.74.26",
129
+ "@stacksjs/image": "^0.74.27",
130
+ "@stacksjs/lint": "^0.74.27",
131
+ "@stacksjs/logging": "^0.74.27",
132
+ "@stacksjs/notifications": "^0.74.27",
133
+ "@stacksjs/objects": "^0.74.27",
134
+ "@stacksjs/orm": "^0.74.27",
135
+ "@stacksjs/path": "^0.74.27",
136
+ "@stacksjs/payments": "^0.74.27",
137
+ "@stacksjs/realtime": "^0.74.27",
138
+ "@stacksjs/router": "^0.74.27",
139
139
  "@stacksjs/rpx": "^0.11.42",
140
- "@stacksjs/scheduler": "^0.74.26",
141
- "@stacksjs/search-engine": "^0.74.26",
142
- "@stacksjs/security": "^0.74.26",
143
- "@stacksjs/server": "^0.74.26",
144
- "@stacksjs/sites": "^0.74.26",
145
- "@stacksjs/skills": "^0.74.26",
146
- "@stacksjs/storage": "^0.74.26",
147
- "@stacksjs/strings": "^0.74.26",
140
+ "@stacksjs/scheduler": "^0.74.27",
141
+ "@stacksjs/search-engine": "^0.74.27",
142
+ "@stacksjs/security": "^0.74.27",
143
+ "@stacksjs/server": "^0.74.27",
144
+ "@stacksjs/sites": "^0.74.27",
145
+ "@stacksjs/skills": "^0.74.27",
146
+ "@stacksjs/storage": "^0.74.27",
147
+ "@stacksjs/strings": "^0.74.27",
148
148
  "@stacksjs/stx": "^0.2.274",
149
- "@stacksjs/testing": "^0.74.26",
150
- "@stacksjs/tinker": "^0.74.26",
149
+ "@stacksjs/testing": "^0.74.27",
150
+ "@stacksjs/tinker": "^0.74.27",
151
151
  "@stacksjs/tlsx": "^0.13.19",
152
152
  "@stacksjs/ts-cloud": "^0.12.10",
153
- "@stacksjs/tunnel": "^0.74.26",
154
- "@stacksjs/types": "^0.74.26",
155
- "@stacksjs/ui": "^0.74.26",
156
- "@stacksjs/utils": "^0.74.26",
157
- "@stacksjs/validation": "^0.74.26",
153
+ "@stacksjs/tunnel": "^0.74.27",
154
+ "@stacksjs/types": "^0.74.27",
155
+ "@stacksjs/ui": "^0.74.27",
156
+ "@stacksjs/utils": "^0.74.27",
157
+ "@stacksjs/validation": "^0.74.27",
158
158
  "ajv": "^8.20.0",
159
159
  "ajv-formats": "^3.0.1",
160
160
  "bun-plugin-stx": "^0.2.274",