@stacksjs/buddy 0.72.39 → 0.72.41
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.
- package/dist/commands/mail.d.ts +32 -0
- package/dist/commands/mail.js +2 -1
- package/dist/commands/migrate.js +1 -1
- package/package.json +46 -46
package/dist/commands/mail.d.ts
CHANGED
|
@@ -2,3 +2,35 @@ import { CLI } from '@stacksjs/clapp';
|
|
|
2
2
|
export declare function resolveDirectMailHost(env?: NodeJS.ProcessEnv): string | undefined;
|
|
3
3
|
export declare function sanitizeLineCount(value?: string): string;
|
|
4
4
|
export declare function mailCommands(buddy: CLI): void;
|
|
5
|
+
/**
|
|
6
|
+
* Find the `mail` binary.
|
|
7
|
+
*
|
|
8
|
+
* pantry installs it project-locally (`pantry/.bin`), so a globally-installed
|
|
9
|
+
* copy is checked last rather than first: a project that pinned a version must
|
|
10
|
+
* get the version it pinned, not whatever happens to be on the developer's
|
|
11
|
+
* PATH.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveMailBinary(root?: string): string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* The catcher's configuration.
|
|
16
|
+
*
|
|
17
|
+
* The same program production runs, with delivery off, authentication off, and
|
|
18
|
+
* bound to loopback. That last one is not tidiness: a process that accepts every
|
|
19
|
+
* message for every recipient and shows them in a UI with no password is an open
|
|
20
|
+
* relay and an open inbox at once, and it must never be reachable from the
|
|
21
|
+
* network.
|
|
22
|
+
*/
|
|
23
|
+
export declare function buildLocalMailConfig(options: { smtpPort: number }): string;
|
|
24
|
+
/**
|
|
25
|
+
* The local catcher's ports, which are mailpit's ports.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately not the mail server's own defaults (2525/8080). Everything
|
|
28
|
+
* anybody has ever pointed at a development mail trap — a README, a
|
|
29
|
+
* docker-compose port mapping, a bookmarked inbox — is pointed at 1025 and
|
|
30
|
+
* 8025, and adopting those numbers means adopting this costs nobody a
|
|
31
|
+
* configuration change. `.env.example` and `config/services.ts` agree.
|
|
32
|
+
*/
|
|
33
|
+
export declare const LOCAL_MAIL_SMTP_PORT: 1025;
|
|
34
|
+
export declare const LOCAL_MAIL_UI_PORT: 8025;
|
|
35
|
+
/** Where the catcher keeps its mailboxes, under the project rather than in $HOME. */
|
|
36
|
+
export declare const LOCAL_MAIL_DIR: 'storage/framework/mail';
|
package/dist/commands/mail.js
CHANGED
|
@@ -60,7 +60,8 @@ var {require}=import.meta;import{log,runCommand}from"@stacksjs/cli";import{ExitC
|
|
|
60
60
|
Shutting down proxy...`);proxy.stop();process.exit(0)})}catch(error){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){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){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){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){log.error("No running mail server found.");log.info(`Looked for instances tagged: ${appName}-mail-server`);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){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){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){log.error("No running mail server found.");log.info(`Looked for instances tagged: ${appName}-mail-server`);process.exit(ExitCode.FatalError)}const result=await ssm.runShellCommand(instanceId,["echo === Service ===","(systemctl status smtp-server --no-pager 2>&1 || systemctl status mail-server --no-pager 2>&1) | head -15","echo === Ports ===",'ss -tlnp 2>&1 | grep -E "(25|587|465|993|143|110|995)" || echo "No mail ports listening"',"echo === Memory ===",`ps aux | grep -E "(smtp-server|bun|mail)" | grep -v grep | awk '{print $6/1024 "MB", $11}'`],{timeoutSeconds:30,maxWaitMs:30000,pollIntervalMs:2000});console.log("");console.log("==========================================================");console.log(" MAIL SERVER STATUS");console.log("==========================================================");console.log("");if(result.success&&result.output)for(const line of result.output.trim().split(`
|
|
62
62
|
`))console.log(` ${line}`);else log.error(`Failed: ${result.error}`);console.log("");process.exit(ExitCode.Success)}catch(error){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{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){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){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){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){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("../../../cloud/src/imap/smtp-server"),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){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)})}
|
|
63
|
+
Shutting down SMTP server...`);await server.stop();resolve()})});process.exit(0)}catch(error){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&&execSync(`${shellQuote(found)} --help 2>&1 || true`,{encoding:"utf-8"}).includes("Mail server management CLI"))return found}catch{}return}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"',"","[tls]","enabled = false","","[auth]","enabled = false","","[antispam]","spam_filter = false","dnsbl = false","greylist = false",""].join(`
|
|
64
|
+
`)}async function runLocalMailCatcher(options){const binary=resolveMailBinary();if(!binary){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.)");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}`);console.log(` Mailbox ${LOCAL_MAIL_DIR}`);console.log("");console.log(" Nothing is delivered onward. 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(`
|
|
64
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(`
|
|
65
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}
|
|
66
67
|
|
package/dist/commands/migrate.js
CHANGED
|
@@ -6,7 +6,7 @@ The model-backed create migrations may be stale for this database - run \`./budd
|
|
|
6
6
|
`),more=result.orphans.length>5?`
|
|
7
7
|
+ ${result.orphans.length-5} more - run \`./buddy doctor\` for the full list.`:"",first=result.orphans[0];log.warn(`${result.total} row${result.total===1?"":"s"} violate foreign keys (orphaned parents), now that SQLite enforces \`foreign_keys = ON\`:
|
|
8
8
|
${sample}${more}
|
|
9
|
-
These were written under the old \`foreign_keys = OFF\` default (#1951). Review and clean up manually - e.g. DELETE FROM ${first.table} WHERE ${first.column} IS NOT NULL AND ${first.column} NOT IN (SELECT id FROM ${first.parent}). migrate never deletes data.`)}catch(err){log.debug(`[migrate] FK orphan scan skipped: ${err instanceof Error?err.message:String(err)}`)}}function describeOp(op){switch(op.kind){case"drop_table":return`drop table "${op.table}" (all rows lost)`;case"drop_column":return`drop column "${op.table}"."${op.column}" (column data lost)`;case"modify_column":return`change type of "${op.table}"."${op.column}" (possible data loss)`;case"rebuild_table":return`rebuild table "${op.table}" (
|
|
9
|
+
These were written under the old \`foreign_keys = OFF\` default (#1951). Review and clean up manually - e.g. DELETE FROM ${first.table} WHERE ${first.column} IS NOT NULL AND ${first.column} NOT IN (SELECT id FROM ${first.parent}). migrate never deletes data.`)}catch(err){log.debug(`[migrate] FK orphan scan skipped: ${err instanceof Error?err.message:String(err)}`)}}function describeOp(op){switch(op.kind){case"drop_table":return`drop table "${op.table}" (all rows lost)`;case"drop_column":return`drop column "${op.table}"."${op.column}" (column data lost)`;case"modify_column":return`change type of "${op.table}"."${op.column}" (possible data loss)`;case"rebuild_table":return`rebuild table "${op.table}" (SQLite rebuilds in place; compare the SQL below against the live schema)`;case"rename_column":return`rename "${op.table}"."${op.from}" \u2192 "${op.to}"`;case"rename_table":return`rename table "${op.from}" \u2192 "${op.to}"`;default:return`${op.kind} on "${op.table}"${op.column?`."${op.column}"`:""}`}}async function missingBaselineNote(){try{const{resolveSnapshotDirectory,snapshotDirectoryIsShared}=await import("@stacksjs/database"),dir=resolveSnapshotDirectory();if(existsSync(dir)&&readdirSync(dir).some((f)=>/^model-snapshot\.\w+\.json$/.test(f)))return;return`No model snapshot in ${dir}, so this change was derived without a baseline and will be proposed again on the next run.${snapshotDirectoryIsShared()?" DB_SNAPSHOT_PATH points there; check the directory exists and is writable.":" If each deploy runs from a new directory, point DB_SNAPSHOT_PATH at a shared one that survives the release."}`}catch(error){log.debug(`[migrate] baseline check skipped: ${error instanceof Error?error.message:String(error)}`);return}}async function confirmDestructiveMigrations(opts){let operations=[];try{const{previewPendingMigrations}=await import("@stacksjs/database");operations=await previewPendingMigrations({fromDb:opts.fromDb,applyRenames:opts.applyRenames})}catch(error){log.debug(`Migration preview unavailable: ${error instanceof Error?error.message:String(error)}`);return!0}const renames=operations.filter((o)=>o.kind==="rename_column"||o.kind==="rename_table");for(const r of renames)log.info(`Detected ${describeOp(r)} - applying as a rename (data preserved). Use --no-rename to drop + add instead.`);const destructive=operations.filter((o)=>o.destructive);if(destructive.length===0)return!0;log.warn(`This migration includes ${destructive.length} potentially destructive change${destructive.length===1?"":"s"}:`);for(const op of destructive)log.warn(` \u2022 ${describeOp(op)}`);const baseline=await missingBaselineNote();if(baseline)log.warn(baseline);if(opts.force)return!0;if(isCI||!hasTTY){log.error("Refusing to apply destructive changes in a non-interactive environment. Re-run with --force to proceed.");return!1}return confirm({message:"Apply these destructive changes?",initial:!1})}function parseGuardBool(raw){if(raw==null||raw==="")return;const v=raw.toLowerCase().trim();if(v==="1"||v==="true"||v==="yes"||v==="on")return!0;if(v==="0"||v==="false"||v==="no"||v==="off")return!1;return}function parseFreshGuard(raw){const v=raw?.toLowerCase().trim();return v==="allow"||v==="confirm"||v==="disabled"?v:void 0}async function resolveMigrationGuards(){const isProd=/^prod/i.test(process.env.APP_ENV||"local");let cfg={};try{const{awaitConfig}=await import("@stacksjs/config");cfg=(await awaitConfig()).database?.safety??{}}catch(error){log.debug(`[migrate] safety config unavailable, using defaults: ${error instanceof Error?error.message:String(error)}`)}const confirmMigrate=parseGuardBool(process.env.DB_MIGRATE_CONFIRM)??cfg.confirmMigrate??!0,migrateFresh=parseFreshGuard(process.env.DB_MIGRATE_FRESH)??cfg.migrateFresh??(isProd?"disabled":"allow");return{confirmMigrate,migrateFresh}}function currentDatabaseLabel(){if((process.env.DB_CONNECTION||"sqlite").toLowerCase()==="sqlite")return process.env.DB_DATABASE_PATH||"database/stacks.sqlite";return process.env.DB_DATABASE||"stacks"}export function migrate(buddy){const descriptions={migrate:"Migrates your database",fresh:"Drop all tables and re-run every migration (destroys all data)",project:"Target a specific project",verbose:"Enable verbose output",auth:"Also migrate auth tables (oauth_clients, oauth_access_tokens, oauth_refresh_tokens, password_resets)",force:"Apply destructive changes (drop column/table, lossy type change) without confirmation",fromDb:"Diff against the live database schema instead of the snapshot (self-heal drift)",noRename:"Treat renamed columns as drop + add instead of a data-preserving rename"};buddy.command("migrate",descriptions.migrate).alias("db:migrate").option("-d, --diff","Show the SQL that would be run",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("-a, --auth",descriptions.auth,{default:!0}).option("--no-auth","Skip auth/oauth table migrations").option("-f, --force",descriptions.force,{default:!1}).option("--create-database","Create the database if it does not exist, without asking",{default:!1}).option("--from-db",descriptions.fromDb,{default:!1}).option("--no-rename",descriptions.noRename).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy migrate` ...",options);const perf=await intro("buddy migrate"),validation=validateModelsExist();if(!validation.valid){console.error(`
|
|
10
10
|
\u274C Error: ${validation.error}
|
|
11
11
|
`);process.exit(ExitCode.FatalError)}const dialectCheck=validateMigrationDialect();if(!dialectCheck.valid){console.error(`
|
|
12
12
|
\u274C Error: ${dialectCheck.error}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/buddy",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.72.
|
|
5
|
+
"version": "0.72.41",
|
|
6
6
|
"description": "Meet Buddy. The Stacks runtime.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -95,57 +95,57 @@
|
|
|
95
95
|
"prepublishOnly": "bun run build"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
|
98
|
-
"@stacksjs/actions": "^0.72.
|
|
99
|
-
"@stacksjs/ai": "^0.72.
|
|
100
|
-
"@stacksjs/alias": "^0.72.
|
|
101
|
-
"@stacksjs/arrays": "^0.72.
|
|
102
|
-
"@stacksjs/auth": "^0.72.
|
|
103
|
-
"@stacksjs/build": "^0.72.
|
|
104
|
-
"@stacksjs/cache": "^0.72.
|
|
105
|
-
"@stacksjs/chat": "^0.72.
|
|
98
|
+
"@stacksjs/actions": "^0.72.41",
|
|
99
|
+
"@stacksjs/ai": "^0.72.41",
|
|
100
|
+
"@stacksjs/alias": "^0.72.41",
|
|
101
|
+
"@stacksjs/arrays": "^0.72.41",
|
|
102
|
+
"@stacksjs/auth": "^0.72.41",
|
|
103
|
+
"@stacksjs/build": "^0.72.41",
|
|
104
|
+
"@stacksjs/cache": "^0.72.41",
|
|
105
|
+
"@stacksjs/chat": "^0.72.41",
|
|
106
106
|
"@stacksjs/clapp": "^0.2.12",
|
|
107
|
-
"@stacksjs/cli": "^0.72.
|
|
108
|
-
"@stacksjs/cloud": "^0.72.
|
|
109
|
-
"@stacksjs/cms": "^0.72.
|
|
110
|
-
"@stacksjs/collections": "^0.72.
|
|
111
|
-
"@stacksjs/config": "^0.72.
|
|
112
|
-
"@stacksjs/database": "^0.72.
|
|
113
|
-
"@stacksjs/desktop-build": "^0.72.
|
|
114
|
-
"@stacksjs/dns": "^0.72.
|
|
107
|
+
"@stacksjs/cli": "^0.72.41",
|
|
108
|
+
"@stacksjs/cloud": "^0.72.41",
|
|
109
|
+
"@stacksjs/cms": "^0.72.41",
|
|
110
|
+
"@stacksjs/collections": "^0.72.41",
|
|
111
|
+
"@stacksjs/config": "^0.72.41",
|
|
112
|
+
"@stacksjs/database": "^0.72.41",
|
|
113
|
+
"@stacksjs/desktop-build": "^0.72.41",
|
|
114
|
+
"@stacksjs/dns": "^0.72.41",
|
|
115
115
|
"@stacksjs/dnsx": "^0.2.3",
|
|
116
|
-
"@stacksjs/email": "^0.72.
|
|
117
|
-
"@stacksjs/enums": "^0.72.
|
|
118
|
-
"@stacksjs/error-handling": "^0.72.
|
|
119
|
-
"@stacksjs/events": "^0.72.
|
|
120
|
-
"@stacksjs/git": "^0.72.
|
|
116
|
+
"@stacksjs/email": "^0.72.41",
|
|
117
|
+
"@stacksjs/enums": "^0.72.41",
|
|
118
|
+
"@stacksjs/error-handling": "^0.72.41",
|
|
119
|
+
"@stacksjs/events": "^0.72.41",
|
|
120
|
+
"@stacksjs/git": "^0.72.41",
|
|
121
121
|
"@stacksjs/gitit": "^0.2.5",
|
|
122
|
-
"@stacksjs/health": "^0.72.
|
|
122
|
+
"@stacksjs/health": "^0.72.41",
|
|
123
123
|
"@stacksjs/httx": "^0.1.10",
|
|
124
|
-
"@stacksjs/image": "^0.72.
|
|
125
|
-
"@stacksjs/lint": "^0.72.
|
|
126
|
-
"@stacksjs/logging": "^0.72.
|
|
127
|
-
"@stacksjs/notifications": "^0.72.
|
|
128
|
-
"@stacksjs/objects": "^0.72.
|
|
129
|
-
"@stacksjs/orm": "^0.72.
|
|
130
|
-
"@stacksjs/path": "^0.72.
|
|
131
|
-
"@stacksjs/payments": "^0.72.
|
|
132
|
-
"@stacksjs/realtime": "^0.72.
|
|
133
|
-
"@stacksjs/router": "^0.72.
|
|
124
|
+
"@stacksjs/image": "^0.72.41",
|
|
125
|
+
"@stacksjs/lint": "^0.72.41",
|
|
126
|
+
"@stacksjs/logging": "^0.72.41",
|
|
127
|
+
"@stacksjs/notifications": "^0.72.41",
|
|
128
|
+
"@stacksjs/objects": "^0.72.41",
|
|
129
|
+
"@stacksjs/orm": "^0.72.41",
|
|
130
|
+
"@stacksjs/path": "^0.72.41",
|
|
131
|
+
"@stacksjs/payments": "^0.72.41",
|
|
132
|
+
"@stacksjs/realtime": "^0.72.41",
|
|
133
|
+
"@stacksjs/router": "^0.72.41",
|
|
134
134
|
"@stacksjs/rpx": "^0.11.42",
|
|
135
|
-
"@stacksjs/search-engine": "^0.72.
|
|
136
|
-
"@stacksjs/security": "^0.72.
|
|
137
|
-
"@stacksjs/server": "^0.72.
|
|
138
|
-
"@stacksjs/sites": "^0.72.
|
|
139
|
-
"@stacksjs/skills": "^0.72.
|
|
140
|
-
"@stacksjs/storage": "^0.72.
|
|
141
|
-
"@stacksjs/strings": "^0.72.
|
|
142
|
-
"@stacksjs/testing": "^0.72.
|
|
135
|
+
"@stacksjs/search-engine": "^0.72.41",
|
|
136
|
+
"@stacksjs/security": "^0.72.41",
|
|
137
|
+
"@stacksjs/server": "^0.72.41",
|
|
138
|
+
"@stacksjs/sites": "^0.72.41",
|
|
139
|
+
"@stacksjs/skills": "^0.72.41",
|
|
140
|
+
"@stacksjs/storage": "^0.72.41",
|
|
141
|
+
"@stacksjs/strings": "^0.72.41",
|
|
142
|
+
"@stacksjs/testing": "^0.72.41",
|
|
143
143
|
"@stacksjs/ts-cloud": "^0.9.4",
|
|
144
|
-
"@stacksjs/tunnel": "^0.72.
|
|
145
|
-
"@stacksjs/types": "^0.72.
|
|
146
|
-
"@stacksjs/ui": "^0.72.
|
|
147
|
-
"@stacksjs/utils": "^0.72.
|
|
148
|
-
"@stacksjs/validation": "^0.72.
|
|
144
|
+
"@stacksjs/tunnel": "^0.72.41",
|
|
145
|
+
"@stacksjs/types": "^0.72.41",
|
|
146
|
+
"@stacksjs/ui": "^0.72.41",
|
|
147
|
+
"@stacksjs/utils": "^0.72.41",
|
|
148
|
+
"@stacksjs/validation": "^0.72.41",
|
|
149
149
|
"ajv": "^8.20.0",
|
|
150
150
|
"ajv-formats": "^3.0.1",
|
|
151
151
|
"ts-pantry": "^0.11.35"
|