@stacksjs/buddy 0.74.26 → 0.74.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/commands/add.js +1 -1
- package/dist/commands/build.js +1 -1
- package/dist/commands/cloud.d.ts +91 -0
- package/dist/commands/cloud.js +7 -3
- package/dist/commands/deploy.d.ts +10 -0
- package/dist/commands/deploy.js +26 -9
- package/dist/commands/email.js +1 -1
- package/dist/commands/generate.d.ts +13 -1
- package/dist/commands/generate.js +1 -1
- package/dist/commands/link.js +1 -1
- package/dist/commands/lint.js +1 -1
- package/dist/commands/mail.d.ts +8 -0
- package/dist/commands/mail.js +2 -2
- package/dist/commands/make.js +1 -1
- package/dist/commands/migrate.js +3 -3
- package/dist/commands/seed.js +1 -1
- package/dist/commands/share.js +1 -1
- package/dist/commands/stacks.js +1 -1
- package/dist/commands/tinker.js +1 -1
- package/dist/commands/types.js +1 -1
- package/dist/lazy-commands.js +1 -1
- package/dist/production-server.js +1 -1
- package/dist/unvendor-rewrite.d.ts +0 -8
- package/dist/unvendor-rewrite.js +1 -1
- package/package.json +55 -55
package/dist/commands/mail.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var {require}=import.meta;import{log,runCommand}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{getErrorMessage}from"@stacksjs/utils";import{CLI}from"@stacksjs/clapp";import{createHash,randomBytes,timingSafeEqual}from"crypto";import{readFileSync,existsSync}from"node:fs";import{execSync,spawnSync}from"node:child_process";export function resolveDirectMailHost(env=process.env){let configured=env.MAIL_SERVER_HOST||(env.HCLOUD_TOKEN||env.HETZNER_API_TOKEN?env.MAIL_HOST:void 0);if(configured&&["127.0.0.1","localhost","mailpit"].includes(configured))configured=env.MAIL_DOMAIN?`mail.${env.MAIL_DOMAIN}`:void 0;if(!configured||configured==="127.0.0.1"||configured==="localhost")return;return configured.replace(/^https?:\/\//,"").split("/")[0]?.split(":")[0]}export function sanitizeLineCount(value){const count=Number.parseInt(value||"50",10);return String(Number.isFinite(count)?Math.min(5000,Math.max(1,count)):50)}function shellQuote(value){return`'${value.replace(/'/g,"'\\''")}'`}async function resolveOperationalMailHost(){if(process.env.MAIL_SERVER_HOST)return resolveDirectMailHost();const token=process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN;if(!token)return;const appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");try{const ip=(await(await fetch(`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent(`${appName}-production-app`)}`,{headers:{Authorization:`Bearer ${token}`}})).json()).servers?.[0]?.public_net?.ipv4?.ip;if(ip)return ip}catch{}const configured=resolveDirectMailHost();if(configured)return configured;const{email}=await import("@stacksjs/config");return email?.domain?`mail.${email.domain}`:void 0}const mailStorageRemote="/usr/local/sbin/mail-storage-external";function loadMailStorageAws(options){const profile=options.profile||process.env.AWS_PROFILE||"default",region=options.region||process.env.AWS_REGION||"us-east-1";process.env.AWS_PROFILE=profile;loadEnvFiles();if(existsSync(".env.production"))try{const{loadEnv}=require("@stacksjs/env");loadEnv({path:".env.production",env:"production",keysFile:".env.keys",overload:!0,quiet:!0})}catch{}const credentialsPath=`${process.env.HOME||process.env.USERPROFILE||""}/.aws/credentials`;if((!process.env.AWS_ACCESS_KEY_ID||!process.env.AWS_SECRET_ACCESS_KEY)&&existsSync(credentialsPath)){const values={};let inProfile=!1;for(const line of readFileSync(credentialsPath,"utf8").split(`
|
|
1
|
+
var {require}=import.meta;import{log,runCommand}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{getErrorMessage}from"@stacksjs/utils";import{CLI}from"@stacksjs/clapp";import{createHash,randomBytes,timingSafeEqual}from"crypto";import{readFileSync,existsSync}from"node:fs";import{execSync,spawnSync}from"node:child_process";export function resolveDirectMailHost(env=process.env){let configured=env.MAIL_SERVER_HOST||(env.HCLOUD_TOKEN||env.HETZNER_API_TOKEN?env.MAIL_HOST:void 0);if(configured&&["127.0.0.1","localhost","mailpit"].includes(configured))configured=env.MAIL_DOMAIN?`mail.${env.MAIL_DOMAIN}`:void 0;if(!configured||configured==="127.0.0.1"||configured==="localhost")return;return configured.replace(/^https?:\/\//,"").split("/")[0]?.split(":")[0]}export function sanitizeLineCount(value){const count=Number.parseInt(value||"50",10);return String(Number.isFinite(count)?Math.min(5000,Math.max(1,count)):50)}function shellQuote(value){return`'${value.replace(/'/g,"'\\''")}'`}async function resolveOperationalMailHost(){if(process.env.MAIL_SERVER_HOST)return resolveDirectMailHost();const token=process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN;if(!token)return;const appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");try{const ip=(await(await fetch(`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent(`${appName}-production-app`)}`,{headers:{Authorization:`Bearer ${token}`}})).json()).servers?.[0]?.public_net?.ipv4?.ip;if(ip)return ip}catch{}const configured=resolveDirectMailHost();if(configured)return configured;const{email}=await import("@stacksjs/config");return email?.domain?`mail.${email.domain}`:void 0}const mailStorageRemote="/usr/local/sbin/mail-storage-external",mailStorageCredential="/etc/credstore.encrypted/mail-storage-key.cred",mailStorageMachineUnit="/etc/systemd/system/mail-storage.service.machine-bound-backup";export function machineBindMailStorageCommand(){return["set -eu",`test -f ${mailStorageMachineUnit}`,"command -v systemd-creds >/dev/null",`install -d -m 0700 ${mailStorageCredential.slice(0,mailStorageCredential.lastIndexOf("/"))}`,`credential_tmp=$(mktemp ${mailStorageCredential}.XXXXXX)`,'cleanup() { rm -f "$credential_tmp"; }',"trap cleanup EXIT HUP INT TERM",'chmod 0600 "$credential_tmp"','systemd-creds encrypt --name=mail-storage-key - "$credential_tmp" >/dev/null','test "$(systemd-creds decrypt --name=mail-storage-key "$credential_tmp" - | wc -c)" -ge 32',`mv "$credential_tmp" ${mailStorageCredential}`,"trap - EXIT HUP INT TERM",`${mailStorageRemote} rollback-machine`,"systemctl is-enabled --quiet mail-storage.service","systemctl is-active --quiet mail-storage.service","mountpoint -q /var/lib/mail-storage","systemctl is-active --quiet mail.service"].join("; ")}function loadMailStorageAws(options){const profile=options.profile||process.env.AWS_PROFILE||"default",region=options.region||process.env.AWS_REGION||"us-east-1";process.env.AWS_PROFILE=profile;loadEnvFiles();if(existsSync(".env.production"))try{const{loadEnv}=require("@stacksjs/env");loadEnv({path:".env.production",env:"production",keysFile:".env.keys",overload:!0,quiet:!0})}catch{}const credentialsPath=`${process.env.HOME||process.env.USERPROFILE||""}/.aws/credentials`;if((!process.env.AWS_ACCESS_KEY_ID||!process.env.AWS_SECRET_ACCESS_KEY)&&existsSync(credentialsPath)){const values={};let inProfile=!1;for(const line of readFileSync(credentialsPath,"utf8").split(`
|
|
2
2
|
`)){const trimmed=line.trim();if(trimmed.startsWith("[")){inProfile=trimmed===`[${profile}]`;continue}if(!inProfile)continue;const separator=trimmed.indexOf("=");if(separator>0)values[trimmed.slice(0,separator).trim()]=trimmed.slice(separator+1).trim()}if(values.aws_access_key_id&&values.aws_secret_access_key){process.env.AWS_ACCESS_KEY_ID=values.aws_access_key_id;process.env.AWS_SECRET_ACCESS_KEY=values.aws_secret_access_key;if(values.aws_session_token)process.env.AWS_SESSION_TOKEN=values.aws_session_token;else delete process.env.AWS_SESSION_TOKEN}}return{profile,region}}function mailStorageSecretId(options){if(options.secret)return options.secret;if(process.env.MAIL_STORAGE_SECRET_ID)return process.env.MAIL_STORAGE_SECRET_ID;const app=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),environment=(process.env.APP_ENV||"production").toLowerCase().replace(/[^a-z0-9-]/g,"-");return`${app}/${environment}/mail-storage-key`}async function mailStorageHost(options){const host=options.host||await resolveOperationalMailHost();if(!host)throw Error("Could not resolve the mail host. Pass --host <hostname-or-ip>.");if(!/^[a-zA-Z0-9._:-]+$/.test(host))throw Error(`Unsafe mail host: ${host}`);return host}function runMailStorageSsh(host,command,input){const result=spawnSync("ssh",["-o","BatchMode=yes","-o","ConnectTimeout=15",`root@${host}`,command],{input,maxBuffer:33554432});if(result.status!==0){const stderr=Buffer.from(result.stderr||[]).toString("utf8").trim();throw Error(stderr||`SSH command failed with status ${result.status}`)}return Buffer.from(result.stdout||[])}function readMailRecoveryKey(){const result=spawnSync("/usr/bin/security",["find-generic-password","-a","mail-storage-recovery","-s","com.stacks.production.mail.luks","-w"],{maxBuffer:1048576,timeout:30000});if(result.status!==0)throw Error("macOS Keychain recovery-key read failed");const raw=Buffer.from(result.stdout||[]),text=raw.toString("ascii").trim(),key=/^[0-9a-f]{128}$/i.test(text)?Buffer.from(text,"hex"):raw.length===65&&raw[64]===10?Buffer.from(raw.subarray(0,64)):Buffer.from(raw);raw.fill(0);if(key.length!==64)throw Error("macOS Keychain returned an invalid recovery key");return key}async function waitForHetznerAction(token,actionId){const deadline=Date.now()+120000;while(Date.now()<deadline){const response=await fetch(`https://api.hetzner.cloud/v1/actions/${actionId}`,{headers:{Authorization:`Bearer ${token}`}}),data=await response.json();if(!response.ok)throw Error(data?.error?.message||`Hetzner action ${actionId} lookup failed`);if(data.action?.status==="success")return;if(data.action?.status==="error")throw Error(data.action?.error?.message||`Hetzner action ${actionId} failed`);await new Promise((resolve)=>setTimeout(resolve,1000))}throw Error(`Hetzner action ${actionId} timed out`)}async function ensureMailStorageVolume(options){loadEnvFiles();const token=process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN;if(!token)throw Error("HCLOUD_TOKEN or HETZNER_API_TOKEN is required.");const headers={Authorization:`Bearer ${token}`,"Content-Type":"application/json"},host=await mailStorageHost(options),server=(await(await fetch(`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent("stacks-production-app")}`,{headers})).json()).servers?.[0];if(!server)throw Error("Hetzner server stacks-production-app was not found.");const app=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),name=`${app}-production-mail`,requestedSize=Number.parseInt(options.size||"20",10);if(!Number.isFinite(requestedSize)||requestedSize<10||requestedSize>1024)throw Error("Mail volume size must be between 10 and 1024 GiB.");let volume=(await(await fetch(`https://api.hetzner.cloud/v1/volumes?name=${encodeURIComponent(name)}`,{headers})).json()).volumes?.[0];if(!volume){const createResponse=await fetch("https://api.hetzner.cloud/v1/volumes",{method:"POST",headers,body:JSON.stringify({name,size:requestedSize,server:server.id,automount:!1,labels:{application:app,environment:"production",purpose:"mail-storage"}})}),createData=await createResponse.json();if(!createResponse.ok)throw Error(createData?.error?.message||"Hetzner volume creation failed.");volume=createData.volume;if(createData.action?.id)await waitForHetznerAction(token,createData.action.id)}else if(!volume.server){const attachResponse=await fetch(`https://api.hetzner.cloud/v1/volumes/${volume.id}/actions/attach`,{method:"POST",headers,body:JSON.stringify({server:server.id,automount:!1})}),attachData=await attachResponse.json();if(!attachResponse.ok)throw Error(attachData?.error?.message||"Hetzner volume attach failed.");await waitForHetznerAction(token,attachData.action.id)}else if(volume.server!==server.id)throw Error(`Hetzner volume ${name} is attached to a different server.`);volume=(await(await fetch(`https://api.hetzner.cloud/v1/volumes/${volume.id}`,{headers})).json()).volume||volume;if(!volume.linux_device)throw Error(`Hetzner volume ${name} has no Linux device path yet.`);return{device:volume.linux_device,host,id:volume.id,name,size:volume.size}}async function ensureMailKmsKey(options){const{region}=loadMailStorageAws(options),{AWSClient}=await import("@stacksjs/cloud"),{S3Client}=await import("@stacksjs/cloud"),{SecretsManagerClient}=await import("@stacksjs/cloud"),client=new AWSClient,request=async(target,body)=>client.request({service:"kms",region,method:"POST",path:"/",headers:{"Content-Type":"application/x-amz-json-1.1","X-Amz-Target":`TrentService.${target}`},body:JSON.stringify(body)}),alias="alias/stacks-production-mail";let keyId=(await request("ListAliases",{Limit:100})).Aliases?.find((item)=>item.AliasName===alias)?.TargetKeyId;if(!keyId){keyId=(await request("CreateKey",{Description:"Customer-managed key for production mail secrets and backups",KeyUsage:"ENCRYPT_DECRYPT",Origin:"AWS_KMS",Tags:[{TagKey:"Application",TagValue:"stacks"},{TagKey:"Environment",TagValue:"production"},{TagKey:"Purpose",TagValue:"mail-storage"}]})).KeyMetadata?.KeyId;if(!keyId)throw Error("AWS KMS did not return a key ID.");await request("CreateAlias",{AliasName:alias,TargetKeyId:keyId});await request("EnableKeyRotation",{KeyId:keyId,RotationPeriodInDays:90})}const arn=(await request("DescribeKey",{KeyId:keyId})).KeyMetadata?.Arn;if(!arn)throw Error("AWS KMS did not return a key ARN.");const secrets=new SecretsManagerClient(region);for(const secretId of["stacks/production/mail-storage-key","stacks/production/mail-restic"])try{await secrets.updateSecret({SecretId:secretId,KmsKeyId:arn})}catch(error){if(error?.code!=="ResourceNotFoundException")throw Error(`Could not re-encrypt secret ${secretId}: ${getErrorMessage(error)}`)}const s3=new S3Client(region);for(const bucket of["stacks-production-s3-backups","stacks-production-s3-email","stacks-production-email"])try{await s3.putBucketEncryption(bucket,"aws:kms",arn)}catch(error){throw Error(`Could not apply KMS encryption to ${bucket}: ${getErrorMessage(error)}`)}return{alias,arn}}async function configureMailRestic(options){const{region}=loadMailStorageAws(options),host=await mailStorageHost(options),bucket=options.bucket||"stacks-production-s3-backups";if(!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket))throw Error(`Unsafe S3 bucket name: ${bucket}`);const kms=await ensureMailKmsKey(options),{AWSClient}=await import("@stacksjs/cloud"),{SecretsManagerClient}=await import("@stacksjs/cloud"),iam=new AWSClient,iamRequest=async(action,params={})=>iam.request({service:"iam",region:"us-east-1",method:"POST",path:"/",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({Action:action,Version:"2010-05-08",...params}).toString()}),userName="stacks-production-mail-backup";try{await iamRequest("GetUser",{UserName:userName})}catch(error){if(error?.code!=="NoSuchEntity")throw error;await iamRequest("CreateUser",{UserName:userName,"Tags.member.1.Key":"Application","Tags.member.1.Value":"stacks","Tags.member.2.Key":"Environment","Tags.member.2.Value":"production","Tags.member.3.Key":"Purpose","Tags.member.3.Value":"mail-backup"})}await iamRequest("PutUserPolicy",{UserName:userName,PolicyName:"mail-restic-backup",PolicyDocument:JSON.stringify({Version:"2012-10-17",Statement:[{Effect:"Allow",Action:["s3:ListBucket","s3:GetBucketLocation"],Resource:`arn:aws:s3:::${bucket}`,Condition:{StringLike:{"s3:prefix":["restic-mail","restic-mail/*"]}}},{Effect:"Allow",Action:["s3:GetObject","s3:PutObject","s3:DeleteObject","s3:AbortMultipartUpload","s3:ListMultipartUploadParts"],Resource:`arn:aws:s3:::${bucket}/restic-mail/*`},{Effect:"Allow",Action:["kms:Decrypt","kms:Encrypt","kms:GenerateDataKey","kms:DescribeKey"],Resource:kms.arn}]})});const listedMembers=(await iamRequest("ListAccessKeys",{UserName:userName})).ListAccessKeysResult?.AccessKeyMetadata?.member,oldKeys=listedMembers?Array.isArray(listedMembers)?listedMembers:[listedMembers]:[];if(oldKeys.length>0&&!options.force)throw Error(`IAM user ${userName} already has a key. Pass --force to rotate it.`);if(oldKeys.length>=2)for(const old of oldKeys)await iamRequest("DeleteAccessKey",{UserName:userName,AccessKeyId:old.AccessKeyId});const access=(await iamRequest("CreateAccessKey",{UserName:userName})).CreateAccessKeyResult?.AccessKey;if(!access?.AccessKeyId||!access?.SecretAccessKey)throw Error("AWS IAM did not return the Restic access key.");const password=randomBytes(48).toString("base64url"),repository=`s3:s3.amazonaws.com/${bucket}/restic-mail`,secretId="stacks/production/mail-restic",secretPayload=JSON.stringify({version:1,repository,password,accessKeyId:access.AccessKeyId,secretAccessKey:access.SecretAccessKey,kmsKeyArn:kms.arn,createdAt:new Date().toISOString()}),secrets=new SecretsManagerClient(region);try{await secrets.createSecret({Name:secretId,Description:"Restic credentials for production mail backups",KmsKeyId:kms.arn,SecretString:secretPayload,Tags:[{Key:"Application",Value:"stacks"},{Key:"Environment",Value:"production"},{Key:"Purpose",Value:"mail-backup"}]})}catch(error){if(error?.code!=="ResourceExistsException")throw error;await secrets.updateSecret({SecretId:secretId,KmsKeyId:kms.arn,SecretString:secretPayload})}const configDir="/var/lib/mail-storage/data/restic",passwordPath=`${configDir}/password`,envPayload=[`RESTIC_REPOSITORY=${repository}`,`RESTIC_PASSWORD_FILE=${passwordPath}`,`AWS_ACCESS_KEY_ID=${access.AccessKeyId}`,`AWS_SECRET_ACCESS_KEY=${access.SecretAccessKey}`,`AWS_DEFAULT_REGION=${region}`,""].join(`
|
|
3
3
|
`);runMailStorageSsh(host,`install -d -m 0700 ${configDir}; umask 077; dd of=${configDir}/restic.env status=none`,Buffer.from(envPayload));runMailStorageSsh(host,`umask 077; dd of=${passwordPath} status=none`,Buffer.from(`${password}
|
|
4
4
|
`));runMailStorageSsh(host,`set -a; . ${configDir}/restic.env; set +a; restic snapshots >/dev/null 2>&1 || restic init`);runMailStorageSsh(host,"systemctl enable --now mail-restic-backup.timer mail-storage-health.timer");runMailStorageSsh(host,"systemctl start mail-restic-backup.service");runMailStorageSsh(host,"/usr/local/sbin/mail-restic-restore-check");for(const old of oldKeys)if(old.AccessKeyId!==access.AccessKeyId)await iamRequest("DeleteAccessKey",{UserName:userName,AccessKeyId:old.AccessKeyId});return{repository,secretId}}function decodeMailStorageSecret(secretString){if(!secretString)throw Error("The mail storage secret has no SecretString value.");let parsed;try{parsed=JSON.parse(secretString)}catch{throw Error("The mail storage secret is not valid JSON.")}if(parsed.version!==1||parsed.encoding!=="base64"||typeof parsed.key!=="string")throw Error("The mail storage secret uses an unsupported format.");const key=Buffer.from(parsed.key,"base64");if(key.length<32)throw Error("The mail storage key is unexpectedly short.");return key}async function getMailStorageSecret(options){const{profile,region}=loadMailStorageAws(options),secretId=mailStorageSecretId(options),{SecretsManagerClient}=await import("@stacksjs/cloud"),value=await new SecretsManagerClient(region,profile).getSecretValue({SecretId:secretId});return{key:decodeMailStorageSecret(value.SecretString),secretId}}async function escrowMailStorageKey(options){const{profile,region}=loadMailStorageAws(options),host=await mailStorageHost(options),secretId=mailStorageSecretId(options),{SecretsManagerClient}=await import("@stacksjs/cloud"),secrets=new SecretsManagerClient(region,profile),key=runMailStorageSsh(host,"systemd-creds decrypt --name=mail-storage-key /etc/credstore.encrypted/mail-storage-key.cred -");if(key.length<32)throw Error("The machine-bound mail storage key is unexpectedly short.");let existing;try{const value=await secrets.getSecretValue({SecretId:secretId});existing=decodeMailStorageSecret(value.SecretString)}catch(error){if(error?.code!=="ResourceNotFoundException")throw error}if(existing&&!timingSafeEqual(existing,key)){existing.fill(0);if(!options.force){key.fill(0);throw Error(`Secret ${secretId} already contains a different key. Pass --force to replace it.`)}}existing?.fill(0);const payload={version:1,encoding:"base64",key:key.toString("base64"),image:"/var/lib/mail-storage.luks",mapper:"mail-storage",createdAt:new Date().toISOString()};if(existing)await secrets.putSecretValue({SecretId:secretId,SecretString:JSON.stringify(payload)});else try{await secrets.createSecret({Name:secretId,Description:`Externally escrowed LUKS2 key for ${host}`,SecretString:JSON.stringify(payload),Tags:[{Key:"Application",Value:process.env.APP_NAME||"stacks"},{Key:"Environment",Value:process.env.APP_ENV||"production"},{Key:"Purpose",Value:"mail-storage"}]})}catch(error){if(error?.code!=="ResourceExistsException")throw error;await secrets.putSecretValue({SecretId:secretId,SecretString:JSON.stringify(payload)})}const verify=await secrets.getSecretValue({SecretId:secretId}),verifiedKey=decodeMailStorageSecret(verify.SecretString),matches=verifiedKey.length===key.length&&timingSafeEqual(verifiedKey,key);verifiedKey.fill(0);key.fill(0);if(!matches)throw Error(`Secret ${secretId} did not verify after writing.`);return{secretId,host}}function addMailStorageOptions(command){return command.option("--host <host>","Mail server hostname or IP").option("--secret <id>","AWS Secrets Manager secret ID").option("--region <region>","AWS region",{default:"us-east-1"}).option("--profile <profile>","AWS credential profile",{default:"default"})}export function mailCommands(buddy){buddy.command("mail:provision","Provision this app's mail from config/email.ts onto the shared mail server (domain + DKIM + mailboxes + MX/SPF/DKIM/DMARC DNS). Reusable + idempotent; the same reconcile buddy deploy runs.").option("--ip <ip>","Mail server IP (defaults to the A record of config.email.domain)").action(async(options)=>{const environment=options.env||process.env.APP_ENV||"production";process.env.APP_ENV=environment;const envFile=`.env.${environment}`;if(existsSync(envFile))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:environment,keysFile:".env.keys",overload:!0,quiet:!0})}catch(err){log.warn(`Could not load ${envFile}: ${getErrorMessage(err)}`)}const{email:emailConfig}=await import("@stacksjs/config"),domain=emailConfig?.domain;if(!domain){await log.error("config/email.ts has no `domain` - set it (e.g. domain: 'bughq.org') and add `mailboxes`.");process.exit(ExitCode.FatalError)}let ip=options.ip;if(!ip)try{ip=(await(await import("node:dns")).promises.resolve4(domain))[0]}catch{await log.error(`Could not resolve an IP for ${domain}. Deploy the site first, or pass --ip <mail-server-ip>.`);process.exit(ExitCode.FatalError)}const{provisionMailTenant,reconcileMailDns}=await import("./deploy");log.info(`Provisioning mail for ${domain} on ${ip}...`);const res=await provisionMailTenant(ip,log);if(res)await reconcileMailDns(res,ip,log);log.success(`Mail provisioned for ${domain}. Add MAIL_PASSWORD_<LOCALPART> env vars to pin mailbox passwords.`);await log.flush();process.exit(ExitCode.Success)});addMailStorageOptions(buddy.command("mail:storage:status","Show encrypted mail storage and external-key status")).action(async(options)=>{loadEnvFiles();try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,`${mailStorageRemote} status`).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to read mail storage status: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:kms:ensure","Create and apply the customer-managed production mail KMS key")).action(async(options)=>{try{const kms=await ensureMailKmsKey(options);console.log(`alias=${kms.alias}`);console.log(`arn=${kms.arn}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to configure mail KMS: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:volume:migrate","Create a dedicated Hetzner volume and migrate encrypted mail storage")).option("--size <gib>","Hetzner volume size in GiB",{default:"20"}).action(async(options)=>{let key,prepareAttempted=!1;try{const volume=await ensureMailStorageVolume(options);key=(await getMailStorageSecret(options)).key;runMailStorageSsh(volume.host,`for i in $(seq 1 60); do test -b ${shellQuote(volume.device)} && exit 0; sleep 1; done; exit 1`);prepareAttempted=!0;runMailStorageSsh(volume.host,["umask 077","key_file=$(mktemp /run/mail-volume-migrate-key.XXXXXX)",`trap 'shred -u "$key_file" 2>/dev/null || rm -f "$key_file"' EXIT`,'dd of="$key_file" bs=64 count=1 iflag=fullblock status=none',`MAIL_ENCRYPTED_IMAGE_SIZE=16G /usr/local/sbin/mail-volume-migrate prepare ${shellQuote(volume.device)}`,`${mailStorageRemote} unlock < "$key_file"`,"/usr/local/sbin/mail-volume-migrate finalize"].join(" && "),key);const status=runMailStorageSsh(volume.host,`${mailStorageRemote} status`).toString("utf8").trim();console.log(`volume=${volume.name}`);console.log(`volume_id=${volume.id}`);console.log(`volume_size_gib=${volume.size}`);console.log(status);process.exit(ExitCode.Success)}catch(error){if(prepareAttempted&&key){const host=await mailStorageHost(options);try{runMailStorageSsh(host,"/usr/local/sbin/mail-volume-migrate rollback")}catch(rollbackError){console.error(`Dedicated-volume rollback step failed: ${getErrorMessage(rollbackError)}`)}try{runMailStorageSsh(host,`${mailStorageRemote} unlock`,key)}catch(unlockError){console.error(`Mail storage re-unlock failed: ${getErrorMessage(unlockError)}`)}}console.error(`Failed to migrate mail storage volume: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:key:recovery:add","Add an independent LUKS recovery key to macOS Keychain")).action(async(options)=>{let currentKey,recoveryKey;try{const host=await mailStorageHost(options);currentKey=(await getMailStorageSecret(options)).key;recoveryKey=randomBytes(64);runMailStorageSsh(host,`${mailStorageRemote} add-recovery-key`,Buffer.concat([currentKey,recoveryKey]));const account="mail-storage-recovery",service="com.stacks.production.mail.luks",encoded=recoveryKey.toString("base64"),keychainWriter=`
|
|
@@ -56,7 +56,7 @@ var {require}=import.meta;import{log,runCommand}from"@stacksjs/cli";import{ExitC
|
|
|
56
56
|
guard status == errSecSuccess, let stored = result as? Data, stored == secret
|
|
57
57
|
else { exit(3) }
|
|
58
58
|
`;if(spawnSync("/usr/bin/swift",["-e",keychainWriter],{input:`${encoded}
|
|
59
|
-
`,maxBuffer:1048576}).status!==0)throw Error("macOS Keychain write failed");console.log(`keychain_service=${service}`);console.log("recovery_keyslot=verified");process.exit(ExitCode.Success)}catch(error){console.error(`Failed to add recovery key: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{currentKey?.fill(0);recoveryKey?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:key:recovery:verify","Verify the Keychain recovery key against the live LUKS header")).action(async(options)=>{let key;try{const host=await mailStorageHost(options);key=readMailRecoveryKey();runMailStorageSsh(host,"cryptsetup open --test-passphrase --type luks2 --key-file - /var/lib/mail-storage.luks",key);console.log("Keychain recovery key unlocks the live mail volume.");process.exit(ExitCode.Success)}catch(error){console.error(`Recovery key verification failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:key:slot:remove","Remove an unusable non-primary LUKS keyslot")).option("--slot <number>","LUKS keyslot number to remove").action(async(options)=>{let key;try{const slot=Number.parseInt(options.slot||"",10);if(!Number.isInteger(slot)||slot<1||slot>31)throw Error("Pass a non-primary keyslot from 1 through 31.");const host=await mailStorageHost(options);key=(await getMailStorageSecret(options)).key;const output=runMailStorageSsh(host,`${mailStorageRemote} remove-keyslot ${slot}`,key).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to remove LUKS keyslot: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:restic:configure","Configure client-encrypted Restic backups to S3")).option("--force","Rotate an existing dedicated IAM access key",{default:!1}).action(async(options)=>{try{const result=await configureMailRestic(options);console.log(`repository=${result.repository}`);console.log(`recovery_secret=${result.secretId}`);console.log("restore_check=ok");process.exit(ExitCode.Success)}catch(error){console.error(`Failed to configure Restic mail backups: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:restore:check","Restore and validate the newest off-host mail snapshot")).action(async(options)=>{try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,"/usr/local/sbin/mail-restic-restore-check").toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Mail restore check failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:maildir:migrate","Migrate live mailboxes to canonical Maildir++ layout")).action(async(options)=>{try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,"/usr/local/sbin/maildir-migrate migrate").toString("utf8").trim();console.log(output);const status=runMailStorageSsh(host,"/usr/local/sbin/maildir-migrate status").toString("utf8").trim();console.log(status);process.exit(ExitCode.Success)}catch(error){console.error(`Maildir++ migration failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:forward:compile","Compile and validate the live RFC 5228 forwarding script")).action(async(options)=>{try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,'/usr/local/sbin/mail-forward-compile /opt/mail/forwards.json /opt/mail/forwards.sieve && sed -n "1,160p" /opt/mail/forwards.sieve').toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Forwarding script compilation failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:escrow","Copy the machine-bound LUKS key to AWS Secrets Manager")).option("--force","Replace a different existing secret",{default:!1}).action(async(options)=>{try{const result=await escrowMailStorageKey(options);console.log(`Mail storage key verified in AWS Secrets Manager: ${result.secretId}`);console.log(`Host: ${result.host}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to escrow mail storage key: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:key:verify","Verify the external key against the LUKS2 header")).action(async(options)=>{let key;try{const host=await mailStorageHost(options),secret=await getMailStorageSecret(options);key=secret.key;runMailStorageSsh(host,"cryptsetup open --test-passphrase --type luks2 --key-file - /var/lib/mail-storage.luks",key);console.log(`External key ${secret.secretId} unlocks the mail volume.`);process.exit(ExitCode.Success)}catch(error){console.error(`Mail storage key verification failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:unlock","Unlock external mail storage and start mail")).action(async(options)=>{let key;try{const host=await mailStorageHost(options),secret=await getMailStorageSecret(options);key=secret.key;const output=runMailStorageSsh(host,`${mailStorageRemote} unlock`,key).toString("utf8").trim();console.log(output);console.log(`External key: ${secret.secretId}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to unlock mail storage: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:lock","Stop mail and lock external mail storage")).action(async(options)=>{loadEnvFiles();try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,`${mailStorageRemote} lock`).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to lock mail storage: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:backup","Create and verify an encrypted local mail backup")).action(async(options)=>{loadEnvFiles();try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,["set -eu","systemctl reset-failed mail-backup.service 2>/dev/null || true","systemctl start mail-backup.service",'latest=$(find -L /opt/mail/backups -maxdepth 1 -type f -name "mail-backup-*.tgz" -printf "%T@ %p\\n" | sort -nr | head -1 | cut -d" " -f2-)','test -n "$latest"','tar -tzf "$latest" >/dev/null','printf "backup=%s\\n" "$latest"','stat -c "mode=%a owner=%U:%G size=%s" "$latest"'].join("; ")).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to create mail backup: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:header:backup","Stream a LUKS header backup to S3 with KMS encryption")).option("--bucket <bucket>","Recovery bucket (defaults to <app>-<env>-s3-backups)").action(async(options)=>{try{const{region}=loadMailStorageAws(options),host=await mailStorageHost(options),app=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),environment=(process.env.APP_ENV||"production").toLowerCase().replace(/[^a-z0-9-]/g,"-"),bucket=options.bucket||`${app}-${environment}-s3-backups`;if(!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket))throw Error(`Unsafe S3 bucket name: ${bucket}`);const objectKey=`luks-headers/mail-storage-${new Date().toISOString().replace(/[-:]/g,"").replace(/\.\d{3}Z$/,"Z")}.header`,{AWSClient}=await import("@stacksjs/cloud"),{S3Client}=await import("@stacksjs/cloud"),kms=await ensureMailKmsKey(options),s3=new S3Client(region);await s3.putBucketEncryption(bucket,"aws:kms",kms.arn);const uploadUrl=s3.generatePresignedPutUrl(bucket,objectKey,"application/octet-stream",600),remoteCommand=["set -eu","tmp_dir=$(mktemp -d /run/mail-storage-header.XXXXXX)",'tmp="$tmp_dir/header"',`trap 'shred -u "$tmp" 2>/dev/null || rm -f "$tmp"; rmdir "$tmp_dir"' EXIT`,'cryptsetup luksHeaderBackup /var/lib/mail-storage.luks --header-backup-file "$tmp"',`curl --fail-with-body --silent --show-error -H 'Content-Type: application/octet-stream' --upload-file "$tmp" ${shellQuote(uploadUrl)} 1>&2`].join("; ");runMailStorageSsh(host,remoteCommand);const object=await s3.headObject(bucket,objectKey);if(!object?.ContentLength||object.ContentLength<1048576)throw Error("The uploaded LUKS header size did not verify.");const head=await new AWSClient().request({service:"s3",region,method:"HEAD",path:`/${bucket}/${objectKey}`,returnHeaders:!0}),encryption=head?.headers?.["x-amz-server-side-encryption"];if(encryption!=="aws:kms")throw Error(`The LUKS header object reported unexpected encryption: ${encryption||"none"}`);const keyId=head?.headers?.["x-amz-server-side-encryption-aws-kms-key-id"];if(keyId!==kms.arn)throw Error(`The LUKS header object used an unexpected KMS key: ${keyId||"none"}`);console.log(`header=s3://${bucket}/${objectKey}`);console.log("encryption=aws:kms");console.log(`kms_key=${kms.arn}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to back up the LUKS header: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:externalize","Move the LUKS key off-host and require external unlocks")).option("--force","Replace a different existing secret",{default:!1}).action(async(options)=>{let configured=!1,key;try{const escrow=await escrowMailStorageKey(options);key=(await getMailStorageSecret(options)).key;runMailStorageSsh(escrow.host,`${mailStorageRemote} configure-external`);configured=!0;runMailStorageSsh(escrow.host,`${mailStorageRemote} unlock`,key);runMailStorageSsh(escrow.host,`${mailStorageRemote} finalize-external`);const status=runMailStorageSsh(escrow.host,`${mailStorageRemote} status`).toString("utf8").trim();console.log(status);console.log(`External key: ${escrow.secretId}`);process.exit(ExitCode.Success)}catch(error){if(configured)try{const host=await mailStorageHost(options);runMailStorageSsh(host,`${mailStorageRemote} rollback-machine`)}catch(rollbackError){console.error(`Automatic rollback also failed: ${getErrorMessage(rollbackError)}`)}console.error(`Failed to externalize mail storage: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});buddy.command("mail:user:add <email>","Add a mail user").option("--password <password>","User password (generated if not provided)").action(async(email,options)=>{log.info(`Adding mail user: ${email}`);try{const password=options.password||randomBytes(16).toString("base64").replace(/[+/=]/g,"").substring(0,16),passwordHash=createHash("sha256").update(password).digest("hex"),tableName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-mail-users`,{DynamoDBClient}=await import("@stacksjs/ts-cloud");await new DynamoDBClient(process.env.AWS_REGION||"us-east-1").putItem({TableName:tableName,Item:{email:{S:email.toLowerCase()},passwordHash:{S:passwordHash},createdAt:{S:new Date().toISOString()}}});log.success(`User ${email} added successfully!`);console.log("");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log(" MAIL CREDENTIALS");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");console.log(` Email: ${email}`);console.log(` Password: ${password}`);console.log("");console.log(" To connect with Mail.app, run:");console.log(" buddy mail:proxy");console.log("");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");if(!options.password)log.warn("Save this password! It will not be shown again.");await log.flush();process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to add user: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:user:list","List mail users").action(async()=>{try{const tableName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-mail-users`,{DynamoDBClient}=await import("@stacksjs/ts-cloud"),result=await new DynamoDBClient(process.env.AWS_REGION||"us-east-1").scan({TableName:tableName});console.log("");console.log("Mail Users:");console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");if(!result.Items||result.Items.length===0)console.log(" No users found");else for(const item of result.Items){const email=item.email?.S||"unknown",createdAt=item.createdAt?.S||"unknown";console.log(` \uD83D\uDCE7 ${email}`);console.log(` Created: ${createdAt}`)}console.log("");process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to list users: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:user:delete <email>","Delete a mail user").action(async(email)=>{try{const tableName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-mail-users`,{DynamoDBClient}=await import("@stacksjs/ts-cloud");await new DynamoDBClient(process.env.AWS_REGION||"us-east-1").deleteItem({TableName:tableName,Key:{email:{S:email.toLowerCase()}}});log.success(`User ${email} deleted`);await log.flush();process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to delete user: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:proxy","Start local IMAP proxy for Mail.app").option("--port <port>","IMAP proxy port",{default:"1993"}).option("--api <url>","Mail API URL").action(async(options)=>{log.info("Starting IMAP proxy...");try{let apiUrl=options.api;if(!apiUrl){const{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),cfn=new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1"),stackName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-cloud`;try{apiUrl=((await cfn.describeStacks({stackName})).Stacks?.[0]?.Outputs||[]).find((o)=>o.OutputKey==="MailApiUrl")?.OutputValue}catch(e){}}if(!apiUrl){await log.error("Mail API URL not found. Run `buddy deploy` first to create the mail infrastructure.");process.exit(ExitCode.FatalError)}console.log("");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log(" IMAP PROXY");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");console.log(` API URL: ${apiUrl}`);console.log(` Port: ${options.port}`);console.log("");console.log(" Configure Mail.app with:");console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");console.log(" Account Type: IMAP");console.log(" Incoming Server: localhost");console.log(` Incoming Port: ${options.port}`);console.log(" SSL: No (local connection)");console.log(" Outgoing Server: localhost");console.log(` Outgoing Port: ${options.port}`);console.log(" Username: your-email@stacksjs.com");console.log(" Password: your-password");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");process.env.MAIL_API_URL=apiUrl;process.env.IMAP_PORT=options.port;const{ImapProxy}=await import("../../mail-server/src/proxy/imap-proxy"),proxy=new ImapProxy(Number.parseInt(options.port,10),apiUrl);await proxy.start();process.on("SIGINT",()=>{console.log(`
|
|
59
|
+
`,maxBuffer:1048576}).status!==0)throw Error("macOS Keychain write failed");console.log(`keychain_service=${service}`);console.log("recovery_keyslot=verified");process.exit(ExitCode.Success)}catch(error){console.error(`Failed to add recovery key: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{currentKey?.fill(0);recoveryKey?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:key:recovery:verify","Verify the Keychain recovery key against the live LUKS header")).action(async(options)=>{let key;try{const host=await mailStorageHost(options);key=readMailRecoveryKey();runMailStorageSsh(host,"cryptsetup open --test-passphrase --type luks2 --key-file - /var/lib/mail-storage.luks",key);console.log("Keychain recovery key unlocks the live mail volume.");process.exit(ExitCode.Success)}catch(error){console.error(`Recovery key verification failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:key:slot:remove","Remove an unusable non-primary LUKS keyslot")).option("--slot <number>","LUKS keyslot number to remove").action(async(options)=>{let key;try{const slot=Number.parseInt(options.slot||"",10);if(!Number.isInteger(slot)||slot<1||slot>31)throw Error("Pass a non-primary keyslot from 1 through 31.");const host=await mailStorageHost(options);key=(await getMailStorageSecret(options)).key;const output=runMailStorageSsh(host,`${mailStorageRemote} remove-keyslot ${slot}`,key).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to remove LUKS keyslot: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:restic:configure","Configure client-encrypted Restic backups to S3")).option("--force","Rotate an existing dedicated IAM access key",{default:!1}).action(async(options)=>{try{const result=await configureMailRestic(options);console.log(`repository=${result.repository}`);console.log(`recovery_secret=${result.secretId}`);console.log("restore_check=ok");process.exit(ExitCode.Success)}catch(error){console.error(`Failed to configure Restic mail backups: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:restore:check","Restore and validate the newest off-host mail snapshot")).action(async(options)=>{try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,"/usr/local/sbin/mail-restic-restore-check").toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Mail restore check failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:maildir:migrate","Migrate live mailboxes to canonical Maildir++ layout")).action(async(options)=>{try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,"/usr/local/sbin/maildir-migrate migrate").toString("utf8").trim();console.log(output);const status=runMailStorageSsh(host,"/usr/local/sbin/maildir-migrate status").toString("utf8").trim();console.log(status);process.exit(ExitCode.Success)}catch(error){console.error(`Maildir++ migration failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:forward:compile","Compile and validate the live RFC 5228 forwarding script")).action(async(options)=>{try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,'/usr/local/sbin/mail-forward-compile /opt/mail/forwards.json /opt/mail/forwards.sieve && sed -n "1,160p" /opt/mail/forwards.sieve').toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Forwarding script compilation failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:escrow","Copy the machine-bound LUKS key to AWS Secrets Manager")).option("--force","Replace a different existing secret",{default:!1}).action(async(options)=>{try{const result=await escrowMailStorageKey(options);console.log(`Mail storage key verified in AWS Secrets Manager: ${result.secretId}`);console.log(`Host: ${result.host}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to escrow mail storage key: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:key:verify","Verify the external key against the LUKS2 header")).action(async(options)=>{let key;try{const host=await mailStorageHost(options),secret=await getMailStorageSecret(options);key=secret.key;runMailStorageSsh(host,"cryptsetup open --test-passphrase --type luks2 --key-file - /var/lib/mail-storage.luks",key);console.log(`External key ${secret.secretId} unlocks the mail volume.`);process.exit(ExitCode.Success)}catch(error){console.error(`Mail storage key verification failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:unlock","Unlock external mail storage and start mail")).action(async(options)=>{let key;try{const host=await mailStorageHost(options),secret=await getMailStorageSecret(options);key=secret.key;const output=runMailStorageSsh(host,`${mailStorageRemote} unlock`,key).toString("utf8").trim();console.log(output);console.log(`External key: ${secret.secretId}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to unlock mail storage: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:machine-bind","Enable unattended reboot recovery with a machine-bound encrypted LUKS credential")).action(async(options)=>{let key;try{const host=await mailStorageHost(options),secret=await getMailStorageSecret(options);key=secret.key;const output=runMailStorageSsh(host,machineBindMailStorageCommand(),key).toString("utf8").trim();if(output)console.log(output);const status=runMailStorageSsh(host,`${mailStorageRemote} status`).toString("utf8").trim();console.log(status);console.log(`Recovery secret retained: ${secret.secretId}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to machine-bind mail storage: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:lock","Stop mail and lock external mail storage")).action(async(options)=>{loadEnvFiles();try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,`${mailStorageRemote} lock`).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to lock mail storage: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:backup","Create and verify an encrypted local mail backup")).action(async(options)=>{loadEnvFiles();try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,["set -eu","systemctl reset-failed mail-backup.service 2>/dev/null || true","systemctl start mail-backup.service",'latest=$(find -L /opt/mail/backups -maxdepth 1 -type f -name "mail-backup-*.tgz" -printf "%T@ %p\\n" | sort -nr | head -1 | cut -d" " -f2-)','test -n "$latest"','tar -tzf "$latest" >/dev/null','printf "backup=%s\\n" "$latest"','stat -c "mode=%a owner=%U:%G size=%s" "$latest"'].join("; ")).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to create mail backup: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:header:backup","Stream a LUKS header backup to S3 with KMS encryption")).option("--bucket <bucket>","Recovery bucket (defaults to <app>-<env>-s3-backups)").action(async(options)=>{try{const{region}=loadMailStorageAws(options),host=await mailStorageHost(options),app=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),environment=(process.env.APP_ENV||"production").toLowerCase().replace(/[^a-z0-9-]/g,"-"),bucket=options.bucket||`${app}-${environment}-s3-backups`;if(!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket))throw Error(`Unsafe S3 bucket name: ${bucket}`);const objectKey=`luks-headers/mail-storage-${new Date().toISOString().replace(/[-:]/g,"").replace(/\.\d{3}Z$/,"Z")}.header`,{AWSClient}=await import("@stacksjs/cloud"),{S3Client}=await import("@stacksjs/cloud"),kms=await ensureMailKmsKey(options),s3=new S3Client(region);await s3.putBucketEncryption(bucket,"aws:kms",kms.arn);const uploadUrl=s3.generatePresignedPutUrl(bucket,objectKey,"application/octet-stream",600),remoteCommand=["set -eu","tmp_dir=$(mktemp -d /run/mail-storage-header.XXXXXX)",'tmp="$tmp_dir/header"',`trap 'shred -u "$tmp" 2>/dev/null || rm -f "$tmp"; rmdir "$tmp_dir"' EXIT`,'cryptsetup luksHeaderBackup /var/lib/mail-storage.luks --header-backup-file "$tmp"',`curl --fail-with-body --silent --show-error -H 'Content-Type: application/octet-stream' --upload-file "$tmp" ${shellQuote(uploadUrl)} 1>&2`].join("; ");runMailStorageSsh(host,remoteCommand);const object=await s3.headObject(bucket,objectKey);if(!object?.ContentLength||object.ContentLength<1048576)throw Error("The uploaded LUKS header size did not verify.");const head=await new AWSClient().request({service:"s3",region,method:"HEAD",path:`/${bucket}/${objectKey}`,returnHeaders:!0}),encryption=head?.headers?.["x-amz-server-side-encryption"];if(encryption!=="aws:kms")throw Error(`The LUKS header object reported unexpected encryption: ${encryption||"none"}`);const keyId=head?.headers?.["x-amz-server-side-encryption-aws-kms-key-id"];if(keyId!==kms.arn)throw Error(`The LUKS header object used an unexpected KMS key: ${keyId||"none"}`);console.log(`header=s3://${bucket}/${objectKey}`);console.log("encryption=aws:kms");console.log(`kms_key=${kms.arn}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to back up the LUKS header: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:externalize","Move the LUKS key off-host and require external unlocks")).option("--force","Replace a different existing secret",{default:!1}).action(async(options)=>{let configured=!1,key;try{const escrow=await escrowMailStorageKey(options);key=(await getMailStorageSecret(options)).key;runMailStorageSsh(escrow.host,`${mailStorageRemote} configure-external`);configured=!0;runMailStorageSsh(escrow.host,`${mailStorageRemote} unlock`,key);runMailStorageSsh(escrow.host,`${mailStorageRemote} finalize-external`);const status=runMailStorageSsh(escrow.host,`${mailStorageRemote} status`).toString("utf8").trim();console.log(status);console.log(`External key: ${escrow.secretId}`);process.exit(ExitCode.Success)}catch(error){if(configured)try{const host=await mailStorageHost(options);runMailStorageSsh(host,`${mailStorageRemote} rollback-machine`)}catch(rollbackError){console.error(`Automatic rollback also failed: ${getErrorMessage(rollbackError)}`)}console.error(`Failed to externalize mail storage: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});buddy.command("mail:user:add <email>","Add a mail user").option("--password <password>","User password (generated if not provided)").action(async(email,options)=>{log.info(`Adding mail user: ${email}`);try{const password=options.password||randomBytes(16).toString("base64").replace(/[+/=]/g,"").substring(0,16),passwordHash=createHash("sha256").update(password).digest("hex"),tableName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-mail-users`,{DynamoDBClient}=await import("@stacksjs/ts-cloud");await new DynamoDBClient(process.env.AWS_REGION||"us-east-1").putItem({TableName:tableName,Item:{email:{S:email.toLowerCase()},passwordHash:{S:passwordHash},createdAt:{S:new Date().toISOString()}}});log.success(`User ${email} added successfully!`);console.log("");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log(" MAIL CREDENTIALS");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");console.log(` Email: ${email}`);console.log(` Password: ${password}`);console.log("");console.log(" To connect with Mail.app, run:");console.log(" buddy mail:proxy");console.log("");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");if(!options.password)log.warn("Save this password! It will not be shown again.");await log.flush();process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to add user: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:user:list","List mail users").action(async()=>{try{const tableName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-mail-users`,{DynamoDBClient}=await import("@stacksjs/ts-cloud"),result=await new DynamoDBClient(process.env.AWS_REGION||"us-east-1").scan({TableName:tableName});console.log("");console.log("Mail Users:");console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");if(!result.Items||result.Items.length===0)console.log(" No users found");else for(const item of result.Items){const email=item.email?.S||"unknown",createdAt=item.createdAt?.S||"unknown";console.log(` \uD83D\uDCE7 ${email}`);console.log(` Created: ${createdAt}`)}console.log("");process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to list users: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:user:delete <email>","Delete a mail user").action(async(email)=>{try{const tableName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-mail-users`,{DynamoDBClient}=await import("@stacksjs/ts-cloud");await new DynamoDBClient(process.env.AWS_REGION||"us-east-1").deleteItem({TableName:tableName,Key:{email:{S:email.toLowerCase()}}});log.success(`User ${email} deleted`);await log.flush();process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to delete user: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:proxy","Start local IMAP proxy for Mail.app").option("--port <port>","IMAP proxy port",{default:"1993"}).option("--api <url>","Mail API URL").action(async(options)=>{log.info("Starting IMAP proxy...");try{let apiUrl=options.api;if(!apiUrl){const{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),cfn=new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1"),stackName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-cloud`;try{apiUrl=((await cfn.describeStacks({stackName})).Stacks?.[0]?.Outputs||[]).find((o)=>o.OutputKey==="MailApiUrl")?.OutputValue}catch(e){}}if(!apiUrl){await log.error("Mail API URL not found. Run `buddy deploy` first to create the mail infrastructure.");process.exit(ExitCode.FatalError)}console.log("");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log(" IMAP PROXY");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");console.log(` API URL: ${apiUrl}`);console.log(` Port: ${options.port}`);console.log("");console.log(" Configure Mail.app with:");console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");console.log(" Account Type: IMAP");console.log(" Incoming Server: localhost");console.log(` Incoming Port: ${options.port}`);console.log(" SSL: No (local connection)");console.log(" Outgoing Server: localhost");console.log(` Outgoing Port: ${options.port}`);console.log(" Username: your-email@stacksjs.com");console.log(" Password: your-password");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");process.env.MAIL_API_URL=apiUrl;process.env.IMAP_PORT=options.port;const{ImapProxy}=await import("../../mail-server/src/proxy/imap-proxy"),proxy=new ImapProxy(Number.parseInt(options.port,10),apiUrl);await proxy.start();process.on("SIGINT",()=>{console.log(`
|
|
60
60
|
Shutting down proxy...`);proxy.stop();process.exit(0)})}catch(error){await log.error(`Failed to start proxy: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:test","Test mail API connection").action(async()=>{try{const{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),cfn=new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1"),stackName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-cloud`,mailApiOutput=((await cfn.describeStacks({stackName})).Stacks?.[0]?.Outputs||[]).find((o)=>o.OutputKey==="MailApiUrl");if(!mailApiOutput?.OutputValue){await log.error("Mail API not deployed. Run `buddy deploy` first.");process.exit(ExitCode.FatalError)}const apiUrl=mailApiOutput.OutputValue;log.info(`Testing Mail API at ${apiUrl}`);const response=await fetch(`${apiUrl}/mailboxes`,{headers:{Authorization:`Basic ${Buffer.from("test@stacksjs.com:test").toString("base64")}`}});if(response.status===401)log.success("Mail API is responding (auth required)");else if(response.ok){log.success("Mail API is working!");const data=await response.json();console.log("Response:",JSON.stringify(data,null,2))}else log.warn(`Mail API returned status ${response.status}`);await log.flush();process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to test API: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:credentials [email]","Show SMTP credentials for email access").action(async(emailAddress)=>{loadEnvFiles();const domain=process.env.APP_DOMAIN||process.env.MAIL_DOMAIN||"stacksjs.com",mailHost=`mail.${domain}`,email=emailAddress||`chris@${domain}`,localPart=email.includes("@")?email.split("@")[0]:email,username=email,envKey=`MAIL_PASSWORD_${localPart.toUpperCase().replace(/[^A-Z0-9]/g,"_")}`,rawPassword=process.env[envKey],isCiphertext=typeof rawPassword==="string"&&rawPassword.replace(/^["']/,"").startsWith("encrypted:"),password=isCiphertext?void 0:rawPassword;console.log("");console.log("==========================================================");console.log(` EMAIL CREDENTIALS - ${email}`);console.log("==========================================================");console.log("");console.log(` Email: ${email}`);console.log(` Host: ${mailHost}`);console.log(" Port: 587");console.log(" Security: STARTTLS");console.log(` Username: ${username}`);if(password)console.log(` Password: ${password}`);else if(isCiphertext)console.log(" Password: (encrypted - this machine has no private key for .env.production)");else console.log(` Password: (not set - add ${envKey} to .env.production)`);console.log("");console.log("==========================================================");console.log("");process.exit(ExitCode.Success)});buddy.command("mail:logs","Show mail server logs from production").option("-n, --lines <count>","Number of log lines to show",{default:"50"}).option("-f, --follow","Follow log output (poll every 5s)").option("--filter <pattern>","Filter logs by pattern (e.g. AUTH, LOGIN, error)").action(async(options)=>{loadEnvFiles();const directHost=await resolveOperationalMailHost();if(directHost){const lines=sanitizeLineCount(options.lines),filter=options.filter?options.filter.replace(/[^a-zA-Z0-9_.@\s-]/g,""):"",command=`journalctl -u mail --no-pager -n ${lines}${filter?` | grep -iE ${shellQuote(filter)}`:""}`;try{const output=execSync(`ssh -o BatchMode=yes -o ConnectTimeout=10 root@${shellQuote(directHost)} ${shellQuote(command)}`,{encoding:"utf8"});console.log(output);process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to fetch logs from ${directHost}: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}}const region=process.env.AWS_REGION||"us-east-1",appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");try{const{EC2Client,SSMClient}=await import("@stacksjs/ts-cloud"),ec2=new EC2Client(region),ssm=new SSMClient(region);log.info("Finding mail server...");const instanceId=(await ec2.describeInstances({Filters:[{Name:"tag:Name",Values:[`${appName}-mail-server`]},{Name:"instance-state-name",Values:["running"]}]})).Reservations?.[0]?.Instances?.[0]?.InstanceId;if(!instanceId){await log.error("No running mail server found.");log.info(`Looked for instances tagged: ${appName}-mail-server`);await log.flush();process.exit(ExitCode.FatalError)}log.info(`Found instance: ${instanceId}`);const lines=sanitizeLineCount(options.lines),sanitizedFilter=options.filter?options.filter.replace(/[^a-zA-Z0-9_.@\s-]/g,""):"",filterCmd=sanitizedFilter?` | grep -iE '${sanitizedFilter}'`:"",fetchLogs=async()=>{const cmd=`journalctl -u smtp-server -u mail-server --no-pager -n ${lines} --since "10 minutes ago" 2>&1${filterCmd}`,result=await ssm.runShellCommand(instanceId,[cmd],{timeoutSeconds:30,maxWaitMs:30000,pollIntervalMs:2000});if(!result.success){log.error(`Failed to fetch logs: ${result.error||"Unknown error"}`);return!1}if(result.output){const logLines=result.output.trim().split(`
|
|
61
61
|
`);for(const line of logLines)if(line.includes("error")||line.includes("Error")||line.includes("AUTH failed"))console.log(` \x1B[31m${line}\x1B[0m`);else if(line.includes("warning")||line.includes("Warning"))console.log(` \x1B[33m${line}\x1B[0m`);else if(line.includes("AUTH success")||line.includes("LOGIN completed")||line.includes("authenticated"))console.log(` \x1B[32m${line}\x1B[0m`);else console.log(` ${line}`)}else console.log(" No log entries found.");return!0};console.log("");console.log("==========================================================");console.log(" MAIL SERVER LOGS");console.log("==========================================================");console.log("");await fetchLogs();if(options.follow){console.log("");log.info("Following logs (Ctrl+C to stop)...");let polling=!1;const poll=setInterval(()=>{if(polling)return;polling=!0;console.log("");fetchLogs().catch((e)=>log.error("Log polling failed:",e)).finally(()=>{polling=!1})},5000);await new Promise((resolve)=>{process.on("SIGINT",()=>{clearInterval(poll);resolve()})})}console.log("");process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to fetch logs: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:status","Show mail server status").action(async()=>{loadEnvFiles();const directHost=await resolveOperationalMailHost();if(directHost){const command='systemctl is-active mail; systemctl show mail -p NRestarts -p ActiveEnterTimestamp --value; ss -H -ltn | grep -E "(:(25|143|465|587|993))\\b"; systemctl is-enabled mail-health.timer; systemctl is-active mail-health.timer';try{const output=execSync(`ssh -o BatchMode=yes -o ConnectTimeout=10 root@${shellQuote(directHost)} ${shellQuote(command)}`,{encoding:"utf8"});console.log(output);process.exit(ExitCode.Success)}catch(error){await log.error(`Mail server ${directHost} is unhealthy: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}}const region=process.env.AWS_REGION||"us-east-1",appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");try{const{EC2Client,SSMClient}=await import("@stacksjs/ts-cloud"),ec2=new EC2Client(region),ssm=new SSMClient(region),instanceId=(await ec2.describeInstances({Filters:[{Name:"tag:Name",Values:[`${appName}-mail-server`]},{Name:"instance-state-name",Values:["running"]}]})).Reservations?.[0]?.Instances?.[0]?.InstanceId;if(!instanceId){await log.error("No running mail server found.");log.info(`Looked for instances tagged: ${appName}-mail-server`);await log.flush();process.exit(ExitCode.FatalError)}const result=await ssm.runShellCommand(instanceId,["echo === Service ===","(systemctl status smtp-server --no-pager 2>&1 || systemctl status mail-server --no-pager 2>&1) | head -15","echo === Ports ===",'ss -tlnp 2>&1 | grep -E "(25|587|465|993|143|110|995)" || echo "No mail ports listening"',"echo === Memory ===",`ps aux | grep -E "(smtp-server|bun|mail)" | grep -v grep | awk '{print $6/1024 "MB", $11}'`],{timeoutSeconds:30,maxWaitMs:30000,pollIntervalMs:2000});console.log("");console.log("==========================================================");console.log(" MAIL SERVER STATUS");console.log("==========================================================");console.log("");if(result.success&&result.output)for(const line of result.output.trim().split(`
|
|
62
62
|
`))console.log(` ${line}`);else log.error(`Failed: ${result.error}`);console.log("");await log.flush();process.exit(ExitCode.Success)}catch(error){await log.error(`Failed to get status: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:port25:request","Request port 25 unblock for direct SMTP delivery").option("--provider <provider>","Cloud provider: aws or hetzner",{default:"aws"}).option("--instance-id <id>","AWS EC2 instance ID (auto-detected if not provided)").option("--server-id <id>","Hetzner server ID (auto-detected if not provided)").option("--elastic-ip <ip>","AWS Elastic IP address").option("--rdns <hostname>","Reverse DNS hostname (e.g. mail.stacksjs.com)").option("--use-case <text>","Description of email use case").action(async(options)=>{loadEnvFiles();const domain=process.env.APP_DOMAIN||process.env.MAIL_DOMAIN||"stacksjs.com",provider=options.provider.toLowerCase();if(provider==="aws")await requestAwsPort25(options,domain);else if(provider==="hetzner")await requestHetznerPort25(options,domain);else{await log.error(`Unknown provider: ${provider}. Use 'aws' or 'hetzner'.`);process.exit(ExitCode.FatalError)}});buddy.command("mail:port25:status","Check outbound port 25 status on the mail server").option("--provider <provider>","Cloud provider: aws or hetzner",{default:"aws"}).action(async(options)=>{loadEnvFiles();const region=process.env.AWS_REGION||"us-east-1",appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");if(options.provider==="hetzner"){const hetznerToken=process.env.HETZNER_API_TOKEN;if(!hetznerToken){await log.error("HETZNER_API_TOKEN not set");process.exit(ExitCode.FatalError)}try{const ip=(await(await fetch("https://api.hetzner.cloud/v1/servers?label_selector=service=mail",{headers:{Authorization:`Bearer ${hetznerToken}`}})).json()).servers?.[0]?.public_net?.ipv4?.ip;if(!ip){const mailServer=(await(await fetch("https://api.hetzner.cloud/v1/servers",{headers:{Authorization:`Bearer ${hetznerToken}`}})).json()).servers?.find((s)=>s.name?.includes("mail"));if(mailServer){const serverIp=mailServer.public_net?.ipv4?.ip;log.info(`Found Hetzner server: ${mailServer.name} (${serverIp})`);const result=execSync(`ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@${serverIp} "timeout 5 bash -c 'echo QUIT > /dev/tcp/gmail-smtp-in.l.google.com/25' 2>&1 && echo PORT_25_OPEN || echo PORT_25_BLOCKED"`,{encoding:"utf-8"});printPort25Status(result.trim(),"Hetzner",serverIp)}else log.error("No mail server found on Hetzner")}else{const result=execSync(`ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@${ip} "timeout 5 bash -c 'echo QUIT > /dev/tcp/gmail-smtp-in.l.google.com/25' 2>&1 && echo PORT_25_OPEN || echo PORT_25_BLOCKED"`,{encoding:"utf-8"});printPort25Status(result.trim(),"Hetzner",ip)}}catch(error){await log.error(`Failed: ${getErrorMessage(error)}`)}process.exit(ExitCode.Success)}try{const{EC2Client,SSMClient}=await import("@stacksjs/ts-cloud"),ec2=new EC2Client(region),ssm=new SSMClient(region),instance=(await ec2.describeInstances({Filters:[{Name:"tag:Name",Values:[`${appName}-mail-server`]},{Name:"instance-state-name",Values:["running"]}]})).Reservations?.[0]?.Instances?.[0],instanceId=instance?.InstanceId;if(!instanceId){await log.error("No running mail server found.");process.exit(ExitCode.FatalError)}const publicIp=instance?.PublicIpAddress||"unknown";log.info(`Testing port 25 on ${instanceId} (${publicIp})...`);const result=await ssm.runShellCommand(instanceId,['timeout 5 bash -c "echo QUIT > /dev/tcp/gmail-smtp-in.l.google.com/25" 2>&1 && echo PORT_25_OPEN || echo PORT_25_BLOCKED'],{timeoutSeconds:15,maxWaitMs:20000,pollIntervalMs:2000});printPort25Status(result.output?.trim()||"PORT_25_BLOCKED","AWS",publicIp);process.exit(ExitCode.Success)}catch(error){await log.error(`Failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:server","Start SMTP relay server").option("--port <port>","SMTP port",{default:"587"}).action(async(options)=>{loadEnvFiles();const domain=process.env.APP_DOMAIN||process.env.MAIL_DOMAIN||"stacksjs.com",port=Number.parseInt(options.port,10),mailboxes=["chris","blake","glenn"],users={},generated=[];for(const name of mailboxes){const envKey=`MAIL_PASSWORD_${name.toUpperCase()}`;let password=process.env[envKey];if(!password){password=randomBytes(16).toString("base64url");generated.push(`${envKey}=${password}`)}users[name]={password,email:`${name}@${domain}`}}if(generated.length>0){console.log("");log.warn("Generated missing passwords. Add these to .env.production:");for(const line of generated)console.log(` ${line}`)}console.log("");console.log("==========================================================");console.log(" SMTP RELAY SERVER");console.log("==========================================================");console.log("");console.log(` Host: mail.${domain}`);console.log(` Port: ${port}`);console.log(` Domain: ${domain}`);console.log("");console.log(" Mailboxes:");for(const[name,user]of Object.entries(users))console.log(` ${name} (${user.email})`);console.log("");console.log(" Relaying through AWS SES");console.log("==========================================================");console.log("");try{const{SmtpServer}=await import("@stacksjs/cloud"),server=new SmtpServer({port,host:"0.0.0.0",domain,users,sentBucket:"stacks-production-email",sentPrefix:"sent/"});await server.start();await new Promise((resolve)=>{process.on("SIGINT",async()=>{console.log(`
|
package/dist/commands/make.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import process from"node:process";import{createFactory,createMiddleware,createMigration,createModel,createNotification,createPage,invoke,makeAction,makeCertificate,makeCommand,makeComponent,makeDatabase,makeFunction,makeJob,makeLanguage,makeMail,makePage,makePolicy,makeQueueTable,makeResource,makeStack,setDryRun}from"@stacksjs/actions";import{intro,italic,onUnknownSubcommand,outro}from"@stacksjs/cli";import{log}from"@stacksjs/logging";import{ExitCode}from"@stacksjs/types";export function make(buddy){const descriptions={action:"Create a new action",command:"Create a new CLI command",model:"Create a new model",middleware:"Create a new middleware",component:"Create a new component",page:"Create a new page",function:"Create a new function",job:"Create a new job",language:"Create a new language",database:"Create a new database",migration:"Create a new migration",factory:"Create a new factory",notification:"Create a new notification",mail:"Create a new Mailable + companion stx template",policy:"Create a new authorization policy",resource:"Create a new API resource",name:"The name of the action",queue:"Make queue migration",queueTable:"Create the queue jobs table migration",stack:"Create a new stack",certificate:"Create a new SSL Certificate",select:"What are you trying to make?",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("make [make]","The make command").option("-a, --action [action]",descriptions.action,{default:!1}).option("-c, --component [component]",descriptions.component,{default:!1}).option("-d, --database [database]",descriptions.database,{default:!1}).option("-f, --factory [factory]",descriptions.factory,{default:!1}).option("-fn, --function [function]",descriptions.function,{default:!1}).option("-l, --language [language]",descriptions.language,{default:!1}).option("-m, --model [model]",descriptions.model,{default:!1}).option("-mw, --middleware [middleware]",descriptions.middleware,{default:!1}).option("-p, --page [page]",descriptions.page,{default:!1}).option("-mg, --migration [migration]",descriptions.migration,{default:!1}).option("-n, --notification [notification]",descriptions.notification,{default:!1}).option("-qt, --queue-table",descriptions.queue,{default:!1}).option("-s, --stack [stack]",descriptions.stack,{default:!1}).option("--dry-run","Preview the files that would be generated without writing",{default:!1}).option("--with-validation","Include a validation rules block in the generated stub",{default:!1}).option("--with-auth","Include auth-aware boilerplate in the generated stub",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(make,options)=>{log.debug("Running `buddy make` ...",options);if(!buddy.args[0]){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}setDryRun(Boolean(options.dryRun||options["dry-run"]));if(make){options.name=buddy.args[1]??make;switch(make){case"action":await makeAction(options);break;case"certificate":await makeCertificate();break;case"command":await makeCommand(options);break;case"component":await makeComponent(options);break;case"database":makeDatabase(options);break;case"function":await makeFunction(options);break;case"job":await makeJob(options);break;case"language":await makeLanguage(options);break;case"mail":await makeMail(options);break;case"migration":await createMigration(options);break;case"middleware":await createMiddleware(options);break;case"model":await createModel(options);break;case"page":await createPage(options);break;case"notification":await createNotification(options);break;case"policy":await makePolicy({name:options.name,model:typeof options.model==="string"?options.model:void 0,register:options.register});break;case"resource":await makeResource({name:options.name,model:typeof options.model==="string"?options.model:void 0});break;case"queue-table":await makeQueueTable();break;case"stack":await makeStack(options);break;case"factory":await createFactory(options);break;default:{console.error(`Unknown make subcommand: ${make}`);console.error("Valid subcommands: action, certificate, command, component, database, factory, function, job, language, mail, middleware, migration, model, notification, page, policy, queue-table, resource, stack");process.exit(ExitCode.InvalidArgument)}}}await invoke(options);process.exit(ExitCode.Success)});buddy.command("make:action [name]",descriptions.action).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--dry-run","Preview the file without writing",{default:!1}).option("--with-validation","Generate a stub that calls validate()",{default:!1}).option("--with-auth","Generate a stub that requires an authenticated user",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.info("Running `buddy make:action` ...");log.debug("Running `buddy make:action` ...",name,options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");await log.flush();process.exit(ExitCode.FatalError)}setDryRun(Boolean(options.dryRun||options["dry-run"]));await makeAction(options)});buddy.command("make:certificate",descriptions.certificate).alias("make:cert").example("buddy make:certificate").action(async(options)=>{log.debug("Running `buddy make:certificate` ...",options);await makeCertificate()});buddy.command("scaffold:crud [name]","Generate model, migration, and CRUD actions").alias("make:crud").alias("make:scaffold").option("-n, --name [name]","Resource name (PascalCase)",{default:!1}).option("-f, --fields [fields]","Comma-separated field list, e.g. title:string,body:text,published:boolean",{default:""}).option("--dry-run","Preview the generated files without writing",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy scaffold:crud Post --fields=title:string,body:text,published:boolean").action(async(name,options)=>{name=name??options.name;if(!name){console.error("scaffold:crud requires a resource name. Example: buddy scaffold:crud Post --fields=title:string,body:text");process.exit(ExitCode.FatalError)}const{scaffoldCrud}=await import("@stacksjs/actions");setDryRun(Boolean(options.dryRun||options["dry-run"]));try{await scaffoldCrud(name,options)}catch(err){console.error("scaffold:crud failed:",err);process.exit(ExitCode.FatalError)}});buddy.command("make:command [name]",descriptions.command).option("-n, --name [name]",descriptions.name,{default:!1}).option("-s, --signature [signature]","The command signature (CLI name)",{default:!1}).option("-d, --description [description]","The command description",{default:!1}).option("--register","Also add an entry to app/Commands.ts (optional - commands are auto-discovered)",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy make:command SendEmails").example("buddy make:command SendEmails --signature=send-emails").action(async(name,options)=>{log.debug("Running `buddy make:command` ...",options);const perf=await intro("buddy make:command");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a command name.");console.error("Example: buddy make:command SendEmails");process.exit(ExitCode.FatalError)}if(!await makeCommand(options)){await outro("While running the make:command command, there was an issue",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} command.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:component [name]",descriptions.component).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:component` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makeComponent(options)});buddy.command("make:database [name]",descriptions.database).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action((name,options)=>{log.debug("Running `buddy make:database` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a database name via the `--name` option, or as the command\u2019s argument.");console.error("Example: `buddy make:database my-cool-database`");console.error("Or: `buddy make:database --name=my-cool-database`");console.error("Read more about the documentation here: https://stacksjs.com/docs/make/database");process.exit(ExitCode.FatalError)}makeDatabase(options)});buddy.command("make:factory [name]",descriptions.factory).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:factory` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await createFactory(options)});buddy.command("make:function [name]",descriptions.function).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:function` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makeFunction(options)});buddy.command("make:lang [name]",descriptions.language).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:lang` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makeLanguage(options)});buddy.command("make:migration [name]",descriptions.migration).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:migration` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a migration name");process.exit(ExitCode.FatalError)}await createMigration(options)});buddy.command("make:model [name]",descriptions.model).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:model` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a model name");process.exit(ExitCode.FatalError)}await createModel(options)});buddy.command("make:mail [name]",descriptions.mail).option("-n, --name [name]",descriptions.name,{default:!1}).option("-f, --force","Overwrite existing files",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy make:mail OrderShipped").example("buddy make:mail welcome-back // PascalCases to WelcomeBack, kebab-cases to welcome-back").example("buddy make:mail Welcome --force // overwrite existing files").action(async(name,options)=>{log.debug("Running `buddy make:mail` ...",options);const perf=await intro("buddy make:mail");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name (e.g. `buddy make:mail OrderShipped`).");process.exit(ExitCode.FatalError)}await makeMail(options);await outro(`Created your ${italic(name)} mailable.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:notification [name]",descriptions.notification).option("-n, --name [name]",descriptions.name,{default:!1}).option("-e, --email","Is it an email notification?",{default:!0}).option("-c, --chat","Is it a chat notification?",{default:!1}).option("-s, --sms","Is it a SMS notification?",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:notification` ...",options);const perf=await intro("buddy make:notification");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}if(!await createNotification(options)){await outro("While running the make:notification command, there was an issue",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} notification.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:policy [name]",descriptions.policy).option("-n, --name [name]",descriptions.name,{default:!1}).option("-m, --model [model]","The model this policy is for",{default:!1}).option("--no-register","Do not register in Gates.ts",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy make:policy PostPolicy").example("buddy make:policy CommentPolicy --model=Comment").action(async(name,options)=>{log.debug("Running `buddy make:policy` ...",options);const perf=await intro("buddy make:policy");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a policy name.");console.error("Example: buddy make:policy PostPolicy");process.exit(ExitCode.FatalError)}if(!await makePolicy(options)){await outro("While running the make:policy command, there was an issue",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} policy.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:resource [name]",descriptions.resource).option("-n, --name [name]",descriptions.name,{default:!1}).option("-m, --model [model]","The model this resource is for",{default:!1}).option("-c, --collection","Create a collection resource",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy make:resource UserResource").example("buddy make:resource PostResource --model=Post").example("buddy make:resource PostCollection --collection").action(async(name,options)=>{log.debug("Running `buddy make:resource` ...",options);const perf=await intro("buddy make:resource");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a resource name.");console.error("Example: buddy make:resource UserResource");process.exit(ExitCode.FatalError)}if(!await makeResource(options)){await outro("While running the make:resource command, there was an issue",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} resource.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:queue-table",descriptions.queueTable).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy make queue:table` ...",options);await makeQueueTable()});buddy.command("make:stack [name]",descriptions.stack).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:stack` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makeStack(options)});buddy.command("make:view [name]",descriptions.page).alias("make:page [name]").option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:view` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makePage(options)});buddy.command("make:job [name]",descriptions.job).option("-n, --name [name]",descriptions.name,{default:!1}).option("-q, --queue [queue]","The queue to dispatch to",{default:"default"}).option("-c, --class","Create a class-based job",{default:!1}).option("-t, --tries [tries]","Number of retry attempts",{default:3}).option("-b, --backoff [backoff]","Backoff delay in seconds",{default:3}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:job` ...",options);const perf=await intro("buddy make:job");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a job name.");console.error("Example: buddy make:job SendWelcomeEmail");process.exit(ExitCode.FatalError)}if(!await makeJob(options)){await outro("While running the make:job command, there was an issue",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} job.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"make")}
|
|
1
|
+
import process from"node:process";import{createFactory,createMiddleware,createMigration,createModel,createNotification,createPage,invoke,makeAction,makeCertificate,makeCommand,makeComponent,makeDatabase,makeFunction,makeJob,makeLanguage,makeMail,makePage,makePolicy,makeQueueTable,makeResource,makeStack,setDryRun}from"@stacksjs/actions";import{intro,italic,onUnknownSubcommand,outro}from"@stacksjs/cli";import{log}from"@stacksjs/logging";import{ExitCode}from"@stacksjs/types";export function make(buddy){const descriptions={action:"Create a new action",command:"Create a new CLI command",model:"Create a new model",middleware:"Create a new middleware",component:"Create a new component",page:"Create a new page",function:"Create a new function",job:"Create a new job",language:"Create a new language",database:"Create a new database",migration:"Create a new migration",factory:"Create a new factory",notification:"Create a new notification",mail:"Create a new Mailable + companion stx template",policy:"Create a new authorization policy",resource:"Create a new API resource",name:"The name of the action",queue:"Make queue migration",queueTable:"Create the queue jobs table migration",stack:"Create a new stack",certificate:"Create a new SSL Certificate",select:"What are you trying to make?",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("make [make]","The make command").option("-a, --action [action]",descriptions.action,{default:!1}).option("-c, --component [component]",descriptions.component,{default:!1}).option("-d, --database [database]",descriptions.database,{default:!1}).option("-f, --factory [factory]",descriptions.factory,{default:!1}).option("-fn, --function [function]",descriptions.function,{default:!1}).option("-l, --language [language]",descriptions.language,{default:!1}).option("-m, --model [model]",descriptions.model,{default:!1}).option("-mw, --middleware [middleware]",descriptions.middleware,{default:!1}).option("-p, --page [page]",descriptions.page,{default:!1}).option("-mg, --migration [migration]",descriptions.migration,{default:!1}).option("-n, --notification [notification]",descriptions.notification,{default:!1}).option("-qt, --queue-table",descriptions.queue,{default:!1}).option("-s, --stack [stack]",descriptions.stack,{default:!1}).option("--dry-run","Preview the files that would be generated without writing",{default:!1}).option("--with-validation","Include a validation rules block in the generated stub",{default:!1}).option("--with-auth","Include auth-aware boilerplate in the generated stub",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(make,options)=>{log.debug("Running `buddy make` ...",options);if(!buddy.args[0]){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}setDryRun(Boolean(options.dryRun||options["dry-run"]));if(make){options.name=buddy.args[1]??make;switch(make){case"action":await makeAction(options);break;case"certificate":await makeCertificate();break;case"command":await makeCommand(options);break;case"component":await makeComponent(options);break;case"database":makeDatabase(options);break;case"function":await makeFunction(options);break;case"job":await makeJob(options);break;case"language":await makeLanguage(options);break;case"mail":await makeMail(options);break;case"migration":await createMigration(options);break;case"middleware":await createMiddleware(options);break;case"model":await createModel(options);break;case"page":await createPage(options);break;case"notification":await createNotification(options);break;case"policy":await makePolicy({name:options.name,model:typeof options.model==="string"?options.model:void 0,register:options.register});break;case"resource":await makeResource({name:options.name,model:typeof options.model==="string"?options.model:void 0});break;case"queue-table":await makeQueueTable();break;case"stack":await makeStack(options);break;case"factory":await createFactory(options);break;default:{console.error(`Unknown make subcommand: ${make}`);console.error("Valid subcommands: action, certificate, command, component, database, factory, function, job, language, mail, middleware, migration, model, notification, page, policy, queue-table, resource, stack");process.exit(ExitCode.InvalidArgument)}}}await invoke(options);process.exit(ExitCode.Success)});buddy.command("make:action [name]",descriptions.action).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--dry-run","Preview the file without writing",{default:!1}).option("--with-validation","Generate a stub that calls validate()",{default:!1}).option("--with-auth","Generate a stub that requires an authenticated user",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.info("Running `buddy make:action` ...");log.debug("Running `buddy make:action` ...",name,options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");await log.flush();process.exit(ExitCode.FatalError)}setDryRun(Boolean(options.dryRun||options["dry-run"]));await makeAction(options)});buddy.command("make:certificate",descriptions.certificate).alias("make:cert").example("buddy make:certificate").action(async(options)=>{log.debug("Running `buddy make:certificate` ...",options);await makeCertificate()});buddy.command("scaffold:crud [name]","Generate model, migration, and CRUD actions").alias("make:crud").alias("make:scaffold").option("-n, --name [name]","Resource name (PascalCase)",{default:!1}).option("-f, --fields [fields]","Comma-separated field list, e.g. title:string,body:text,published:boolean",{default:""}).option("--dry-run","Preview the generated files without writing",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy scaffold:crud Post --fields=title:string,body:text,published:boolean").action(async(name,options)=>{name=name??options.name;if(!name){console.error("scaffold:crud requires a resource name. Example: buddy scaffold:crud Post --fields=title:string,body:text");process.exit(ExitCode.FatalError)}const{scaffoldCrud}=await import("@stacksjs/actions");setDryRun(Boolean(options.dryRun||options["dry-run"]));try{await scaffoldCrud(name,options)}catch(err){console.error("scaffold:crud failed:",err);process.exit(ExitCode.FatalError)}});buddy.command("make:command [name]",descriptions.command).option("-n, --name [name]",descriptions.name,{default:!1}).option("-s, --signature [signature]","The command signature (CLI name)",{default:!1}).option("-d, --description [description]","The command description",{default:!1}).option("--register","Also add an entry to app/Commands.ts (optional - commands are auto-discovered)",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy make:command SendEmails").example("buddy make:command SendEmails --signature=send-emails").action(async(name,options)=>{log.debug("Running `buddy make:command` ...",options);const perf=await intro("buddy make:command");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a command name.");console.error("Example: buddy make:command SendEmails");process.exit(ExitCode.FatalError)}if(!await makeCommand(options)){await outro("While running the make:command command, there was an issue",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} command.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:component [name]",descriptions.component).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:component` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makeComponent(options)});buddy.command("make:database [name]",descriptions.database).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action((name,options)=>{log.debug("Running `buddy make:database` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a database name via the `--name` option, or as the command\u2019s argument.");console.error("Example: `buddy make:database my-cool-database`");console.error("Or: `buddy make:database --name=my-cool-database`");console.error("Read more about the documentation here: https://stacksjs.com/docs/make/database");process.exit(ExitCode.FatalError)}makeDatabase(options)});buddy.command("make:factory [name]",descriptions.factory).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:factory` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await createFactory(options)});buddy.command("make:function [name]",descriptions.function).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:function` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makeFunction(options)});buddy.command("make:lang [name]",descriptions.language).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:lang` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makeLanguage(options)});buddy.command("make:migration [name]",descriptions.migration).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:migration` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a migration name");process.exit(ExitCode.FatalError)}await createMigration(options)});buddy.command("make:model [name]",descriptions.model).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:model` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a model name");process.exit(ExitCode.FatalError)}await createModel(options)});buddy.command("make:mail [name]",descriptions.mail).option("-n, --name [name]",descriptions.name,{default:!1}).option("-f, --force","Overwrite existing files",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy make:mail OrderShipped").example("buddy make:mail welcome-back // PascalCases to WelcomeBack, kebab-cases to welcome-back").example("buddy make:mail Welcome --force // overwrite existing files").action(async(name,options)=>{log.debug("Running `buddy make:mail` ...",options);const perf=await intro("buddy make:mail");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name (e.g. `buddy make:mail OrderShipped`).");process.exit(ExitCode.FatalError)}await makeMail(options);await outro(`Created your ${italic(name)} mailable.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:notification [name]",descriptions.notification).option("-n, --name [name]",descriptions.name,{default:!1}).option("-e, --email","Is it an email notification?",{default:!0}).option("-c, --chat","Is it a chat notification?",{default:!1}).option("-s, --sms","Is it a SMS notification?",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:notification` ...",options);const perf=await intro("buddy make:notification");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}if(!await createNotification(options)){await outro("While running the make:notification command, there was an issue",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} notification.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:policy [name]",descriptions.policy).option("-n, --name [name]",descriptions.name,{default:!1}).option("-m, --model [model]","The model this policy is for",{default:!1}).option("--no-register","Do not register in Gates.ts",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy make:policy PostPolicy").example("buddy make:policy CommentPolicy --model=Comment").action(async(name,options)=>{log.debug("Running `buddy make:policy` ...",options);const perf=await intro("buddy make:policy");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a policy name.");console.error("Example: buddy make:policy PostPolicy");process.exit(ExitCode.FatalError)}if(!await makePolicy(options)){await outro("While running the make:policy command, there was an issue",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} policy.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:resource [name]",descriptions.resource).option("-n, --name [name]",descriptions.name,{default:!1}).option("-m, --model [model]","The model this resource is for",{default:!1}).option("-c, --collection","Create a collection resource",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy make:resource UserResource").example("buddy make:resource PostResource --model=Post").example("buddy make:resource PostCollection --collection").action(async(name,options)=>{log.debug("Running `buddy make:resource` ...",options);const perf=await intro("buddy make:resource");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a resource name.");console.error("Example: buddy make:resource UserResource");process.exit(ExitCode.FatalError)}if(!await makeResource(options)){await outro("While running the make:resource command, there was an issue",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} resource.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:queue-table",descriptions.queueTable).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy make queue:table` ...",options);await makeQueueTable()});buddy.command("make:stack [name]",descriptions.stack).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:stack` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makeStack(options)});buddy.command("make:view [name]",descriptions.page).alias("make:page [name]").option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:view` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makePage(options)});buddy.command("make:job [name]",descriptions.job).option("-n, --name [name]",descriptions.name,{default:!1}).option("-q, --queue [queue]","The queue to dispatch to",{default:"default"}).option("-c, --class","Create a class-based job",{default:!1}).option("-t, --tries [tries]","Number of retry attempts",{default:3}).option("-b, --backoff [backoff]","Backoff delay in seconds",{default:3}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:job` ...",options);const perf=await intro("buddy make:job");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a job name.");console.error("Example: buddy make:job SendWelcomeEmail");process.exit(ExitCode.FatalError)}if(!await makeJob(options)){await outro("While running the make:job command, there was an issue",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} job.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"make")}
|
package/dist/commands/migrate.js
CHANGED
|
@@ -17,9 +17,9 @@ These were written under the old \`foreign_keys = OFF\` default (#1951). Review
|
|
|
17
17
|
`);process.exit(ExitCode.FatalError)}const guards=await resolveMigrationGuards(),dbLabel=currentDatabaseLabel(),APP_ENV=process.env.APP_ENV||"local";if(guards.migrateFresh==="disabled"){await log.error(`\`buddy migrate:fresh\` is disabled by your migration safety guards (it DROPS every table).
|
|
18
18
|
Target: ${APP_ENV} database "${dbLabel}"
|
|
19
19
|
To allow it, set database.safety.migrateFresh to 'allow' in config/database.ts,
|
|
20
|
-
or run once with: DB_MIGRATE_FRESH=allow ./buddy migrate:fresh`);await outro('migrate:fresh refused - the migrateFresh guard is set to "disabled".',{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}if(!(guards.migrateFresh==="allow"&&options.force===!0)){if(isCI||!hasTTY){const hint=guards.migrateFresh==="confirm"?'Guard is "confirm": migrate:fresh must be run interactively.':"Re-run with --force to drop the database non-interactively.";await log.error(`Refusing to drop the ${APP_ENV} database "${dbLabel}" in a non-interactive environment. ${hint}`);await outro("migrate:fresh cancelled.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}log.warn(`This will DROP ALL TABLES in the ${APP_ENV} database "${dbLabel}" and rebuild them from scratch. All data will be lost.`);await log.flush();if((await text({message:`Type the database name "${dbLabel}" to confirm (blank to cancel):`})).trim()!==dbLabel){await outro("migrate:fresh cancelled - confirmation did not match.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}await preflightDatabase({createDatabase:options.createDatabase,command:"migrate:fresh"});await ensureDatabaseOrExit();const result=await runAction(Action.MigrateFresh,options);if(resultFailed(result))log.error("Model migrations failed - applying auth/notification/RBAC table guarantees before exiting.");if(options.auth!==!1){log.debug("Migrating auth tables...");try{const{migrateAuthTables,migrateNotificationTables,migrateRbacTables,migrateTraitTables}=await import("@stacksjs/database"),authResult=await migrateAuthTables({verbose:options.verbose});if(!authResult.success)log.error(`Failed to migrate auth tables: ${authResult.error}`);const notifResult=await migrateNotificationTables({verbose:options.verbose});if(!notifResult.success)log.error(`Failed to migrate notification tables: ${notifResult.error}`);const rbacResult=await migrateRbacTables({verbose:options.verbose});if(!rbacResult.success)log.error(`Failed to migrate RBAC tables: ${rbacResult.error}`);const traitResult=await migrateTraitTables({verbose:options.verbose});if(!traitResult.success)log.error(`Failed to migrate polymorphic trait tables: ${traitResult.error}`)}catch(error){log.error("Failed to migrate auth/notification/RBAC/trait tables:",error)}}try{const{ensureUuidColumns,sqlHelpers}=await import("@stacksjs/database"),driver=process.env.DB_CONNECTION||"sqlite";await ensureUuidColumns(sqlHelpers(driver),{verbose:options.verbose})}catch(error){log.error("Failed to ensure uuid columns post-migration:",error)}if(resultFailed(result)){await outro("While running the migrate:fresh command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await reportMissingForeignKeys();await reportSchemaDrift();if(options.seed){log.info("Running database seeders...");try{const{runApplicationSeeders,seed}=await import("@stacksjs/database"),seedResult=await seed({verbose:options.verbose,fresh:!0}),applicationSeedResult=await runApplicationSeeders({verbose:options.verbose}),failures=seedResult.failed+applicationSeedResult.failed;if(failures>0){log.warn(`Seeding completed with ${failures} failure(s)`);for(const r of seedResult.results)if(!r.success)log.error(` - ${r.model}: ${r.error}`);for(const r of applicationSeedResult.results)if(!r.success)log.error(` - ${r.seeder}: ${r.error}`)}else log.success(`Seeded ${seedResult.successful} model(s) and ran ${applicationSeedResult.successful} application seeder(s)`)}catch(error){log.error("Failed to run seeders:",error)}}const parts=[];if(options.auth!==!1)parts.push("auth tables");if(options.seed)parts.push("seeded");const suffix=parts.length>0?` & ${parts.join(" & ")}`:"",marker=readMigrateMarker(),countPhrase=marker==null?"":marker.appliedCount===0?" (0 applied - no migration files found?)":` (${marker.appliedCount} migration${marker.appliedCount===1?"":"s"} applied)`;await outro(`All tables dropped successfully & migrated successfully${countPhrase}${suffix}`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:
|
|
20
|
+
or run once with: DB_MIGRATE_FRESH=allow ./buddy migrate:fresh`);await outro('migrate:fresh refused - the migrateFresh guard is set to "disabled".',{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}if(!(guards.migrateFresh==="allow"&&options.force===!0)){if(isCI||!hasTTY){const hint=guards.migrateFresh==="confirm"?'Guard is "confirm": migrate:fresh must be run interactively.':"Re-run with --force to drop the database non-interactively.";await log.error(`Refusing to drop the ${APP_ENV} database "${dbLabel}" in a non-interactive environment. ${hint}`);await outro("migrate:fresh cancelled.",{startTime:perf,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}log.warn(`This will DROP ALL TABLES in the ${APP_ENV} database "${dbLabel}" and rebuild them from scratch. All data will be lost.`);await log.flush();if((await text({message:`Type the database name "${dbLabel}" to confirm (blank to cancel):`})).trim()!==dbLabel){await outro("migrate:fresh cancelled - confirmation did not match.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}await preflightDatabase({createDatabase:options.createDatabase,command:"migrate:fresh"});await ensureDatabaseOrExit();const result=await runAction(Action.MigrateFresh,options);if(resultFailed(result))log.error("Model migrations failed - applying auth/notification/RBAC table guarantees before exiting.");if(options.auth!==!1){log.debug("Migrating auth tables...");try{const{migrateAuthTables,migrateNotificationTables,migrateRbacTables,migrateTraitTables}=await import("@stacksjs/database"),authResult=await migrateAuthTables({verbose:options.verbose});if(!authResult.success)log.error(`Failed to migrate auth tables: ${authResult.error}`);const notifResult=await migrateNotificationTables({verbose:options.verbose});if(!notifResult.success)log.error(`Failed to migrate notification tables: ${notifResult.error}`);const rbacResult=await migrateRbacTables({verbose:options.verbose});if(!rbacResult.success)log.error(`Failed to migrate RBAC tables: ${rbacResult.error}`);const traitResult=await migrateTraitTables({verbose:options.verbose});if(!traitResult.success)log.error(`Failed to migrate polymorphic trait tables: ${traitResult.error}`)}catch(error){log.error("Failed to migrate auth/notification/RBAC/trait tables:",error)}}try{const{ensureUuidColumns,sqlHelpers}=await import("@stacksjs/database"),driver=process.env.DB_CONNECTION||"sqlite";await ensureUuidColumns(sqlHelpers(driver),{verbose:options.verbose})}catch(error){log.error("Failed to ensure uuid columns post-migration:",error)}if(resultFailed(result)){await outro("While running the migrate:fresh command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await reportMissingForeignKeys();await reportSchemaDrift();if(options.seed){log.info("Running database seeders...");try{const{runApplicationSeeders,seed}=await import("@stacksjs/database"),seedResult=await seed({verbose:options.verbose,fresh:!0}),applicationSeedResult=await runApplicationSeeders({verbose:options.verbose}),failures=seedResult.failed+applicationSeedResult.failed;if(failures>0){log.warn(`Seeding completed with ${failures} failure(s)`);for(const r of seedResult.results)if(!r.success)log.error(` - ${r.model}: ${r.error}`);for(const r of applicationSeedResult.results)if(!r.success)log.error(` - ${r.seeder}: ${r.error}`)}else log.success(`Seeded ${seedResult.successful} model(s) and ran ${applicationSeedResult.successful} application seeder(s)`)}catch(error){log.error("Failed to run seeders:",error)}}const parts=[];if(options.auth!==!1)parts.push("auth tables");if(options.seed)parts.push("seeded");const suffix=parts.length>0?` & ${parts.join(" & ")}`:"",marker=readMigrateMarker(),countPhrase=marker==null?"":marker.appliedCount===0?" (0 applied - no migration files found?)":` (${marker.appliedCount} migration${marker.appliedCount===1?"":"s"} applied)`;await outro(`All tables dropped successfully & migrated successfully${countPhrase}${suffix}`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:switch <driver>","Pre-flight check + plan for switching DB_CONNECTION between sqlite / mysql / vitess / postgres").action(async(driver)=>{log.debug(`Running \`buddy migrate:switch ${driver}\` ...`);const perf=await intro("buddy migrate:switch"),target=driver.toLowerCase();if(!new Set(["sqlite","mysql","vitess","postgres"]).has(target)){console.log(`
|
|
21
21
|
Unknown target driver "${driver}". Allowed: sqlite, mysql, vitess, postgres.
|
|
22
|
-
`);await outro("Aborted.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}const current=(process.env.DB_CONNECTION||"sqlite").toLowerCase();if(current===target){console.log(`
|
|
22
|
+
`);await outro("Aborted.",{startTime:perf,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}const current=(process.env.DB_CONNECTION||"sqlite").toLowerCase();if(current===target){console.log(`
|
|
23
23
|
DB_CONNECTION is already "${target}". Nothing to switch.
|
|
24
24
|
`);await outro("No-op.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const missingEnv=({sqlite:["DB_DATABASE"],mysql:["DB_HOST","DB_PORT","DB_USERNAME","DB_PASSWORD","DB_DATABASE"],vitess:["DB_HOST","DB_PORT","DB_USERNAME","DB_PASSWORD","DB_DATABASE"],postgres:["DB_HOST","DB_PORT","DB_USERNAME","DB_PASSWORD","DB_DATABASE"]}[target]??[]).filter((k)=>!process.env[k]);let alterCount=0,uniqueIdxCount=0;try{const migrationsDir=resolveMigrationDirectory(target);if(existsSync(migrationsDir)){const files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql"));for(const f of files){const content=stripSqlNoise(readFileSync(`${migrationsDir}/${f}`,"utf-8")).toLowerCase();if(/alter\s+table[\s\S]*add\s+constraint/.test(content))alterCount++;if(/^\s*create\s+unique\s+index/m.test(content))uniqueIdxCount++}}}catch{}const auditTarget=target==="vitess"?"mysql":target,switchAudit=auditMigrationCorpus({dir:resolveMigrationDirectory(target),target:auditTarget}),switchBlocked=switchAudit.incompatible.length>0,blockedFiles=new Set(switchAudit.incompatible.map((m)=>m.file)).size,dialectNote=switchBlocked?`
|
|
25
25
|
\u26D4 ${blockedFiles} of ${switchAudit.total} migration files are ${switchAudit.inferred??"another dialect"}-flavoured and will NOT run on ${target}.
|
|
@@ -78,6 +78,6 @@ ${unrebuildable.map((t)=>` ${t}`).join(`
|
|
|
78
78
|
Repair with: ./buddy migrate:status --reconcile
|
|
79
79
|
That repoints renumbered ledger rows and records migrations the schema
|
|
80
80
|
already proves. It never runs SQL from a migration file.
|
|
81
|
-
`);await outro("Drift detected.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}const fixed=await reconcileMigrationLedger({includePartial:options.includePartial});if(fixed.remapped.length>0)log.success(`Repointed ${fixed.remapped.length} ledger row(s) at their renumbered file.`);if(fixed.recorded.length>0)log.success(`Recorded ${fixed.recorded.length} migration(s) the schema already reflects.`);if(fixed.pruned.length)log.success(`Pruned ${fixed.pruned.length} duplicate ledger row(s) left by a renumbering.`);if(fixed.skipped.length>0){log.warn(`Left ${fixed.skipped.length} entr(ies) alone:`);console.log(fixed.skipped.slice(0,8).map((s)=>` ${s.file} - ${s.reason}`).join(`
|
|
81
|
+
`);await outro("Drift detected.",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}const fixed=await reconcileMigrationLedger({includePartial:options.includePartial});if(fixed.remapped.length>0)log.success(`Repointed ${fixed.remapped.length} ledger row(s) at their renumbered file.`);if(fixed.recorded.length>0)log.success(`Recorded ${fixed.recorded.length} migration(s) the schema already reflects.`);if(fixed.pruned.length)log.success(`Pruned ${fixed.pruned.length} duplicate ledger row(s) left by a renumbering.`);if(fixed.skipped.length>0){log.warn(`Left ${fixed.skipped.length} entr(ies) alone:`);console.log(fixed.skipped.slice(0,8).map((s)=>` ${s.file} - ${s.reason}`).join(`
|
|
82
82
|
`)+(fixed.skipped.length>8?`
|
|
83
83
|
\u2026 +${fixed.skipped.length-8} more`:""))}if(fixed.remapped.length===0&&fixed.recorded.length===0&&fixed.pruned.length===0)log.info("Nothing could be repaired automatically.");await outro("Reconciled.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"migrate")}
|
package/dist/commands/seed.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import process from"node:process";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";export function seed(buddy){const descriptions={seed:"Seed your database",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("seed",descriptions.seed).alias("db:seed").option("-p, --project [project]",descriptions.project,{default:!1}).option("--only [models]","Comma-separated list of models to seed",{default:""}).option("--except [models]","Comma-separated list of models to skip",{default:""}).option("--only-seeders [seeders]","Comma-separated list of application seeder classes to run",{default:""}).option("--except-seeders [seeders]","Comma-separated list of application seeder classes to skip",{default:""}).option("--skip-models","Skip model-factory seeding",{default:!1}).option("--skip-application-seeders","Skip application seeders",{default:!1}).option("--include-defaults","Also seed the framework's built-in models",{default:!1}).option("--allow-protected","Seed auth/oauth models even on a non-fresh DB (will invalidate live tokens)",{default:!1}).option("--fresh","Truncate tables before seeding",{default:!1}).option("--append","Add rows to tables that already have some, instead of skipping them",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy seed` ...",options);const perf=await intro("buddy seed"),{injectGlobalAutoImports}=await import("@stacksjs/server");await injectGlobalAutoImports();const{runApplicationSeeders,seed:seedDatabase}=await import("@stacksjs/database"),list=(value)=>value?value.split(",").map((entry)=>entry.trim()).filter(Boolean):void 0,summary=options.skipModels?{total:0,successful:0,failed:0,results:[],duration:0}:await seedDatabase({verbose:options.verbose,fresh:options.fresh,append:options.append,only:list(options.only),except:list(options.except),includeDefaults:options.includeDefaults,allowProtected:options.allowProtected}),applicationSummary=options.skipApplicationSeeders?{total:0,successful:0,failed:0,results:[],duration:0}:await runApplicationSeeders({verbose:options.verbose,only:list(options.onlySeeders),except:list(options.exceptSeeders)}),APP_ENV=process.env.APP_ENV||"local";if(summary.total===0&&applicationSummary.total===0){await outro("No matching model or application seeders were found.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const failures=summary.failed+applicationSummary.failed;await outro(`Seeded your ${APP_ENV} database. ${summary.successful}/${summary.total} model(s) and ${applicationSummary.successful}/${applicationSummary.total} application seeder(s) completed${failures>0?`, ${failures} failed`:""}.`,{startTime:perf,useSeconds:!0});process.exit(failures>0?ExitCode.FatalError:ExitCode.Success)});buddy.command("seed:roles","Seed default RBAC role packs (admin, dev, client)").alias("roles:seed").action(async()=>{const perf=await intro("buddy seed:roles");try{const{seedDefaultRoles}=await import("@stacksjs/auth"),result=await seedDefaultRoles();if(result.created.length===0&&result.skipped.length>0)await outro(`All ${result.skipped.length} default role packs already exist - nothing to do.`,{startTime:perf,useSeconds:!0});else{const createdNames=result.created.map((r)=>r.name).join(", ");await outro(`Created ${result.created.length} role pack(s): ${createdNames}. Skipped ${result.skipped.length} existing.`,{startTime:perf,useSeconds:!0})}process.exit(ExitCode.Success)}catch(err){await outro("Failed to seed default roles. Most often: migrations haven't run yet (try `./buddy migrate` first).",{startTime:perf,useSeconds:!0},err);process.exit(ExitCode.FatalError)}});onUnknownSubcommand(buddy,"seed")}
|
|
1
|
+
import process from"node:process";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";export function seed(buddy){const descriptions={seed:"Seed your database",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("seed",descriptions.seed).alias("db:seed").option("-p, --project [project]",descriptions.project,{default:!1}).option("--only [models]","Comma-separated list of models to seed",{default:""}).option("--except [models]","Comma-separated list of models to skip",{default:""}).option("--only-seeders [seeders]","Comma-separated list of application seeder classes to run",{default:""}).option("--except-seeders [seeders]","Comma-separated list of application seeder classes to skip",{default:""}).option("--skip-models","Skip model-factory seeding",{default:!1}).option("--skip-application-seeders","Skip application seeders",{default:!1}).option("--include-defaults","Also seed the framework's built-in models",{default:!1}).option("--allow-protected","Seed auth/oauth models even on a non-fresh DB (will invalidate live tokens)",{default:!1}).option("--fresh","Truncate tables before seeding",{default:!1}).option("--append","Add rows to tables that already have some, instead of skipping them",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy seed` ...",options);const perf=await intro("buddy seed"),{injectGlobalAutoImports}=await import("@stacksjs/server");await injectGlobalAutoImports();const{runApplicationSeeders,seed:seedDatabase}=await import("@stacksjs/database"),list=(value)=>value?value.split(",").map((entry)=>entry.trim()).filter(Boolean):void 0,summary=options.skipModels?{total:0,successful:0,failed:0,results:[],duration:0}:await seedDatabase({verbose:options.verbose,fresh:options.fresh,append:options.append,only:list(options.only),except:list(options.except),includeDefaults:options.includeDefaults,allowProtected:options.allowProtected}),applicationSummary=options.skipApplicationSeeders?{total:0,successful:0,failed:0,results:[],duration:0}:await runApplicationSeeders({verbose:options.verbose,only:list(options.onlySeeders),except:list(options.exceptSeeders)}),APP_ENV=process.env.APP_ENV||"local";if(summary.total===0&&applicationSummary.total===0){await outro("No matching model or application seeders were found.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const failures=summary.failed+applicationSummary.failed;await outro(`Seeded your ${APP_ENV} database. ${summary.successful}/${summary.total} model(s) and ${applicationSummary.successful}/${applicationSummary.total} application seeder(s) completed${failures>0?`, ${failures} failed`:""}.`,{startTime:perf,useSeconds:!0,type:failures>0?"error":"success"});process.exit(failures>0?ExitCode.FatalError:ExitCode.Success)});buddy.command("seed:roles","Seed default RBAC role packs (admin, dev, client)").alias("roles:seed").action(async()=>{const perf=await intro("buddy seed:roles");try{const{seedDefaultRoles}=await import("@stacksjs/auth"),result=await seedDefaultRoles();if(result.created.length===0&&result.skipped.length>0)await outro(`All ${result.skipped.length} default role packs already exist - nothing to do.`,{startTime:perf,useSeconds:!0});else{const createdNames=result.created.map((r)=>r.name).join(", ");await outro(`Created ${result.created.length} role pack(s): ${createdNames}. Skipped ${result.skipped.length} existing.`,{startTime:perf,useSeconds:!0})}process.exit(ExitCode.Success)}catch(err){await outro("Failed to seed default roles. Most often: migrations haven't run yet (try `./buddy migrate` first).",{startTime:perf,useSeconds:!0},err);process.exit(ExitCode.FatalError)}});onUnknownSubcommand(buddy,"seed")}
|
package/dist/commands/share.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{getErrorMessage}from"@stacksjs/utils";import process from"node:process";import{runApiDevServer,runDashboardDevServer,runDesktopDevServer,runDocsDevServer,runFrontendDevServer}from"@stacksjs/actions";import{bold,cyan,dim,green,intro,log,outro,spinner}from"@stacksjs/cli";import{ports as configPorts}from"@stacksjs/config";import{ExitCode}from"@stacksjs/types";const originalStdoutWrite=process.stdout.write.bind(process.stdout),originalStderrWrite=process.stderr.write.bind(process.stderr),{log:originalConsoleLog,error:originalConsoleError,warn:originalConsoleWarn,info:originalConsoleInfo}=console;let _muted=!1,_verboseBuffer=[];function muteOutput(){_muted=!0;_verboseBuffer=[];const filter=(fn)=>{return function(chunk,...args){if(_muted){_verboseBuffer.push(String(chunk));return!0}return fn(chunk,...args)}};process.stdout.write=filter(originalStdoutWrite);process.stderr.write=filter(originalStderrWrite);console.log=(...args)=>{if(!_muted)originalConsoleLog(...args)};console.error=(...args)=>{if(!_muted)originalConsoleError(...args)};console.warn=(...args)=>{if(!_muted)originalConsoleWarn(...args)};console.info=(...args)=>{if(!_muted)originalConsoleInfo(...args)}}function unmuteOutput(){_muted=!1;process.stdout.write=originalStdoutWrite;process.stderr.write=originalStderrWrite;console.log=originalConsoleLog;console.error=originalConsoleError;console.warn=originalConsoleWarn;console.info=originalConsoleInfo}async function waitForPort(port,host="localhost",timeoutMs=30000){const start=Date.now();while(Date.now()-start<timeoutMs)try{await(await fetch(`http://${host}:${port}`,{signal:AbortSignal.timeout(1000)})).arrayBuffer();return}catch{await Bun.sleep(300)}throw Error(`Timed out waiting for server on port ${port}`)}const devServerRunners={frontend:runFrontendDevServer,api:runApiDevServer,backend:runApiDevServer,admin:runDashboardDevServer,dashboard:runDashboardDevServer,desktop:runDesktopDevServer,docs:runDocsDevServer},companionServices={frontend:[{port:configPorts?.api||3008,suffix:"api",label:"API",runner:runApiDevServer},{port:configPorts?.docs||3006,suffix:"docs",label:"Docs",runner:runDocsDevServer}]};function capitalize(s){return s.charAt(0).toUpperCase()+s.slice(1)}export function share(buddy){buddy.command("share [type]","Share your local development server via a public tunnel").option("-p, --port <port>","Local port to share").option("--server <url>","Tunnel server URL",{default:"api.localtunnel.dev"}).option("--subdomain <name>","Request a specific subdomain").option("--verbose","Enable verbose output",{default:!1}).action(async(type,options)=>{const perf=await intro("buddy share"),serviceType=type||"frontend",defaultPorts={frontend:configPorts?.frontend||3000,api:configPorts?.api||3008,backend:configPorts?.backend||3001,admin:configPorts?.admin||3002,dashboard:configPorts?.admin||3002,library:configPorts?.library||3003,desktop:configPorts?.desktop||3004,email:configPorts?.email||3005,docs:configPorts?.docs||3006,inspect:configPorts?.inspect||3007},port=options.port?Number.parseInt(options.port,10):defaultPorts[serviceType]||3000;if(Number.isNaN(port)||port<1||port>65535){await log.error(`Invalid port: ${options.port}`);process.exit(ExitCode.InvalidArgument)}const server=options.server||"api.localtunnel.dev",tunnels=[],companions=companionServices[serviceType]||[],s=spinner();try{const{localTunnel}=await import("@stacksjs/tunnel");console.log();const runner=devServerRunners[serviceType];if(runner){s.start(`Starting ${serviceType} dev server...`);muteOutput();runner({verbose:options.verbose??!1}).catch(()=>{});await waitForPort(port);unmuteOutput();s.succeed(`${bold(capitalize(serviceType))} ready ${dim(`on :${port}`)}`)}const startedCompanions=[];if(companions.length>0){s.start("Starting companion services...");muteOutput();for(const companion of companions)companion.runner({verbose:options.verbose??!1}).catch(()=>{});const results=await Promise.allSettled(companions.map((c)=>waitForPort(c.port,"localhost",60000)));unmuteOutput();for(let i=0;i<companions.length;i++){const result=results[i],companion=companions[i];if(!result||!companion)continue;if(result.status==="fulfilled"){startedCompanions.push(companion);s.succeed(`${bold(companion.label)} ready ${dim(`on :${companion.port}`)}`)}else s.fail(`${companion.label} failed to start ${dim(`on :${companion.port}`)}`)}}console.log();s.start("Creating tunnel...");const primaryTunnel=await localTunnel({port,server,subdomain:options.subdomain,verbose:options.verbose,onRequest:(req)=>{if(options.verbose)console.log(` ${dim(`${req.method} ${req.url}`)}`)},onReconnecting:(info)=>{s.start(`Reconnecting... (attempt ${info.attempt})`)}});tunnels.push(primaryTunnel);const baseSubdomain=primaryTunnel.subdomain;for(const companion of startedCompanions)try{const companionTunnel=await localTunnel({port:companion.port,server,subdomain:`${baseSubdomain}-${companion.suffix}`,verbose:options.verbose,onReconnecting:(info)=>{s.start(`${companion.label}: reconnecting... (attempt ${info.attempt})`)}});tunnels.push(companionTunnel)}catch{}s.succeed(`Connected ${dim(`to ${server}`)}`);const entries=[{label:capitalize(serviceType),url:primaryTunnel.url,local:`localhost:${port}`}];for(const companion of startedCompanions){const tunnel=tunnels.find((t)=>t.subdomain===`${baseSubdomain}-${companion.suffix}`);if(tunnel)entries.push({label:companion.label,url:tunnel.url,local:`localhost:${companion.port}`})}const maxLabel=Math.max(...entries.map((e)=>e.label.length));console.log();for(const entry of entries){const padding=" ".repeat(maxLabel-entry.label.length+1);console.log(` ${green("\u279C")} ${bold(entry.label)}${padding} ${cyan(entry.url)}`)}console.log();for(const entry of entries){const padding=" ".repeat(maxLabel-entry.label.length+1);console.log(` ${dim(entry.label)}${dim(padding)}${dim(entry.url)} ${dim("\u2192")} ${dim(entry.local)}`)}console.log();console.log(` ${dim("press Ctrl+C to stop")}`);console.log();const cleanup=async()=>{console.log();s.start("Closing tunnels...");await Promise.all(tunnels.map((t)=>t.close()));s.succeed("Tunnels closed");outro("Stopped sharing",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)};process.on("SIGINT",()=>cleanup());process.on("SIGTERM",()=>cleanup());await new Promise(()=>{})}catch(error){unmuteOutput();s.fail(getErrorMessage(error));const caught=error instanceof Error?error:Error(String(error));if(caught.message.includes("timeout")||caught.message.includes("ECONNREFUSED")){log.error(`Could not reach tunnel server at ${server}`);log.info(`Verify with: curl -sk https://${server}/status`)}else log.error(`Failed to create tunnel: ${caught.message}`);if(options.verbose)log.error(caught.stack);for(const t of tunnels)t.close();await outro("Share failed",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}})}
|
|
1
|
+
import{getErrorMessage}from"@stacksjs/utils";import process from"node:process";import{runApiDevServer,runDashboardDevServer,runDesktopDevServer,runDocsDevServer,runFrontendDevServer}from"@stacksjs/actions";import{bold,cyan,dim,green,intro,log,outro,spinner}from"@stacksjs/cli";import{ports as configPorts}from"@stacksjs/config";import{ExitCode}from"@stacksjs/types";const originalStdoutWrite=process.stdout.write.bind(process.stdout),originalStderrWrite=process.stderr.write.bind(process.stderr),{log:originalConsoleLog,error:originalConsoleError,warn:originalConsoleWarn,info:originalConsoleInfo}=console;let _muted=!1,_verboseBuffer=[];function muteOutput(){_muted=!0;_verboseBuffer=[];const filter=(fn)=>{return function(chunk,...args){if(_muted){_verboseBuffer.push(String(chunk));return!0}return fn(chunk,...args)}};process.stdout.write=filter(originalStdoutWrite);process.stderr.write=filter(originalStderrWrite);console.log=(...args)=>{if(!_muted)originalConsoleLog(...args)};console.error=(...args)=>{if(!_muted)originalConsoleError(...args)};console.warn=(...args)=>{if(!_muted)originalConsoleWarn(...args)};console.info=(...args)=>{if(!_muted)originalConsoleInfo(...args)}}function unmuteOutput(){_muted=!1;process.stdout.write=originalStdoutWrite;process.stderr.write=originalStderrWrite;console.log=originalConsoleLog;console.error=originalConsoleError;console.warn=originalConsoleWarn;console.info=originalConsoleInfo}async function waitForPort(port,host="localhost",timeoutMs=30000){const start=Date.now();while(Date.now()-start<timeoutMs)try{await(await fetch(`http://${host}:${port}`,{signal:AbortSignal.timeout(1000)})).arrayBuffer();return}catch{await Bun.sleep(300)}throw Error(`Timed out waiting for server on port ${port}`)}const devServerRunners={frontend:runFrontendDevServer,api:runApiDevServer,backend:runApiDevServer,admin:runDashboardDevServer,dashboard:runDashboardDevServer,desktop:runDesktopDevServer,docs:runDocsDevServer},companionServices={frontend:[{port:configPorts?.api||3008,suffix:"api",label:"API",runner:runApiDevServer},{port:configPorts?.docs||3006,suffix:"docs",label:"Docs",runner:runDocsDevServer}]};function capitalize(s){return s.charAt(0).toUpperCase()+s.slice(1)}export function share(buddy){buddy.command("share [type]","Share your local development server via a public tunnel").option("-p, --port <port>","Local port to share").option("--server <url>","Tunnel server URL",{default:"api.localtunnel.dev"}).option("--subdomain <name>","Request a specific subdomain").option("--verbose","Enable verbose output",{default:!1}).action(async(type,options)=>{const perf=await intro("buddy share"),serviceType=type||"frontend",defaultPorts={frontend:configPorts?.frontend||3000,api:configPorts?.api||3008,backend:configPorts?.backend||3001,admin:configPorts?.admin||3002,dashboard:configPorts?.admin||3002,library:configPorts?.library||3003,desktop:configPorts?.desktop||3004,email:configPorts?.email||3005,docs:configPorts?.docs||3006,inspect:configPorts?.inspect||3007},port=options.port?Number.parseInt(options.port,10):defaultPorts[serviceType]||3000;if(Number.isNaN(port)||port<1||port>65535){await log.error(`Invalid port: ${options.port}`);process.exit(ExitCode.InvalidArgument)}const server=options.server||"api.localtunnel.dev",tunnels=[],companions=companionServices[serviceType]||[],s=spinner();try{const{localTunnel}=await import("@stacksjs/tunnel");console.log();const runner=devServerRunners[serviceType];if(runner){s.start(`Starting ${serviceType} dev server...`);muteOutput();runner({verbose:options.verbose??!1}).catch(()=>{});await waitForPort(port);unmuteOutput();s.succeed(`${bold(capitalize(serviceType))} ready ${dim(`on :${port}`)}`)}const startedCompanions=[];if(companions.length>0){s.start("Starting companion services...");muteOutput();for(const companion of companions)companion.runner({verbose:options.verbose??!1}).catch(()=>{});const results=await Promise.allSettled(companions.map((c)=>waitForPort(c.port,"localhost",60000)));unmuteOutput();for(let i=0;i<companions.length;i++){const result=results[i],companion=companions[i];if(!result||!companion)continue;if(result.status==="fulfilled"){startedCompanions.push(companion);s.succeed(`${bold(companion.label)} ready ${dim(`on :${companion.port}`)}`)}else s.fail(`${companion.label} failed to start ${dim(`on :${companion.port}`)}`)}}console.log();s.start("Creating tunnel...");const primaryTunnel=await localTunnel({port,server,subdomain:options.subdomain,verbose:options.verbose,onRequest:(req)=>{if(options.verbose)console.log(` ${dim(`${req.method} ${req.url}`)}`)},onReconnecting:(info)=>{s.start(`Reconnecting... (attempt ${info.attempt})`)}});tunnels.push(primaryTunnel);const baseSubdomain=primaryTunnel.subdomain;for(const companion of startedCompanions)try{const companionTunnel=await localTunnel({port:companion.port,server,subdomain:`${baseSubdomain}-${companion.suffix}`,verbose:options.verbose,onReconnecting:(info)=>{s.start(`${companion.label}: reconnecting... (attempt ${info.attempt})`)}});tunnels.push(companionTunnel)}catch{}s.succeed(`Connected ${dim(`to ${server}`)}`);const entries=[{label:capitalize(serviceType),url:primaryTunnel.url,local:`localhost:${port}`}];for(const companion of startedCompanions){const tunnel=tunnels.find((t)=>t.subdomain===`${baseSubdomain}-${companion.suffix}`);if(tunnel)entries.push({label:companion.label,url:tunnel.url,local:`localhost:${companion.port}`})}const maxLabel=Math.max(...entries.map((e)=>e.label.length));console.log();for(const entry of entries){const padding=" ".repeat(maxLabel-entry.label.length+1);console.log(` ${green("\u279C")} ${bold(entry.label)}${padding} ${cyan(entry.url)}`)}console.log();for(const entry of entries){const padding=" ".repeat(maxLabel-entry.label.length+1);console.log(` ${dim(entry.label)}${dim(padding)}${dim(entry.url)} ${dim("\u2192")} ${dim(entry.local)}`)}console.log();console.log(` ${dim("press Ctrl+C to stop")}`);console.log();const cleanup=async()=>{console.log();s.start("Closing tunnels...");await Promise.all(tunnels.map((t)=>t.close()));s.succeed("Tunnels closed");outro("Stopped sharing",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)};process.on("SIGINT",()=>cleanup());process.on("SIGTERM",()=>cleanup());await new Promise(()=>{})}catch(error){unmuteOutput();s.fail(getErrorMessage(error));const caught=error instanceof Error?error:Error(String(error));if(caught.message.includes("timeout")||caught.message.includes("ECONNREFUSED")){log.error(`Could not reach tunnel server at ${server}`);log.info(`Verify with: curl -sk https://${server}/status`)}else log.error(`Failed to create tunnel: ${caught.message}`);if(options.verbose)log.error(caught.stack);for(const t of tunnels)t.close();await outro("Share failed",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}})}
|
package/dist/commands/stacks.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import process from"node:process";import{installStack,listStacks,uninstallStack}from"@stacksjs/actions";import{intro,italic,onUnknownSubcommand,outro}from"@stacksjs/cli";import{log}from"@stacksjs/logging";import{ExitCode}from"@stacksjs/types";export function stacks(buddy){const descriptions={install:"Install a stack into your project",uninstall:"Uninstall a stack from your project",list:"List available and installed stacks",force:"Force overwrite existing files",dryRun:"Show what would be installed without making changes",conflict:"Conflict resolution strategy: skip, overwrite, or backup",verbose:"Enable verbose output",project:"Target a specific Stacks project"};buddy.command("stack:install <name>",descriptions.install).option("--force",descriptions.force,{default:!1}).option("--dry-run",descriptions.dryRun,{default:!1}).option("--conflict <strategy>",descriptions.conflict,{default:"skip"}).option("-p, --project <path>",descriptions.project).option("--verbose",descriptions.verbose,{default:!1}).example("buddy add calendar").example("buddy add table --force").example("buddy add calendar --conflict backup --verbose").example("buddy add table --dry-run").action(async(name,options)=>{const perf=await intro("buddy stack:install");if(!name){await log.error("You need to specify a stack name.");log.info("Example: buddy add calendar");await log.flush();process.exit(ExitCode.FatalError)}if(!await installStack({name,force:options.force,dryRun:options.dryRun,conflict:options.conflict||"skip",project:options.project,verbose:options.verbose})&&!options.dryRun){await outro("Failed to install stack",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Stack ${italic(name)} installed.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("stack:uninstall <name>",descriptions.uninstall).option("--force",descriptions.force,{default:!1}).option("-p, --project <path>",descriptions.project).option("--verbose",descriptions.verbose,{default:!1}).example("buddy stack:uninstall blog").example("buddy stack:uninstall blog --force").action(async(name,options)=>{const perf=await intro("buddy stack:uninstall");if(!name){await log.error("You need to specify a stack name.");process.exit(ExitCode.FatalError)}if(!await uninstallStack({name,force:options.force,project:options.project,verbose:options.verbose})){await outro("Failed to uninstall stack",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Stack ${italic(name)} uninstalled.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("stack:list",descriptions.list).alias("stack:ls").option("-p, --project <path>",descriptions.project).example("buddy stack:list").action(async(options)=>{const perf=await intro("buddy stack:list"),entries=await listStacks(options.project);if(entries.length===0)log.info("No stacks found. Install one with: buddy add <name>");else{log.info(`Found ${entries.length} stack(s):`);log.info("");for(const entry of entries){const status=entry.installed?"[installed]":"[available]",desc=entry.description?` - ${entry.description}`:"",files=entry.fileCount?` (${entry.fileCount} files)`:"";log.info(` ${entry.name} ${italic(`v${entry.version}`)} ${status}${files}${desc}`)}}await outro("",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"stack")}
|
|
1
|
+
import process from"node:process";import{installStack,listStacks,uninstallStack}from"@stacksjs/actions";import{intro,italic,onUnknownSubcommand,outro}from"@stacksjs/cli";import{log}from"@stacksjs/logging";import{ExitCode}from"@stacksjs/types";export function stacks(buddy){const descriptions={install:"Install a stack into your project",uninstall:"Uninstall a stack from your project",list:"List available and installed stacks",force:"Force overwrite existing files",dryRun:"Show what would be installed without making changes",conflict:"Conflict resolution strategy: skip, overwrite, or backup",verbose:"Enable verbose output",project:"Target a specific Stacks project"};buddy.command("stack:install <name>",descriptions.install).option("--force",descriptions.force,{default:!1}).option("--dry-run",descriptions.dryRun,{default:!1}).option("--conflict <strategy>",descriptions.conflict,{default:"skip"}).option("-p, --project <path>",descriptions.project).option("--verbose",descriptions.verbose,{default:!1}).example("buddy add calendar").example("buddy add table --force").example("buddy add calendar --conflict backup --verbose").example("buddy add table --dry-run").action(async(name,options)=>{const perf=await intro("buddy stack:install");if(!name){await log.error("You need to specify a stack name.");log.info("Example: buddy add calendar");await log.flush();process.exit(ExitCode.FatalError)}if(!await installStack({name,force:options.force,dryRun:options.dryRun,conflict:options.conflict||"skip",project:options.project,verbose:options.verbose})&&!options.dryRun){await outro("Failed to install stack",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}await outro(`Stack ${italic(name)} installed.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("stack:uninstall <name>",descriptions.uninstall).option("--force",descriptions.force,{default:!1}).option("-p, --project <path>",descriptions.project).option("--verbose",descriptions.verbose,{default:!1}).example("buddy stack:uninstall blog").example("buddy stack:uninstall blog --force").action(async(name,options)=>{const perf=await intro("buddy stack:uninstall");if(!name){await log.error("You need to specify a stack name.");process.exit(ExitCode.FatalError)}if(!await uninstallStack({name,force:options.force,project:options.project,verbose:options.verbose})){await outro("Failed to uninstall stack",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}await outro(`Stack ${italic(name)} uninstalled.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("stack:list",descriptions.list).alias("stack:ls").option("-p, --project <path>",descriptions.project).example("buddy stack:list").action(async(options)=>{const perf=await intro("buddy stack:list"),entries=await listStacks(options.project);if(entries.length===0)log.info("No stacks found. Install one with: buddy add <name>");else{log.info(`Found ${entries.length} stack(s):`);log.info("");for(const entry of entries){const status=entry.installed?"[installed]":"[available]",desc=entry.description?` - ${entry.description}`:"",files=entry.fileCount?` (${entry.fileCount} files)`:"";log.info(` ${entry.name} ${italic(`v${entry.version}`)} ${status}${files}${desc}`)}}await outro("",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"stack")}
|
package/dist/commands/tinker.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import process from"node:process";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";export function tinker(buddy){const descriptions={tinker:"Interactive REPL with Stacks framework preloaded",eval:"Evaluate a single expression and exit",print:"Evaluate, print, and exit",noBanner:"Skip the welcome banner",preload:"Additional modules to preload (comma-separated)",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("tinker",descriptions.tinker).option("-e, --eval [expression]",descriptions.eval,{default:""}).option("--print [expression]",descriptions.print,{default:""}).option("--no-banner",descriptions.noBanner,{default:!1}).option("--preload [modules]",descriptions.preload,{default:""}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy tinker` ...",options);const perf=await intro("buddy tinker");try{const{startTinker}=await import("@stacksjs/tinker"),preloadModules=typeof options.preload==="string"&&options.preload?options.preload.split(",").map((m)=>m.trim()):void 0;if((await startTinker({eval:typeof options.eval==="string"&&options.eval?options.eval:void 0,print:typeof options.print==="string"&&options.print?options.print:void 0,banner:options.noBanner!==!0,preload:preloadModules,verbose:options.verbose===!0})).exitCode!==0){await outro("Tinker session ended with errors",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro("Tinker session ended.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}catch(error){await outro("While running the tinker command, there was an issue",{startTime:perf,useSeconds:!0},error instanceof Error?error:void 0);process.exit(ExitCode.FatalError)}});onUnknownSubcommand(buddy,"tinker")}
|
|
1
|
+
import process from"node:process";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";export function tinker(buddy){const descriptions={tinker:"Interactive REPL with Stacks framework preloaded",eval:"Evaluate a single expression and exit",print:"Evaluate, print, and exit",noBanner:"Skip the welcome banner",preload:"Additional modules to preload (comma-separated)",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("tinker",descriptions.tinker).option("-e, --eval [expression]",descriptions.eval,{default:""}).option("--print [expression]",descriptions.print,{default:""}).option("--no-banner",descriptions.noBanner,{default:!1}).option("--preload [modules]",descriptions.preload,{default:""}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy tinker` ...",options);const perf=await intro("buddy tinker");try{const{startTinker}=await import("@stacksjs/tinker"),preloadModules=typeof options.preload==="string"&&options.preload?options.preload.split(",").map((m)=>m.trim()):void 0;if((await startTinker({eval:typeof options.eval==="string"&&options.eval?options.eval:void 0,print:typeof options.print==="string"&&options.print?options.print:void 0,banner:options.noBanner!==!0,preload:preloadModules,verbose:options.verbose===!0})).exitCode!==0){await outro("Tinker session ended with errors",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}await outro("Tinker session ended.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}catch(error){await outro("While running the tinker command, there was an issue",{startTime:perf,useSeconds:!0},error instanceof Error?error:void 0);process.exit(ExitCode.FatalError)}});onUnknownSubcommand(buddy,"tinker")}
|
package/dist/commands/types.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{log}from"@stacksjs/logging";import{onUnknownSubcommand}from"@stacksjs/cli";import{runTypeGeneration}from"./generate";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("-w, --watch","Re-run on changes to models/ and config/",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy types:generate` ...",options);await runTypeGeneration(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")}
|
package/dist/lazy-commands.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const commandRegistry={about:{path:"./commands/about.js",exportName:"about"},add:{path:"./commands/add.js",exportName:"add"},"ai:context":{path:"./commands/ai-context.js",exportName:"aiContext"},auth:{path:"./commands/auth.js",exportName:"auth"},build:{path:"./commands/build.js",exportName:"build"},cd:{path:"./commands/cd.js",exportName:"cd"},changelog:{path:"./commands/changelog.js",exportName:"changelog"},clean:{path:"./commands/clean.js",exportName:"clean"},cloud:{path:"./commands/cloud.js",exportName:"cloud"},commit:{path:"./commands/commit.js",exportName:"commit"},completion:{path:"./commands/completion.js",exportName:"completion"},"config:migrate":{path:"./commands/config-migrate.js",exportName:"configMigrate"},configure:{path:"./commands/configure.js",exportName:"configure"},create:{path:"./commands/create.js",exportName:"create"},new:{path:"./commands/create.js",exportName:"create"},deploy:{path:"./commands/deploy.js",exportName:"deploy"},"deploy:rollback":{path:"./commands/deploy.js",exportName:"deploy"},dev:{path:"./commands/dev.js",exportName:"dev"},"desktop:apple:doctor":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:csr":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:init":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:package":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:provision":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:publish":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},dns:{path:"./commands/dns.js",exportName:"dns"},"dns:pull":{path:"./commands/dns.js",exportName:"dns"},"dns:diff":{path:"./commands/dns.js",exportName:"dns"},"dns:sync":{path:"./commands/dns.js",exportName:"dns"},doctor:{path:"./commands/doctor.js",exportName:"doctor"},domains:{path:"./commands/domains.js",exportName:"domains"},email:{path:"./commands/email.js",exportName:"email"},env:{path:"./commands/env.js",exportName:"env"},"extension:init":{path:"./commands/extension.js",exportName:"extension"},"extension:build":{path:"./commands/extension.js",exportName:"extension"},"extension:package":{path:"./commands/extension.js",exportName:"extension"},"extension:chrome:publish":{path:"./commands/extension.js",exportName:"extension"},"extension:chrome:status":{path:"./commands/extension.js",exportName:"extension"},"extension:firefox:publish":{path:"./commands/extension.js",exportName:"extension"},"extension:safari:init":{path:"./commands/extension.js",exportName:"extension"},"extension:safari:provision":{path:"./commands/extension.js",exportName:"extension"},"extension:safari:app":{path:"./commands/extension.js",exportName:"extension"},"extension:safari:publish":{path:"./commands/extension.js",exportName:"extension"},"dashboard:install":{path:"./commands/features.js",exportName:"features"},"dashboard:uninstall":{path:"./commands/features.js",exportName:"features"},"commerce:install":{path:"./commands/features.js",exportName:"features"},"commerce:uninstall":{path:"./commands/features.js",exportName:"features"},"cms:install":{path:"./commands/features.js",exportName:"features"},"cms:uninstall":{path:"./commands/features.js",exportName:"features"},"marketing:install":{path:"./commands/features.js",exportName:"features"},"marketing:uninstall":{path:"./commands/features.js",exportName:"features"},"monitoring:install":{path:"./commands/features.js",exportName:"features"},"monitoring:uninstall":{path:"./commands/features.js",exportName:"features"},"realtime:install":{path:"./commands/features.js",exportName:"features"},"realtime:uninstall":{path:"./commands/features.js",exportName:"features"},"queue:install":{path:"./commands/features.js",exportName:"features"},"queue:uninstall":{path:"./commands/features.js",exportName:"features"},"db:backup":{path:"./commands/db.js",exportName:"db"},"db:backups":{path:"./commands/db.js",exportName:"db"},"db:restore":{path:"./commands/db.js",exportName:"db"},fresh:{path:"./commands/fresh.js",exportName:"fresh"},generate:{path:"./commands/generate.js",exportName:"generate"},http:{path:"./commands/http.js",exportName:"http"},install:{path:"./commands/install.js",exportName:"install"},key:{path:"./commands/key.js",exportName:"key"},lint:{path:"./commands/lint.js",exportName:"lint"},format:{path:"./commands/lint.js",exportName:"lint"},"format:check":{path:"./commands/lint.js",exportName:"lint"},libs:{path:"./commands/libs.js",exportName:"libs"},libraries:{path:"./commands/libs.js",exportName:"libs"},list:{path:"./commands/list.js",exportName:"list"},mail:{path:"./commands/mail.js",exportName:"mailCommands"},"mail:preview":{path:"./commands/mail.js",exportName:"mailCommands"},maintenance:{path:"./commands/maintenance.js",exportName:"maintenance"},down:{path:"./commands/maintenance.js",exportName:"maintenance"},up:{path:"./commands/maintenance.js",exportName:"maintenance"},status:{path:"./commands/maintenance.js",exportName:"maintenance"},"coming-soon":{path:"./commands/maintenance.js",exportName:"maintenance"},"coming-soon:status":{path:"./commands/maintenance.js",exportName:"maintenance"},launch:{path:"./commands/maintenance.js",exportName:"maintenance"},make:{path:"./commands/make.js",exportName:"make"},"scaffold:crud":{path:"./commands/make.js",exportName:"make"},migrate:{path:"./commands/migrate.js",exportName:"migrate"},"migrate:fresh":{path:"./commands/migrate.js",exportName:"migrate"},"migrate:switch":{path:"./commands/migrate.js",exportName:"migrate"},"migrate:dns":{path:"./commands/migrate.js",exportName:"migrate"},"migrate:project":{path:"./commands/migrate-project.js",exportName:"migrateProject"},outdated:{path:"./commands/outdated.js",exportName:"outdated"},package:{path:"./commands/package.js",exportName:"packageCommands"},phone:{path:"./commands/phone.js",exportName:"phone"},ports:{path:"./commands/ports.js",exportName:"ports"},"server:flash":{path:"./commands/server.js",exportName:"server"},"server:first-boot":{path:"./commands/server.js",exportName:"server"},"server:doctor":{path:"./commands/server.js",exportName:"server"},"server:setup":{path:"./commands/server.js",exportName:"server"},"server:trust":{path:"./commands/server.js",exportName:"server"},"link:core":{path:"./commands/link.js",exportName:"link"},"user:add":{path:"./commands/user.js",exportName:"user"},"user:list":{path:"./commands/user.js",exportName:"user"},"unlink:core":{path:"./commands/link.js",exportName:"link"},prepublish:{path:"./commands/prepublish.js",exportName:"prepublish"},projects:{path:"./commands/projects.js",exportName:"projects"},publish:{path:"./commands/publish.js",exportName:"publish"},"publish:model":{path:"./commands/publish.js",exportName:"publish"},"publish:controller":{path:"./commands/publish.js",exportName:"publish"},"publish:middleware":{path:"./commands/publish.js",exportName:"publish"},"publish:action":{path:"./commands/publish.js",exportName:"publish"},"publish:core":{path:"./commands/publish.js",exportName:"publish"},"core:status":{path:"./commands/publish.js",exportName:"publish"},queue:{path:"./commands/queue.js",exportName:"queue"},release:{path:"./commands/release.js",exportName:"release"},route:{path:"./commands/route.js",exportName:"route"},saas:{path:"./commands/saas.js",exportName:"saas"},"stripe:setup":{path:"./commands/saas.js",exportName:"saas"},schedule:{path:"./commands/schedule.js",exportName:"schedule"},search:{path:"./commands/search.js",exportName:"search"},"search-engine:update":{path:"./commands/search.js",exportName:"search"},"search-engine:settings":{path:"./commands/search.js",exportName:"search"},seed:{path:"./commands/seed.js",exportName:"seed"},"seed:roles":{path:"./commands/seed.js",exportName:"seed"},"roles:seed":{path:"./commands/seed.js",exportName:"seed"},preview:{path:"./commands/serve.js",exportName:"preview"},serve:{path:"./commands/serve.js",exportName:"serve"},"serve:api":{path:"./commands/serve.js",exportName:"serveApi"},setup:{path:"./commands/setup.js",exportName:"setup"},"setup:ai":{path:"./commands/setup.js",exportName:"setup"},"setup:ssl":{path:"./commands/setup.js",exportName:"setup"},"setup:oh-my-zsh":{path:"./commands/setup.js",exportName:"setup"},"ai:setup":{path:"./commands/setup.js",exportName:"setup"},share:{path:"./commands/share.js",exportName:"share"},"simulator:doctor":{path:"./commands/simulator.js",exportName:"simulator"},"simulator:open":{path:"./commands/simulator.js",exportName:"simulator"},stack:{path:"./commands/stacks.js",exportName:"stacks"},sms:{path:"./commands/sms.js",exportName:"sms"},telemetry:{path:"./commands/telemetry.js",exportName:"telemetryCommand"},test:{path:"./commands/test.js",exportName:"test"},tinker:{path:"./commands/tinker.js",exportName:"tinker"},types:{path:"./commands/types.js",exportName:"types"},undeploy:{path:"./commands/cloud.js",exportName:"cloud"},upgrade:{path:"./commands/upgrade.js",exportName:"upgrade"},version:{path:"./commands/version.js",exportName:"version"},"docs:buddy":{path:"./commands/docs.js",exportName:"docs"},"docs:buddy:check":{path:"./commands/docs.js",exportName:"docs"},"docs:artifacts":{path:"./commands/docs.js",exportName:"docs"},"docs:artifacts:check":{path:"./commands/docs.js",exportName:"docs"},"docs:links":{path:"./commands/docs.js",exportName:"docs"},"docs:links:check":{path:"./commands/docs.js",exportName:"docs"}},commandGroups={minimal:["version","help"],development:["dev","build","test","lint"],database:["migrate","seed","fresh"],scaffolding:["make","generate"],deployment:["deploy","release","cloud","server"],info:["about","doctor","list"]},loadedRegistrars=new WeakMap;function loaderKey(loader){return`${loader.path}#${loader.exportName}`}export function markLoaded(buddy,commandName){const loader=commandRegistry[commandName];if(!loader)return;let set=loadedRegistrars.get(buddy);if(!set){set=new Set;loadedRegistrars.set(buddy,set)}set.add(loaderKey(loader))}export async function loadCommand(commandName,buddy){const loader=commandRegistry[commandName];if(!loader)return!1;let set=loadedRegistrars.get(buddy);if(!set){set=new Set;loadedRegistrars.set(buddy,set)}const key=loaderKey(loader);if(set.has(key))return!0;set.add(key);try{const commandFunction=(await import(loader.path))[loader.exportName];if(typeof commandFunction==="function"){commandFunction(buddy);return!0}else return!1}catch{return!1}}export async function loadCommands(commandNames,buddy){const timeout=(ms)=>new Promise((_,reject)=>setTimeout(()=>reject(Error("timeout")),ms)),seen=new Set,unique=[];for(const name of commandNames){const loader=commandRegistry[name],key=loader?`${loader.path}#${loader.exportName}`:`name:${name}`;if(seen.has(key))continue;seen.add(key);unique.push(name)}await Promise.all(unique.map(async(name)=>{try{await Promise.race([loadCommand(name,buddy),timeout(5000)])}catch{}}))}export async function loadCommandGroup(groupName,buddy){const commands=commandGroups[groupName];if(commands)await loadCommands(commands,buddy)}export async function loadAllCommands(buddy){const allCommands=Object.keys(commandRegistry);await loadCommands(allCommands,buddy)}export function getCommandNames(){return Object.keys(commandRegistry)}export function getCommandsToLoad(args){const requestedCommand=args[0],isVersionFlag=requestedCommand==="--version"||requestedCommand==="-v",isHelpFlag=requestedCommand==="--help"||requestedCommand==="-h",isHelpWord=requestedCommand==="help";if(isVersionFlag)return["version"];if(!requestedCommand||isHelpFlag||isHelpWord)return Object.keys(commandRegistry);const baseCommand=requestedCommand.split(":")[0];if(baseCommand==="list")return["list",...Object.keys(commandRegistry).filter((k)=>k!=="list")];if(commandRegistry[requestedCommand])return[requestedCommand];if(commandRegistry[baseCommand])return[baseCommand];return Object.keys(commandRegistry)}
|
|
1
|
+
const commandRegistry={about:{path:"./commands/about.js",exportName:"about"},add:{path:"./commands/add.js",exportName:"add"},"ai:context":{path:"./commands/ai-context.js",exportName:"aiContext"},auth:{path:"./commands/auth.js",exportName:"auth"},build:{path:"./commands/build.js",exportName:"build"},cd:{path:"./commands/cd.js",exportName:"cd"},changelog:{path:"./commands/changelog.js",exportName:"changelog"},clean:{path:"./commands/clean.js",exportName:"clean"},cloud:{path:"./commands/cloud.js",exportName:"cloud"},commit:{path:"./commands/commit.js",exportName:"commit"},completion:{path:"./commands/completion.js",exportName:"completion"},"config:migrate":{path:"./commands/config-migrate.js",exportName:"configMigrate"},configure:{path:"./commands/configure.js",exportName:"configure"},create:{path:"./commands/create.js",exportName:"create"},new:{path:"./commands/create.js",exportName:"create"},deploy:{path:"./commands/deploy.js",exportName:"deploy"},"deploy:rollback":{path:"./commands/deploy.js",exportName:"deploy"},dev:{path:"./commands/dev.js",exportName:"dev"},"desktop:apple:doctor":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:csr":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:init":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:package":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:provision":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:publish":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},dns:{path:"./commands/dns.js",exportName:"dns"},"dns:pull":{path:"./commands/dns.js",exportName:"dns"},"dns:diff":{path:"./commands/dns.js",exportName:"dns"},"dns:sync":{path:"./commands/dns.js",exportName:"dns"},doctor:{path:"./commands/doctor.js",exportName:"doctor"},domains:{path:"./commands/domains.js",exportName:"domains"},email:{path:"./commands/email.js",exportName:"email"},env:{path:"./commands/env.js",exportName:"env"},"extension:init":{path:"./commands/extension.js",exportName:"extension"},"extension:build":{path:"./commands/extension.js",exportName:"extension"},"extension:package":{path:"./commands/extension.js",exportName:"extension"},"extension:chrome:publish":{path:"./commands/extension.js",exportName:"extension"},"extension:chrome:status":{path:"./commands/extension.js",exportName:"extension"},"extension:firefox:publish":{path:"./commands/extension.js",exportName:"extension"},"extension:safari:init":{path:"./commands/extension.js",exportName:"extension"},"extension:safari:provision":{path:"./commands/extension.js",exportName:"extension"},"extension:safari:app":{path:"./commands/extension.js",exportName:"extension"},"extension:safari:publish":{path:"./commands/extension.js",exportName:"extension"},"dashboard:install":{path:"./commands/features.js",exportName:"features"},"dashboard:uninstall":{path:"./commands/features.js",exportName:"features"},"commerce:install":{path:"./commands/features.js",exportName:"features"},"commerce:uninstall":{path:"./commands/features.js",exportName:"features"},"cms:install":{path:"./commands/features.js",exportName:"features"},"cms:uninstall":{path:"./commands/features.js",exportName:"features"},"marketing:install":{path:"./commands/features.js",exportName:"features"},"marketing:uninstall":{path:"./commands/features.js",exportName:"features"},"monitoring:install":{path:"./commands/features.js",exportName:"features"},"monitoring:uninstall":{path:"./commands/features.js",exportName:"features"},"realtime:install":{path:"./commands/features.js",exportName:"features"},"realtime:uninstall":{path:"./commands/features.js",exportName:"features"},"queue:install":{path:"./commands/features.js",exportName:"features"},"queue:uninstall":{path:"./commands/features.js",exportName:"features"},"db:backup":{path:"./commands/db.js",exportName:"db"},"db:backups":{path:"./commands/db.js",exportName:"db"},"db:restore":{path:"./commands/db.js",exportName:"db"},fresh:{path:"./commands/fresh.js",exportName:"fresh"},generate:{path:"./commands/generate.js",exportName:"generate"},http:{path:"./commands/http.js",exportName:"http"},install:{path:"./commands/install.js",exportName:"install"},key:{path:"./commands/key.js",exportName:"key"},lint:{path:"./commands/lint.js",exportName:"lint"},format:{path:"./commands/lint.js",exportName:"lint"},"format:check":{path:"./commands/lint.js",exportName:"lint"},libs:{path:"./commands/libs.js",exportName:"libs"},libraries:{path:"./commands/libs.js",exportName:"libs"},list:{path:"./commands/list.js",exportName:"list"},mail:{path:"./commands/mail.js",exportName:"mailCommands"},"mail:preview":{path:"./commands/mail.js",exportName:"mailCommands"},maintenance:{path:"./commands/maintenance.js",exportName:"maintenance"},down:{path:"./commands/maintenance.js",exportName:"maintenance"},up:{path:"./commands/maintenance.js",exportName:"maintenance"},status:{path:"./commands/maintenance.js",exportName:"maintenance"},"coming-soon":{path:"./commands/maintenance.js",exportName:"maintenance"},"coming-soon:status":{path:"./commands/maintenance.js",exportName:"maintenance"},launch:{path:"./commands/maintenance.js",exportName:"maintenance"},make:{path:"./commands/make.js",exportName:"make"},"scaffold:crud":{path:"./commands/make.js",exportName:"make"},migrate:{path:"./commands/migrate.js",exportName:"migrate"},"migrate:fresh":{path:"./commands/migrate.js",exportName:"migrate"},"migrate:switch":{path:"./commands/migrate.js",exportName:"migrate"},"migrate:project":{path:"./commands/migrate-project.js",exportName:"migrateProject"},outdated:{path:"./commands/outdated.js",exportName:"outdated"},package:{path:"./commands/package.js",exportName:"packageCommands"},phone:{path:"./commands/phone.js",exportName:"phone"},ports:{path:"./commands/ports.js",exportName:"ports"},"server:flash":{path:"./commands/server.js",exportName:"server"},"server:first-boot":{path:"./commands/server.js",exportName:"server"},"server:doctor":{path:"./commands/server.js",exportName:"server"},"server:setup":{path:"./commands/server.js",exportName:"server"},"server:trust":{path:"./commands/server.js",exportName:"server"},"link:core":{path:"./commands/link.js",exportName:"link"},"user:add":{path:"./commands/user.js",exportName:"user"},"user:list":{path:"./commands/user.js",exportName:"user"},"unlink:core":{path:"./commands/link.js",exportName:"link"},prepublish:{path:"./commands/prepublish.js",exportName:"prepublish"},projects:{path:"./commands/projects.js",exportName:"projects"},publish:{path:"./commands/publish.js",exportName:"publish"},"publish:model":{path:"./commands/publish.js",exportName:"publish"},"publish:controller":{path:"./commands/publish.js",exportName:"publish"},"publish:middleware":{path:"./commands/publish.js",exportName:"publish"},"publish:action":{path:"./commands/publish.js",exportName:"publish"},"publish:core":{path:"./commands/publish.js",exportName:"publish"},"core:status":{path:"./commands/publish.js",exportName:"publish"},queue:{path:"./commands/queue.js",exportName:"queue"},release:{path:"./commands/release.js",exportName:"release"},route:{path:"./commands/route.js",exportName:"route"},saas:{path:"./commands/saas.js",exportName:"saas"},"stripe:setup":{path:"./commands/saas.js",exportName:"saas"},schedule:{path:"./commands/schedule.js",exportName:"schedule"},search:{path:"./commands/search.js",exportName:"search"},"search-engine:update":{path:"./commands/search.js",exportName:"search"},"search-engine:settings":{path:"./commands/search.js",exportName:"search"},seed:{path:"./commands/seed.js",exportName:"seed"},"seed:roles":{path:"./commands/seed.js",exportName:"seed"},"roles:seed":{path:"./commands/seed.js",exportName:"seed"},preview:{path:"./commands/serve.js",exportName:"preview"},serve:{path:"./commands/serve.js",exportName:"serve"},"serve:api":{path:"./commands/serve.js",exportName:"serveApi"},setup:{path:"./commands/setup.js",exportName:"setup"},"setup:ai":{path:"./commands/setup.js",exportName:"setup"},"setup:ssl":{path:"./commands/setup.js",exportName:"setup"},"setup:oh-my-zsh":{path:"./commands/setup.js",exportName:"setup"},"ai:setup":{path:"./commands/setup.js",exportName:"setup"},share:{path:"./commands/share.js",exportName:"share"},"simulator:doctor":{path:"./commands/simulator.js",exportName:"simulator"},"simulator:open":{path:"./commands/simulator.js",exportName:"simulator"},stack:{path:"./commands/stacks.js",exportName:"stacks"},sms:{path:"./commands/sms.js",exportName:"sms"},telemetry:{path:"./commands/telemetry.js",exportName:"telemetryCommand"},test:{path:"./commands/test.js",exportName:"test"},tinker:{path:"./commands/tinker.js",exportName:"tinker"},types:{path:"./commands/types.js",exportName:"types"},undeploy:{path:"./commands/cloud.js",exportName:"cloud"},upgrade:{path:"./commands/upgrade.js",exportName:"upgrade"},version:{path:"./commands/version.js",exportName:"version"},"docs:buddy":{path:"./commands/docs.js",exportName:"docs"},"docs:buddy:check":{path:"./commands/docs.js",exportName:"docs"},"docs:artifacts":{path:"./commands/docs.js",exportName:"docs"},"docs:artifacts:check":{path:"./commands/docs.js",exportName:"docs"},"docs:links":{path:"./commands/docs.js",exportName:"docs"},"docs:links:check":{path:"./commands/docs.js",exportName:"docs"}},commandGroups={minimal:["version","help"],development:["dev","build","test","lint"],database:["migrate","seed","fresh"],scaffolding:["make","generate"],deployment:["deploy","release","cloud","server"],info:["about","doctor","list"]},loadedRegistrars=new WeakMap;function loaderKey(loader){return`${loader.path}#${loader.exportName}`}export function markLoaded(buddy,commandName){const loader=commandRegistry[commandName];if(!loader)return;let set=loadedRegistrars.get(buddy);if(!set){set=new Set;loadedRegistrars.set(buddy,set)}set.add(loaderKey(loader))}export async function loadCommand(commandName,buddy){const loader=commandRegistry[commandName];if(!loader)return!1;let set=loadedRegistrars.get(buddy);if(!set){set=new Set;loadedRegistrars.set(buddy,set)}const key=loaderKey(loader);if(set.has(key))return!0;set.add(key);try{const commandFunction=(await import(loader.path))[loader.exportName];if(typeof commandFunction==="function"){commandFunction(buddy);return!0}else return!1}catch{return!1}}export async function loadCommands(commandNames,buddy){const timeout=(ms)=>new Promise((_,reject)=>setTimeout(()=>reject(Error("timeout")),ms)),seen=new Set,unique=[];for(const name of commandNames){const loader=commandRegistry[name],key=loader?`${loader.path}#${loader.exportName}`:`name:${name}`;if(seen.has(key))continue;seen.add(key);unique.push(name)}await Promise.all(unique.map(async(name)=>{try{await Promise.race([loadCommand(name,buddy),timeout(5000)])}catch{}}))}export async function loadCommandGroup(groupName,buddy){const commands=commandGroups[groupName];if(commands)await loadCommands(commands,buddy)}export async function loadAllCommands(buddy){const allCommands=Object.keys(commandRegistry);await loadCommands(allCommands,buddy)}export function getCommandNames(){return Object.keys(commandRegistry)}export function getCommandsToLoad(args){const requestedCommand=args[0],isVersionFlag=requestedCommand==="--version"||requestedCommand==="-v",isHelpFlag=requestedCommand==="--help"||requestedCommand==="-h",isHelpWord=requestedCommand==="help";if(isVersionFlag)return["version"];if(!requestedCommand||isHelpFlag||isHelpWord)return Object.keys(commandRegistry);const baseCommand=requestedCommand.split(":")[0];if(baseCommand==="list")return["list",...Object.keys(commandRegistry).filter((k)=>k!=="list")];if(commandRegistry[requestedCommand])return[requestedCommand];if(commandRegistry[baseCommand])return[baseCommand];return Object.keys(commandRegistry)}
|