@stacksjs/buddy 0.74.16 → 0.74.17

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.
@@ -1 +1 @@
1
- import{existsSync,mkdirSync,readdirSync,rmSync,statSync}from"node:fs";import{isAbsolute,join,resolve}from"node:path";import process from"node:process";import{confirmOrNull,intro,log,outro}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{backupFileName,backupObjectKey,describeCommand,dumpCommand,dumpSqlite,isBackupFileName,prunableBackups,resolveBackupDestination,resolveBackupTarget,parseBackupDestination,restoreCommand,restoreSqlite,toolFailureDetail,withoutPassword}from"../database-backup";const DEFAULT_BACKUP_DIR="storage/backups/database",DEFAULT_RETAIN=7;export async function backupTarget(){const{config,overridesReady}=await import("@stacksjs/config");await overridesReady.catch(()=>{});return resolveBackupTarget(config?.database)}export async function backupDestination(override){if(override?.trim())return parseBackupDestination(override);const{config,overridesReady}=await import("@stacksjs/config");await overridesReady.catch(()=>{});return resolveBackupDestination(config?.database,process.env)}async function uploadBackup(destination,file,fileName){const{Storage}=await import("@stacksjs/storage"),key=backupObjectKey(destination,fileName),diskName=destination.kind==="disk"?destination.target:"s3",disk=Storage.disk(diskName);if(typeof disk.putStream!=="function")throw TypeError(`The '${diskName}' disk cannot stream uploads, so a database dump cannot be copied to it. Use an S3-backed disk for \`backups.destination\`.`);await disk.putStream(key,Bun.file(file).stream());return destination.kind==="disk"?`disk://${destination.target}/${key}`:`s3://${destination.target}/${key}`}function backupDir(out){const dir=out?.trim()||DEFAULT_BACKUP_DIR;return isAbsolute(dir)?dir:resolve(process.cwd(),dir)}function existingBackups(dir){if(!existsSync(dir))return[];return readdirSync(dir).filter(isBackupFileName).sort()}function sqliteDatabaseExists(target){const path=sqliteFile(target);return existsSync(path)&&statSync(path).size>0}function sqliteFile(target){return isAbsolute(target.database)?target.database:resolve(process.cwd(),target.database)}async function runTool(command,password){const proc=Bun.spawn([command.bin,...command.args],{env:{...process.env,...command.env},stdout:"pipe",stderr:"pipe"}),[code,stderr]=await Promise.all([proc.exited,new Response(proc.stderr).text()]);if(code===0)return;const detail=toolFailureDetail(stderr,command.bin)||`exit code ${code}`;throw Error(withoutPassword(`${command.bin}: ${detail}`,password))}function prune(dir,retain){const removed=prunableBackups(existingBackups(dir),retain);for(const name of removed)rmSync(join(dir,name),{force:!0});return removed}export function db(buddy){buddy.command("db:backup","Dump the application database to a file").option("--out [dir]",`Where to write the dump (default: ${DEFAULT_BACKUP_DIR})`).option("--retain [count]","How many dumps to keep",{default:String(DEFAULT_RETAIN)}).option("--before-migrations","Deploy mode: succeed quietly when there is no database yet",{default:!1}).option("--destination [uri]","Copy the dump offsite: s3://bucket/prefix or disk://name/prefix").option("--verbose","Enable verbose output",{default:!1}).example("buddy db:backup").example("buddy db:backup --out /var/backups/app --retain 30").example("buddy db:backup --destination disk://backups/daily").action(async(options)=>{const perf=await intro("buddy db:backup");try{const target=await backupTarget();if(!target){await outro("No dumpable database is configured; nothing to back up.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(target.engine==="sqlite"&&!sqliteDatabaseExists(target)){const message=`No database at ${target.database} yet; nothing to back up.`;if(options.beforeMigrations){await outro(message,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}log.warn(message);await outro("Nothing backed up.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const dir=backupDir(options.out);mkdirSync(dir,{recursive:!0});const name=backupFileName(target,new Date),destination=join(dir,name),command=dumpCommand(target,destination);if(command){if(options.verbose)log.info(describeCommand(command));await runTool(command,target.password)}else await dumpSqlite(sqliteFile(target),destination);const size=existsSync(destination)?statSync(destination).size:0;log.success(`Wrote ${destination} (${(size/1024).toFixed(1)} KB)`);const retain=Number.parseInt(String(options.retain??DEFAULT_RETAIN),10),removed=prune(dir,retain);if(removed.length)log.info(`Pruned ${removed.length} older dump(s), keeping ${retain}.`);const offsite=await backupDestination(options.destination);if(offsite){const uploaded=await uploadBackup(offsite,destination,name);log.success(`Copied to ${uploaded}`)}else log.info("This dump is on the same disk as the database. Set `backups.destination` in config/database.ts (or DB_BACKUP_DESTINATION) to copy it off the box.");await outro("Database backed up",{startTime:perf,useSeconds:!0})}catch(error){await log.error("Database backup failed:",error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("db:backups","List the database dumps that have been taken").option("--out [dir]",`Where dumps are kept (default: ${DEFAULT_BACKUP_DIR})`).example("buddy db:backups").action(async(options)=>{const dir=backupDir(options.out),backups=existingBackups(dir);if(!backups.length){log.info(`No dumps in ${dir}.`);await log.flush();process.exit(ExitCode.Success)}log.info(`${backups.length} dump(s) in ${dir}, oldest first:`);for(const name of backups){const size=statSync(join(dir,name)).size;log.info(` ${name} ${(size/1024).toFixed(1)} KB`)}process.exit(ExitCode.Success)});buddy.command("db:restore [file]","Restore the application database from a dump").option("--out [dir]",`Where dumps are kept (default: ${DEFAULT_BACKUP_DIR})`).option("--force","Skip the confirmation prompt",{default:!1}).option("--verbose","Enable verbose output",{default:!1}).example("buddy db:restore").example("buddy db:restore 2026-08-13T09-00-00-000.sqlite.sqlite --force").action(async(file,options)=>{const perf=await intro("buddy db:restore");try{const target=await backupTarget();if(!target){await log.error("No restorable database is configured.");process.exit(ExitCode.FatalError)}const dir=backupDir(options.out),backups=existingBackups(dir),name=file??backups[backups.length-1];if(!name){await log.error(`No dumps found in ${dir}.`);process.exit(ExitCode.FatalError)}const source=isAbsolute(name)?name:join(dir,name);if(!existsSync(source)){await log.error(`No such dump: ${source}`);process.exit(ExitCode.FatalError)}if(!options.force){if(await confirmOrNull({message:`Replace ${target.engine} database "${target.database}" with ${name}? The current contents are lost.`,initial:!1})!==!0){await outro("Restore cancelled.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}const command=restoreCommand(target,source);if(command){if(options.verbose)log.info(describeCommand(command));await runTool(command,target.password)}else{const displaced=await restoreSqlite(source,sqliteFile(target),Date.now());if(displaced)log.info(`The database that was there is kept at ${displaced}`)}log.success(`Restored ${target.database} from ${name}`);await outro("Database restored",{startTime:perf,useSeconds:!0})}catch(error){await log.error("Database restore failed:",error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)})}
1
+ import{existsSync,mkdirSync,readdirSync,rmSync,statSync}from"node:fs";import{isAbsolute,join,resolve}from"node:path";import process from"node:process";import{confirmOrNull,intro,log,outro}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{backupFileName,backupObjectKey,describeCommand,dumpCommand,dumpSqlite,isBackupFileName,prunableBackups,resolveBackupDestination,resolveBackupTarget,parseBackupDestination,restoreCommand,restoreSqlite,toolFailureDetail,withoutPassword}from"../database-backup";const DEFAULT_BACKUP_DIR="storage/backups/database",DEFAULT_RETAIN=7;export async function backupTarget(){const{config,overridesReady}=await import("@stacksjs/config");await overridesReady.catch(()=>{});return resolveBackupTarget(config?.database)}export async function backupDestination(override){if(override?.trim())return parseBackupDestination(override);const{config,overridesReady}=await import("@stacksjs/config");await overridesReady.catch(()=>{});return resolveBackupDestination(config?.database,process.env)}async function uploadBackup(destination,file,fileName){const{Storage}=await import("@stacksjs/storage"),key=backupObjectKey(destination,fileName),diskName=destination.kind==="disk"?destination.target:"s3",disk=Storage.disk(diskName);if(typeof disk.putStream!=="function")throw TypeError(`The '${diskName}' disk cannot stream uploads, so a database dump cannot be copied to it. Use an S3-backed disk for \`backups.destination\`.`);await disk.putStream(key,Bun.file(file).stream());return destination.kind==="disk"?`disk://${destination.target}/${key}`:`s3://${destination.target}/${key}`}function backupDir(out){const dir=out?.trim()||DEFAULT_BACKUP_DIR;return isAbsolute(dir)?dir:resolve(process.cwd(),dir)}function existingBackups(dir){if(!existsSync(dir))return[];return readdirSync(dir).filter(isBackupFileName).sort()}function sqliteDatabaseExists(target){const path=sqliteFile(target);return existsSync(path)&&statSync(path).size>0}function sqliteFile(target){return isAbsolute(target.database)?target.database:resolve(process.cwd(),target.database)}async function runTool(command,password){const proc=Bun.spawn([command.bin,...command.args],{env:{...process.env,...command.env},stdout:"pipe",stderr:"pipe"}),[code,stderr]=await Promise.all([proc.exited,new Response(proc.stderr).text()]);if(code===0)return;const detail=toolFailureDetail(stderr,command.bin)||`exit code ${code}`;throw Error(withoutPassword(`${command.bin}: ${detail}`,password))}function prune(dir,retain){const removed=prunableBackups(existingBackups(dir),retain);for(const name of removed)rmSync(join(dir,name),{force:!0});return removed}export function db(buddy){buddy.command("db:backup","Dump the application database to a file").option("--out [dir]",`Where to write the dump (default: ${DEFAULT_BACKUP_DIR})`).option("--retain [count]","How many dumps to keep",{default:String(DEFAULT_RETAIN)}).option("--before-migrations","Deploy mode: succeed quietly when there is no database yet",{default:!1}).option("--destination [uri]","Copy the dump offsite: s3://bucket/prefix or disk://name/prefix").option("--verbose","Enable verbose output",{default:!1}).example("buddy db:backup").example("buddy db:backup --out /var/backups/app --retain 30").example("buddy db:backup --destination disk://backups/daily").action(async(options)=>{const perf=await intro("buddy db:backup");try{const target=await backupTarget();if(!target){await outro("No dumpable database is configured; nothing to back up.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(target.engine==="sqlite"&&!sqliteDatabaseExists(target)){const message=`No database at ${target.database} yet; nothing to back up.`;if(options.beforeMigrations){await outro(message,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}log.warn(message);await outro("Nothing backed up.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const dir=backupDir(options.out);mkdirSync(dir,{recursive:!0});const name=backupFileName(target,new Date),destination=join(dir,name),command=dumpCommand(target,destination);if(command){if(options.verbose)log.info(describeCommand(command));await runTool(command,target.password)}else await dumpSqlite(sqliteFile(target),destination);const size=existsSync(destination)?statSync(destination).size:0;log.success(`Wrote ${destination} (${(size/1024).toFixed(1)} KB)`);const retain=Number.parseInt(String(options.retain??DEFAULT_RETAIN),10),removed=prune(dir,retain);if(removed.length)log.info(`Pruned ${removed.length} older dump(s), keeping ${retain}.`);const offsite=await backupDestination(options.destination);if(offsite){const uploaded=await uploadBackup(offsite,destination,name);log.success(`Copied to ${uploaded}`)}else log.info("This dump is on the same disk as the database. Set `backups.destination` in config/database.ts (or DB_BACKUP_DESTINATION) to copy it off the box.");await outro("Database backed up",{startTime:perf,useSeconds:!0})}catch(error){await log.error("Database backup failed:",error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("db:backups","List the database dumps that have been taken").option("--out [dir]",`Where dumps are kept (default: ${DEFAULT_BACKUP_DIR})`).example("buddy db:backups").action(async(options)=>{const dir=backupDir(options.out),backups=existingBackups(dir);if(!backups.length){log.info(`No dumps in ${dir}.`);await log.flush();process.exit(ExitCode.Success)}log.info(`${backups.length} dump(s) in ${dir}, oldest first:`);for(const name of backups){const size=statSync(join(dir,name)).size;log.info(` ${name} ${(size/1024).toFixed(1)} KB`)}await log.flush();process.exit(ExitCode.Success)});buddy.command("db:restore [file]","Restore the application database from a dump").option("--out [dir]",`Where dumps are kept (default: ${DEFAULT_BACKUP_DIR})`).option("--force","Skip the confirmation prompt",{default:!1}).option("--verbose","Enable verbose output",{default:!1}).example("buddy db:restore").example("buddy db:restore 2026-08-13T09-00-00-000.sqlite.sqlite --force").action(async(file,options)=>{const perf=await intro("buddy db:restore");try{const target=await backupTarget();if(!target){await log.error("No restorable database is configured.");process.exit(ExitCode.FatalError)}const dir=backupDir(options.out),backups=existingBackups(dir),name=file??backups[backups.length-1];if(!name){await log.error(`No dumps found in ${dir}.`);process.exit(ExitCode.FatalError)}const source=isAbsolute(name)?name:join(dir,name);if(!existsSync(source)){await log.error(`No such dump: ${source}`);process.exit(ExitCode.FatalError)}if(!options.force){if(await confirmOrNull({message:`Replace ${target.engine} database "${target.database}" with ${name}? The current contents are lost.`,initial:!1})!==!0){await outro("Restore cancelled.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}const command=restoreCommand(target,source);if(command){if(options.verbose)log.info(describeCommand(command));await runTool(command,target.password)}else{const displaced=await restoreSqlite(source,sqliteFile(target),Date.now());if(displaced)log.info(`The database that was there is kept at ${displaced}`)}log.success(`Restored ${target.database} from ${name}`);await outro("Database restored",{startTime:perf,useSeconds:!0})}catch(error){await log.error("Database restore failed:",error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)})}
@@ -1 +1 @@
1
- import process from"node:process";import{log}from"@stacksjs/cli";import{config}from"@stacksjs/config";import{HttxClient}from"@stacksjs/httx";import{ExitCode}from"@stacksjs/types";export function http(buddy){const descriptions={http:"Send an HTTP request to a domain",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("http [domain]",descriptions.http).option("-p, --project [project]",descriptions.project,{default:!1}).option("-v, --verbose",descriptions.verbose,{default:!1}).action(async(domain,options)=>{log.debug("Running `buddy http [domain]` ...",options);const url=domain||config.app.url;if(!url)throw Error("No domain configured. Pass a domain or set config.app.url.");const client=new HttxClient({verbose:options.verbose});log.info(`GET ${url}`);(await client.request(url.startsWith("http")?url:`https://${url}`,{method:"GET"})).match({ok:(response)=>{log.info(`${response.status} ${response.statusText} (${response.timings.duration.toFixed(0)}ms)`);console.log(typeof response.data==="string"?response.data:JSON.stringify(response.data,null,2));process.exit(ExitCode.Success)},err:(error)=>{console.error(`Request failed: ${error.message}`);process.exit(ExitCode.FatalError)}})})}
1
+ import process from"node:process";import{log}from"@stacksjs/cli";import{config}from"@stacksjs/config";import{HttxClient}from"@stacksjs/httx";import{ExitCode}from"@stacksjs/types";export function http(buddy){const descriptions={http:"Send an HTTP request to a domain",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("http [domain]",descriptions.http).option("-p, --project [project]",descriptions.project,{default:!1}).option("-v, --verbose",descriptions.verbose,{default:!1}).action(async(domain,options)=>{log.debug("Running `buddy http [domain]` ...",options);const url=domain||config.app.url;if(!url)throw Error("No domain configured. Pass a domain or set config.app.url.");const client=new HttxClient({verbose:options.verbose});log.info(`GET ${url}`);(await client.request(url.startsWith("http")?url:`https://${url}`,{method:"GET"})).match({ok:(response)=>{console.log(`${response.status} ${response.statusText} (${response.timings.duration.toFixed(0)}ms)`);console.log(typeof response.data==="string"?response.data:JSON.stringify(response.data,null,2));process.exit(ExitCode.Success)},err:(error)=>{console.error(`Request failed: ${error.message}`);process.exit(ExitCode.FatalError)}})})}
@@ -56,11 +56,11 @@ 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.");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
- 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}`);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(`
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(`
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
- `))console.log(` ${line}`);else log.error(`Failed: ${result.error}`);console.log("");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(`
63
- Shutting down SMTP server...`);await server.stop();resolve()})});process.exit(0)}catch(error){await log.error(`Failed to start SMTP server: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:preview","Open the dev-only Mailable preview UI in your browser").option("--no-open","Print the URL but skip the browser-open step",{default:!1}).option("--port <port>","Override the API port (default: from config.ports.api / PORT_API)").example("buddy mail:preview").example("buddy mail:preview --no-open").example("buddy mail:preview --port 4000").action(async(options)=>{const{config}=await import("@stacksjs/config"),portFromOption=options.port?Number(options.port):void 0,portFromEnv=process.env.PORT_API?Number(process.env.PORT_API):void 0,url=`http://localhost:${portFromOption||portFromEnv||config.ports?.api||3008}/_stacks/mail/preview`;log.info(`Mailable preview: ${url}`);log.info("(Make sure `./buddy dev:api` is running.)");if(options.open!==!1)try{execSync(`open "${url}" 2>/dev/null || xdg-open "${url}" 2>/dev/null || true`,{encoding:"utf-8"})}catch{}process.exit(ExitCode.Success)});buddy.command("mail:dev","Run the local mail catcher: accepts every message, delivers none, shows them in the webmail UI").option("--port <port>",`SMTP port (default: ${LOCAL_MAIL_SMTP_PORT})`).option("--ui-port <port>",`Webmail UI port (default: ${LOCAL_MAIL_UI_PORT})`).option("--no-open","Do not open the webmail UI in a browser",{default:!1}).example("buddy mail:dev").example("buddy mail:dev --port 2525 --ui-port 8080").action(async(options)=>{loadEnvFiles();await runLocalMailCatcher({smtpPort:Number(options.port)||LOCAL_MAIL_SMTP_PORT,uiPort:Number(options.uiPort)||LOCAL_MAIL_UI_PORT,open:options.open!==!1})})}export const LOCAL_MAIL_SMTP_PORT=1025,LOCAL_MAIL_UI_PORT=8025,LOCAL_MAIL_DIR="storage/framework/mail";export function resolveMailBinary(root=process.cwd()){const local=`${root}/pantry/.bin/mail`;if(existsSync(local))return local;try{const found=execSync("command -v mail 2>/dev/null",{encoding:"utf-8"}).trim();if(found&&isMailServer(found))return found}catch{}return}function isMailServer(binary){try{return execSync(`${shellQuote(binary)} </dev/null 2>&1 || true`,{encoding:"utf-8",timeout:5000}).includes("Mail server management CLI")}catch{return!1}}export function buildLocalMailConfig(options){return["# Generated by `buddy mail:dev`. Edits are overwritten on the next run.","","[server]",'host = "127.0.0.1"',`port = ${options.smtpPort}`,'hostname = "localhost"',"catch_all = true","","[tls]","enabled = false","","[auth]","enabled = false","","[antispam]","spam_filter = false","dnsbl = false","greylist = false",""].join(`
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(`
63
+ Shutting down SMTP server...`);await server.stop();resolve()})});process.exit(0)}catch(error){await log.error(`Failed to start SMTP server: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:preview","Open the dev-only Mailable preview UI in your browser").option("--no-open","Print the URL but skip the browser-open step",{default:!1}).option("--port <port>","Override the API port (default: from config.ports.api / PORT_API)").example("buddy mail:preview").example("buddy mail:preview --no-open").example("buddy mail:preview --port 4000").action(async(options)=>{const{config}=await import("@stacksjs/config"),portFromOption=options.port?Number(options.port):void 0,portFromEnv=process.env.PORT_API?Number(process.env.PORT_API):void 0,url=`http://localhost:${portFromOption||portFromEnv||config.ports?.api||3008}/_stacks/mail/preview`;log.info(`Mailable preview: ${url}`);log.info("(Make sure `./buddy dev:api` is running.)");if(options.open!==!1)try{execSync(`open "${url}" 2>/dev/null || xdg-open "${url}" 2>/dev/null || true`,{encoding:"utf-8"})}catch{}await log.flush();process.exit(ExitCode.Success)});buddy.command("mail:dev","Run the local mail catcher: accepts every message, delivers none, shows them in the webmail UI").option("--port <port>",`SMTP port (default: ${LOCAL_MAIL_SMTP_PORT})`).option("--ui-port <port>",`Webmail UI port (default: ${LOCAL_MAIL_UI_PORT})`).option("--no-open","Do not open the webmail UI in a browser",{default:!1}).example("buddy mail:dev").example("buddy mail:dev --port 2525 --ui-port 8080").action(async(options)=>{loadEnvFiles();await runLocalMailCatcher({smtpPort:Number(options.port)||LOCAL_MAIL_SMTP_PORT,uiPort:Number(options.uiPort)||LOCAL_MAIL_UI_PORT,open:options.open!==!1})})}export const LOCAL_MAIL_SMTP_PORT=1025,LOCAL_MAIL_UI_PORT=8025,LOCAL_MAIL_DIR="storage/framework/mail";export function resolveMailBinary(root=process.cwd()){const local=`${root}/pantry/.bin/mail`;if(existsSync(local))return local;try{const found=execSync("command -v mail 2>/dev/null",{encoding:"utf-8"}).trim();if(found&&isMailServer(found))return found}catch{}return}function isMailServer(binary){try{return execSync(`${shellQuote(binary)} </dev/null 2>&1 || true`,{encoding:"utf-8",timeout:5000}).includes("Mail server management CLI")}catch{return!1}}export function buildLocalMailConfig(options){return["# Generated by `buddy mail:dev`. Edits are overwritten on the next run.","","[server]",'host = "127.0.0.1"',`port = ${options.smtpPort}`,'hostname = "localhost"',"catch_all = true","","[tls]","enabled = false","","[auth]","enabled = false","","[antispam]","spam_filter = false","dnsbl = false","greylist = false",""].join(`
64
64
  `)}async function runLocalMailCatcher(options){const binary=resolveMailBinary();if(!binary){await log.error("The mail server is not installed.");log.info("Install it with: pantry install github.com/mail-os/mail");log.info("(It is declared in config/deps.ts, so `pantry install` on its own is enough.)");await log.flush();process.exit(ExitCode.FatalError)}const dir=`${process.cwd()}/${LOCAL_MAIL_DIR}`,configPath=`${dir}/catcher.toml`;await runCommand(`mkdir -p ${shellQuote(dir)}`,{silent:!0});await Bun.write(configPath,buildLocalMailConfig({smtpPort:options.smtpPort}));console.log("");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\u2500");console.log(" Local mail catcher");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\u2500");console.log(` SMTP 127.0.0.1:${options.smtpPort}`);console.log(` Webmail http://localhost:${options.uiPort} (sign in: dev / dev)`);console.log(` Mailbox ${LOCAL_MAIL_DIR}`);console.log("");console.log(" Every recipient is accepted; nothing is delivered onward.");console.log(" Ctrl-C to stop.");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\u2500");console.log("");if(options.open)try{execSync(`open "http://localhost:${options.uiPort}" 2>/dev/null || xdg-open "http://localhost:${options.uiPort}" 2>/dev/null || true`,{encoding:"utf-8"})}catch{}const child=Bun.spawn([binary,"serve","--config",configPath],{cwd:dir,env:{...process.env,SMTP_DB_PATH:`${dir}/catcher.db`,SMTP_ENABLE_WEBMAIL:"true",SMTP_WEBMAIL_PORT:String(options.uiPort),SMTP_WEBMAIL_SECURE_COOKIES:"false"},stdout:"inherit",stderr:"inherit"});process.on("SIGINT",()=>{child.kill();process.exit(ExitCode.Success)});process.exit(await child.exited)}function loadEnvFiles(){const envPath=".env.production";if(existsSync(envPath))try{const{loadEnv}=require("@stacksjs/env");loadEnv({path:envPath,env:"production",keysFile:".env.keys",overload:!1,quiet:!0})}catch{const content=readFileSync(envPath,"utf-8");for(const line of content.split(`
65
65
  `)){const eq=line.indexOf("=");if(eq>0&&!line.startsWith("#")){const key=line.slice(0,eq).trim(),value=line.slice(eq+1).trim();if(!process.env[key])process.env[key]=value}}}if(!process.env.AWS_ACCESS_KEY_ID||!process.env.AWS_SECRET_ACCESS_KEY){const awsCredsPath=`${process.env.HOME||process.env.USERPROFILE||""}/.aws/credentials`;if(existsSync(awsCredsPath)){const credsContent=readFileSync(awsCredsPath,"utf-8"),profile=process.env.AWS_PROFILE||"default",lines=credsContent.split(`
66
66
  `);let inProfile=!1;for(const line of lines){const trimmed=line.trim();if(trimmed.startsWith("[")){inProfile=trimmed===`[${profile}]`;continue}if(inProfile&&trimmed.includes("=")){const eq=trimmed.indexOf("="),key=trimmed.slice(0,eq).trim(),value=trimmed.slice(eq+1).trim();if(key==="aws_access_key_id")process.env.AWS_ACCESS_KEY_ID=value;if(key==="aws_secret_access_key")process.env.AWS_SECRET_ACCESS_KEY=value}}}}}function printPort25Status(result,provider,ip){console.log("");console.log("==========================================================");console.log(" PORT 25 STATUS");console.log("==========================================================");console.log("");console.log(` Provider: ${provider}`);console.log(` Server IP: ${ip}`);if(result.includes("PORT_25_OPEN")){console.log(" Port 25: \x1B[32mOPEN\x1B[0m");console.log("");console.log(" Direct SMTP delivery is available!");console.log(" Set SMTP_DELIVERY_METHOD=direct in your server env.")}else{console.log(" Port 25: \x1B[31mBLOCKED\x1B[0m");console.log("");console.log(` Run: buddy mail:port25:request --provider ${provider.toLowerCase()}`)}console.log("");console.log("==========================================================");console.log("")}async function requestAwsPort25(options,domain){const region=process.env.AWS_REGION||"us-east-1",appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");let{instanceId,elasticIp}=options;if(!instanceId||!elasticIp)try{const{EC2Client}=await import("@stacksjs/ts-cloud"),instance=(await new EC2Client(region).describeInstances({Filters:[{Name:"tag:Name",Values:[`${appName}-mail-server`]},{Name:"instance-state-name",Values:["running"]}]})).Reservations?.[0]?.Instances?.[0];if(instance){instanceId=instanceId||instance.InstanceId;elasticIp=elasticIp||instance.PublicIpAddress}}catch{}const rdns=options.rdns||`mail.${domain}`,useCase=options.useCase||[`We run a self-hosted mail server (mail.${domain}) for our organization.`,"We send transactional emails (account notifications, password resets) and","occasional team communication. Expected volume: <1000 emails/day.","We have SPF, DKIM, and DMARC configured. We need outbound port 25","to deliver email directly to recipient mail servers via MX records."].join(" ");console.log("");console.log("==========================================================");console.log(" AWS PORT 25 UNBLOCK REQUEST");console.log("==========================================================");console.log("");console.log(` Instance: ${instanceId||"(not detected)"}`);console.log(` Elastic IP: ${elasticIp||"(not detected)"}`);console.log(` Reverse DNS: ${rdns}`);console.log(` Region: ${region}`);console.log("");try{const result=execSync(`aws support create-case --subject "Request to remove email sending limitations" --communication-body "${useCase}
@@ -68,4 +68,4 @@ Shutting down SMTP server...`);await server.stop();resolve()})});process.exit(0)
68
68
  Elastic IP: ${elasticIp||"N/A"}
69
69
  Instance ID: ${instanceId||"N/A"}
70
70
  Reverse DNS: ${rdns}
71
- Region: ${region}" --service-code "amazon-ec2" --category-code "port-25-throttle" --severity-code "normal" --cc-email-addresses "" --language "en" --region us-east-1 2>&1`,{encoding:"utf-8"});if(result.includes("caseId"))try{const caseId=JSON.parse(result).caseId;log.success(`Support case created: ${caseId}`);log.info("");log.info(" AWS will process this within 24-48 hours.");log.info(" Check status: aws support describe-cases --region us-east-1");log.info("");await log.flush();process.exit(ExitCode.Success)}catch{log.error("Failed to parse AWS support case response")}}catch{}if(elasticIp){log.info("Setting up reverse DNS (rDNS) for Elastic IP...");try{const allocResult=execSync(`aws ec2 describe-addresses --public-ips ${elasticIp} --region ${region} --query 'Addresses[0].AllocationId' --output text 2>&1`,{encoding:"utf-8"}).trim();if(allocResult&&!allocResult.includes("error")){execSync(`aws ec2 modify-address-attribute --allocation-id ${allocResult} --domain-name ${rdns} --region ${region} 2>&1`,{encoding:"utf-8"});log.success(`Reverse DNS requested: ${elasticIp} -> ${rdns}`);console.log(" (rDNS propagation may take a few minutes)")}}catch(error){log.warn(`rDNS setup failed: ${getErrorMessage(error)}`);console.log(" You may need to set this up manually in the AWS console.")}}console.log("");console.log(" The Support API requires a Business or Enterprise support plan.");console.log(" Opening the AWS web form instead...");console.log("");console.log(" Fill in these details:");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\u2500");console.log(` Email address: admin@${domain}`);console.log(" Use case: Sending outbound email from self-hosted mail server");console.log(` Elastic IP: ${elasticIp||"(enter your Elastic IP)"}`);console.log(` rDNS: ${rdns}`);console.log(` Region: ${region}`);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\u2500");console.log("");const formUrl="https://console.aws.amazon.com/support/contacts#/rdns-limits";try{execSync(`open "${formUrl}" 2>/dev/null || xdg-open "${formUrl}" 2>/dev/null || echo ""`,{encoding:"utf-8"});log.info(`Opened: ${formUrl}`)}catch{console.log(" Open this URL in your browser:");console.log(` ${formUrl}`)}console.log("");console.log(" After AWS approves (24-48 hours), verify with:");console.log(" buddy mail:port25:status");console.log("");process.exit(ExitCode.Success)}async function requestHetznerPort25(options,domain){const hetznerToken=process.env.HETZNER_API_TOKEN;if(!hetznerToken){await log.error("HETZNER_API_TOKEN not set. Add it to .env or set it as an environment variable.");process.exit(ExitCode.FatalError)}let serverId=options.serverId,serverIp="",serverName="";try{const data=await(await fetch("https://api.hetzner.cloud/v1/servers",{headers:{Authorization:`Bearer ${hetznerToken}`}})).json(),mailServer=data.servers?.find((s)=>s.name?.includes("mail"))||data.servers?.[0];if(mailServer){serverId=serverId||String(mailServer.id);serverIp=mailServer.public_net?.ipv4?.ip||"";serverName=mailServer.name||""}}catch{}const rdns=options.rdns||`mail.${domain}`,useCase=options.useCase||[`Self-hosted mail server for ${domain}.`,"Transactional + team email, <1000 emails/day.","SPF/DKIM/DMARC configured."].join(" ");console.log("");console.log("==========================================================");console.log(" HETZNER PORT 25 UNBLOCK REQUEST");console.log("==========================================================");console.log("");console.log(` Server: ${serverName} (ID: ${serverId||"unknown"})`);console.log(` Server IP: ${serverIp||"(not detected)"}`);console.log(` rDNS: ${rdns}`);console.log("");if(serverId&&serverIp){log.info("Setting reverse DNS...");try{const rdnsData=await(await fetch(`https://api.hetzner.cloud/v1/servers/${serverId}/actions/change_dns_ptr`,{method:"POST",headers:{Authorization:`Bearer ${hetznerToken}`,"Content-Type":"application/json"},body:JSON.stringify({ip:serverIp,dns_ptr:rdns})})).json();if(rdnsData.action?.status==="running"||rdnsData.action?.status==="success")log.success(`Reverse DNS set: ${serverIp} -> ${rdns}`);else log.warn(`rDNS response: ${JSON.stringify(rdnsData.error||rdnsData)}`)}catch(error){log.warn(`rDNS failed: ${getErrorMessage(error)}`)}}console.log("");console.log(" To request port 25 unblock from Hetzner:");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\u2500");console.log(" 1. Log in to https://console.hetzner.cloud");console.log(` 2. Select project containing server "${serverName}"`);console.log(" 3. Go to Support (left sidebar) -> New support request");console.log(" 4. Use this template:");console.log("");console.log(" Subject: Request to unblock outbound port 25 (SMTP)");console.log("");console.log(" Body:");console.log(" \u2500\u2500\u2500\u2500\u2500");console.log(` Server: ${serverName} (ID: ${serverId}, IP: ${serverIp})`);console.log(` Use case: ${useCase}`);console.log(` Reverse DNS: ${serverIp} -> ${rdns}`);console.log(" We comply with all anti-spam regulations (CAN-SPAM, GDPR).");console.log(" Please unblock outbound ports 25 and 465 for this server.");console.log(" \u2500\u2500\u2500\u2500\u2500");console.log("");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\u2500");const consoleUrl="https://console.hetzner.cloud";try{execSync(`open "${consoleUrl}" 2>/dev/null || xdg-open "${consoleUrl}" 2>/dev/null || echo ""`,{encoding:"utf-8"});log.info(`Opened: ${consoleUrl}`)}catch{console.log(` Open: ${consoleUrl}`)}console.log("");console.log(" After Hetzner approves, verify with:");console.log(" buddy mail:port25:status --provider hetzner");console.log("");process.exit(ExitCode.Success)}
71
+ Region: ${region}" --service-code "amazon-ec2" --category-code "port-25-throttle" --severity-code "normal" --cc-email-addresses "" --language "en" --region us-east-1 2>&1`,{encoding:"utf-8"});if(result.includes("caseId"))try{const caseId=JSON.parse(result).caseId;log.success(`Support case created: ${caseId}`);log.info("");log.info(" AWS will process this within 24-48 hours.");log.info(" Check status: aws support describe-cases --region us-east-1");log.info("");await log.flush();process.exit(ExitCode.Success)}catch{log.error("Failed to parse AWS support case response")}}catch{}if(elasticIp){log.info("Setting up reverse DNS (rDNS) for Elastic IP...");try{const allocResult=execSync(`aws ec2 describe-addresses --public-ips ${elasticIp} --region ${region} --query 'Addresses[0].AllocationId' --output text 2>&1`,{encoding:"utf-8"}).trim();if(allocResult&&!allocResult.includes("error")){execSync(`aws ec2 modify-address-attribute --allocation-id ${allocResult} --domain-name ${rdns} --region ${region} 2>&1`,{encoding:"utf-8"});log.success(`Reverse DNS requested: ${elasticIp} -> ${rdns}`);console.log(" (rDNS propagation may take a few minutes)")}}catch(error){log.warn(`rDNS setup failed: ${getErrorMessage(error)}`);console.log(" You may need to set this up manually in the AWS console.")}}console.log("");console.log(" The Support API requires a Business or Enterprise support plan.");console.log(" Opening the AWS web form instead...");console.log("");console.log(" Fill in these details:");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\u2500");console.log(` Email address: admin@${domain}`);console.log(" Use case: Sending outbound email from self-hosted mail server");console.log(` Elastic IP: ${elasticIp||"(enter your Elastic IP)"}`);console.log(` rDNS: ${rdns}`);console.log(` Region: ${region}`);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\u2500");console.log("");const formUrl="https://console.aws.amazon.com/support/contacts#/rdns-limits";try{execSync(`open "${formUrl}" 2>/dev/null || xdg-open "${formUrl}" 2>/dev/null || echo ""`,{encoding:"utf-8"});log.info(`Opened: ${formUrl}`)}catch{console.log(" Open this URL in your browser:");console.log(` ${formUrl}`)}console.log("");console.log(" After AWS approves (24-48 hours), verify with:");console.log(" buddy mail:port25:status");console.log("");await log.flush();process.exit(ExitCode.Success)}async function requestHetznerPort25(options,domain){const hetznerToken=process.env.HETZNER_API_TOKEN;if(!hetznerToken){await log.error("HETZNER_API_TOKEN not set. Add it to .env or set it as an environment variable.");process.exit(ExitCode.FatalError)}let serverId=options.serverId,serverIp="",serverName="";try{const data=await(await fetch("https://api.hetzner.cloud/v1/servers",{headers:{Authorization:`Bearer ${hetznerToken}`}})).json(),mailServer=data.servers?.find((s)=>s.name?.includes("mail"))||data.servers?.[0];if(mailServer){serverId=serverId||String(mailServer.id);serverIp=mailServer.public_net?.ipv4?.ip||"";serverName=mailServer.name||""}}catch{}const rdns=options.rdns||`mail.${domain}`,useCase=options.useCase||[`Self-hosted mail server for ${domain}.`,"Transactional + team email, <1000 emails/day.","SPF/DKIM/DMARC configured."].join(" ");console.log("");console.log("==========================================================");console.log(" HETZNER PORT 25 UNBLOCK REQUEST");console.log("==========================================================");console.log("");console.log(` Server: ${serverName} (ID: ${serverId||"unknown"})`);console.log(` Server IP: ${serverIp||"(not detected)"}`);console.log(` rDNS: ${rdns}`);console.log("");if(serverId&&serverIp){log.info("Setting reverse DNS...");try{const rdnsData=await(await fetch(`https://api.hetzner.cloud/v1/servers/${serverId}/actions/change_dns_ptr`,{method:"POST",headers:{Authorization:`Bearer ${hetznerToken}`,"Content-Type":"application/json"},body:JSON.stringify({ip:serverIp,dns_ptr:rdns})})).json();if(rdnsData.action?.status==="running"||rdnsData.action?.status==="success")log.success(`Reverse DNS set: ${serverIp} -> ${rdns}`);else log.warn(`rDNS response: ${JSON.stringify(rdnsData.error||rdnsData)}`)}catch(error){log.warn(`rDNS failed: ${getErrorMessage(error)}`)}}console.log("");console.log(" To request port 25 unblock from Hetzner:");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\u2500");console.log(" 1. Log in to https://console.hetzner.cloud");console.log(` 2. Select project containing server "${serverName}"`);console.log(" 3. Go to Support (left sidebar) -> New support request");console.log(" 4. Use this template:");console.log("");console.log(" Subject: Request to unblock outbound port 25 (SMTP)");console.log("");console.log(" Body:");console.log(" \u2500\u2500\u2500\u2500\u2500");console.log(` Server: ${serverName} (ID: ${serverId}, IP: ${serverIp})`);console.log(` Use case: ${useCase}`);console.log(` Reverse DNS: ${serverIp} -> ${rdns}`);console.log(" We comply with all anti-spam regulations (CAN-SPAM, GDPR).");console.log(" Please unblock outbound ports 25 and 465 for this server.");console.log(" \u2500\u2500\u2500\u2500\u2500");console.log("");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\u2500");const consoleUrl="https://console.hetzner.cloud";try{execSync(`open "${consoleUrl}" 2>/dev/null || xdg-open "${consoleUrl}" 2>/dev/null || echo ""`,{encoding:"utf-8"});log.info(`Opened: ${consoleUrl}`)}catch{console.log(` Open: ${consoleUrl}`)}console.log("");console.log(" After Hetzner approves, verify with:");console.log(" buddy mail:port25:status --provider hetzner");console.log("");await log.flush();process.exit(ExitCode.Success)}
@@ -1 +1 @@
1
- import{existsSync}from"node:fs";import{writeFile}from"node:fs/promises";import{join,resolve}from"node:path";import process from"node:process";import{log,onUnknownSubcommand}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{DRIVERS,renderReport,runMigrator}from"../migrators";export function migrateProject(buddy){buddy.command("migrate:project [target]","Port an existing project (Laravel, Rails) into a Stacks project.").option("--from <framework>",`Source framework - one of: ${Object.keys(DRIVERS).join(", ")}`,{default:"laravel"}).option("--source <path>","Path to the existing source project. Defaults to the current working directory.",{default:!1}).option("--dry-run","Show what would be translated without writing files.",{default:!1}).option("--verbose","Print the per-entry table after the summary.",{default:!1}).action(async(target,options)=>{const from=options.from??"laravel";if(!(from in DRIVERS)){await log.error(`Unknown source framework: '${from}'. Supported: ${Object.keys(DRIVERS).join(", ")}.`);process.exit(ExitCode.FatalError)}const sourcePath=options.source?resolve(process.cwd(),String(options.source)):process.cwd();if(!existsSync(sourcePath)){await log.error(`Source project not found: ${sourcePath}`);process.exit(ExitCode.FatalError)}const targetPath=target?resolve(process.cwd(),target):process.cwd();log.info(`Migrating ${from} project: ${sourcePath} \u2192 ${targetPath}`);if(options.dryRun)log.info("(dry run - no files will be written)");try{const report=await runMigrator({source:sourcePath,target:targetPath,from,dryRun:options.dryRun===!0}),translated=report.entries.filter((e)=>e.status==="translated").length,copied=report.entries.filter((e)=>e.status==="copied").length,skipped=report.entries.filter((e)=>e.status==="skipped").length,failed=report.entries.filter((e)=>e.status==="failed").length;log.info(`Translated: ${translated} | Copied: ${copied} | Skipped: ${skipped} | Failed: ${failed}`);if(options.verbose)for(const e of report.entries){const arrow=e.target?` \u2192 ${e.target}`:"",note=e.note?` - ${e.note}`:"";log.info(` [${e.status}] ${e.source}${arrow}${note}`)}if(!options.dryRun){const reportPath=join(targetPath,"MIGRATION_REPORT.md");await writeFile(reportPath,renderReport(report));log.success(`Report written to ${reportPath}`)}process.exit(ExitCode.Success)}catch(err){await log.error(`Migration failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}});onUnknownSubcommand(buddy,"migrate:project")}
1
+ import{existsSync}from"node:fs";import{writeFile}from"node:fs/promises";import{join,resolve}from"node:path";import process from"node:process";import{log,onUnknownSubcommand}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{DRIVERS,renderReport,runMigrator}from"../migrators";export function migrateProject(buddy){buddy.command("migrate:project [target]","Port an existing project (Laravel, Rails) into a Stacks project.").option("--from <framework>",`Source framework - one of: ${Object.keys(DRIVERS).join(", ")}`,{default:"laravel"}).option("--source <path>","Path to the existing source project. Defaults to the current working directory.",{default:!1}).option("--dry-run","Show what would be translated without writing files.",{default:!1}).option("--verbose","Print the per-entry table after the summary.",{default:!1}).action(async(target,options)=>{const from=options.from??"laravel";if(!(from in DRIVERS)){await log.error(`Unknown source framework: '${from}'. Supported: ${Object.keys(DRIVERS).join(", ")}.`);process.exit(ExitCode.FatalError)}const sourcePath=options.source?resolve(process.cwd(),String(options.source)):process.cwd();if(!existsSync(sourcePath)){await log.error(`Source project not found: ${sourcePath}`);process.exit(ExitCode.FatalError)}const targetPath=target?resolve(process.cwd(),target):process.cwd();log.info(`Migrating ${from} project: ${sourcePath} \u2192 ${targetPath}`);if(options.dryRun)log.info("(dry run - no files will be written)");try{const report=await runMigrator({source:sourcePath,target:targetPath,from,dryRun:options.dryRun===!0}),translated=report.entries.filter((e)=>e.status==="translated").length,copied=report.entries.filter((e)=>e.status==="copied").length,skipped=report.entries.filter((e)=>e.status==="skipped").length,failed=report.entries.filter((e)=>e.status==="failed").length;log.info(`Translated: ${translated} | Copied: ${copied} | Skipped: ${skipped} | Failed: ${failed}`);if(options.verbose)for(const e of report.entries){const arrow=e.target?` \u2192 ${e.target}`:"",note=e.note?` - ${e.note}`:"";log.info(` [${e.status}] ${e.source}${arrow}${note}`)}if(!options.dryRun){const reportPath=join(targetPath,"MIGRATION_REPORT.md");await writeFile(reportPath,renderReport(report));log.success(`Report written to ${reportPath}`)}await log.flush();process.exit(ExitCode.Success)}catch(err){await log.error(`Migration failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}});onUnknownSubcommand(buddy,"migrate:project")}
@@ -1 +1 @@
1
- import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function queue(buddy){const descriptions={project:"Target a specific project",queue:"Specify queue name",verbose:"Enable verbose output",id:"Job or batch ID",all:"Apply to all items",force:"Force the operation without confirmation",connection:"Queue connection to use",concurrency:"Number of concurrent workers"};buddy.command("queue:work","Start processing jobs on the queue").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("-c, --connection [connection]",descriptions.connection,{default:!1}).option("--concurrency [concurrency]",descriptions.concurrency,{default:"1"}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:work` ...",options);const PARENT_BACKSTOP_MS=(Number(process.env.STACKS_QUEUE_SHUTDOWN_GRACE_MS)||1e4)+5000;let shuttingDown=!1;const onSignal=(signal)=>{if(shuttingDown)return;shuttingDown=!0;log.info(`[queue] Received ${signal}; waiting for the worker to drain\u2026`);setTimeout(()=>{log.warn("[queue] Worker drain overran its window - forcing shutdown.");process.exit(ExitCode.FatalError)},PARENT_BACKSTOP_MS).unref()};process.on("SIGINT",()=>onSignal("SIGINT"));process.on("SIGTERM",()=>onSignal("SIGTERM"));const perf=await intro("buddy queue:work"),result=await runAction(Action.QueueWork,options);if(resultFailed(result)){await outro("While running the queue:work command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("queue:failed","List all failed jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--since [duration]","Only show jobs failed since (e.g. 1h, 30m, 2d)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:failed` ...",options);const perf=await intro("buddy queue:failed"),result=await runAction(Action.QueueFailed,options);if(resultFailed(result)){await outro("While running the queue:failed command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Listed all failed jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:retry [id]","Retry failed jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--all",descriptions.all,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(id,options)=>{log.debug("Running `buddy queue:retry` ...",options);const perf=await intro("buddy queue:retry"),result=await runAction(Action.QueueRetry,{...options,id});if(resultFailed(result)){await outro("While running the queue:retry command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}const message=options.all?"Retried all failed jobs":`Retried failed job ${id}`;await outro(message,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:clear","Clear all jobs from the queue").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:clear` ...",options);const perf=await intro("buddy queue:clear"),result=await runAction(Action.QueueClear,options);if(resultFailed(result)){await outro("While running the queue:clear command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}const queueName=options.queue||"default";await outro(`Cleared all jobs from the "${queueName}" queue`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:list","List queued jobs (flat row view, filterable by queue/status)").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--status [status]","Filter by pending | reserved | delayed",{default:!1}).option("--limit [limit]","Maximum rows to display (default 50)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:list` ...",options);const perf=await intro("buddy queue:list"),result=await runAction(Action.QueueList,options);if(resultFailed(result)){await outro("While running the queue:list command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Listed queued jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:status","Display the status of queue workers").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:status` ...",options);const perf=await intro("buddy queue:status"),result=await runAction(Action.QueueStatus,options);if(resultFailed(result)){await outro("While running the queue:status command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Queue status displayed",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:flush","Delete all failed jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:flush` ...",options);const perf=await intro("buddy queue:flush"),result=await runAction(Action.QueueFlush,options);if(resultFailed(result)){await outro("While running the queue:flush command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Flushed all failed jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:dlq","List dead-letter jobs (jobs that re-failed after retry)").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--reason [reason]","Filter by reason (repeat-failure | poison-detected | circuit-broken | manual)",{default:!1}).option("--since [duration]","Only show rows dead-lettered since (e.g. 1h, 30m, 2d)",{default:!1}).option("--limit [limit]","Maximum rows to display (default 100)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:dlq` ...",options);const perf=await intro("buddy queue:dlq"),result=await runAction(Action.QueueDlq,options);if(resultFailed(result)){await outro("While running queue:dlq there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Listed dead-letter jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:dlq:retry","Re-enqueue a dead-letter job back into its queue").option("--id [id]","Dead-letter row id to retry",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:dlq:retry` ...",options);const perf=await intro("buddy queue:dlq:retry"),result=await runAction(Action.QueueDlqRetry,options);if(resultFailed(result)){await outro("While running queue:dlq:retry there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Re-enqueued dead-letter job",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:dlq:purge","Delete dead-letter rows older than a retention window").option("--older-than-days [days]","Retention window in days (default 30)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:dlq:purge` ...",options);const perf=await intro("buddy queue:dlq:purge"),result=await runAction(Action.QueueDlqPurge,options);if(resultFailed(result)){await outro("While running queue:dlq:purge there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Purged dead-letter rows",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:quarantine","List quarantined jobs (or --add a manual quarantine)").option("--add [jobName]","Manually quarantine a job class",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:quarantine` ...",options);const perf=await intro("buddy queue:quarantine"),result=await runAction(Action.QueueQuarantine,options);if(resultFailed(result)){await outro("While running queue:quarantine there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("queue:quarantine done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:unquarantine","Lift the quarantine on a job class").option("--name [name]","Job class name to unquarantine",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:unquarantine` ...",options);const perf=await intro("buddy queue:unquarantine"),result=await runAction(Action.QueueUnquarantine,options);if(resultFailed(result)){await outro("While running queue:unquarantine there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Unquarantined",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:pause","Manually pause a queue (circuit-breaker)").option("--queue [name]","Queue name to pause",{default:!1}).option("--for [seconds]","Pause duration in seconds (default 300)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:pause` ...",options);const perf=await intro("buddy queue:pause"),result=await runAction(Action.QueuePause,options);if(resultFailed(result)){await outro("While running queue:pause there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Paused queue",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:resume","Resume a paused queue (circuit-breaker)").option("--queue [name]","Queue name to resume",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:resume` ...",options);const perf=await intro("buddy queue:resume"),result=await runAction(Action.QueueResume,options);if(resultFailed(result)){await outro("While running queue:resume there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Resumed queue",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:table","Create a migration for the jobs database table").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:table` ...",options);const perf=await intro("buddy queue:table"),result=await runAction(Action.QueueTable,options);if(resultFailed(result)){await outro("While running the queue:table command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Jobs table migration created",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:monitor","Monitor queue status in real-time").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("-i, --interval [interval]","Refresh interval in milliseconds",{default:"2000"}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:monitor` ...",options);const result=await runAction(Action.QueueMonitor,options);if(resultFailed(result)){await log.error("While running the queue:monitor command, there was an issue",result.error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("queue:inspect [id]","Inspect a specific job").option("-p, --project [project]",descriptions.project,{default:!1}).option("--failed","Inspect a failed job",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(id,options)=>{log.debug("Running `buddy queue:inspect` ...",options);const perf=await intro("buddy queue:inspect"),result=await runAction(Action.QueueInspect,{...options,id});if(resultFailed(result)){await outro("While running the queue:inspect command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Job inspection complete",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:schedule","Start the job scheduler for cron-based jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:schedule` ...",options);const result=await runAction(Action.QueueSchedule,options);if(resultFailed(result)){await log.error("While running the queue:schedule command, there was an issue",result.error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("queue:schedule:list","List all scheduled jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:schedule:list` ...",options);const perf=await intro("buddy queue:schedule:list"),result=await runAction(Action.QueueScheduleList,options);if(resultFailed(result)){await outro("While running the queue:schedule:list command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Listed all scheduled jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"queue")}
1
+ import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function queue(buddy){const descriptions={project:"Target a specific project",queue:"Specify queue name",verbose:"Enable verbose output",id:"Job or batch ID",all:"Apply to all items",force:"Force the operation without confirmation",connection:"Queue connection to use",concurrency:"Number of concurrent workers"};buddy.command("queue:work","Start processing jobs on the queue").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("-c, --connection [connection]",descriptions.connection,{default:!1}).option("--concurrency [concurrency]",descriptions.concurrency,{default:"1"}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:work` ...",options);const PARENT_BACKSTOP_MS=(Number(process.env.STACKS_QUEUE_SHUTDOWN_GRACE_MS)||1e4)+5000;let shuttingDown=!1;const onSignal=(signal)=>{if(shuttingDown)return;shuttingDown=!0;log.info(`[queue] Received ${signal}; waiting for the worker to drain\u2026`);setTimeout(()=>{console.error("[queue] Worker drain overran its window - forcing shutdown.");process.exit(ExitCode.FatalError)},PARENT_BACKSTOP_MS).unref()};process.on("SIGINT",()=>onSignal("SIGINT"));process.on("SIGTERM",()=>onSignal("SIGTERM"));const perf=await intro("buddy queue:work"),result=await runAction(Action.QueueWork,options);if(resultFailed(result)){await outro("While running the queue:work command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("queue:failed","List all failed jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--since [duration]","Only show jobs failed since (e.g. 1h, 30m, 2d)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:failed` ...",options);const perf=await intro("buddy queue:failed"),result=await runAction(Action.QueueFailed,options);if(resultFailed(result)){await outro("While running the queue:failed command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Listed all failed jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:retry [id]","Retry failed jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--all",descriptions.all,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(id,options)=>{log.debug("Running `buddy queue:retry` ...",options);const perf=await intro("buddy queue:retry"),result=await runAction(Action.QueueRetry,{...options,id});if(resultFailed(result)){await outro("While running the queue:retry command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}const message=options.all?"Retried all failed jobs":`Retried failed job ${id}`;await outro(message,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:clear","Clear all jobs from the queue").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:clear` ...",options);const perf=await intro("buddy queue:clear"),result=await runAction(Action.QueueClear,options);if(resultFailed(result)){await outro("While running the queue:clear command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}const queueName=options.queue||"default";await outro(`Cleared all jobs from the "${queueName}" queue`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:list","List queued jobs (flat row view, filterable by queue/status)").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--status [status]","Filter by pending | reserved | delayed",{default:!1}).option("--limit [limit]","Maximum rows to display (default 50)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:list` ...",options);const perf=await intro("buddy queue:list"),result=await runAction(Action.QueueList,options);if(resultFailed(result)){await outro("While running the queue:list command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Listed queued jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:status","Display the status of queue workers").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:status` ...",options);const perf=await intro("buddy queue:status"),result=await runAction(Action.QueueStatus,options);if(resultFailed(result)){await outro("While running the queue:status command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Queue status displayed",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:flush","Delete all failed jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:flush` ...",options);const perf=await intro("buddy queue:flush"),result=await runAction(Action.QueueFlush,options);if(resultFailed(result)){await outro("While running the queue:flush command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Flushed all failed jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:dlq","List dead-letter jobs (jobs that re-failed after retry)").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--reason [reason]","Filter by reason (repeat-failure | poison-detected | circuit-broken | manual)",{default:!1}).option("--since [duration]","Only show rows dead-lettered since (e.g. 1h, 30m, 2d)",{default:!1}).option("--limit [limit]","Maximum rows to display (default 100)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:dlq` ...",options);const perf=await intro("buddy queue:dlq"),result=await runAction(Action.QueueDlq,options);if(resultFailed(result)){await outro("While running queue:dlq there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Listed dead-letter jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:dlq:retry","Re-enqueue a dead-letter job back into its queue").option("--id [id]","Dead-letter row id to retry",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:dlq:retry` ...",options);const perf=await intro("buddy queue:dlq:retry"),result=await runAction(Action.QueueDlqRetry,options);if(resultFailed(result)){await outro("While running queue:dlq:retry there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Re-enqueued dead-letter job",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:dlq:purge","Delete dead-letter rows older than a retention window").option("--older-than-days [days]","Retention window in days (default 30)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:dlq:purge` ...",options);const perf=await intro("buddy queue:dlq:purge"),result=await runAction(Action.QueueDlqPurge,options);if(resultFailed(result)){await outro("While running queue:dlq:purge there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Purged dead-letter rows",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:quarantine","List quarantined jobs (or --add a manual quarantine)").option("--add [jobName]","Manually quarantine a job class",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:quarantine` ...",options);const perf=await intro("buddy queue:quarantine"),result=await runAction(Action.QueueQuarantine,options);if(resultFailed(result)){await outro("While running queue:quarantine there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("queue:quarantine done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:unquarantine","Lift the quarantine on a job class").option("--name [name]","Job class name to unquarantine",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:unquarantine` ...",options);const perf=await intro("buddy queue:unquarantine"),result=await runAction(Action.QueueUnquarantine,options);if(resultFailed(result)){await outro("While running queue:unquarantine there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Unquarantined",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:pause","Manually pause a queue (circuit-breaker)").option("--queue [name]","Queue name to pause",{default:!1}).option("--for [seconds]","Pause duration in seconds (default 300)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:pause` ...",options);const perf=await intro("buddy queue:pause"),result=await runAction(Action.QueuePause,options);if(resultFailed(result)){await outro("While running queue:pause there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Paused queue",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:resume","Resume a paused queue (circuit-breaker)").option("--queue [name]","Queue name to resume",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:resume` ...",options);const perf=await intro("buddy queue:resume"),result=await runAction(Action.QueueResume,options);if(resultFailed(result)){await outro("While running queue:resume there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Resumed queue",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:table","Create a migration for the jobs database table").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:table` ...",options);const perf=await intro("buddy queue:table"),result=await runAction(Action.QueueTable,options);if(resultFailed(result)){await outro("While running the queue:table command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Jobs table migration created",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:monitor","Monitor queue status in real-time").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("-i, --interval [interval]","Refresh interval in milliseconds",{default:"2000"}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:monitor` ...",options);const result=await runAction(Action.QueueMonitor,options);if(resultFailed(result)){await log.error("While running the queue:monitor command, there was an issue",result.error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("queue:inspect [id]","Inspect a specific job").option("-p, --project [project]",descriptions.project,{default:!1}).option("--failed","Inspect a failed job",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(id,options)=>{log.debug("Running `buddy queue:inspect` ...",options);const perf=await intro("buddy queue:inspect"),result=await runAction(Action.QueueInspect,{...options,id});if(resultFailed(result)){await outro("While running the queue:inspect command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Job inspection complete",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:schedule","Start the job scheduler for cron-based jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:schedule` ...",options);const result=await runAction(Action.QueueSchedule,options);if(resultFailed(result)){await log.error("While running the queue:schedule command, there was an issue",result.error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("queue:schedule:list","List all scheduled jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:schedule:list` ...",options);const perf=await intro("buddy queue:schedule:list"),result=await runAction(Action.QueueScheduleList,options);if(resultFailed(result)){await outro("While running the queue:schedule:list command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Listed all scheduled jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"queue")}
@@ -1 +1 @@
1
- import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,log,outro}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function schedule(buddy){const descriptions={project:"Target a specific project",schedule:"Run the scheduler",verbose:"Enable verbose output",list:"List all registered scheduled tasks with their next run time",status:"Show currently-held overlap locks (this-process only)",runOne:"Run one registered scheduled task immediately",enable:"Resume a paused scheduled task",disable:"Pause a scheduled task without editing source"};buddy.command("schedule:run",descriptions.schedule).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy schedule:run` ...",options);const perf=await intro("buddy schedule:run"),result=await runAction(Action.ScheduleRun,options);if(resultFailed(result)){await outro("While running the schedule:run command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("schedule:list",descriptions.list).option("--json","Print a machine-readable schedule registry",{default:!1}).action(async(options)=>{try{const{runScheduler,Schedule}=await import("@stacksjs/scheduler");await runScheduler();await Promise.resolve();const jobs=Schedule.listJobs();if(options.json){console.log(`STACKS_SCHEDULE_JSON=${JSON.stringify({jobs,locks:Schedule.listLocks()})}`);await Schedule.gracefulShutdown();process.exit(ExitCode.Success)}if(jobs.length===0){log.info("No scheduled tasks registered.");await Schedule.gracefulShutdown();process.exit(ExitCode.Success)}const nameWidth=Math.max(4,...jobs.map((j)=>j.name.length)),patternWidth=Math.max(7,...jobs.map((j)=>(j.pattern??"").length));log.info(`${"NAME".padEnd(nameWidth)} ${"PATTERN".padEnd(patternWidth)} STATUS TIMEZONE NEXT RUN (UTC)`);for(const j of jobs){const next=j.nextRun?j.nextRun.toISOString():"-";log.info(`${j.name.padEnd(nameWidth)} ${(j.pattern??"").padEnd(patternWidth)} ${(j.enabled?"active":"paused").padEnd(6)} ${(j.timezone??"UTC").padEnd(25)} ${next}`)}await Schedule.gracefulShutdown();process.exit(ExitCode.Success)}catch(err){await log.error(`[schedule:list] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}});buddy.command("schedule:status",descriptions.status).option("--json","Print machine-readable scheduler status",{default:!1}).action(async(options)=>{try{const{Schedule}=await import("@stacksjs/scheduler"),held=Schedule.listLocks();if(options.json){console.log(`STACKS_SCHEDULE_STATUS_JSON=${JSON.stringify({locks:held})}`);process.exit(ExitCode.Success)}if(held.length===0)log.info("No in-process scheduler locks held.");else{log.info(`Held locks (${held.length}):`);for(const name of held)log.info(` \u2022 ${name}`)}process.exit(ExitCode.Success)}catch(err){await log.error(`[schedule:status] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}});buddy.command("schedule:run-one <name>",descriptions.runOne).action(async(name)=>{try{const{runScheduler,Schedule}=await import("@stacksjs/scheduler");await runScheduler();await Promise.resolve();await Schedule.runNow(name);await Schedule.gracefulShutdown();log.success(`Scheduled task ${name} completed.`);await log.flush();process.exit(ExitCode.Success)}catch(err){await log.error(`[schedule:run-one] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}});for(const[command,enabled,description]of[["schedule:enable",!0,descriptions.enable],["schedule:disable",!1,descriptions.disable]])buddy.command(`${command} <name>`,description).action(async(name)=>{try{const{runScheduler,Schedule}=await import("@stacksjs/scheduler");await runScheduler();await Promise.resolve();const exists=Schedule.listJobs().some((job)=>job.name===name);await Schedule.gracefulShutdown();if(!exists)throw Error(`Scheduled task "${name}" was not found.`);Schedule.setEnabled(name,enabled);log.success(`Scheduled task ${name} ${enabled?"resumed":"paused"}.`);await log.flush();process.exit(ExitCode.Success)}catch(err){await log.error(`[${command}] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}})}
1
+ import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,log,outro}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function schedule(buddy){const descriptions={project:"Target a specific project",schedule:"Run the scheduler",verbose:"Enable verbose output",list:"List all registered scheduled tasks with their next run time",status:"Show currently-held overlap locks (this-process only)",runOne:"Run one registered scheduled task immediately",enable:"Resume a paused scheduled task",disable:"Pause a scheduled task without editing source"};buddy.command("schedule:run",descriptions.schedule).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy schedule:run` ...",options);const perf=await intro("buddy schedule:run"),result=await runAction(Action.ScheduleRun,options);if(resultFailed(result)){await outro("While running the schedule:run command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("schedule:list",descriptions.list).option("--json","Print a machine-readable schedule registry",{default:!1}).action(async(options)=>{try{const{runScheduler,Schedule}=await import("@stacksjs/scheduler");await runScheduler();await Promise.resolve();const jobs=Schedule.listJobs();if(options.json){console.log(`STACKS_SCHEDULE_JSON=${JSON.stringify({jobs,locks:Schedule.listLocks()})}`);await Schedule.gracefulShutdown();process.exit(ExitCode.Success)}if(jobs.length===0){log.info("No scheduled tasks registered.");await Schedule.gracefulShutdown();process.exit(ExitCode.Success)}const nameWidth=Math.max(4,...jobs.map((j)=>j.name.length)),patternWidth=Math.max(7,...jobs.map((j)=>(j.pattern??"").length));log.info(`${"NAME".padEnd(nameWidth)} ${"PATTERN".padEnd(patternWidth)} STATUS TIMEZONE NEXT RUN (UTC)`);for(const j of jobs){const next=j.nextRun?j.nextRun.toISOString():"-";log.info(`${j.name.padEnd(nameWidth)} ${(j.pattern??"").padEnd(patternWidth)} ${(j.enabled?"active":"paused").padEnd(6)} ${(j.timezone??"UTC").padEnd(25)} ${next}`)}await Schedule.gracefulShutdown();process.exit(ExitCode.Success)}catch(err){await log.error(`[schedule:list] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}});buddy.command("schedule:status",descriptions.status).option("--json","Print machine-readable scheduler status",{default:!1}).action(async(options)=>{try{const{Schedule}=await import("@stacksjs/scheduler"),held=Schedule.listLocks();if(options.json){console.log(`STACKS_SCHEDULE_STATUS_JSON=${JSON.stringify({locks:held})}`);process.exit(ExitCode.Success)}if(held.length===0)log.info("No in-process scheduler locks held.");else{log.info(`Held locks (${held.length}):`);for(const name of held)log.info(` \u2022 ${name}`)}await log.flush();process.exit(ExitCode.Success)}catch(err){await log.error(`[schedule:status] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}});buddy.command("schedule:run-one <name>",descriptions.runOne).action(async(name)=>{try{const{runScheduler,Schedule}=await import("@stacksjs/scheduler");await runScheduler();await Promise.resolve();await Schedule.runNow(name);await Schedule.gracefulShutdown();log.success(`Scheduled task ${name} completed.`);await log.flush();process.exit(ExitCode.Success)}catch(err){await log.error(`[schedule:run-one] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}});for(const[command,enabled,description]of[["schedule:enable",!0,descriptions.enable],["schedule:disable",!1,descriptions.disable]])buddy.command(`${command} <name>`,description).action(async(name)=>{try{const{runScheduler,Schedule}=await import("@stacksjs/scheduler");await runScheduler();await Promise.resolve();const exists=Schedule.listJobs().some((job)=>job.name===name);await Schedule.gracefulShutdown();if(!exists)throw Error(`Scheduled task "${name}" was not found.`);Schedule.setEnabled(name,enabled);log.success(`Scheduled task ${name} ${enabled?"resumed":"paused"}.`);await log.flush();process.exit(ExitCode.Success)}catch(err){await log.error(`[${command}] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}})}
@@ -1,5 +1,5 @@
1
1
  import{cpSync,existsSync,readFileSync,writeFileSync}from"node:fs";import{homedir}from"node:os";import{join}from"node:path";import process from"node:process";import{runAction}from"@stacksjs/actions";import{log,onUnknownSubcommand,runCommand}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{handleError}from"@stacksjs/error-handling";import{path as p}from"@stacksjs/path";import{copyFile,storage}from"@stacksjs/storage";import{ExitCode}from"@stacksjs/types";import{setupPrettyDevEnvironment}from"./dev";import{resultFailed}from"../result";function getTimeoutMs(envVar,fallbackMs){const value=Number(process.env[envVar]);if(Number.isFinite(value)&&value>0)return value;return fallbackMs}const PANTRY_CHECK_TIMEOUT_MS=getTimeoutMs("PANTRY_CHECK_TIMEOUT_MS",15000),PANTRY_INSTALL_TIMEOUT_MS=getTimeoutMs("PANTRY_INSTALL_TIMEOUT_MS",600000),PANTRY_DEPENDENCIES_TIMEOUT_MS=getTimeoutMs("PANTRY_DEPENDENCIES_TIMEOUT_MS",1200000),KEYGEN_TIMEOUT_MS=getTimeoutMs("KEYGEN_TIMEOUT_MS",120000),AWS_CONFIG_TIMEOUT_MS=getTimeoutMs("AWS_CONFIG_TIMEOUT_MS",900000);export function setup(buddy){const descriptions={setup:"This command ensures your project is setup correctly",ssl:"Setup SSL certificates and hosts file for HTTPS development",ai:"Set the project up for an AI coding agent (Claude Code, Codex, Cursor, Copilot, Gemini)",copy:"Copy the agent files instead of symlinking them, so they can be edited per project",force:"Overwrite files that already exist",ohMyZsh:"Enable Oh My Zsh",aws:"Ensures AWS is connected to the project",project:"Target a specific project",verbose:"Enable verbose output",domain:"Custom domain to setup (defaults to APP_URL)",skipHosts:"Skip adding domain to hosts file",skipTrust:"Skip trusting the certificate",skipAws:"Skip AWS configuration during setup",skipKeygen:"Skip generating an application key during setup"};buddy.command("setup",descriptions.setup).alias("ensure").option("-p, --project [project]",descriptions.project,{default:!1}).option("--skip-aws",descriptions.skipAws,{default:!1}).option("--skip-keygen",descriptions.skipKeygen,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy setup` ...",options);await ensurePantryInstalled();await optimizePantryDeps();await initializeProject(options)});buddy.command("setup:ssl",descriptions.ssl).alias("ssl:setup").option("-d, --domain [domain]",descriptions.domain).option("--skip-hosts",descriptions.skipHosts,{default:!1}).option("--skip-trust",descriptions.skipTrust,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy setup:ssl` ...",options);if(!await setupPrettyDevEnvironment({domain:options.domain,skipHosts:options.skipHosts,skipTrust:options.skipTrust,verbose:options.verbose})){log.warn("SSL setup completed with warnings");log.info("You may need to manually trust certificates or update hosts file")}});buddy.command("setup:ai [provider]",descriptions.ai).alias("ai:setup").option("--copy",descriptions.copy,{default:!1}).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(provider,options)=>{log.debug("Running `buddy setup:ai` ...",options);const{AI_PROVIDERS,isAiProvider,reportAiSetup,setupAiProvider}=await import("./setup-ai");let id=provider;if(!id){if(!process.stdin.isTTY){await log.error(`\`setup:ai\` needs a provider when stdin is not a terminal. Pass one: ${AI_PROVIDERS.map((entry)=>`buddy setup:ai ${entry.id}`).join(", ")}`);process.exit(ExitCode.InvalidArgument)}const{select}=await import("@stacksjs/cli");id=await select({message:"Which AI coding agent do you use?",choices:AI_PROVIDERS.map((entry)=>({value:entry.id,label:entry.label})),initial:0})}if(!id||!isAiProvider(id)){await log.error(`Unknown AI provider: ${id}. Expected one of: ${AI_PROVIDERS.map((entry)=>entry.id).join(", ")}`);process.exit(ExitCode.InvalidArgument)}const definition=AI_PROVIDERS.find((entry)=>entry.id===id);reportAiSetup(definition,setupAiProvider(id,{copy:options.copy,force:options.force}))});buddy.command("setup:oh-my-zsh",descriptions.ohMyZsh).alias("upgrade:oh-my-zsh").option("--verbose",descriptions.verbose,{default:!1}).action(async(_options)=>{log.debug("Running `buddy setup:oh-my-zsh` ...",_options);const result=await runAction(Action.UpgradeShell);if(resultFailed(result)){await log.error(result.error);process.exit(ExitCode.FatalError)}});onUnknownSubcommand(buddy,"setup")}async function isPantryInstalled(){try{return(await runCommand("pantry --version",{silent:!0,timeoutMs:PANTRY_CHECK_TIMEOUT_MS})).isOk}catch{return!1}}async function installPantry(){const bundledInstaller=p.frameworkPath("scripts/pantry-install"),command=existsSync(bundledInstaller)?[bundledInstaller]:["sh","-c","curl -fsSL https://pantry.dev | bash"],result=await runCommand(command,{timeoutMs:PANTRY_INSTALL_TIMEOUT_MS}),localBin=join(homedir(),".local","bin");if(!process.env.PATH?.split(":").includes(localBin))process.env.PATH=`${localBin}:${process.env.PATH||""}`;if(result.isOk&&await isPantryInstalled())return;if(resultFailed(result))handleError(result.error);else await log.error("Pantry installed but is not available on PATH. Open a new shell and run `buddy setup` again.");process.exit(ExitCode.FatalError)}export async function ensurePantryInstalled(){if(await isPantryInstalled())return;log.info("Pantry is required. Installing it from https://pantry.dev...");await installPantry()}export async function ensurePantryDependencies(cwd){await ensurePantryInstalled();log.info("Installing project dependencies with Pantry...");const result=await runCommand("pantry install",{cwd,timeoutMs:PANTRY_DEPENDENCIES_TIMEOUT_MS});if(resultFailed(result)){handleError(result.error);process.exit(ExitCode.FatalError)}if(existsSync(join(cwd,"package.json"))&&!existsSync(join(cwd,"node_modules"))){await log.error("Pantry completed without installing the project JavaScript dependencies.");process.exit(ExitCode.FatalError)}log.success("Installed project dependencies with Pantry")}function hasAppKey(cwd){const envPath=join(cwd,".env");if(!existsSync(envPath))return!1;return/^APP_KEY=.+$/m.test(readFileSync(envPath,"utf-8"))}export async function ensureAppKey(cwd){if(hasAppKey(cwd)||process.env.APP_KEY&&process.env.APP_KEY.length>0){log.success("APP_KEY existed");return}const keyResult=await runCommand("./buddy key:generate",{cwd,timeoutMs:KEYGEN_TIMEOUT_MS});if(resultFailed(keyResult)){handleError(keyResult.error);process.exit(ExitCode.FatalError)}log.success("Generated application key")}async function runInitialMigration(cwd){const appEnv=(process.env.APP_ENV||process.env.NODE_ENV||"local").toLowerCase();if(!["local","development","dev","test"].includes(appEnv)){log.info(`Skipping initial migration in the ${appEnv} environment`);return}log.info("Running initial database migration...");try{const result=await runAction(Action.Migrate,{cwd});if(resultFailed(result)){log.warn("Initial migration did not complete - you can run it later via ./buddy migrate");log.debug(result.error);return}log.success("Database is migrated")}catch(error){log.warn("Initial migration did not complete - you can run it later via ./buddy migrate");log.debug(error)}}async function initializeProject(options){const cwd=options.cwd||p.projectPath();await ensurePantryDependencies(cwd);await ensureEnvIsSet(options);if(!options.skipKeygen)await ensureAppKey(cwd);await runInitialMigration(cwd);ensureIdeSettings(cwd);if(!options.skipAws){log.info("Ensuring AWS is connected...");try{const awsResult=await runCommand("./buddy configure:aws",{cwd,timeoutMs:AWS_CONFIG_TIMEOUT_MS});if(resultFailed(awsResult)){log.warn("AWS not configured - you can do this later via ./buddy configure:aws");log.debug(awsResult.error)}else log.success("Configured AWS")}catch(error){log.warn("AWS not configured - you can do this later via ./buddy configure:aws");log.debug(error)}}log.success("Project is setup");log.info("Run `./buddy doctor` anytime to check your setup. Happy coding! \uD83D\uDC99")}export function ensureIdeSettings(cwd){const source=p.frameworkPath("defaults/ide/vscode/.vscode"),destination=join(cwd,".vscode");if(existsSync(destination)){log.debug(".vscode already exists; keeping the project settings");return}if(!existsSync(source)){log.debug("No bundled VS Code settings found; skipping IDE setup");return}cpSync(source,destination,{recursive:!0});log.success("Installed project VS Code settings")}const DB_CONNECTION_PACKAGES={postgres:{name:"postgresql.org",version:"^17.10",service:"postgres"},mysql:{name:"mysql.com",version:"^9.2",service:"mysql"},sqlite:{name:"sqlite.org",version:"^3.47.2"}};function databaseAliases(connection,pkg){return[connection,pkg.name]}export function pantryDatabasePackage(connection){return DB_CONNECTION_PACKAGES[connection]}function detectDbPackage(cwd){const envPath=join(cwd,".env"),envExamplePath=join(cwd,".env.example"),filePath=existsSync(envPath)?envPath:existsSync(envExamplePath)?envExamplePath:void 0;if(!filePath)return;const match=readFileSync(filePath,"utf-8").match(/^DB_CONNECTION=(.+)$/m);if(!match)return;const value=match[1].trim().replace(/['"]/g,"");return pantryDatabasePackage(value)}export async function optimizePantryDeps(){const cwd=p.projectPath(),depsConfigPath=join(cwd,"config","deps.ts");if(!existsSync(depsConfigPath)){log.debug("No config/deps.ts found, skipping dependency optimization");return}let configDeps={},configServices=[],configDefined={};try{const mod=await import(depsConfigPath),config=mod.config||mod.default;if(config?.dependencies)configDeps={...config.dependencies};if(Array.isArray(config?.services?.autoStart))configServices=config.services.autoStart.filter((name)=>typeof name==="string");if(config?.services?.define&&typeof config.services.define==="object")configDefined=config.services.define}catch(err){log.debug("Could not load config/deps.ts, skipping dependency optimization");return}const dbPackage=detectDbPackage(cwd),autoStart=[...configServices];if(dbPackage){const selected=new Set(Object.entries(DB_CONNECTION_PACKAGES).filter(([,pkg])=>pkg.name===dbPackage.name).flatMap(([connection,pkg])=>databaseAliases(connection,pkg))),unused=new Set(Object.entries(DB_CONNECTION_PACKAGES).flatMap(([connection,pkg])=>databaseAliases(connection,pkg)).filter((alias)=>!selected.has(alias)));for(const pkg of Object.keys(configDeps)){const domain=pkg.split("/")[0];if(unused.has(domain)){log.info(`DB_CONNECTION selects ${dbPackage.name}, dropping unused ${pkg}`);delete configDeps[pkg]}}if(!Object.keys(configDeps).some((key)=>{const domain=key.split("/")[0];return selected.has(domain)})){log.info(`Detected DB_CONNECTION requires ${dbPackage.name}, adding to dependencies`);configDeps[dbPackage.name]=dbPackage.version}if(dbPackage.service&&!autoStart.includes(dbPackage.service))autoStart.push(dbPackage.service)}const lines=["# Auto-generated from config/deps.ts and .env sniffing.","# This file is regenerated on each `buddy setup` run.","#","# To learn more, please visit:","# https://stacksjs.com/docs/dependency-management","","dependencies:"];for(const[pkg,version]of Object.entries(configDeps))lines.push(` ${pkg}: ${version}`);const defined=Object.entries(configDefined);if(autoStart.length>0||defined.length>0){lines.push("","services:"," enabled: true");if(autoStart.length>0){lines.push(" autoStart:");for(const service of autoStart)lines.push(` - ${service}`)}if(defined.length>0){lines.push(" define:");for(const[name,definition]of defined){if(!definition||typeof definition!=="object")continue;lines.push(` ${name}:`);for(const[key,value]of Object.entries(definition)){if(value===void 0||value===null)continue;lines.push(` ${key}: ${String(value)}`)}}}}const depsYamlPath=join(cwd,"deps.yaml");writeFileSync(depsYamlPath,`${lines.join(`
2
2
  `)}
3
- `);log.success("Generated deps.yaml from config/deps.ts")}export async function ensureEnvIsSet(options){log.info("Ensuring .env exists...");const cwd=options.cwd||p.projectPath(),envPath=`${cwd}/.env`,envExamplePath=`${cwd}/.env.example`;if(storage.doesNotExist(envPath)){try{copyFile(envExamplePath,envPath)}catch(error){handleError(error);process.exit(ExitCode.FatalError)}log.success(".env created")}else log.success(".env existed")}function envValues(contents){return contents.split(`
3
+ `);log.success("Generated deps.yaml from config/deps.ts")}export async function ensureEnvIsSet(options){log.info("Ensuring .env exists...");const cwd=options.cwd||p.projectPath(),envPath=`${cwd}/.env`,envExamplePath=`${cwd}/.env.example`;if(storage.doesNotExist(envPath)){try{copyFile(envExamplePath,envPath)}catch(error){handleError(error);await log.flush();process.exit(ExitCode.FatalError)}log.success(".env created")}else log.success(".env existed")}function envValues(contents){return contents.split(`
4
4
  `).map((line)=>line.trim()).filter((line)=>line.length>0&&!line.startsWith("#")&&!line.startsWith("DOTENV_PUBLIC_KEY")).map((line)=>line.slice(line.indexOf("=")+1).trim()).map((value)=>value.replace(/^['"]/,""))}function isCiphertext(value){return value.startsWith("encrypted:")||value.startsWith("enc:")}export function isEnvFileEncrypted(contents){return envValues(contents).every(isCiphertext)}function deployEnvTemplate(environment){return[`# Secrets for the ${environment} environment.`,"#","# Values here are encrypted with the public key below and decrypted at","# deploy time with the matching private key in .env.keys, which is NOT","# committed. This file is - that is the point: the ciphertext is reviewable","# and diffable, and losing a laptop does not leak production.","#","# Add one with:",`# buddy env:set STRIPE_SECRET_KEY sk_live_\u2026 --env ${environment}`,"#","# Left empty on purpose. Nothing was copied out of your .env: that file","# describes a laptop, and a server is not one.",""].join(`
5
5
  `)}export async function ensureDeployEnvIsSet(cwd,environment){if(["development","dev","local","test"].includes(environment))return;const fileName=`.env.${environment}`,filePath=join(cwd,fileName),created=!existsSync(filePath);if(created)writeFileSync(filePath,deployEnvTemplate(environment));const contents=readFileSync(filePath,"utf-8");if(!created&&isEnvFileEncrypted(contents)){log.success(`${fileName} existed`);return}const{encryptEnv}=await import("@stacksjs/env"),result=encryptEnv({file:fileName,cwd});if(!result.success){await log.error(`Could not encrypt ${fileName}: ${result.error??"unknown error"}`);process.exit(ExitCode.FatalError)}if(created){log.success(`${fileName} created (empty, encrypted - add secrets with \`buddy env:set KEY value --env ${environment}\`)`);return}const encrypted=envValues(contents).filter((value)=>!isCiphertext(value)).length;log.success(`${fileName} encrypted (${encrypted} value${encrypted===1?"":"s"})`)}
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.16",
5
+ "version": "0.74.17",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -95,69 +95,69 @@
95
95
  "prepublishOnly": "bun run build"
96
96
  },
97
97
  "dependencies": {
98
- "@stacksjs/actions": "^0.74.16",
99
- "@stacksjs/ai": "^0.74.16",
100
- "@stacksjs/alias": "^0.74.16",
101
- "@stacksjs/analytics": "^0.74.16",
102
- "@stacksjs/api": "^0.74.16",
103
- "@stacksjs/arrays": "^0.74.16",
104
- "@stacksjs/auth": "^0.74.16",
105
- "@stacksjs/browser-extension": "^0.74.16",
106
- "@stacksjs/build": "^0.74.16",
107
- "@stacksjs/cache": "^0.74.16",
108
- "@stacksjs/chat": "^0.74.16",
98
+ "@stacksjs/actions": "^0.74.17",
99
+ "@stacksjs/ai": "^0.74.17",
100
+ "@stacksjs/alias": "^0.74.17",
101
+ "@stacksjs/analytics": "^0.74.17",
102
+ "@stacksjs/api": "^0.74.17",
103
+ "@stacksjs/arrays": "^0.74.17",
104
+ "@stacksjs/auth": "^0.74.17",
105
+ "@stacksjs/browser-extension": "^0.74.17",
106
+ "@stacksjs/build": "^0.74.17",
107
+ "@stacksjs/cache": "^0.74.17",
108
+ "@stacksjs/chat": "^0.74.17",
109
109
  "@stacksjs/clapp": "^0.2.12",
110
- "@stacksjs/cli": "^0.74.16",
111
- "@stacksjs/cloud": "^0.74.16",
112
- "@stacksjs/cms": "^0.74.16",
113
- "@stacksjs/collections": "^0.74.16",
114
- "@stacksjs/config": "^0.74.16",
115
- "@stacksjs/database": "^0.74.16",
116
- "@stacksjs/desktop-build": "^0.74.16",
117
- "@stacksjs/dns": "^0.74.16",
110
+ "@stacksjs/cli": "^0.74.17",
111
+ "@stacksjs/cloud": "^0.74.17",
112
+ "@stacksjs/cms": "^0.74.17",
113
+ "@stacksjs/collections": "^0.74.17",
114
+ "@stacksjs/config": "^0.74.17",
115
+ "@stacksjs/database": "^0.74.17",
116
+ "@stacksjs/desktop-build": "^0.74.17",
117
+ "@stacksjs/dns": "^0.74.17",
118
118
  "@stacksjs/dnsx": "^0.2.3",
119
- "@stacksjs/email": "^0.74.16",
120
- "@stacksjs/enums": "^0.74.16",
121
- "@stacksjs/env": "^0.74.16",
122
- "@stacksjs/error-handling": "^0.74.16",
123
- "@stacksjs/events": "^0.74.16",
124
- "@stacksjs/features": "^0.74.16",
125
- "@stacksjs/git": "^0.74.16",
119
+ "@stacksjs/email": "^0.74.17",
120
+ "@stacksjs/enums": "^0.74.17",
121
+ "@stacksjs/env": "^0.74.17",
122
+ "@stacksjs/error-handling": "^0.74.17",
123
+ "@stacksjs/events": "^0.74.17",
124
+ "@stacksjs/features": "^0.74.17",
125
+ "@stacksjs/git": "^0.74.17",
126
126
  "@stacksjs/gitit": "^0.2.5",
127
- "@stacksjs/health": "^0.74.16",
127
+ "@stacksjs/health": "^0.74.17",
128
128
  "@stacksjs/httx": "^0.1.10",
129
- "@stacksjs/image": "^0.74.16",
130
- "@stacksjs/lint": "^0.74.16",
131
- "@stacksjs/logging": "^0.74.16",
132
- "@stacksjs/notifications": "^0.74.16",
133
- "@stacksjs/objects": "^0.74.16",
134
- "@stacksjs/orm": "^0.74.16",
135
- "@stacksjs/path": "^0.74.16",
136
- "@stacksjs/payments": "^0.74.16",
137
- "@stacksjs/realtime": "^0.74.16",
138
- "@stacksjs/router": "^0.74.16",
129
+ "@stacksjs/image": "^0.74.17",
130
+ "@stacksjs/lint": "^0.74.17",
131
+ "@stacksjs/logging": "^0.74.17",
132
+ "@stacksjs/notifications": "^0.74.17",
133
+ "@stacksjs/objects": "^0.74.17",
134
+ "@stacksjs/orm": "^0.74.17",
135
+ "@stacksjs/path": "^0.74.17",
136
+ "@stacksjs/payments": "^0.74.17",
137
+ "@stacksjs/realtime": "^0.74.17",
138
+ "@stacksjs/router": "^0.74.17",
139
139
  "@stacksjs/rpx": "^0.11.42",
140
- "@stacksjs/scheduler": "^0.74.16",
141
- "@stacksjs/search-engine": "^0.74.16",
142
- "@stacksjs/security": "^0.74.16",
143
- "@stacksjs/server": "^0.74.16",
144
- "@stacksjs/sites": "^0.74.16",
145
- "@stacksjs/skills": "^0.74.16",
146
- "@stacksjs/storage": "^0.74.16",
147
- "@stacksjs/strings": "^0.74.16",
148
- "@stacksjs/stx": "^0.2.269",
149
- "@stacksjs/testing": "^0.74.16",
150
- "@stacksjs/tinker": "^0.74.16",
140
+ "@stacksjs/scheduler": "^0.74.17",
141
+ "@stacksjs/search-engine": "^0.74.17",
142
+ "@stacksjs/security": "^0.74.17",
143
+ "@stacksjs/server": "^0.74.17",
144
+ "@stacksjs/sites": "^0.74.17",
145
+ "@stacksjs/skills": "^0.74.17",
146
+ "@stacksjs/storage": "^0.74.17",
147
+ "@stacksjs/strings": "^0.74.17",
148
+ "@stacksjs/stx": "^0.2.271",
149
+ "@stacksjs/testing": "^0.74.17",
150
+ "@stacksjs/tinker": "^0.74.17",
151
151
  "@stacksjs/tlsx": "^0.13.19",
152
152
  "@stacksjs/ts-cloud": "^0.12.10",
153
- "@stacksjs/tunnel": "^0.74.16",
154
- "@stacksjs/types": "^0.74.16",
155
- "@stacksjs/ui": "^0.74.16",
156
- "@stacksjs/utils": "^0.74.16",
157
- "@stacksjs/validation": "^0.74.16",
153
+ "@stacksjs/tunnel": "^0.74.17",
154
+ "@stacksjs/types": "^0.74.17",
155
+ "@stacksjs/ui": "^0.74.17",
156
+ "@stacksjs/utils": "^0.74.17",
157
+ "@stacksjs/validation": "^0.74.17",
158
158
  "ajv": "^8.20.0",
159
159
  "ajv-formats": "^3.0.1",
160
- "bun-plugin-stx": "^0.2.269",
160
+ "bun-plugin-stx": "^0.2.271",
161
161
  "ts-pantry": "^0.11.35"
162
162
  },
163
163
  "devDependencies": {