@stacksjs/buddy 0.72.57 → 0.72.61

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 process from"node:process";import{runCommit}from"@stacksjs/actions";import{log}from"@stacksjs/logging";import{onUnknownSubcommand}from"@stacksjs/cli";export function commit(buddy){const descriptions={commit:"Commit your staged changes",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("commit",descriptions.commit).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy commit` ...",options);await runCommit(options)});onUnknownSubcommand(buddy,"commit")}
1
+ import{runCommit}from"@stacksjs/actions";import{log}from"@stacksjs/logging";import{onUnknownSubcommand}from"@stacksjs/cli";export function commit(buddy){const descriptions={commit:"Commit your staged changes",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("commit",descriptions.commit).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy commit` ...",options);await runCommit(options)});onUnknownSubcommand(buddy,"commit")}
@@ -1,4 +1,4 @@
1
- import{existsSync,mkdirSync,readFileSync,readdirSync,statSync,writeFileSync}from"node:fs";import{homedir}from"node:os";import{dirname,isAbsolute,join,relative,resolve}from"node:path";import process from"node:process";import{pathToFileURL}from"node:url";import{runAction}from"@stacksjs/actions";import{italic,onUnknownSubcommand,outro,prompts,runCommand}from"@stacksjs/cli";import{app,dns as dnsConfig,email as emailConfig,cloud as cloudConfig}from"@stacksjs/config";import{addDomain,hasUserDomainBeenAddedToCloud,syncDnsConfig}from"@stacksjs/dns";import{loadProjectDnsConfig}from"../config";import{encryptEnv,env}from"@stacksjs/env";import{Action}from"@stacksjs/enums";import{path as p}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{getErrorCode,getErrorMessage}from"@stacksjs/utils";import{withDeployNotification}from"../deploy-notify";import{ensureAppKey,ensureDeployEnvIsSet,ensureEnvIsSet}from"./setup";import{resultFailed}from"../result";import{findUnbackedManagedServices,unbackedDataMessage}from"../unbacked-data";import{applyDeploymentDomainOverride,createDeploymentPreview,deploymentPreviewJsonPrefix,formatDeploymentPreview,resolveDeploymentEnvironment}from"./deploy-preview";const log={info:(...args)=>console.log("\u2139",...args),success:(...args)=>console.log("\u2713",...args),warn:(...args)=>console.log("\u26A0",...args),error:(...args)=>console.error("\u2717",...args),debug:(...args)=>{if(process.argv.includes("--verbose")||process.argv.includes("-v"))console.log("\uD83D\uDD0D",...args)}},MAIL_PACKAGE_DOMAIN="github.com/mail-os/mail",MAIL_PACKAGE_SPEC=`${MAIL_PACKAGE_DOMAIN}@0.1.0`,MAIL_TARGET_PLATFORM="linux-x86_64",MAIL_BINARY_NAMES=["mail","mail-x86_64-linux","mail-x86_64-linux-gnu"];export function resolveTsCloudCliPath(tsCloudEntry=import.meta.resolve("@stacksjs/ts-cloud")){return resolve(dirname(new URL(tsCloudEntry).pathname),"bin/cli.js")}export async function runDeployRollback(site,options,execute=async(command)=>{return await Bun.spawn(command,{cwd:p.projectPath(),env:process.env,stdin:"inherit",stdout:"inherit",stderr:"inherit"}).exited}){const environment=resolveDeploymentEnvironment({option:options.env}),command=[process.execPath,resolveTsCloudCliPath(),"deploy:rollback"];if(site)command.push(site);command.push("--env",environment);if(options.to)command.push("--to",options.to);if(options.dryRun)command.push("--dry-run");if(options.verbose)command.push("--verbose");return await execute(command)}function collectMatchingFiles(root,names,maxDepth=8){const nameSet=new Set(names),matches=[];if(!existsSync(root))return matches;function walk(dir,depth){if(depth>maxDepth)return;for(const entry of readdirSync(dir)){if(entry===".git"||entry==="node_modules")continue;const fullPath=join(dir,entry),stat=statSync(fullPath);if(stat.isDirectory())walk(fullPath,depth+1);else if(stat.isFile()&&nameSet.has(entry))matches.push(fullPath)}}walk(root,0);return matches}function isElfBinary(filePath){const header=readFileSync(filePath).slice(0,4);return header[0]===127&&header[1]===69&&header[2]===76&&header[3]===70}function resolvePantryInstallCommand(){const localPantryCli=join(homedir(),"Code","Tools","pantry","packages","ts-pantry","bin","cli.ts");if(existsSync(localPantryCli))return{command:"bun",args:[localPantryCli]};const projectPantry=p.projectPath("pantry/.bin/pantry");if(existsSync(projectPantry))return{command:projectPantry,args:[]};const globalPantry=join(homedir(),".local","share","pantry","global","bin","pantry");if(existsSync(globalPantry))return{command:globalPantry,args:[]};return{command:"pantry",args:[]}}async function installMailBinaryWithPantry(){const{execFileSync}=await import("node:child_process"),pantry=resolvePantryInstallCommand();execFileSync(pantry.command,[...pantry.args,"install",MAIL_PACKAGE_SPEC,"--install-dir",p.projectPath("pantry"),"--platform",MAIL_TARGET_PLATFORM,"--quiet"],{cwd:p.projectPath(),stdio:process.argv.includes("--verbose")||process.argv.includes("-v")?"inherit":"pipe",env:process.env})}async function findPantryMailBinary(){const directCandidates=[...MAIL_BINARY_NAMES.map((name)=>p.projectPath(`pantry/.bin/${name}`)),...MAIL_BINARY_NAMES.map((name)=>join(homedir(),".local","share","pantry","global","bin",name))];for(const candidate of directCandidates)if(existsSync(candidate)&&isElfBinary(candidate))return candidate;for(const root of[p.projectPath("pantry"),join(homedir(),".local","share","pantry")])for(const candidate of collectMatchingFiles(root,MAIL_BINARY_NAMES))if(isElfBinary(candidate))return candidate;return null}async function ensureDeployPrerequisites(verbose=!1,environment="production"){const cwd=p.projectPath();await ensureEnvIsSet({cwd,verbose});await ensureDeployEnvIsSet(cwd,environment);await ensureAppKey(cwd)}function loadAwsCredentialsFromFile(){const credentialsPath=join(homedir(),".aws","credentials"),configPath=join(homedir(),".aws","config");if(!existsSync(credentialsPath))return{};try{const lines=readFileSync(credentialsPath,"utf-8").split(`
1
+ import{existsSync,mkdirSync,readFileSync,readdirSync,statSync,writeFileSync}from"node:fs";import{homedir}from"node:os";import{dirname,isAbsolute,join,relative,resolve}from"node:path";import process from"node:process";import{pathToFileURL}from"node:url";import{runAction}from"@stacksjs/actions";import{italic,onUnknownSubcommand,outro,prompts}from"@stacksjs/cli";import{app,dns as dnsConfig,email as emailConfig,cloud as cloudConfig}from"@stacksjs/config";import{addDomain,hasUserDomainBeenAddedToCloud,syncDnsConfig}from"@stacksjs/dns";import{loadProjectDnsConfig}from"../config";import{env}from"@stacksjs/env";import{Action}from"@stacksjs/enums";import{path as p}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{getErrorCode,getErrorMessage}from"@stacksjs/utils";import{withDeployNotification}from"../deploy-notify";import{ensureAppKey,ensureDeployEnvIsSet,ensureEnvIsSet}from"./setup";import{resultFailed}from"../result";import{findUnbackedManagedServices,unbackedDataMessage}from"../unbacked-data";import{applyDeploymentDomainOverride,createDeploymentPreview,deploymentPreviewJsonPrefix,formatDeploymentPreview,resolveDeploymentEnvironment}from"./deploy-preview";const log={info:(...args)=>console.log("\u2139",...args),success:(...args)=>console.log("\u2713",...args),warn:(...args)=>console.log("\u26A0",...args),error:(...args)=>console.error("\u2717",...args),debug:(...args)=>{if(process.argv.includes("--verbose")||process.argv.includes("-v"))console.log("\uD83D\uDD0D",...args)}},MAIL_PACKAGE_DOMAIN="github.com/mail-os/mail",MAIL_PACKAGE_SPEC=`${MAIL_PACKAGE_DOMAIN}@0.1.0`,MAIL_TARGET_PLATFORM="linux-x86_64",MAIL_BINARY_NAMES=["mail","mail-x86_64-linux","mail-x86_64-linux-gnu"];export function resolveTsCloudCliPath(tsCloudEntry=import.meta.resolve("@stacksjs/ts-cloud")){return resolve(dirname(new URL(tsCloudEntry).pathname),"bin/cli.js")}export async function runDeployRollback(site,options,execute=async(command)=>{return await Bun.spawn(command,{cwd:p.projectPath(),env:process.env,stdin:"inherit",stdout:"inherit",stderr:"inherit"}).exited}){const environment=resolveDeploymentEnvironment({option:options.env}),command=[process.execPath,resolveTsCloudCliPath(),"deploy:rollback"];if(site)command.push(site);command.push("--env",environment);if(options.to)command.push("--to",options.to);if(options.dryRun)command.push("--dry-run");if(options.verbose)command.push("--verbose");return await execute(command)}function collectMatchingFiles(root,names,maxDepth=8){const nameSet=new Set(names),matches=[];if(!existsSync(root))return matches;function walk(dir,depth){if(depth>maxDepth)return;for(const entry of readdirSync(dir)){if(entry===".git"||entry==="node_modules")continue;const fullPath=join(dir,entry),stat=statSync(fullPath);if(stat.isDirectory())walk(fullPath,depth+1);else if(stat.isFile()&&nameSet.has(entry))matches.push(fullPath)}}walk(root,0);return matches}function isElfBinary(filePath){const header=readFileSync(filePath).slice(0,4);return header[0]===127&&header[1]===69&&header[2]===76&&header[3]===70}function resolvePantryInstallCommand(){const localPantryCli=join(homedir(),"Code","Tools","pantry","packages","ts-pantry","bin","cli.ts");if(existsSync(localPantryCli))return{command:"bun",args:[localPantryCli]};const projectPantry=p.projectPath("pantry/.bin/pantry");if(existsSync(projectPantry))return{command:projectPantry,args:[]};const globalPantry=join(homedir(),".local","share","pantry","global","bin","pantry");if(existsSync(globalPantry))return{command:globalPantry,args:[]};return{command:"pantry",args:[]}}async function installMailBinaryWithPantry(){const{execFileSync}=await import("node:child_process"),pantry=resolvePantryInstallCommand();execFileSync(pantry.command,[...pantry.args,"install",MAIL_PACKAGE_SPEC,"--install-dir",p.projectPath("pantry"),"--platform",MAIL_TARGET_PLATFORM,"--quiet"],{cwd:p.projectPath(),stdio:process.argv.includes("--verbose")||process.argv.includes("-v")?"inherit":"pipe",env:process.env})}async function findPantryMailBinary(){const directCandidates=[...MAIL_BINARY_NAMES.map((name)=>p.projectPath(`pantry/.bin/${name}`)),...MAIL_BINARY_NAMES.map((name)=>join(homedir(),".local","share","pantry","global","bin",name))];for(const candidate of directCandidates)if(existsSync(candidate)&&isElfBinary(candidate))return candidate;for(const root of[p.projectPath("pantry"),join(homedir(),".local","share","pantry")])for(const candidate of collectMatchingFiles(root,MAIL_BINARY_NAMES))if(isElfBinary(candidate))return candidate;return null}async function ensureDeployPrerequisites(verbose=!1,environment="production"){const cwd=p.projectPath();await ensureEnvIsSet({cwd,verbose});await ensureDeployEnvIsSet(cwd,environment);await ensureAppKey(cwd)}function loadAwsCredentialsFromFile(){const credentialsPath=join(homedir(),".aws","credentials"),configPath=join(homedir(),".aws","config");if(!existsSync(credentialsPath))return{};try{const lines=readFileSync(credentialsPath,"utf-8").split(`
2
2
  `),profiles=["default","stacks"];let currentProfile="",credentials={};const profileCredentials={};for(const line of lines){const trimmed=line.trim(),profileMatch=trimmed.match(/^\[(.+)\]$/);if(profileMatch?.[1]){currentProfile=profileMatch[1];profileCredentials[currentProfile]={};continue}const keyValue=trimmed.match(/^(\w+)\s*=\s*(.+)$/);if(keyValue&&currentProfile){const[,key,value]=keyValue,target=profileCredentials[currentProfile];if(!target||value===void 0)continue;if(key==="aws_access_key_id")target.accessKeyId=value;else if(key==="aws_secret_access_key")target.secretAccessKey=value}}for(const profile of profiles)if(profileCredentials[profile]?.accessKeyId&&profileCredentials[profile]?.secretAccessKey){credentials=profileCredentials[profile];log.debug(`Using AWS credentials from ~/.aws/credentials [${profile}] profile`);break}if(!credentials.accessKeyId){for(const[profile,creds]of Object.entries(profileCredentials))if(creds.accessKeyId&&creds.secretAccessKey){credentials=creds;log.debug(`Using AWS credentials from ~/.aws/credentials [${profile}] profile`);break}}let region;if(existsSync(configPath)){const regionMatch=readFileSync(configPath,"utf-8").match(/region\s*=\s*(.+)/);if(regionMatch?.[1])region=regionMatch[1].trim()}return{...credentials,region}}catch(error){log.debug("Failed to read AWS credentials file:",error);return{}}}async function setupEmailDnsRecords(emailDomain,region,logger,options){logger.info("Setting up email DNS records...");try{const{SESClient}=await import("@stacksjs/ts-cloud"),{Route53Client}=await import("@stacksjs/ts-cloud"),ses=new SESClient(region),route53=new Route53Client(region);logger.info(`Getting DKIM tokens for ${emailDomain}...`);const tokens=(await ses.getEmailIdentity(emailDomain)).DkimAttributes?.Tokens||[];if(tokens.length===0){logger.warn("No DKIM tokens found - domain may not be set up in SES yet");return}logger.info(`Found ${tokens.length} DKIM tokens`);const zone=(await route53.listHostedZones()).HostedZones?.find((z)=>z.Name===`${emailDomain}.`);if(!zone){logger.warn(`Hosted zone not found for ${emailDomain} - DNS records must be added manually`);logger.info("DKIM records needed:");for(const token of tokens)logger.info(` CNAME: ${token}._domainkey.${emailDomain} -> ${token}.dkim.amazonses.com`);logger.info(` MX: ${emailDomain} -> 10 inbound-smtp.${region}.amazonaws.com`);return}const hostedZoneId=zone.Id?.replace("/hostedzone/","");logger.info(`Found hosted zone: ${hostedZoneId}`);for(const token of tokens){const recordName=`${token}._domainkey.${emailDomain}`,recordValue=`${token}.dkim.amazonses.com`;try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:recordName,Type:"CNAME",TTL:300,ResourceRecords:[{Value:recordValue}]}}]}});logger.success(`Added DKIM record: ${token}._domainkey`)}catch(e){logger.warn(`Failed to add DKIM record: ${getErrorMessage(e)}`)}}const mailSubdomain=options?.mailSubdomain||"mail",mxTarget=options?.mode==="server"?`10 ${mailSubdomain}.${emailDomain}`:`10 inbound-smtp.${region}.amazonaws.com`;try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:emailDomain,Type:"MX",TTL:300,ResourceRecords:[{Value:mxTarget}]}}]}});logger.success(`Added MX record: ${mxTarget}`)}catch(e){logger.warn(`Failed to add MX record: ${getErrorMessage(e)}`)}try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:emailDomain,Type:"TXT",TTL:300,ResourceRecords:[{Value:'"v=spf1 include:amazonses.com ~all"'}]}}]}});logger.success("Added SPF record")}catch(e){logger.warn(`Failed to add SPF record: ${getErrorMessage(e)}`)}try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:`_dmarc.${emailDomain}`,Type:"TXT",TTL:300,ResourceRecords:[{Value:`"v=DMARC1;p=quarantine;pct=25;rua=mailto:dmarcreports@${emailDomain}"`}]}}]}});logger.success("Added DMARC record")}catch(e){logger.warn(`Failed to add DMARC record: ${getErrorMessage(e)}`)}try{const ruleSetName=`${process.env.APP_NAME?.toLowerCase().replace(/[^a-z0-9]/g,"-")||"stacks"}-email-rules`;await ses.setActiveReceiptRuleSet(ruleSetName);logger.success(`Activated email receipt rule set: ${ruleSetName}`)}catch(e){logger.warn(`Failed to activate receipt rule set: ${getErrorMessage(e)}`)}logger.success("Email DNS records configured!");logger.info("Note: DKIM verification may take 5-15 minutes to complete")}catch(error){logger.warn(`Failed to set up email DNS records: ${getErrorMessage(error)}`);logger.info("You can manually set up DNS records using: buddy email:verify")}}async function createDefaultMailUser(appName,emailDomain,region,logger){try{const{DynamoDBClient}=await import("@stacksjs/ts-cloud"),crypto=await import("crypto"),dynamodb=new DynamoDBClient(region),tableName=`${appName}-mail-users`,mailboxes=emailConfig?.mailboxes||[];if(mailboxes.length===0){const defaultEmail=`admin@${emailDomain}`,defaultPassword=crypto.randomBytes(16).toString("hex"),passwordHash=crypto.createHash("sha256").update(defaultPassword).digest("hex");try{await dynamodb.putItem({TableName:tableName,Item:{email:{S:defaultEmail},passwordHash:{S:passwordHash},createdAt:{S:new Date().toISOString()},displayName:{S:"Admin"}}});logger.success(`Created default mail user: ${defaultEmail}`);logger.info(`Password: ${defaultPassword}`);logger.info("Save this password - it will not be shown again!")}catch(e){const msg=getErrorMessage(e);if(msg.includes("ConditionalCheckFailedException")||msg.includes("already exists"))logger.debug("Default mail user already exists");else throw e}}else for(const mailbox of mailboxes){const mb=mailbox,email=typeof mailbox==="string"?`${mailbox}@${emailDomain}`:`${mb.name||mb.address?.split("@")[0]}@${emailDomain}`,password=typeof mailbox==="object"&&mb.password?mb.password:crypto.randomBytes(16).toString("hex"),passwordHash=crypto.createHash("sha256").update(password).digest("hex");try{await dynamodb.putItem({TableName:tableName,Item:{email:{S:email},passwordHash:{S:passwordHash},createdAt:{S:new Date().toISOString()},displayName:{S:typeof mailbox==="object"?mb.displayName||mb.name||email:mailbox}}});logger.success(`Created mail user: ${email}`);if(typeof mailbox!=="object"||!mb.password)logger.info(` Password: ${password}`)}catch(e){const msg=getErrorMessage(e);if(msg.includes("ConditionalCheckFailedException"))logger.debug(`Mail user ${email} already exists`);else logger.warn(`Failed to create mail user ${email}: ${msg}`)}}}catch(error){logger.warn(`Failed to create mail users: ${getErrorMessage(error)}`)}}async function uploadMailServerToS3(bucketName,region,mode){try{const{S3Client:S3}=await import("@stacksjs/ts-cloud"),s3Client=new S3(region);if(mode==="serverless"){const serverTsPath=p.frameworkPath("core/mail-server/server.ts");if(existsSync(serverTsPath)){const serverCode=readFileSync(serverTsPath,"utf-8");await s3Client.putObject({bucket:bucketName,key:"mail-server/server.ts",body:serverCode,contentType:"text/typescript"});log.success("Uploaded serverless mail server code to S3")}const pkgPath=p.frameworkPath("core/mail-server/package.json");if(existsSync(pkgPath)){const pkgJson=readFileSync(pkgPath,"utf-8");await s3Client.putObject({bucket:bucketName,key:"mail-server/package.json",body:pkgJson,contentType:"application/json"})}return}let binaryUploaded=!1;try{await installMailBinaryWithPantry();const linuxBinaryPath=await findPantryMailBinary();if(linuxBinaryPath&&existsSync(linuxBinaryPath)&&isElfBinary(linuxBinaryPath)){log.info(`Uploading Pantry mail binary: ${linuxBinaryPath}`);const binaryContent=readFileSync(linuxBinaryPath);await s3Client.putObject({bucket:bucketName,key:"mail-server/smtp-server",body:binaryContent,contentType:"application/octet-stream"});log.success("Uploaded Linux x86_64 mail server binary to S3");binaryUploaded=!0}}catch(error){log.debug(`Pantry mail install failed: ${getErrorMessage(error)}`)}if(!binaryUploaded)log.warn(`No Pantry-provided ${MAIL_TARGET_PLATFORM} mail binary found. Release ${MAIL_PACKAGE_DOMAIN}, then run the Pantry binary sync for that package.`)}catch(uploadErr){log.debug(`Could not upload mail server to S3 (bucket may not exist yet): ${uploadErr.message}`)}}async function loadTsCloudConfig(envName){try{const base=p.projectPath("config/cloud.ts");return(await import(envName?`${base}?env=${envName}`:base)).tsCloud}catch(err){log.debug("Could not load config/cloud.ts tsCloud export:",err);return}}function resolveProvider(tsCloudConfig){return tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws"}function readWaitSecs(name,defaultSecs){const secs=process.env[name]?Number.parseInt(process.env[name],10):Number.NaN;return Number.isFinite(secs)&&secs>0?secs:defaultSecs}function fmtDuration(secs){const m=Math.floor(secs/60),s=secs%60;return m?s?`${m}m${s}s`:`${m}m`:`${s}s`}export function pollFailureDetail(error){const collapsed=(error instanceof Error?error.message:typeof error==="string"?error:"").replace(/\s+/g," ").trim();if(!collapsed)return;return collapsed.length>300?`${collapsed.slice(0,297)}...`:collapsed}export function sshUnreachableMessage(opts){const detail=pollFailureDetail(opts.lastError);return`SSH did not become reachable on ${opts.ip} within ${fmtDuration(opts.waitSecs)} (waited ${opts.elapsedSecs}s).`+(detail?`
3
3
  Last attempt: ${detail}`:"")+`
4
4
  A connection timeout means the box is probably still booting, so raise TS_CLOUD_SSH_WAIT_SECS and retry. "Permission denied" means the key is not authorized, and a refused or reset connection (especially after earlier attempts got further) usually means fail2ban banned this IP. Waiting longer fixes neither.`}export function bunRuntimeMissingMessage(opts){const detail=pollFailureDetail(opts.lastError);return`bun runtime did not appear at /usr/local/bin/bun within ${fmtDuration(opts.waitSecs)} (waited ${opts.elapsedSecs}s).`+(detail?`
@@ -4,7 +4,10 @@ import{assertFrameworkRepo}from"./framework-repo";import{existsSync,mkdirSync,re
4
4
  `).split(`
5
5
  `).map((line)=>line.trimEnd()).join(`
6
6
  `).trimEnd()}
7
- `}export function loadBuddyInventory(){const result=Bun.spawnSync([process.execPath,cliPath,"list","--json"],{cwd:root,env:{...process.env,NO_COLOR:"1"},stdout:"pipe",stderr:"pipe"});if(result.exitCode!==0)throw Error(`Buddy command inventory failed (${result.exitCode}): ${result.stderr.toString().trim()}`);const inventory=JSON.parse(result.stdout.toString());if(!Array.isArray(inventory.commands)||!Number.isInteger(inventory.total))throw Error("Buddy command inventory returned an invalid payload");return inventory}export async function run(){assertFrameworkRepo(root,"docs:buddy");const mode=process.argv.includes("--write")?"write":process.argv.includes("--check")?"check":null;if(!mode){console.error("usage: bun storage/framework/core/buddy/src/commands/docs/buddy-commands.ts --write | --check");process.exit(2)}const expected=renderBuddyCommandReference(loadBuddyInventory());if(mode==="write"){mkdirSync(dirname(outputPath),{recursive:!0});writeFileSync(outputPath,expected);console.log(`Wrote ${outputPath}`)}else{if(!existsSync(outputPath))throw Error("Buddy command reference is missing; run bun run docs:buddy");const current=readFileSync(outputPath,"utf8");if(current!==expected){const currentLines=current.split(`
7
+ `}export function loadBuddyInventory(){const result=Bun.spawnSync([process.execPath,cliPath,"list","--json"],{cwd:root,env:{...process.env,NO_COLOR:"1"},stdout:"pipe",stderr:"pipe"}),stdout=result.stdout.toString(),stderr=result.stderr.toString().trim();if(result.exitCode!==0)throw Error(`Buddy command inventory failed (${result.exitCode}): ${stderr}`);const start=stdout.indexOf("{"),end=stdout.lastIndexOf("}");if(start===-1||end<=start){const noise=(stdout.trim()||stderr||"(no output)").split(`
8
+ `).slice(0,10).join(`
9
+ `);throw Error(`Buddy command inventory printed no JSON payload. \`buddy list --json\` said:
10
+ ${noise}`)}let inventory;try{inventory=JSON.parse(stdout.slice(start,end+1))}catch(error){throw Error(`Buddy command inventory returned unparseable JSON: ${error.message}`)}if(!Array.isArray(inventory.commands)||!Number.isInteger(inventory.total))throw Error("Buddy command inventory returned an invalid payload");return inventory}export async function run(){assertFrameworkRepo(root,"docs:buddy");const mode=process.argv.includes("--write")?"write":process.argv.includes("--check")?"check":null;if(!mode){console.error("usage: bun storage/framework/core/buddy/src/commands/docs/buddy-commands.ts --write | --check");process.exit(2)}const expected=renderBuddyCommandReference(loadBuddyInventory());if(mode==="write"){mkdirSync(dirname(outputPath),{recursive:!0});writeFileSync(outputPath,expected);console.log(`Wrote ${outputPath}`)}else{if(!existsSync(outputPath))throw Error("Buddy command reference is missing; run bun run docs:buddy");const current=readFileSync(outputPath,"utf8");if(current!==expected){const currentLines=current.split(`
8
11
  `),expectedLines=expected.split(`
9
12
  `),differences=[];for(let i=0;i<Math.max(currentLines.length,expectedLines.length)&&differences.length<6;i++)if(currentLines[i]!==expectedLines[i])differences.push(` line ${i+1}:
10
13
  committed: ${JSON.stringify(currentLines[i]??"<end of file>")}
@@ -1,4 +1,4 @@
1
- import{readFileSync,existsSync}from"node:fs";import process from"node:process";import{email as emailConfig}from"@stacksjs/config";import{extractEmailPreview}from"@stacksjs/email";import{ExitCode}from"@stacksjs/types";import{onUnknownSubcommand}from"@stacksjs/cli";import{getErrorMessage}from"@stacksjs/utils";import{reprocessInboundEmails}from"../email-reprocess";const TIMEOUT_MS=30000;async function withTimeout(promise,ms=TIMEOUT_MS){let timeoutId;const timeoutPromise=new Promise((_,reject)=>{timeoutId=setTimeout(()=>reject(Error(`Operation timed out after ${ms}ms`)),ms)});try{return await Promise.race([promise,timeoutPromise])}finally{clearTimeout(timeoutId)}}let _awsCredsLoaded=!1;async function validateS3Bucket(bucket,region){try{const{S3Client}=await import("@stacksjs/ts-cloud"),s3=new S3Client(region),list=s3.listObjects;if(typeof list!=="function")return{ok:!0};await withTimeout(list.call(s3,{bucket,maxKeys:1}),5000);return{ok:!0}}catch(err){const message=err instanceof Error?err.message:String(err);if(/NoSuchBucket/i.test(message))return{ok:!1,reason:`Bucket '${bucket}' does not exist in region '${region}'.`,hint:"Run `buddy deploy` to create email infrastructure, or pass --bucket <name> if you know the correct bucket."};if(/AccessDenied|Forbidden/i.test(message))return{ok:!1,reason:`Access denied to bucket '${bucket}'.`,hint:"Check that AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (or your IAM role) have s3:ListBucket permission for this bucket."};if(/region/i.test(message)||/PermanentRedirect/i.test(message))return{ok:!1,reason:`Bucket '${bucket}' is not in region '${region}'.`,hint:"Set AWS_REGION to the bucket's actual region, or pass the right bucket for this region."};return{ok:!1,reason:message,hint:"Check AWS connectivity and credentials."}}}async function loadAwsCredentials(){if(_awsCredsLoaded)return;_awsCredsLoaded=!0;const envPath=".env.production";if(!existsSync(envPath))return;const{parse}=await import("@stacksjs/env"),content=readFileSync(envPath,"utf-8"),{parsed}=parse(content);for(const[key,value]of Object.entries(parsed))if(process.env[key]===void 0)process.env[key]=value}const descriptions={email:"Email server management commands",verify:"Check domain verification status",test:"Send a test email",list:"List configured mailboxes",logs:"View email processing logs",status:"Show email server status",inbox:"View inbox emails from S3",reprocess:"Reprocess raw emails from S3 into mailbox structure"};export function email(buddy){buddy.command("email",descriptions.email).alias("mail").action(async()=>{console.log(`
1
+ import{readFileSync,existsSync}from"node:fs";import process from"node:process";import{email as emailConfig}from"@stacksjs/config";import{ExitCode}from"@stacksjs/types";import{onUnknownSubcommand}from"@stacksjs/cli";import{getErrorMessage}from"@stacksjs/utils";import{reprocessInboundEmails}from"../email-reprocess";const TIMEOUT_MS=30000;async function withTimeout(promise,ms=TIMEOUT_MS){let timeoutId;const timeoutPromise=new Promise((_,reject)=>{timeoutId=setTimeout(()=>reject(Error(`Operation timed out after ${ms}ms`)),ms)});try{return await Promise.race([promise,timeoutPromise])}finally{clearTimeout(timeoutId)}}let _awsCredsLoaded=!1;async function validateS3Bucket(bucket,region){try{const{S3Client}=await import("@stacksjs/ts-cloud"),s3=new S3Client(region),list=s3.listObjects;if(typeof list!=="function")return{ok:!0};await withTimeout(list.call(s3,{bucket,maxKeys:1}),5000);return{ok:!0}}catch(err){const message=err instanceof Error?err.message:String(err);if(/NoSuchBucket/i.test(message))return{ok:!1,reason:`Bucket '${bucket}' does not exist in region '${region}'.`,hint:"Run `buddy deploy` to create email infrastructure, or pass --bucket <name> if you know the correct bucket."};if(/AccessDenied|Forbidden/i.test(message))return{ok:!1,reason:`Access denied to bucket '${bucket}'.`,hint:"Check that AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (or your IAM role) have s3:ListBucket permission for this bucket."};if(/region/i.test(message)||/PermanentRedirect/i.test(message))return{ok:!1,reason:`Bucket '${bucket}' is not in region '${region}'.`,hint:"Set AWS_REGION to the bucket's actual region, or pass the right bucket for this region."};return{ok:!1,reason:message,hint:"Check AWS connectivity and credentials."}}}async function loadAwsCredentials(){if(_awsCredsLoaded)return;_awsCredsLoaded=!0;const envPath=".env.production";if(!existsSync(envPath))return;const{parse}=await import("@stacksjs/env"),content=readFileSync(envPath,"utf-8"),{parsed}=parse(content);for(const[key,value]of Object.entries(parsed))if(process.env[key]===void 0)process.env[key]=value}const descriptions={email:"Email server management commands",verify:"Check domain verification status",test:"Send a test email",list:"List configured mailboxes",logs:"View email processing logs",status:"Show email server status",inbox:"View inbox emails from S3",reprocess:"Reprocess raw emails from S3 into mailbox structure"};export function email(buddy){buddy.command("email",descriptions.email).alias("mail").action(async()=>{console.log(`
2
2
  \uD83D\uDCE7 Email Server Commands
3
3
  `);console.log(" buddy email:verify - Check domain verification status");console.log(" buddy email:test - Send a test email");console.log(" buddy email:list - List configured mailboxes");console.log(" buddy email:inbox - View inbox emails from S3");console.log(" buddy email:reprocess - Reprocess raw emails into mailbox structure");console.log(" buddy email:logs - View email processing logs");console.log(" buddy email:status - Show email server status");console.log("")});buddy.command("email:verify",descriptions.verify).action(async()=>{console.log(`
4
4
  \uD83D\uDCE7 Checking Email Domain Verification...
@@ -1 +1,2 @@
1
- import process from"node:process";import{log}from"@stacksjs/logging";import{onUnknownSubcommand}from"@stacksjs/cli";export function list(buddy){const descriptions={list:"List all available Buddy commands",project:"Target a specific project",verbose:"Enable verbose output",filter:"Filter commands by name or group",namespace:"Filter commands by namespace (e.g., make, env, db)",grouped:"Group commands by category",format:"Output format (text, json)",json:"Output the complete command inventory as JSON"};buddy.command("list",descriptions.list).option("-p, --project [project]",descriptions.project,{default:!1}).option("-f, --filter [filter]",descriptions.filter).option("-n, --namespace [namespace]",descriptions.namespace).option("-g, --grouped",descriptions.grouped,{default:!0}).option("--format [format]",descriptions.format,{default:"text"}).option("-J, --json",descriptions.json,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy list").example("buddy list --filter=make").example("buddy list --namespace=make").example("buddy list --namespace=env").example("buddy list --no-grouped").example("buddy list --format=json").example("buddy list --json").action(async(options)=>{log.debug("Running `buddy list` ...",options);const{bold,dim,green}=await import("@stacksjs/cli"),commands=buddy.commands||[];let filteredCommands=commands;if(options.filter){const filterStr=String(options.filter).toLowerCase();filteredCommands=commands.filter((cmd)=>{const name=canonicalCommandName(cmd),desc=cmd.description||"";return name.toLowerCase().includes(filterStr)||desc.toLowerCase().includes(filterStr)})}if(options.namespace){const namespaceStr=String(options.namespace).toLowerCase();filteredCommands=filteredCommands.filter((cmd)=>{return(cmd.namespace||"").toLowerCase()===namespaceStr})}if(filteredCommands.length===0){if(usesJsonOutput(options))console.log(JSON.stringify({commands:[],total:0},null,2));else{console.log(dim("No commands found"));if(options.filter)console.log(dim(`Try removing the filter: ${options.filter}`))}return}if(usesJsonOutput(options)){const jsonOutput=filteredCommands.map(commandInventoryEntry).sort((a,b)=>a.name.localeCompare(b.name));console.log(JSON.stringify({commands:jsonOutput,total:jsonOutput.length},null,2));return}if(options.grouped){const groups=new Map;for(const cmd of filteredCommands){const name=canonicalCommandName(cmd);if(!name)continue;let group="General";if(name.includes(":"))group=name.split(":")[0]??"General";else if(["dev","build","test","lint"].includes(name))group="Development";else if(["deploy","release","publish"].includes(name))group="Deployment";else if(["migrate","seed","fresh"].includes(name))group="Database";else if(["doctor","about","version"].includes(name))group="Info";if(!groups.has(group))groups.set(group,[]);groups.get(group).push(cmd)}console.log("");console.log(bold(green("Available Commands:")));console.log("");const sortedGroups=Array.from(groups.entries()).sort((a,b)=>{const order=["Development","General","Info"],aIndex=order.indexOf(a[0]),bIndex=order.indexOf(b[0]);if(aIndex>=0&&bIndex>=0)return aIndex-bIndex;if(aIndex>=0)return-1;if(bIndex>=0)return 1;return a[0].localeCompare(b[0])});for(const[group,cmds]of sortedGroups){console.log(bold(`${group}:`));cmds.sort((a,b)=>canonicalCommandName(a).localeCompare(canonicalCommandName(b)));for(const cmd of cmds){const name=canonicalCommandName(cmd).padEnd(20),desc=cmd.description||"";console.log(` ${green(name)} ${dim(desc)}`)}console.log("")}}else{console.log("");console.log(bold(green("Available Commands:")));console.log("");filteredCommands.sort((a,b)=>canonicalCommandName(a).localeCompare(canonicalCommandName(b)));for(const cmd of filteredCommands){const name=canonicalCommandName(cmd).padEnd(20),desc=cmd.description||"";console.log(` ${green(name)} ${dim(desc)}`)}console.log("")}console.log(dim(`Total: ${filteredCommands.length} commands`));console.log("")});onUnknownSubcommand(buddy,"list")}export function commandInventoryEntry(command){const entry={name:canonicalCommandName(command),description:command.description,aliases:[...command.aliasNames],usage:command.usageLine,arguments:command.args.map((argument)=>({name:argument.value,required:argument.required,variadic:argument.variadic})),options:command.options.map((option)=>({name:option.name,flags:declaredOptionFlags(option.rawName),description:option.description,required:option.required===!0,boolean:option.isBoolean===!0,negated:option.negated,...option.config.default===void 0?{}:{default:option.config.default}})),examples:command.examples.map((example)=>typeof example==="string"?example:example("buddy"))};if(command.namespace)entry.namespace=command.namespace;return entry}function canonicalCommandName(command){return command.namespace?`${command.namespace}:${command.name}`:command.name}function declaredOptionFlags(rawName){return[...rawName.matchAll(/-{1,2}([\w-]+)/g)].map((match)=>match[1])}function usesJsonOutput(options){return options.json===!0||options.format==="json"}
1
+ import{log}from"@stacksjs/logging";import{onUnknownSubcommand}from"@stacksjs/cli";export function list(buddy){const descriptions={list:"List all available Buddy commands",project:"Target a specific project",verbose:"Enable verbose output",filter:"Filter commands by name or group",namespace:"Filter commands by namespace (e.g., make, env, db)",grouped:"Group commands by category",format:"Output format (text, json)",json:"Output the complete command inventory as JSON"};buddy.command("list",descriptions.list).option("-p, --project [project]",descriptions.project,{default:!1}).option("-f, --filter [filter]",descriptions.filter).option("-n, --namespace [namespace]",descriptions.namespace).option("-g, --grouped",descriptions.grouped,{default:!0}).option("--format [format]",descriptions.format,{default:"text"}).option("-J, --json",descriptions.json,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy list").example("buddy list --filter=make").example("buddy list --namespace=make").example("buddy list --namespace=env").example("buddy list --no-grouped").example("buddy list --format=json").example("buddy list --json").action(async(options)=>{log.debug("Running `buddy list` ...",options);const{bold,dim,green}=await import("@stacksjs/cli"),commands=buddy.commands||[];let filteredCommands=commands;if(options.filter){const filterStr=String(options.filter).toLowerCase();filteredCommands=commands.filter((cmd)=>{const name=canonicalCommandName(cmd),desc=cmd.description||"";return name.toLowerCase().includes(filterStr)||desc.toLowerCase().includes(filterStr)})}if(options.namespace){const namespaceStr=String(options.namespace).toLowerCase();filteredCommands=filteredCommands.filter((cmd)=>{return(cmd.namespace||"").toLowerCase()===namespaceStr})}if(filteredCommands.length===0){if(usesJsonOutput(options))await writeJson({commands:[],total:0});else{console.log(dim("No commands found"));if(options.filter)console.log(dim(`Try removing the filter: ${options.filter}`))}return}if(usesJsonOutput(options)){const jsonOutput=filteredCommands.map(commandInventoryEntry).sort((a,b)=>a.name.localeCompare(b.name));await writeJson({commands:jsonOutput,total:jsonOutput.length});return}if(options.grouped){const groups=new Map;for(const cmd of filteredCommands){const name=canonicalCommandName(cmd);if(!name)continue;let group="General";if(name.includes(":"))group=name.split(":")[0]??"General";else if(["dev","build","test","lint"].includes(name))group="Development";else if(["deploy","release","publish"].includes(name))group="Deployment";else if(["migrate","seed","fresh"].includes(name))group="Database";else if(["doctor","about","version"].includes(name))group="Info";if(!groups.has(group))groups.set(group,[]);groups.get(group).push(cmd)}console.log("");console.log(bold(green("Available Commands:")));console.log("");const sortedGroups=Array.from(groups.entries()).sort((a,b)=>{const order=["Development","General","Info"],aIndex=order.indexOf(a[0]),bIndex=order.indexOf(b[0]);if(aIndex>=0&&bIndex>=0)return aIndex-bIndex;if(aIndex>=0)return-1;if(bIndex>=0)return 1;return a[0].localeCompare(b[0])});for(const[group,cmds]of sortedGroups){console.log(bold(`${group}:`));cmds.sort((a,b)=>canonicalCommandName(a).localeCompare(canonicalCommandName(b)));for(const cmd of cmds){const name=canonicalCommandName(cmd).padEnd(20),desc=cmd.description||"";console.log(` ${green(name)} ${dim(desc)}`)}console.log("")}}else{console.log("");console.log(bold(green("Available Commands:")));console.log("");filteredCommands.sort((a,b)=>canonicalCommandName(a).localeCompare(canonicalCommandName(b)));for(const cmd of filteredCommands){const name=canonicalCommandName(cmd).padEnd(20),desc=cmd.description||"";console.log(` ${green(name)} ${dim(desc)}`)}console.log("")}console.log(dim(`Total: ${filteredCommands.length} commands`));console.log("")});onUnknownSubcommand(buddy,"list")}export function commandInventoryEntry(command){const entry={name:canonicalCommandName(command),description:command.description,aliases:[...command.aliasNames],usage:command.usageLine,arguments:command.args.map((argument)=>({name:argument.value,required:argument.required,variadic:argument.variadic})),options:command.options.map((option)=>({name:option.name,flags:declaredOptionFlags(option.rawName),description:option.description,required:option.required===!0,boolean:option.isBoolean===!0,negated:option.negated,...option.config.default===void 0?{}:{default:option.config.default}})),examples:command.examples.map((example)=>typeof example==="string"?example:example("buddy"))};if(command.namespace)entry.namespace=command.namespace;return entry}function canonicalCommandName(command){return command.namespace?`${command.namespace}:${command.name}`:command.name}function declaredOptionFlags(rawName){return[...rawName.matchAll(/-{1,2}([\w-]+)/g)].map((match)=>match[1])}async function writeJson(payload){const text=`${JSON.stringify(payload,null,2)}
2
+ `;await new Promise((resolve,reject)=>{process.stdout.write(text,(error)=>error?reject(error):resolve())})}function usesJsonOutput(options){return options.json===!0||options.format==="json"}
@@ -1,4 +1,4 @@
1
- var {require}=import.meta;import{log,runCommand}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{getErrorMessage}from"@stacksjs/utils";import{CLI}from"@stacksjs/clapp";import{createHash,createHmac,randomBytes,timingSafeEqual}from"crypto";import{readFileSync,existsSync}from"node:fs";import{execSync,spawnSync}from"node:child_process";export function resolveDirectMailHost(env=process.env){let configured=env.MAIL_SERVER_HOST||(env.HCLOUD_TOKEN||env.HETZNER_API_TOKEN?env.MAIL_HOST:void 0);if(configured&&["127.0.0.1","localhost","mailpit"].includes(configured))configured=env.MAIL_DOMAIN?`mail.${env.MAIL_DOMAIN}`:void 0;if(!configured||configured==="127.0.0.1"||configured==="localhost")return;return configured.replace(/^https?:\/\//,"").split("/")[0]?.split(":")[0]}export function sanitizeLineCount(value){const count=Number.parseInt(value||"50",10);return String(Number.isFinite(count)?Math.min(5000,Math.max(1,count)):50)}function shellQuote(value){return`'${value.replace(/'/g,"'\\''")}'`}async function resolveOperationalMailHost(){if(process.env.MAIL_SERVER_HOST)return resolveDirectMailHost();const token=process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN;if(!token)return;const appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");try{const ip=(await(await fetch(`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent(`${appName}-production-app`)}`,{headers:{Authorization:`Bearer ${token}`}})).json()).servers?.[0]?.public_net?.ipv4?.ip;if(ip)return ip}catch{}const configured=resolveDirectMailHost();if(configured)return configured;const{email}=await import("@stacksjs/config");return email?.domain?`mail.${email.domain}`:void 0}const mailStorageRemote="/usr/local/sbin/mail-storage-external";function loadMailStorageAws(options){const profile=options.profile||process.env.AWS_PROFILE||"default",region=options.region||process.env.AWS_REGION||"us-east-1";process.env.AWS_PROFILE=profile;loadEnvFiles();if(existsSync(".env.production"))try{const{loadEnv}=require("@stacksjs/env");loadEnv({path:".env.production",env:"production",keysFile:".env.keys",overload:!0,quiet:!0})}catch{}const credentialsPath=`${process.env.HOME||process.env.USERPROFILE||""}/.aws/credentials`;if((!process.env.AWS_ACCESS_KEY_ID||!process.env.AWS_SECRET_ACCESS_KEY)&&existsSync(credentialsPath)){const values={};let inProfile=!1;for(const line of readFileSync(credentialsPath,"utf8").split(`
1
+ var {require}=import.meta;import{log,runCommand}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{getErrorMessage}from"@stacksjs/utils";import{CLI}from"@stacksjs/clapp";import{createHash,randomBytes,timingSafeEqual}from"crypto";import{readFileSync,existsSync}from"node:fs";import{execSync,spawnSync}from"node:child_process";export function resolveDirectMailHost(env=process.env){let configured=env.MAIL_SERVER_HOST||(env.HCLOUD_TOKEN||env.HETZNER_API_TOKEN?env.MAIL_HOST:void 0);if(configured&&["127.0.0.1","localhost","mailpit"].includes(configured))configured=env.MAIL_DOMAIN?`mail.${env.MAIL_DOMAIN}`:void 0;if(!configured||configured==="127.0.0.1"||configured==="localhost")return;return configured.replace(/^https?:\/\//,"").split("/")[0]?.split(":")[0]}export function sanitizeLineCount(value){const count=Number.parseInt(value||"50",10);return String(Number.isFinite(count)?Math.min(5000,Math.max(1,count)):50)}function shellQuote(value){return`'${value.replace(/'/g,"'\\''")}'`}async function resolveOperationalMailHost(){if(process.env.MAIL_SERVER_HOST)return resolveDirectMailHost();const token=process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN;if(!token)return;const appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");try{const ip=(await(await fetch(`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent(`${appName}-production-app`)}`,{headers:{Authorization:`Bearer ${token}`}})).json()).servers?.[0]?.public_net?.ipv4?.ip;if(ip)return ip}catch{}const configured=resolveDirectMailHost();if(configured)return configured;const{email}=await import("@stacksjs/config");return email?.domain?`mail.${email.domain}`:void 0}const mailStorageRemote="/usr/local/sbin/mail-storage-external";function loadMailStorageAws(options){const profile=options.profile||process.env.AWS_PROFILE||"default",region=options.region||process.env.AWS_REGION||"us-east-1";process.env.AWS_PROFILE=profile;loadEnvFiles();if(existsSync(".env.production"))try{const{loadEnv}=require("@stacksjs/env");loadEnv({path:".env.production",env:"production",keysFile:".env.keys",overload:!0,quiet:!0})}catch{}const credentialsPath=`${process.env.HOME||process.env.USERPROFILE||""}/.aws/credentials`;if((!process.env.AWS_ACCESS_KEY_ID||!process.env.AWS_SECRET_ACCESS_KEY)&&existsSync(credentialsPath)){const values={};let inProfile=!1;for(const line of readFileSync(credentialsPath,"utf8").split(`
2
2
  `)){const trimmed=line.trim();if(trimmed.startsWith("[")){inProfile=trimmed===`[${profile}]`;continue}if(!inProfile)continue;const separator=trimmed.indexOf("=");if(separator>0)values[trimmed.slice(0,separator).trim()]=trimmed.slice(separator+1).trim()}if(values.aws_access_key_id&&values.aws_secret_access_key){process.env.AWS_ACCESS_KEY_ID=values.aws_access_key_id;process.env.AWS_SECRET_ACCESS_KEY=values.aws_secret_access_key;if(values.aws_session_token)process.env.AWS_SESSION_TOKEN=values.aws_session_token;else delete process.env.AWS_SESSION_TOKEN}}return{profile,region}}function mailStorageSecretId(options){if(options.secret)return options.secret;if(process.env.MAIL_STORAGE_SECRET_ID)return process.env.MAIL_STORAGE_SECRET_ID;const app=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),environment=(process.env.APP_ENV||"production").toLowerCase().replace(/[^a-z0-9-]/g,"-");return`${app}/${environment}/mail-storage-key`}async function mailStorageHost(options){const host=options.host||await resolveOperationalMailHost();if(!host)throw Error("Could not resolve the mail host. Pass --host <hostname-or-ip>.");if(!/^[a-zA-Z0-9._:-]+$/.test(host))throw Error(`Unsafe mail host: ${host}`);return host}function runMailStorageSsh(host,command,input){const result=spawnSync("ssh",["-o","BatchMode=yes","-o","ConnectTimeout=15",`root@${host}`,command],{input,maxBuffer:33554432});if(result.status!==0){const stderr=Buffer.from(result.stderr||[]).toString("utf8").trim();throw Error(stderr||`SSH command failed with status ${result.status}`)}return Buffer.from(result.stdout||[])}function readMailRecoveryKey(){const result=spawnSync("/usr/bin/security",["find-generic-password","-a","mail-storage-recovery","-s","com.stacks.production.mail.luks","-w"],{maxBuffer:1048576,timeout:30000});if(result.status!==0)throw Error("macOS Keychain recovery-key read failed");const raw=Buffer.from(result.stdout||[]),text=raw.toString("ascii").trim(),key=/^[0-9a-f]{128}$/i.test(text)?Buffer.from(text,"hex"):raw.length===65&&raw[64]===10?Buffer.from(raw.subarray(0,64)):Buffer.from(raw);raw.fill(0);if(key.length!==64)throw Error("macOS Keychain returned an invalid recovery key");return key}async function waitForHetznerAction(token,actionId){const deadline=Date.now()+120000;while(Date.now()<deadline){const response=await fetch(`https://api.hetzner.cloud/v1/actions/${actionId}`,{headers:{Authorization:`Bearer ${token}`}}),data=await response.json();if(!response.ok)throw Error(data?.error?.message||`Hetzner action ${actionId} lookup failed`);if(data.action?.status==="success")return;if(data.action?.status==="error")throw Error(data.action?.error?.message||`Hetzner action ${actionId} failed`);await new Promise((resolve)=>setTimeout(resolve,1000))}throw Error(`Hetzner action ${actionId} timed out`)}async function ensureMailStorageVolume(options){loadEnvFiles();const token=process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN;if(!token)throw Error("HCLOUD_TOKEN or HETZNER_API_TOKEN is required.");const headers={Authorization:`Bearer ${token}`,"Content-Type":"application/json"},host=await mailStorageHost(options),server=(await(await fetch(`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent("stacks-production-app")}`,{headers})).json()).servers?.[0];if(!server)throw Error("Hetzner server stacks-production-app was not found.");const app=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),name=`${app}-production-mail`,requestedSize=Number.parseInt(options.size||"20",10);if(!Number.isFinite(requestedSize)||requestedSize<10||requestedSize>1024)throw Error("Mail volume size must be between 10 and 1024 GiB.");let volume=(await(await fetch(`https://api.hetzner.cloud/v1/volumes?name=${encodeURIComponent(name)}`,{headers})).json()).volumes?.[0];if(!volume){const createResponse=await fetch("https://api.hetzner.cloud/v1/volumes",{method:"POST",headers,body:JSON.stringify({name,size:requestedSize,server:server.id,automount:!1,labels:{application:app,environment:"production",purpose:"mail-storage"}})}),createData=await createResponse.json();if(!createResponse.ok)throw Error(createData?.error?.message||"Hetzner volume creation failed.");volume=createData.volume;if(createData.action?.id)await waitForHetznerAction(token,createData.action.id)}else if(!volume.server){const attachResponse=await fetch(`https://api.hetzner.cloud/v1/volumes/${volume.id}/actions/attach`,{method:"POST",headers,body:JSON.stringify({server:server.id,automount:!1})}),attachData=await attachResponse.json();if(!attachResponse.ok)throw Error(attachData?.error?.message||"Hetzner volume attach failed.");await waitForHetznerAction(token,attachData.action.id)}else if(volume.server!==server.id)throw Error(`Hetzner volume ${name} is attached to a different server.`);volume=(await(await fetch(`https://api.hetzner.cloud/v1/volumes/${volume.id}`,{headers})).json()).volume||volume;if(!volume.linux_device)throw Error(`Hetzner volume ${name} has no Linux device path yet.`);return{device:volume.linux_device,host,id:volume.id,name,size:volume.size}}async function ensureMailKmsKey(options){const{region}=loadMailStorageAws(options),{AWSClient}=await import("../../../cloud/src/imap/client"),{S3Client}=await import("../../../cloud/src/imap/s3"),{SecretsManagerClient}=await import("../../../cloud/src/imap/secrets-manager"),client=new AWSClient,request=async(target,body)=>client.request({service:"kms",region,method:"POST",path:"/",headers:{"Content-Type":"application/x-amz-json-1.1","X-Amz-Target":`TrentService.${target}`},body:JSON.stringify(body)}),alias="alias/stacks-production-mail";let keyId=(await request("ListAliases",{Limit:100})).Aliases?.find((item)=>item.AliasName===alias)?.TargetKeyId;if(!keyId){keyId=(await request("CreateKey",{Description:"Customer-managed key for production mail secrets and backups",KeyUsage:"ENCRYPT_DECRYPT",Origin:"AWS_KMS",Tags:[{TagKey:"Application",TagValue:"stacks"},{TagKey:"Environment",TagValue:"production"},{TagKey:"Purpose",TagValue:"mail-storage"}]})).KeyMetadata?.KeyId;if(!keyId)throw Error("AWS KMS did not return a key ID.");await request("CreateAlias",{AliasName:alias,TargetKeyId:keyId});await request("EnableKeyRotation",{KeyId:keyId,RotationPeriodInDays:90})}const arn=(await request("DescribeKey",{KeyId:keyId})).KeyMetadata?.Arn;if(!arn)throw Error("AWS KMS did not return a key ARN.");const secrets=new SecretsManagerClient(region);for(const secretId of["stacks/production/mail-storage-key","stacks/production/mail-restic"])try{await secrets.updateSecret({SecretId:secretId,KmsKeyId:arn})}catch(error){if(error?.code!=="ResourceNotFoundException")throw Error(`Could not re-encrypt secret ${secretId}: ${getErrorMessage(error)}`)}const s3=new S3Client(region);for(const bucket of["stacks-production-s3-backups","stacks-production-s3-email","stacks-production-email"])try{await s3.putBucketEncryption(bucket,"aws:kms",arn)}catch(error){throw Error(`Could not apply KMS encryption to ${bucket}: ${getErrorMessage(error)}`)}return{alias,arn}}async function configureMailRestic(options){const{region}=loadMailStorageAws(options),host=await mailStorageHost(options),bucket=options.bucket||"stacks-production-s3-backups";if(!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket))throw Error(`Unsafe S3 bucket name: ${bucket}`);const kms=await ensureMailKmsKey(options),{AWSClient}=await import("../../../cloud/src/imap/client"),{SecretsManagerClient}=await import("../../../cloud/src/imap/secrets-manager"),iam=new AWSClient,iamRequest=async(action,params={})=>iam.request({service:"iam",region:"us-east-1",method:"POST",path:"/",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({Action:action,Version:"2010-05-08",...params}).toString()}),userName="stacks-production-mail-backup";try{await iamRequest("GetUser",{UserName:userName})}catch(error){if(error?.code!=="NoSuchEntity")throw error;await iamRequest("CreateUser",{UserName:userName,"Tags.member.1.Key":"Application","Tags.member.1.Value":"stacks","Tags.member.2.Key":"Environment","Tags.member.2.Value":"production","Tags.member.3.Key":"Purpose","Tags.member.3.Value":"mail-backup"})}await iamRequest("PutUserPolicy",{UserName:userName,PolicyName:"mail-restic-backup",PolicyDocument:JSON.stringify({Version:"2012-10-17",Statement:[{Effect:"Allow",Action:["s3:ListBucket","s3:GetBucketLocation"],Resource:`arn:aws:s3:::${bucket}`,Condition:{StringLike:{"s3:prefix":["restic-mail","restic-mail/*"]}}},{Effect:"Allow",Action:["s3:GetObject","s3:PutObject","s3:DeleteObject","s3:AbortMultipartUpload","s3:ListMultipartUploadParts"],Resource:`arn:aws:s3:::${bucket}/restic-mail/*`},{Effect:"Allow",Action:["kms:Decrypt","kms:Encrypt","kms:GenerateDataKey","kms:DescribeKey"],Resource:kms.arn}]})});const listedMembers=(await iamRequest("ListAccessKeys",{UserName:userName})).ListAccessKeysResult?.AccessKeyMetadata?.member,oldKeys=listedMembers?Array.isArray(listedMembers)?listedMembers:[listedMembers]:[];if(oldKeys.length>0&&!options.force)throw Error(`IAM user ${userName} already has a key. Pass --force to rotate it.`);if(oldKeys.length>=2)for(const old of oldKeys)await iamRequest("DeleteAccessKey",{UserName:userName,AccessKeyId:old.AccessKeyId});const access=(await iamRequest("CreateAccessKey",{UserName:userName})).CreateAccessKeyResult?.AccessKey;if(!access?.AccessKeyId||!access?.SecretAccessKey)throw Error("AWS IAM did not return the Restic access key.");const password=randomBytes(48).toString("base64url"),repository=`s3:s3.amazonaws.com/${bucket}/restic-mail`,secretId="stacks/production/mail-restic",secretPayload=JSON.stringify({version:1,repository,password,accessKeyId:access.AccessKeyId,secretAccessKey:access.SecretAccessKey,kmsKeyArn:kms.arn,createdAt:new Date().toISOString()}),secrets=new SecretsManagerClient(region);try{await secrets.createSecret({Name:secretId,Description:"Restic credentials for production mail backups",KmsKeyId:kms.arn,SecretString:secretPayload,Tags:[{Key:"Application",Value:"stacks"},{Key:"Environment",Value:"production"},{Key:"Purpose",Value:"mail-backup"}]})}catch(error){if(error?.code!=="ResourceExistsException")throw error;await secrets.updateSecret({SecretId:secretId,KmsKeyId:kms.arn,SecretString:secretPayload})}const configDir="/var/lib/mail-storage/data/restic",passwordPath=`${configDir}/password`,envPayload=[`RESTIC_REPOSITORY=${repository}`,`RESTIC_PASSWORD_FILE=${passwordPath}`,`AWS_ACCESS_KEY_ID=${access.AccessKeyId}`,`AWS_SECRET_ACCESS_KEY=${access.SecretAccessKey}`,`AWS_DEFAULT_REGION=${region}`,""].join(`
3
3
  `);runMailStorageSsh(host,`install -d -m 0700 ${configDir}; umask 077; dd of=${configDir}/restic.env status=none`,Buffer.from(envPayload));runMailStorageSsh(host,`umask 077; dd of=${passwordPath} status=none`,Buffer.from(`${password}
4
4
  `));runMailStorageSsh(host,`set -a; . ${configDir}/restic.env; set +a; restic snapshots >/dev/null 2>&1 || restic init`);runMailStorageSsh(host,"systemctl enable --now mail-restic-backup.timer mail-storage-health.timer");runMailStorageSsh(host,"systemctl start mail-restic-backup.service");runMailStorageSsh(host,"/usr/local/sbin/mail-restic-restore-check");for(const old of oldKeys)if(old.AccessKeyId!==access.AccessKeyId)await iamRequest("DeleteAccessKey",{UserName:userName,AccessKeyId:old.AccessKeyId});return{repository,secretId}}function decodeMailStorageSecret(secretString){if(!secretString)throw Error("The mail storage secret has no SecretString value.");let parsed;try{parsed=JSON.parse(secretString)}catch{throw Error("The mail storage secret is not valid JSON.")}if(parsed.version!==1||parsed.encoding!=="base64"||typeof parsed.key!=="string")throw Error("The mail storage secret uses an unsupported format.");const key=Buffer.from(parsed.key,"base64");if(key.length<32)throw Error("The mail storage key is unexpectedly short.");return key}async function getMailStorageSecret(options){const{profile,region}=loadMailStorageAws(options),secretId=mailStorageSecretId(options),{SecretsManagerClient}=await import("../../../cloud/src/imap/secrets-manager"),value=await new SecretsManagerClient(region,profile).getSecretValue({SecretId:secretId});return{key:decodeMailStorageSecret(value.SecretString),secretId}}async function escrowMailStorageKey(options){const{profile,region}=loadMailStorageAws(options),host=await mailStorageHost(options),secretId=mailStorageSecretId(options),{SecretsManagerClient}=await import("../../../cloud/src/imap/secrets-manager"),secrets=new SecretsManagerClient(region,profile),key=runMailStorageSsh(host,"systemd-creds decrypt --name=mail-storage-key /etc/credstore.encrypted/mail-storage-key.cred -");if(key.length<32)throw Error("The machine-bound mail storage key is unexpectedly short.");let existing;try{const value=await secrets.getSecretValue({SecretId:secretId});existing=decodeMailStorageSecret(value.SecretString)}catch(error){if(error?.code!=="ResourceNotFoundException")throw error}if(existing&&!timingSafeEqual(existing,key)){existing.fill(0);if(!options.force){key.fill(0);throw Error(`Secret ${secretId} already contains a different key. Pass --force to replace it.`)}}existing?.fill(0);const payload={version:1,encoding:"base64",key:key.toString("base64"),image:"/var/lib/mail-storage.luks",mapper:"mail-storage",createdAt:new Date().toISOString()};if(existing)await secrets.putSecretValue({SecretId:secretId,SecretString:JSON.stringify(payload)});else try{await secrets.createSecret({Name:secretId,Description:`Externally escrowed LUKS2 key for ${host}`,SecretString:JSON.stringify(payload),Tags:[{Key:"Application",Value:process.env.APP_NAME||"stacks"},{Key:"Environment",Value:process.env.APP_ENV||"production"},{Key:"Purpose",Value:"mail-storage"}]})}catch(error){if(error?.code!=="ResourceExistsException")throw error;await secrets.putSecretValue({SecretId:secretId,SecretString:JSON.stringify(payload)})}const verify=await secrets.getSecretValue({SecretId:secretId}),verifiedKey=decodeMailStorageSecret(verify.SecretString),matches=verifiedKey.length===key.length&&timingSafeEqual(verifiedKey,key);verifiedKey.fill(0);key.fill(0);if(!matches)throw Error(`Secret ${secretId} did not verify after writing.`);return{secretId,host}}function addMailStorageOptions(command){return command.option("--host <host>","Mail server hostname or IP").option("--secret <id>","AWS Secrets Manager secret ID").option("--region <region>","AWS region",{default:"us-east-1"}).option("--profile <profile>","AWS credential profile",{default:"default"})}export function mailCommands(buddy){buddy.command("mail:provision","Provision this app's mail from config/email.ts onto the shared mail server (domain + DKIM + mailboxes + MX/SPF/DKIM/DMARC DNS). Reusable + idempotent; the same reconcile buddy deploy runs.").option("--ip <ip>","Mail server IP (defaults to the A record of config.email.domain)").action(async(options)=>{const environment=options.env||process.env.APP_ENV||"production";process.env.APP_ENV=environment;const envFile=`.env.${environment}`;if(existsSync(envFile))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:environment,keysFile:".env.keys",overload:!0,quiet:!0})}catch(err){log.warn(`Could not load ${envFile}: ${getErrorMessage(err)}`)}const{email:emailConfig}=await import("@stacksjs/config"),domain=emailConfig?.domain;if(!domain){log.error("config/email.ts has no `domain` - set it (e.g. domain: 'bughq.org') and add `mailboxes`.");process.exit(ExitCode.FatalError)}let ip=options.ip;if(!ip)try{ip=(await(await import("node:dns")).promises.resolve4(domain))[0]}catch{log.error(`Could not resolve an IP for ${domain}. Deploy the site first, or pass --ip <mail-server-ip>.`);process.exit(ExitCode.FatalError)}const{provisionMailTenant,reconcileMailDns}=await import("./deploy");log.info(`Provisioning mail for ${domain} on ${ip}...`);const res=await provisionMailTenant(ip,log);if(res)await reconcileMailDns(res,ip,log);log.success(`Mail provisioned for ${domain}. Add MAIL_PASSWORD_<LOCALPART> env vars to pin mailbox passwords.`);process.exit(ExitCode.Success)});addMailStorageOptions(buddy.command("mail:storage:status","Show encrypted mail storage and external-key status")).action(async(options)=>{loadEnvFiles();try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,`${mailStorageRemote} status`).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to read mail storage status: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:kms:ensure","Create and apply the customer-managed production mail KMS key")).action(async(options)=>{try{const kms=await ensureMailKmsKey(options);console.log(`alias=${kms.alias}`);console.log(`arn=${kms.arn}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to configure mail KMS: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:volume:migrate","Create a dedicated Hetzner volume and migrate encrypted mail storage")).option("--size <gib>","Hetzner volume size in GiB",{default:"20"}).action(async(options)=>{let key,prepareAttempted=!1;try{const volume=await ensureMailStorageVolume(options);key=(await getMailStorageSecret(options)).key;runMailStorageSsh(volume.host,`for i in $(seq 1 60); do test -b ${shellQuote(volume.device)} && exit 0; sleep 1; done; exit 1`);prepareAttempted=!0;runMailStorageSsh(volume.host,["umask 077","key_file=$(mktemp /run/mail-volume-migrate-key.XXXXXX)",`trap 'shred -u "$key_file" 2>/dev/null || rm -f "$key_file"' EXIT`,'dd of="$key_file" bs=64 count=1 iflag=fullblock status=none',`MAIL_ENCRYPTED_IMAGE_SIZE=16G /usr/local/sbin/mail-volume-migrate prepare ${shellQuote(volume.device)}`,`${mailStorageRemote} unlock < "$key_file"`,"/usr/local/sbin/mail-volume-migrate finalize"].join(" && "),key);const status=runMailStorageSsh(volume.host,`${mailStorageRemote} status`).toString("utf8").trim();console.log(`volume=${volume.name}`);console.log(`volume_id=${volume.id}`);console.log(`volume_size_gib=${volume.size}`);console.log(status);process.exit(ExitCode.Success)}catch(error){if(prepareAttempted&&key){const host=await mailStorageHost(options);try{runMailStorageSsh(host,"/usr/local/sbin/mail-volume-migrate rollback")}catch(rollbackError){console.error(`Dedicated-volume rollback step failed: ${getErrorMessage(rollbackError)}`)}try{runMailStorageSsh(host,`${mailStorageRemote} unlock`,key)}catch(unlockError){console.error(`Mail storage re-unlock failed: ${getErrorMessage(unlockError)}`)}}console.error(`Failed to migrate mail storage volume: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:key:recovery:add","Add an independent LUKS recovery key to macOS Keychain")).action(async(options)=>{let currentKey,recoveryKey;try{const host=await mailStorageHost(options);currentKey=(await getMailStorageSecret(options)).key;recoveryKey=randomBytes(64);runMailStorageSsh(host,`${mailStorageRemote} add-recovery-key`,Buffer.concat([currentKey,recoveryKey]));const account="mail-storage-recovery",service="com.stacks.production.mail.luks",encoded=recoveryKey.toString("base64"),keychainWriter=`
@@ -1,4 +1,4 @@
1
- import{closeSync,existsSync,mkdirSync,openSync,readdirSync,readFileSync,rmSync}from"node:fs";import{relative}from"node:path";import process from"node:process";import{confirm,intro,log,onUnknownSubcommand,outro,text}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{hasTTY,isCI}from"@stacksjs/env";import{appPath,frameworkPath,frameworkRuntimePath,projectPath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{preflightDatabase}from"../database-preflight";import{DDL_CONSTRAINT_OVERRIDE_ENV,DIALECT_OVERRIDE_ENV,auditDdlConstraints,auditMigrationCorpus,dialectCapabilities,formatDdlConstraintError,formatMigrationDialectError,relativeMigrationDirectory,resolveMigrationDirectory,stripSqlNoise}from"@stacksjs/database";import{resultFailed}from"../result";let _runAction;async function runAction(...args){if(!_runAction)_runAction=(await import("@stacksjs/actions")).runAction;return _runAction(...args)}function acquireMigrationLock(){const lockDir=frameworkRuntimePath(),lockFile=`${lockDir}/migrations.lock`;try{if(!existsSync(lockDir))mkdirSync(lockDir,{recursive:!0});const fd=openSync(lockFile,"wx");try{closeSync(fd)}catch{}return{acquired:!0,release:()=>{try{rmSync(lockFile,{force:!0})}catch{}}}}catch{return{acquired:!1,release:()=>{}}}}async function ensureDatabaseOrExit(){try{const{ensureDatabaseReady}=await import("@stacksjs/database");await ensureDatabaseReady()}catch(error){log.syncError(error instanceof Error?error.message:String(error));process.exit(ExitCode.FatalError)}}function readMigrateMarker(){const file=frameworkRuntimePath("last-migrate-result.json");if(!existsSync(file))return null;try{const raw=readFileSync(file,"utf8"),parsed=JSON.parse(raw),n=typeof parsed.appliedCount==="number"?parsed.appliedCount:null;if(n===null||!Number.isFinite(n))return null;return{appliedCount:Math.max(0,Math.floor(n))}}catch{return null}finally{try{rmSync(file,{force:!0})}catch{}}}function countModelFiles(dir){if(!existsSync(dir))return 0;let count=0;const entries=readdirSync(dir,{withFileTypes:!0});for(const entry of entries)if(entry.isDirectory())count+=countModelFiles(`${dir}/${entry.name}`);else if(entry.name.endsWith(".ts")&&!entry.name.startsWith(".")&&!entry.name.startsWith("index"))count++;return count}function validateModelsExist(){const userModelsPath=appPath("Models"),defaultModelsPath=frameworkPath("defaults/app/Models"),userModelCount=countModelFiles(userModelsPath),defaultModelCount=countModelFiles(defaultModelsPath);if(userModelCount===0&&defaultModelCount===0)return{valid:!1,error:"No models found. Please create models in app/Models or ensure framework defaults exist."};return{valid:!0}}export function validateMigrationDialect(cwd=process.cwd(),options={}){const driver=String(options.driver||process.env.DB_CONNECTION||"sqlite").toLowerCase(),dir=resolveMigrationDirectory(driver,{cwd}),relativeDir=relativeMigrationDirectory(dir,cwd);if(process.env[DIALECT_OVERRIDE_ENV]!=="1"){const caps=dialectCapabilities(driver),target=caps.wire==="mysql"?"mysql":caps.wire==="postgres"?"postgres":"sqlite";if(driver==="sqlite"||driver==="postgres"||caps.wire==="mysql"){const audit=auditMigrationCorpus({dir,target});if(!audit.empty&&audit.incompatible.length>0)return{valid:!1,error:formatMigrationDialectError(audit,target,relativeDir)}}}if(process.env[DDL_CONSTRAINT_OVERRIDE_ENV]!=="1"){const constraints=auditDdlConstraints({dir,dialect:driver});if(!constraints.empty&&constraints.violations.length>0)return{valid:!1,error:formatDdlConstraintError(constraints,driver,relativeDir)}}return{valid:!0}}async function reportSchemaDrift(){try{const{auditSchemaDrift,formatSchemaDrift}=await import("@stacksjs/database"),drift=await auditSchemaDrift();if(drift.skipped||drift.clean)return;log.warn(await formatSchemaDrift(drift))}catch(err){log.debug(`[migrate] schema drift check skipped: ${err instanceof Error?err.message:String(err)}`)}}async function reportMissingForeignKeys(){try{const{auditForeignKeys}=await import("@stacksjs/database"),result=await auditForeignKeys();if(result.absentTable.length>0)log.debug(`[migrate] ${result.absentTable.length} declared foreign key(s) belong to tables that are not in this database (${[...new Set(result.absentTable.map((fk)=>fk.fromTable))].join(", ")}); not reported as missing.`);if(result.missing.length===0)return;const sample=result.missing.slice(0,5).map((fk)=>` \u2022 ${fk.fromTable}.${fk.fromColumn} \u2192 ${fk.toTable}.${fk.toColumn} (${fk.model})`).join(`
1
+ import{closeSync,existsSync,mkdirSync,openSync,readdirSync,readFileSync,rmSync}from"node:fs";import{relative}from"node:path";import process from"node:process";import{confirm,intro,log,onUnknownSubcommand,outro,text}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{hasTTY,isCI}from"@stacksjs/env";import{appPath,frameworkPath,frameworkRuntimePath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{preflightDatabase}from"../database-preflight";import{DDL_CONSTRAINT_OVERRIDE_ENV,DIALECT_OVERRIDE_ENV,auditDdlConstraints,auditMigrationCorpus,dialectCapabilities,formatDdlConstraintError,formatMigrationDialectError,relativeMigrationDirectory,resolveMigrationDirectory,stripSqlNoise}from"@stacksjs/database";import{resultFailed}from"../result";let _runAction;async function runAction(...args){if(!_runAction)_runAction=(await import("@stacksjs/actions")).runAction;return _runAction(...args)}function acquireMigrationLock(){const lockDir=frameworkRuntimePath(),lockFile=`${lockDir}/migrations.lock`;try{if(!existsSync(lockDir))mkdirSync(lockDir,{recursive:!0});const fd=openSync(lockFile,"wx");try{closeSync(fd)}catch{}return{acquired:!0,release:()=>{try{rmSync(lockFile,{force:!0})}catch{}}}}catch{return{acquired:!1,release:()=>{}}}}async function ensureDatabaseOrExit(){try{const{ensureDatabaseReady}=await import("@stacksjs/database");await ensureDatabaseReady()}catch(error){log.syncError(error instanceof Error?error.message:String(error));process.exit(ExitCode.FatalError)}}function readMigrateMarker(){const file=frameworkRuntimePath("last-migrate-result.json");if(!existsSync(file))return null;try{const raw=readFileSync(file,"utf8"),parsed=JSON.parse(raw),n=typeof parsed.appliedCount==="number"?parsed.appliedCount:null;if(n===null||!Number.isFinite(n))return null;return{appliedCount:Math.max(0,Math.floor(n))}}catch{return null}finally{try{rmSync(file,{force:!0})}catch{}}}function countModelFiles(dir){if(!existsSync(dir))return 0;let count=0;const entries=readdirSync(dir,{withFileTypes:!0});for(const entry of entries)if(entry.isDirectory())count+=countModelFiles(`${dir}/${entry.name}`);else if(entry.name.endsWith(".ts")&&!entry.name.startsWith(".")&&!entry.name.startsWith("index"))count++;return count}function validateModelsExist(){const userModelsPath=appPath("Models"),defaultModelsPath=frameworkPath("defaults/app/Models"),userModelCount=countModelFiles(userModelsPath),defaultModelCount=countModelFiles(defaultModelsPath);if(userModelCount===0&&defaultModelCount===0)return{valid:!1,error:"No models found. Please create models in app/Models or ensure framework defaults exist."};return{valid:!0}}export function validateMigrationDialect(cwd=process.cwd(),options={}){const driver=String(options.driver||process.env.DB_CONNECTION||"sqlite").toLowerCase(),dir=resolveMigrationDirectory(driver,{cwd}),relativeDir=relativeMigrationDirectory(dir,cwd);if(process.env[DIALECT_OVERRIDE_ENV]!=="1"){const caps=dialectCapabilities(driver),target=caps.wire==="mysql"?"mysql":caps.wire==="postgres"?"postgres":"sqlite";if(driver==="sqlite"||driver==="postgres"||caps.wire==="mysql"){const audit=auditMigrationCorpus({dir,target});if(!audit.empty&&audit.incompatible.length>0)return{valid:!1,error:formatMigrationDialectError(audit,target,relativeDir)}}}if(process.env[DDL_CONSTRAINT_OVERRIDE_ENV]!=="1"){const constraints=auditDdlConstraints({dir,dialect:driver});if(!constraints.empty&&constraints.violations.length>0)return{valid:!1,error:formatDdlConstraintError(constraints,driver,relativeDir)}}return{valid:!0}}async function reportSchemaDrift(){try{const{auditSchemaDrift,formatSchemaDrift}=await import("@stacksjs/database"),drift=await auditSchemaDrift();if(drift.skipped||drift.clean)return;log.warn(await formatSchemaDrift(drift))}catch(err){log.debug(`[migrate] schema drift check skipped: ${err instanceof Error?err.message:String(err)}`)}}async function reportMissingForeignKeys(){try{const{auditForeignKeys}=await import("@stacksjs/database"),result=await auditForeignKeys();if(result.absentTable.length>0)log.debug(`[migrate] ${result.absentTable.length} declared foreign key(s) belong to tables that are not in this database (${[...new Set(result.absentTable.map((fk)=>fk.fromTable))].join(", ")}); not reported as missing.`);if(result.missing.length===0)return;const sample=result.missing.slice(0,5).map((fk)=>` \u2022 ${fk.fromTable}.${fk.fromColumn} \u2192 ${fk.toTable}.${fk.toColumn} (${fk.model})`).join(`
2
2
  `),more=result.missing.length>5?`
3
3
  + ${result.missing.length-5} more - run \`./buddy doctor\` for the full list.`:"";log.warn(`${result.missing.length} of ${result.declared.length} declared foreign keys are missing from the live schema:
4
4
  ${sample}${more}
@@ -1 +1 @@
1
- import{$}from"bun";import process from"node:process";import{log}from"@stacksjs/logging";import{projectPath}from"@stacksjs/path";import{onUnknownSubcommand}from"@stacksjs/cli";export function outdated(buddy){const descriptions={outdated:"List all the outdated project dependencies",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("outdated",descriptions.outdated).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async()=>{log.debug("Running `buddy outdated` ...");$.cwd(projectPath());const response=await $`bun outdated`.text();console.log(response)});onUnknownSubcommand(buddy,"outdated")}
1
+ import{$}from"bun";import{log}from"@stacksjs/logging";import{projectPath}from"@stacksjs/path";import{onUnknownSubcommand}from"@stacksjs/cli";export function outdated(buddy){const descriptions={outdated:"List all the outdated project dependencies",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("outdated",descriptions.outdated).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async()=>{log.debug("Running `buddy outdated` ...");$.cwd(projectPath());const response=await $`bun outdated`.text();console.log(response)});onUnknownSubcommand(buddy,"outdated")}
@@ -1 +1 @@
1
- import process from"node:process";import{runAction}from"@stacksjs/actions";import{Action}from"@stacksjs/enums";import{log}from"@stacksjs/logging";import{onUnknownSubcommand}from"@stacksjs/cli";export function prepublish(buddy){const descriptions={command:"Run your prepublish script",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("prepublish",descriptions.command).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy prepublish` ...",options);await runAction(Action.Prepublish,options)});onUnknownSubcommand(buddy,"prepublish")}
1
+ import{runAction}from"@stacksjs/actions";import{Action}from"@stacksjs/enums";import{log}from"@stacksjs/logging";import{onUnknownSubcommand}from"@stacksjs/cli";export function prepublish(buddy){const descriptions={command:"Run your prepublish script",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("prepublish",descriptions.command).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy prepublish` ...",options);await runAction(Action.Prepublish,options)});onUnknownSubcommand(buddy,"prepublish")}
@@ -1 +1 @@
1
- import process from"node:process";import{generateTypes}from"@stacksjs/actions";import{log}from"@stacksjs/logging";import{onUnknownSubcommand}from"@stacksjs/cli";export function types(buddy){const descriptions={generate:"Generate the types of & for your library/libraries",fix:"Fix the generated types of & for your library/libraries (not yet implemented)",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("types:generate",descriptions.generate).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy types:generate` ...",options);await generateTypes(options)});buddy.command("types:fix",descriptions.fix).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async()=>{});onUnknownSubcommand(buddy,"types")}
1
+ import{generateTypes}from"@stacksjs/actions";import{log}from"@stacksjs/logging";import{onUnknownSubcommand}from"@stacksjs/cli";export function types(buddy){const descriptions={generate:"Generate the types of & for your library/libraries",fix:"Fix the generated types of & for your library/libraries (not yet implemented)",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("types:generate",descriptions.generate).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy types:generate` ...",options);await generateTypes(options)});buddy.command("types:fix",descriptions.fix).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async()=>{});onUnknownSubcommand(buddy,"types")}
@@ -1 +1 @@
1
- import{existsSync,readdirSync}from"node:fs";import{copyFile,mkdir,readFile,writeFile}from"node:fs/promises";import{basename,join}from"node:path";import{laravelFilenameToStacks,parseLaravelMigration}from"./migrations";import{parseLaravelModel}from"./models";export const laravelDriver={name:"laravel",async migrate(req){const entries=[];entries.push(...await migrateEnv(req));entries.push(...await migrateMigrations(req));entries.push(...await migrateModels(req));entries.push(...summariseDeferred(req));return entries}};async function migrateEnv(req){const out=[];for(const file of[".env",".env.example"]){const sourcePath=join(req.source,file);if(!existsSync(sourcePath))continue;const targetPath=join(req.target,file);if(!req.dryRun){await mkdir(req.target,{recursive:!0});await copyFile(sourcePath,targetPath)}out.push({source:file,target:file,status:"copied",note:"Laravel-specific keys (BROADCAST_DRIVER, FILESYSTEM_DISK names) may need renaming; Stacks reads APP_*/DB_*/MAIL_* keys as-is."})}return out}async function migrateMigrations(req){const dir=join(req.source,"database","migrations");if(!existsSync(dir))return[];const files=readdirSync(dir).filter((f)=>f.endsWith(".php")).sort(),targetDir=join(req.target,"database","migrations");if(!req.dryRun)await mkdir(targetDir,{recursive:!0});const out=[];for(let i=0;i<files.length;i++){const file=files[i],rel=`database/migrations/${file}`;try{const body=await readFile(join(dir,file),"utf8"),parsed=parseLaravelMigration(body);if(!parsed){out.push({source:rel,target:"",status:"skipped",note:"No `Schema::create(...)` block found (likely an alter / drop / data-only migration)."});continue}const sequence=i+1,outRel=`database/migrations/${laravelFilenameToStacks(file,sequence,parsed.table)}`;if(!req.dryRun)await writeFile(join(req.target,outRel),parsed.sql);const skippedNote=parsed.skipped.length>0?` Skipped ${parsed.skipped.length} line(s) inside the create block - hand-port from the source migration.`:"";out.push({source:rel,target:outRel,status:"translated",note:`CREATE TABLE \`${parsed.table}\` emitted.${skippedNote}`})}catch(err){out.push({source:rel,target:"",status:"failed",note:`Parse error: ${err instanceof Error?err.message:String(err)}`})}}return out}async function migrateModels(req){const dir=join(req.source,"app","Models");if(!existsSync(dir))return[];const files=readdirSync(dir).filter((f)=>f.endsWith(".php")),targetDir=join(req.target,"app","Models");if(!req.dryRun)await mkdir(targetDir,{recursive:!0});const out=[];for(const file of files){const rel=`app/Models/${file}`;try{const body=await readFile(join(dir,file),"utf8"),parsed=parseLaravelModel(body);if(!parsed){out.push({source:rel,target:"",status:"skipped",note:"No class declaration found."});continue}const outRel=`app/Models/${`${parsed.className}.ts`}`;if(!req.dryRun)await writeFile(join(req.target,outRel),parsed.tsSource);const notes=[`Attributes: ${parsed.fillable.length} fillable, ${Object.keys(parsed.casts).length} cast`,parsed.relationships.length>0?`Relationships: ${parsed.relationships.map((r)=>`${r.name}(${r.kind})`).join(", ")}`:null,...parsed.notes].filter(Boolean).join(" | ");out.push({source:rel,target:outRel,status:"translated",note:notes||`Model ${parsed.className} emitted.`})}catch(err){out.push({source:rel,target:"",status:"failed",note:`Parse error: ${err instanceof Error?err.message:String(err)}`})}}return out}const DEFERRED_SURFACES=[{path:"routes",note:"routes/web.php + routes/api.php - port to routes/web.ts + routes/api.ts using the Stacks router DSL."},{path:"app/Http/Controllers",note:"Controllers \u2192 Stacks Actions in app/Actions/. Each controller method maps to one Action file."},{path:"resources/views",note:"Blade templates \u2192 stx components in resources/views/."},{path:"database/seeders",note:"Seeders \u2192 database/seeders/*.ts using Stacks SeederContract."},{path:"app/Console/Commands",note:"Artisan commands \u2192 app/Commands/*.ts in your Stacks project."},{path:"app/Jobs",note:"Queue jobs \u2192 app/Jobs/*.ts; pair with config/queue.ts driver settings."},{path:"composer.json",note:"Dependency map - Laravel packages don't translate 1:1; the Stacks docs cover the closest @stacksjs/* equivalents."}];function summariseDeferred(req){const out=[];for(const surface of DEFERRED_SURFACES){const sourcePath=join(req.source,surface.path);if(!existsSync(sourcePath))continue;out.push({source:surface.path,target:"",status:"skipped",note:surface.note})}return out}
1
+ import{existsSync,readdirSync}from"node:fs";import{copyFile,mkdir,readFile,writeFile}from"node:fs/promises";import{join}from"node:path";import{laravelFilenameToStacks,parseLaravelMigration}from"./migrations";import{parseLaravelModel}from"./models";export const laravelDriver={name:"laravel",async migrate(req){const entries=[];entries.push(...await migrateEnv(req));entries.push(...await migrateMigrations(req));entries.push(...await migrateModels(req));entries.push(...summariseDeferred(req));return entries}};async function migrateEnv(req){const out=[];for(const file of[".env",".env.example"]){const sourcePath=join(req.source,file);if(!existsSync(sourcePath))continue;const targetPath=join(req.target,file);if(!req.dryRun){await mkdir(req.target,{recursive:!0});await copyFile(sourcePath,targetPath)}out.push({source:file,target:file,status:"copied",note:"Laravel-specific keys (BROADCAST_DRIVER, FILESYSTEM_DISK names) may need renaming; Stacks reads APP_*/DB_*/MAIL_* keys as-is."})}return out}async function migrateMigrations(req){const dir=join(req.source,"database","migrations");if(!existsSync(dir))return[];const files=readdirSync(dir).filter((f)=>f.endsWith(".php")).sort(),targetDir=join(req.target,"database","migrations");if(!req.dryRun)await mkdir(targetDir,{recursive:!0});const out=[];for(let i=0;i<files.length;i++){const file=files[i],rel=`database/migrations/${file}`;try{const body=await readFile(join(dir,file),"utf8"),parsed=parseLaravelMigration(body);if(!parsed){out.push({source:rel,target:"",status:"skipped",note:"No `Schema::create(...)` block found (likely an alter / drop / data-only migration)."});continue}const sequence=i+1,outRel=`database/migrations/${laravelFilenameToStacks(file,sequence,parsed.table)}`;if(!req.dryRun)await writeFile(join(req.target,outRel),parsed.sql);const skippedNote=parsed.skipped.length>0?` Skipped ${parsed.skipped.length} line(s) inside the create block - hand-port from the source migration.`:"";out.push({source:rel,target:outRel,status:"translated",note:`CREATE TABLE \`${parsed.table}\` emitted.${skippedNote}`})}catch(err){out.push({source:rel,target:"",status:"failed",note:`Parse error: ${err instanceof Error?err.message:String(err)}`})}}return out}async function migrateModels(req){const dir=join(req.source,"app","Models");if(!existsSync(dir))return[];const files=readdirSync(dir).filter((f)=>f.endsWith(".php")),targetDir=join(req.target,"app","Models");if(!req.dryRun)await mkdir(targetDir,{recursive:!0});const out=[];for(const file of files){const rel=`app/Models/${file}`;try{const body=await readFile(join(dir,file),"utf8"),parsed=parseLaravelModel(body);if(!parsed){out.push({source:rel,target:"",status:"skipped",note:"No class declaration found."});continue}const outRel=`app/Models/${`${parsed.className}.ts`}`;if(!req.dryRun)await writeFile(join(req.target,outRel),parsed.tsSource);const notes=[`Attributes: ${parsed.fillable.length} fillable, ${Object.keys(parsed.casts).length} cast`,parsed.relationships.length>0?`Relationships: ${parsed.relationships.map((r)=>`${r.name}(${r.kind})`).join(", ")}`:null,...parsed.notes].filter(Boolean).join(" | ");out.push({source:rel,target:outRel,status:"translated",note:notes||`Model ${parsed.className} emitted.`})}catch(err){out.push({source:rel,target:"",status:"failed",note:`Parse error: ${err instanceof Error?err.message:String(err)}`})}}return out}const DEFERRED_SURFACES=[{path:"routes",note:"routes/web.php + routes/api.php - port to routes/web.ts + routes/api.ts using the Stacks router DSL."},{path:"app/Http/Controllers",note:"Controllers \u2192 Stacks Actions in app/Actions/. Each controller method maps to one Action file."},{path:"resources/views",note:"Blade templates \u2192 stx components in resources/views/."},{path:"database/seeders",note:"Seeders \u2192 database/seeders/*.ts using Stacks SeederContract."},{path:"app/Console/Commands",note:"Artisan commands \u2192 app/Commands/*.ts in your Stacks project."},{path:"app/Jobs",note:"Queue jobs \u2192 app/Jobs/*.ts; pair with config/queue.ts driver settings."},{path:"composer.json",note:"Dependency map - Laravel packages don't translate 1:1; the Stacks docs cover the closest @stacksjs/* equivalents."}];function summariseDeferred(req){const out=[];for(const surface of DEFERRED_SURFACES){const sourcePath=join(req.source,surface.path);if(!existsSync(sourcePath))continue;out.push({source:surface.path,target:"",status:"skipped",note:surface.note})}return out}
@@ -1 +1 @@
1
- import{existsSync}from"node:fs";import{homedir}from"node:os";import{dirname,join}from"node:path";import process from"node:process";import{installRequestContext,parseCookieHeader}from"@stacksjs/config";import{log}from"@stacksjs/logging";function productionRequestSnapshot(){const{__stxServeContext:snapshot,__stxServeCookies:legacyCookies,__stxServeSearch:legacySearch,__stxServeSite:site}=globalThis;if(!snapshot&&!legacyCookies&&legacySearch===void 0)return;return{...snapshot,cookies:snapshot?.cookies??legacyCookies??{},url:snapshot?.url||snapshot?.search||legacySearch||"",search:snapshot?.search??legacySearch??"",site:snapshot?.site??site??null}}installRequestContext(productionRequestSnapshot);function parseCookies(req){return parseCookieHeader(req.headers.get("cookie"))}export function resolveUserPartialsPath(cwd=process.cwd(),configuredDir){if(configuredDir){const configured=[join(cwd,"resources",configuredDir),join(cwd,configuredDir)].find((candidate)=>existsSync(candidate));if(configured)return configured}const existing=["resources/partials","resources/views/partials","partials","resources/components"].filter((candidate)=>existsSync(join(cwd,candidate)));if(existing.length===0)return;const populated=existing.find((candidate)=>containsTemplates(join(cwd,candidate)));return join(cwd,populated??existing[0])}function containsTemplates(dir){try{return[...new Bun.Glob("**/*.stx").scanSync({cwd:dir,onlyFiles:!0})].length>0}catch{return!1}}export async function loadStxPartialsDir(cwd=process.cwd()){const configPath=join(cwd,"config/stx.ts");if(!existsSync(configPath))return;try{const dir=(await import(configPath)).default?.partialsDir;return typeof dir==="string"&&dir.length>0?dir:void 0}catch{return}}function resolveCsrfMiddlewarePath(){const rel="app/Middleware/Csrf.ts",vendored=join(process.cwd(),"storage/framework/defaults",rel);if(existsSync(vendored))return vendored;try{const pkgJson=Bun.resolveSync("@stacksjs/defaults/package.json",process.cwd());return`${pkgJson.slice(0,pkgJson.lastIndexOf("/"))}/${rel}`}catch{return vendored}}export async function startProductionServer(options){if(options?.port)process.env.PORT=String(options.port);process.env.APP_ENV=process.env.APP_ENV||"production";const port=Number(process.env.PORT)||3000,{config,overridesReady,resolveViewPatterns}=await import("@stacksjs/config");await overridesReady;const{applyViewSecurityHeaders,describeApiProxyRules,describeRedirectRules,injectGlobalAutoImports,resolveApiBase,resolveApiProxyRules,resolveEmbeddableRules,resolveRedirectRules}=await import("@stacksjs/server"),{resolveDefaultsResources}=await import("@stacksjs/actions/dev/defaults-resources"),{stxPageAuthMiddleware}=await import("@stacksjs/auth");await injectGlobalAutoImports();let stxServe;const serveCandidates=[join(homedir(),"Code/Tools/stx/packages/bun-plugin/dist/serve.js"),join(process.cwd(),"pantry/bun-plugin-stx/dist/serve.js")];for(const entry of serveCandidates)try{if(existsSync(entry)){({serve:stxServe}=await import(entry));break}}catch{}if(!stxServe)({serve:stxServe}=await import("bun-plugin-stx/serve"));const stxModule=await resolveVendoredStxModule(),{site:siteConfig,i18n:i18nConfig}=await loadStxSiteConfig(),userViewsPath="resources/views",defaultsResources=resolveDefaultsResources(),defaultViewsPath=join(defaultsResources,"views"),userLayoutsPath=existsSync("resources/views/layouts")?"resources/views/layouts":"resources/layouts",userPartialsPath=resolveUserPartialsPath(process.cwd(),await loadStxPartialsDir()),apiBase=resolveApiBase(config.ports?.api),viewPatterns=resolveViewPatterns(userViewsPath,defaultViewsPath,config?.ui?.defaultViews);for(const name of viewPatterns.missing)log.warn(`ui.defaultViews lists "${name}", which does not exist under ${defaultViewsPath} - ignoring.`);const apiProxyRules=resolveApiProxyRules(config.server?.proxy);if(apiProxyRules.paths.length>0||apiProxyRules.prefixes.length>1)log.info(`API proxy: ${describeApiProxyRules(apiProxyRules)}`);const redirectRules=resolveRedirectRules(config.server?.redirects);if(redirectRules.size>0)log.info(`Redirects: ${describeRedirectRules(redirectRules)}`);const embeddableRules=resolveEmbeddableRules(config.server?.security?.embeddable);if(embeddableRules.paths.length>0||embeddableRules.prefixes.length>0)log.info(`Frameable by other origins: ${[...embeddableRules.paths,...embeddableRules.prefixes].join(" ")}`);log.info(`Starting production server on port ${port}...`);await stxServe({patterns:viewPatterns.patterns,port,autoIncrementPort:!1,reusePort:["production","staging","development"].includes((process.env.APP_ENV||"").toLowerCase()),componentsDir:join(defaultsResources,"components"),layoutsDir:userLayoutsPath,...userPartialsPath&&{partialsDir:userPartialsPath},fallbackLayoutsDir:join(defaultsResources,"layouts"),fallbackPartialsDir:defaultViewsPath,quiet:options?.verbose!==!0,...stxModule&&{stxModule},...i18nConfig&&{i18n:i18nConfig},...siteConfig?.url&&{site:siteConfig},middleware:stxPageAuthMiddleware(),onRequest:async(req)=>{const{maintenanceGate,isApiBoundRequest:isApiBound,proxyToBackend,resolveRedirect}=await import("@stacksjs/server"),gated=await maintenanceGate(req);if(gated)return gated;const url=new URL(req.url),redirected=resolveRedirect(url,redirectRules);if(redirected)return redirected;if(isApiBound(req,url.pathname,apiProxyRules)){if(!apiBase){log.error(`No API target configured for ${url.pathname}. This app shares its host with other deployments, so there is no safe default port to guess - refusing to proxy. Set PORT_API (or API_URL) for this site, and deploy an \`api\` site on its own port.`);return new Response("Bad Gateway",{status:502})}try{return await proxyToBackend(req,apiBase)}catch(error){log.error(`API proxy to ${apiBase} failed: ${error.message}`);return new Response("Bad Gateway",{status:502})}}if(existsSync(join(process.cwd(),"resources/views/blog.stx"))){const{renderBlogFeed}=await import("@stacksjs/actions/blog"),feed=await renderBlogFeed(req);if(feed)return feed}globalThis.__stxServeSearch=url.search;globalThis.__stxServeCookies=parseCookies(req);if(config.analytics?.capturePageviews)import("@stacksjs/analytics").then(({recordPageview})=>recordPageview(req)).catch(()=>{});if(config.sites?.enabled){const sites=await import("@stacksjs/sites"),resolved=await sites.resolveSiteByHost(sites.requestHost(req.headers,sites.sitesOptions()));sites.setCurrentSite(resolved);globalThis.__stxServeSite=sites.toSiteSnapshot(resolved)}else globalThis.__stxServeSite=null;return},onResponse:async(req,response)=>{const secured=applyViewSecurityHeaders(req,response,embeddableRules),method=req.method.toUpperCase();if(method!=="GET"&&method!=="HEAD")return secured;const baseline=secured??response;let current=baseline;if(current.status===404&&config.sites?.enabled)try{const{cmsNotFoundFallback}=await import("@stacksjs/cms"),cmsResponse=await cmsNotFoundFallback(req);if(cmsResponse)current=cmsResponse}catch(error){log.debug(`CMS fallback skipped: ${error.message}`)}try{const{seedCsrfCookieIfMissing}=await import(resolveCsrfMiddlewarePath());return await seedCsrfCookieIfMissing(req,current)??(current===baseline?secured:current)}catch(error){log.debug(`CSRF cookie seeding skipped: ${error.message}`);return current===baseline?secured:current}}});log.success(`Production server listening on http://0.0.0.0:${port}`)}async function resolveVendoredStxModule(){const candidates=[join(homedir(),"Code/Tools/stx/packages/stx/dist/index.js"),join(process.cwd(),"pantry/@stacksjs/stx/dist/index.js")];for(const entry of candidates)try{if(existsSync(entry))return await import(entry)}catch{}try{return await import("@stacksjs/stx")}catch{}return}function fallbackI18nFromSite(site){const locales=site.i18n.locales,defaultLocale=site.i18n.defaultLocale??locales[0];return{locales,defaultLocale,labels:site.i18n.labels??Object.fromEntries(locales.map((c)=>[c,c.toUpperCase()])),translations:{},pickerSelector:site.i18n.pickerSelector??"#lang-picker"}}async function resolveSiteI18n(site){const resolverPaths=[join(homedir(),"Code/Tools/stx/packages/stx/src/site-builder/i18n.ts"),join(homedir(),"Code/Tools/stx/packages/stx/dist/index.js"),join(process.cwd(),"pantry/@stacksjs/stx/dist/index.js")];for(const resolverPath of resolverPaths)try{if(!existsSync(resolverPath))continue;const resolved=await import(resolverPath);if(typeof resolved.resolveI18n!=="function")continue;const i18n=resolved.resolveI18n(site,process.cwd());if(i18n)return i18n}catch{}try{const resolved=await import("@stacksjs/stx");if(typeof resolved.resolveI18n==="function"){const i18n=resolved.resolveI18n(site,process.cwd());if(i18n)return i18n}}catch{}return fallbackI18nFromSite(site)}async function loadStxSiteConfig(){const sitePath=join(process.cwd(),"site.config.ts");if(!existsSync(sitePath))return{};try{const site=(await import(sitePath)).default;if(!site)return{};if(!site.i18n)return{site};const i18n=await resolveSiteI18n(site);return{site,i18n}}catch{}return{}}
1
+ import{existsSync}from"node:fs";import{homedir}from"node:os";import{join}from"node:path";import process from"node:process";import{installRequestContext,parseCookieHeader}from"@stacksjs/config";import{log}from"@stacksjs/logging";function productionRequestSnapshot(){const{__stxServeContext:snapshot,__stxServeCookies:legacyCookies,__stxServeSearch:legacySearch,__stxServeSite:site}=globalThis;if(!snapshot&&!legacyCookies&&legacySearch===void 0)return;return{...snapshot,cookies:snapshot?.cookies??legacyCookies??{},url:snapshot?.url||snapshot?.search||legacySearch||"",search:snapshot?.search??legacySearch??"",site:snapshot?.site??site??null}}installRequestContext(productionRequestSnapshot);function parseCookies(req){return parseCookieHeader(req.headers.get("cookie"))}export function resolveUserPartialsPath(cwd=process.cwd(),configuredDir){if(configuredDir){const configured=[join(cwd,"resources",configuredDir),join(cwd,configuredDir)].find((candidate)=>existsSync(candidate));if(configured)return configured}const existing=["resources/partials","resources/views/partials","partials","resources/components"].filter((candidate)=>existsSync(join(cwd,candidate)));if(existing.length===0)return;const populated=existing.find((candidate)=>containsTemplates(join(cwd,candidate)));return join(cwd,populated??existing[0])}function containsTemplates(dir){try{return[...new Bun.Glob("**/*.stx").scanSync({cwd:dir,onlyFiles:!0})].length>0}catch{return!1}}export async function loadStxPartialsDir(cwd=process.cwd()){const configPath=join(cwd,"config/stx.ts");if(!existsSync(configPath))return;try{const dir=(await import(configPath)).default?.partialsDir;return typeof dir==="string"&&dir.length>0?dir:void 0}catch{return}}function resolveCsrfMiddlewarePath(){const rel="app/Middleware/Csrf.ts",vendored=join(process.cwd(),"storage/framework/defaults",rel);if(existsSync(vendored))return vendored;try{const pkgJson=Bun.resolveSync("@stacksjs/defaults/package.json",process.cwd());return`${pkgJson.slice(0,pkgJson.lastIndexOf("/"))}/${rel}`}catch{return vendored}}export async function startProductionServer(options){if(options?.port)process.env.PORT=String(options.port);process.env.APP_ENV=process.env.APP_ENV||"production";const port=Number(process.env.PORT)||3000,{config,overridesReady,resolveViewPatterns}=await import("@stacksjs/config");await overridesReady;const{applyViewSecurityHeaders,describeApiProxyRules,describeRedirectRules,injectGlobalAutoImports,resolveApiBase,resolveApiProxyRules,resolveEmbeddableRules,resolveRedirectRules}=await import("@stacksjs/server"),{resolveDefaultsResources}=await import("@stacksjs/actions/dev/defaults-resources"),{stxPageAuthMiddleware}=await import("@stacksjs/auth");await injectGlobalAutoImports();let stxServe;const serveCandidates=[join(homedir(),"Code/Tools/stx/packages/bun-plugin/dist/serve.js"),join(process.cwd(),"pantry/bun-plugin-stx/dist/serve.js")];for(const entry of serveCandidates)try{if(existsSync(entry)){({serve:stxServe}=await import(entry));break}}catch{}if(!stxServe)({serve:stxServe}=await import("bun-plugin-stx/serve"));const stxModule=await resolveVendoredStxModule(),{site:siteConfig,i18n:i18nConfig}=await loadStxSiteConfig(),userViewsPath="resources/views",defaultsResources=resolveDefaultsResources(),defaultViewsPath=join(defaultsResources,"views"),userLayoutsPath=existsSync("resources/views/layouts")?"resources/views/layouts":"resources/layouts",userPartialsPath=resolveUserPartialsPath(process.cwd(),await loadStxPartialsDir()),apiBase=resolveApiBase(config.ports?.api),viewPatterns=resolveViewPatterns(userViewsPath,defaultViewsPath,config?.ui?.defaultViews);for(const name of viewPatterns.missing)log.warn(`ui.defaultViews lists "${name}", which does not exist under ${defaultViewsPath} - ignoring.`);const apiProxyRules=resolveApiProxyRules(config.server?.proxy);if(apiProxyRules.paths.length>0||apiProxyRules.prefixes.length>1)log.info(`API proxy: ${describeApiProxyRules(apiProxyRules)}`);const redirectRules=resolveRedirectRules(config.server?.redirects);if(redirectRules.size>0)log.info(`Redirects: ${describeRedirectRules(redirectRules)}`);const embeddableRules=resolveEmbeddableRules(config.server?.security?.embeddable);if(embeddableRules.paths.length>0||embeddableRules.prefixes.length>0)log.info(`Frameable by other origins: ${[...embeddableRules.paths,...embeddableRules.prefixes].join(" ")}`);log.info(`Starting production server on port ${port}...`);await stxServe({patterns:viewPatterns.patterns,port,autoIncrementPort:!1,reusePort:["production","staging","development"].includes((process.env.APP_ENV||"").toLowerCase()),componentsDir:join(defaultsResources,"components"),layoutsDir:userLayoutsPath,...userPartialsPath&&{partialsDir:userPartialsPath},fallbackLayoutsDir:join(defaultsResources,"layouts"),fallbackPartialsDir:defaultViewsPath,quiet:options?.verbose!==!0,...stxModule&&{stxModule},...i18nConfig&&{i18n:i18nConfig},...siteConfig?.url&&{site:siteConfig},middleware:stxPageAuthMiddleware(),onRequest:async(req)=>{const{maintenanceGate,isApiBoundRequest:isApiBound,proxyToBackend,resolveRedirect}=await import("@stacksjs/server"),gated=await maintenanceGate(req);if(gated)return gated;const url=new URL(req.url),redirected=resolveRedirect(url,redirectRules);if(redirected)return redirected;if(isApiBound(req,url.pathname,apiProxyRules)){if(!apiBase){log.error(`No API target configured for ${url.pathname}. This app shares its host with other deployments, so there is no safe default port to guess - refusing to proxy. Set PORT_API (or API_URL) for this site, and deploy an \`api\` site on its own port.`);return new Response("Bad Gateway",{status:502})}try{return await proxyToBackend(req,apiBase)}catch(error){log.error(`API proxy to ${apiBase} failed: ${error.message}`);return new Response("Bad Gateway",{status:502})}}if(existsSync(join(process.cwd(),"resources/views/blog.stx"))){const{renderBlogFeed}=await import("@stacksjs/actions/blog"),feed=await renderBlogFeed(req);if(feed)return feed}globalThis.__stxServeSearch=url.search;globalThis.__stxServeCookies=parseCookies(req);if(config.analytics?.capturePageviews)import("@stacksjs/analytics").then(({recordPageview})=>recordPageview(req)).catch(()=>{});if(config.sites?.enabled){const sites=await import("@stacksjs/sites"),resolved=await sites.resolveSiteByHost(sites.requestHost(req.headers,sites.sitesOptions()));sites.setCurrentSite(resolved);globalThis.__stxServeSite=sites.toSiteSnapshot(resolved)}else globalThis.__stxServeSite=null;return},onResponse:async(req,response)=>{const secured=applyViewSecurityHeaders(req,response,embeddableRules),method=req.method.toUpperCase();if(method!=="GET"&&method!=="HEAD")return secured;const baseline=secured??response;let current=baseline;if(current.status===404&&config.sites?.enabled)try{const{cmsNotFoundFallback}=await import("@stacksjs/cms"),cmsResponse=await cmsNotFoundFallback(req);if(cmsResponse)current=cmsResponse}catch(error){log.debug(`CMS fallback skipped: ${error.message}`)}try{const{seedCsrfCookieIfMissing}=await import(resolveCsrfMiddlewarePath());return await seedCsrfCookieIfMissing(req,current)??(current===baseline?secured:current)}catch(error){log.debug(`CSRF cookie seeding skipped: ${error.message}`);return current===baseline?secured:current}}});log.success(`Production server listening on http://0.0.0.0:${port}`)}async function resolveVendoredStxModule(){const candidates=[join(homedir(),"Code/Tools/stx/packages/stx/dist/index.js"),join(process.cwd(),"pantry/@stacksjs/stx/dist/index.js")];for(const entry of candidates)try{if(existsSync(entry))return await import(entry)}catch{}try{return await import("@stacksjs/stx")}catch{}return}function fallbackI18nFromSite(site){const locales=site.i18n.locales,defaultLocale=site.i18n.defaultLocale??locales[0];return{locales,defaultLocale,labels:site.i18n.labels??Object.fromEntries(locales.map((c)=>[c,c.toUpperCase()])),translations:{},pickerSelector:site.i18n.pickerSelector??"#lang-picker"}}async function resolveSiteI18n(site){const resolverPaths=[join(homedir(),"Code/Tools/stx/packages/stx/src/site-builder/i18n.ts"),join(homedir(),"Code/Tools/stx/packages/stx/dist/index.js"),join(process.cwd(),"pantry/@stacksjs/stx/dist/index.js")];for(const resolverPath of resolverPaths)try{if(!existsSync(resolverPath))continue;const resolved=await import(resolverPath);if(typeof resolved.resolveI18n!=="function")continue;const i18n=resolved.resolveI18n(site,process.cwd());if(i18n)return i18n}catch{}try{const resolved=await import("@stacksjs/stx");if(typeof resolved.resolveI18n==="function"){const i18n=resolved.resolveI18n(site,process.cwd());if(i18n)return i18n}}catch{}return fallbackI18nFromSite(site)}async function loadStxSiteConfig(){const sitePath=join(process.cwd(),"site.config.ts");if(!existsSync(sitePath))return{};try{const site=(await import(sitePath)).default;if(!site)return{};if(!site.i18n)return{site};const i18n=await resolveSiteI18n(site);return{site,i18n}}catch{}return{}}
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.57",
5
+ "version": "0.72.61",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -95,62 +95,62 @@
95
95
  "prepublishOnly": "bun run build"
96
96
  },
97
97
  "dependencies": {
98
- "@stacksjs/actions": "^0.72.57",
99
- "@stacksjs/ai": "^0.72.57",
100
- "@stacksjs/alias": "^0.72.57",
101
- "@stacksjs/analytics": "^0.72.57",
102
- "@stacksjs/arrays": "^0.72.57",
103
- "@stacksjs/auth": "^0.72.57",
104
- "@stacksjs/browser-extension": "^0.72.57",
105
- "@stacksjs/build": "^0.72.57",
106
- "@stacksjs/cache": "^0.72.57",
107
- "@stacksjs/chat": "^0.72.57",
98
+ "@stacksjs/actions": "^0.72.61",
99
+ "@stacksjs/ai": "^0.72.61",
100
+ "@stacksjs/alias": "^0.72.61",
101
+ "@stacksjs/analytics": "^0.72.61",
102
+ "@stacksjs/arrays": "^0.72.61",
103
+ "@stacksjs/auth": "^0.72.61",
104
+ "@stacksjs/browser-extension": "^0.72.61",
105
+ "@stacksjs/build": "^0.72.61",
106
+ "@stacksjs/cache": "^0.72.61",
107
+ "@stacksjs/chat": "^0.72.61",
108
108
  "@stacksjs/clapp": "^0.2.12",
109
- "@stacksjs/cli": "^0.72.57",
110
- "@stacksjs/cloud": "^0.72.57",
111
- "@stacksjs/cms": "^0.72.57",
112
- "@stacksjs/collections": "^0.72.57",
113
- "@stacksjs/config": "^0.72.57",
114
- "@stacksjs/database": "^0.72.57",
115
- "@stacksjs/desktop-build": "^0.72.57",
116
- "@stacksjs/dns": "^0.72.57",
109
+ "@stacksjs/cli": "^0.72.61",
110
+ "@stacksjs/cloud": "^0.72.61",
111
+ "@stacksjs/cms": "^0.72.61",
112
+ "@stacksjs/collections": "^0.72.61",
113
+ "@stacksjs/config": "^0.72.61",
114
+ "@stacksjs/database": "^0.72.61",
115
+ "@stacksjs/desktop-build": "^0.72.61",
116
+ "@stacksjs/dns": "^0.72.61",
117
117
  "@stacksjs/dnsx": "^0.2.3",
118
- "@stacksjs/email": "^0.72.57",
119
- "@stacksjs/enums": "^0.72.57",
120
- "@stacksjs/env": "^0.72.57",
121
- "@stacksjs/error-handling": "^0.72.57",
122
- "@stacksjs/events": "^0.72.57",
123
- "@stacksjs/git": "^0.72.57",
118
+ "@stacksjs/email": "^0.72.61",
119
+ "@stacksjs/enums": "^0.72.61",
120
+ "@stacksjs/env": "^0.72.61",
121
+ "@stacksjs/error-handling": "^0.72.61",
122
+ "@stacksjs/events": "^0.72.61",
123
+ "@stacksjs/git": "^0.72.61",
124
124
  "@stacksjs/gitit": "^0.2.5",
125
- "@stacksjs/health": "^0.72.57",
125
+ "@stacksjs/health": "^0.72.61",
126
126
  "@stacksjs/httx": "^0.1.10",
127
- "@stacksjs/image": "^0.72.57",
128
- "@stacksjs/lint": "^0.72.57",
129
- "@stacksjs/logging": "^0.72.57",
130
- "@stacksjs/notifications": "^0.72.57",
131
- "@stacksjs/objects": "^0.72.57",
132
- "@stacksjs/orm": "^0.72.57",
133
- "@stacksjs/path": "^0.72.57",
134
- "@stacksjs/payments": "^0.72.57",
135
- "@stacksjs/realtime": "^0.72.57",
136
- "@stacksjs/router": "^0.72.57",
127
+ "@stacksjs/image": "^0.72.61",
128
+ "@stacksjs/lint": "^0.72.61",
129
+ "@stacksjs/logging": "^0.72.61",
130
+ "@stacksjs/notifications": "^0.72.61",
131
+ "@stacksjs/objects": "^0.72.61",
132
+ "@stacksjs/orm": "^0.72.61",
133
+ "@stacksjs/path": "^0.72.61",
134
+ "@stacksjs/payments": "^0.72.61",
135
+ "@stacksjs/realtime": "^0.72.61",
136
+ "@stacksjs/router": "^0.72.61",
137
137
  "@stacksjs/rpx": "^0.11.42",
138
- "@stacksjs/scheduler": "^0.72.57",
139
- "@stacksjs/search-engine": "^0.72.57",
140
- "@stacksjs/security": "^0.72.57",
141
- "@stacksjs/server": "^0.72.57",
142
- "@stacksjs/sites": "^0.72.57",
143
- "@stacksjs/skills": "^0.72.57",
144
- "@stacksjs/storage": "^0.72.57",
145
- "@stacksjs/strings": "^0.72.57",
146
- "@stacksjs/testing": "^0.72.57",
147
- "@stacksjs/tinker": "^0.72.57",
138
+ "@stacksjs/scheduler": "^0.72.61",
139
+ "@stacksjs/search-engine": "^0.72.61",
140
+ "@stacksjs/security": "^0.72.61",
141
+ "@stacksjs/server": "^0.72.61",
142
+ "@stacksjs/sites": "^0.72.61",
143
+ "@stacksjs/skills": "^0.72.61",
144
+ "@stacksjs/storage": "^0.72.61",
145
+ "@stacksjs/strings": "^0.72.61",
146
+ "@stacksjs/testing": "^0.72.61",
147
+ "@stacksjs/tinker": "^0.72.61",
148
148
  "@stacksjs/ts-cloud": "^0.11.4",
149
- "@stacksjs/tunnel": "^0.72.57",
150
- "@stacksjs/types": "^0.72.57",
151
- "@stacksjs/ui": "^0.72.57",
152
- "@stacksjs/utils": "^0.72.57",
153
- "@stacksjs/validation": "^0.72.57",
149
+ "@stacksjs/tunnel": "^0.72.61",
150
+ "@stacksjs/types": "^0.72.61",
151
+ "@stacksjs/ui": "^0.72.61",
152
+ "@stacksjs/utils": "^0.72.61",
153
+ "@stacksjs/validation": "^0.72.61",
154
154
  "ajv": "^8.20.0",
155
155
  "ajv-formats": "^3.0.1",
156
156
  "ts-pantry": "^0.11.35"