@stacksjs/buddy 0.72.38 → 0.72.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1 -1
- package/dist/commands/cd.js +1 -1
- package/dist/commands/config-migrate.js +1 -1
- package/dist/commands/create.js +1 -1
- package/dist/commands/deploy.js +6 -6
- package/dist/commands/dev.js +3 -3
- package/dist/commands/dns.js +1 -1
- package/dist/commands/docs/buddy-commands.js +1 -1
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/email.js +1 -1
- package/dist/commands/env.js +1 -1
- package/dist/commands/extension.js +2 -2
- package/dist/commands/features.js +2 -2
- package/dist/commands/link.js +1 -1
- package/dist/commands/lint.js +1 -1
- package/dist/commands/mail.js +2 -2
- package/dist/commands/migrate-project.js +1 -1
- package/dist/commands/migrate.js +10 -10
- package/dist/commands/publish.js +4 -4
- package/dist/commands/queue.js +1 -1
- package/dist/commands/seed.js +1 -1
- package/dist/commands/setup.js +2 -2
- package/dist/deploy-notify.js +2 -2
- package/dist/migrators/index.js +1 -1
- package/dist/migrators/laravel/index.js +1 -1
- package/dist/migrators/laravel/models.js +3 -3
- package/dist/migrators/rails/index.js +1 -1
- package/dist/production-server.js +1 -1
- package/package.json +46 -46
package/dist/commands/link.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import{existsSync,lstatSync,readdirSync,realpathSync}from"node:fs";import fs from"node:fs";import{homedir}from"node:os";import{join,resolve}from"node:path";import process from"node:process";import{italic,log}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";function recordPath(){return join(process.cwd(),"storage/framework/runtime/linked-core.json")}function readRecord(){try{return JSON.parse(fs.readFileSync(recordPath(),"utf-8"))}catch{return null}}function writeRecord(record){fs.mkdirSync(join(process.cwd(),"storage/framework/runtime"),{recursive:!0});fs.writeFileSync(recordPath(),`${JSON.stringify(record,null,2)}
|
|
2
|
-
`)}function resolveFrameworkPath(explicit){const candidates=[explicit,process.env.STACKS_FRAMEWORK_PATH,resolve(process.cwd(),"../stacks"),join(homedir(),"Code/stacks")].filter(Boolean);for(const candidate of candidates){const full=resolve(candidate);if(existsSync(join(full,"storage/framework/core")))return full}return null}function corePackages(framework){const coreDir=join(framework,"storage/framework/core"),found=new Map;for(const entry of readdirSync(coreDir,{withFileTypes:!0})){if(!entry.isDirectory())continue;const dir=join(coreDir,entry.name),manifest=join(dir,"package.json");if(!existsSync(manifest))continue;try{const{name}=JSON.parse(fs.readFileSync(manifest,"utf-8"));if(name?.startsWith("@stacksjs/"))found.set(name,dir)}catch{}}return found}function normalize(name){return`@stacksjs/${name.replace(/^@stacksjs\//,"").replace(/^core\//,"")}`}function isSymlink(path){try{return lstatSync(path).isSymbolicLink()}catch{return!1}}export function link(buddy){buddy.command("link:core [...packages]","Point this app's @stacksjs/* at a local framework checkout").option("--path <path>","Framework checkout to link against").option("--all","Link every core package the app has installed",{default:!1}).example("buddy link:core auth router").example("buddy link:core --all --path ../stacks").action(async(packages,options)=>{const framework=resolveFrameworkPath(options?.path);if(!framework){log.error("No framework checkout found.");log.info("Pass one with `--path <dir>` or set STACKS_FRAMEWORK_PATH.");process.exit(ExitCode.FatalError)}const available=corePackages(framework),modulesDir=join(process.cwd(),"node_modules"),wanted=(packages?.length??0)>0?packages.map(normalize):[...available.keys()].filter((name)=>existsSync(join(modulesDir,name))||options?.all);if(wanted.length===0){log.info("Nothing to link: no installed @stacksjs/* packages found in this project.");await log.flush();process.exit(ExitCode.Success)}const linked=[],unbuilt=[];for(const name of wanted){const source=available.get(name);if(!source){log.warn(`${name} is not in ${italic(framework)}
|
|
2
|
+
`)}function resolveFrameworkPath(explicit){const candidates=[explicit,process.env.STACKS_FRAMEWORK_PATH,resolve(process.cwd(),"../stacks"),join(homedir(),"Code/stacks")].filter(Boolean);for(const candidate of candidates){const full=resolve(candidate);if(existsSync(join(full,"storage/framework/core")))return full}return null}function corePackages(framework){const coreDir=join(framework,"storage/framework/core"),found=new Map;for(const entry of readdirSync(coreDir,{withFileTypes:!0})){if(!entry.isDirectory())continue;const dir=join(coreDir,entry.name),manifest=join(dir,"package.json");if(!existsSync(manifest))continue;try{const{name}=JSON.parse(fs.readFileSync(manifest,"utf-8"));if(name?.startsWith("@stacksjs/"))found.set(name,dir)}catch{}}return found}function normalize(name){return`@stacksjs/${name.replace(/^@stacksjs\//,"").replace(/^core\//,"")}`}function isSymlink(path){try{return lstatSync(path).isSymbolicLink()}catch{return!1}}export function link(buddy){buddy.command("link:core [...packages]","Point this app's @stacksjs/* at a local framework checkout").option("--path <path>","Framework checkout to link against").option("--all","Link every core package the app has installed",{default:!1}).example("buddy link:core auth router").example("buddy link:core --all --path ../stacks").action(async(packages,options)=>{const framework=resolveFrameworkPath(options?.path);if(!framework){log.error("No framework checkout found.");log.info("Pass one with `--path <dir>` or set STACKS_FRAMEWORK_PATH.");process.exit(ExitCode.FatalError)}const available=corePackages(framework),modulesDir=join(process.cwd(),"node_modules"),wanted=(packages?.length??0)>0?packages.map(normalize):[...available.keys()].filter((name)=>existsSync(join(modulesDir,name))||options?.all);if(wanted.length===0){log.info("Nothing to link: no installed @stacksjs/* packages found in this project.");await log.flush();process.exit(ExitCode.Success)}const linked=[],unbuilt=[];for(const name of wanted){const source=available.get(name);if(!source){log.warn(`${name} is not in ${italic(framework)} - skipped.`);continue}const target=join(modulesDir,name);if(isSymlink(target)&&realpathSync(target)===realpathSync(source)){linked.push(name);continue}fs.rmSync(target,{recursive:!0,force:!0});fs.mkdirSync(join(target,".."),{recursive:!0});fs.symlinkSync(source,target,"dir");linked.push(name);if(!existsSync(join(source,"dist")))unbuilt.push(name)}writeRecord({framework,packages:linked});log.success(`Linked ${linked.length} package${linked.length===1?"":"s"} to ${italic(framework)}`);if(unbuilt.length>0){log.warn(`Not built yet: ${unbuilt.join(", ")}`);log.info("An app imports the build output, so each linked package needs `bun build.ts` in its directory.")}log.info("Undo with `buddy unlink:core`.");await log.flush();process.exit(ExitCode.Success)});buddy.command("unlink:core [...packages]","Go back to the installed @stacksjs/* packages").option("--all","Unlink everything that was linked",{default:!1}).example("buddy unlink:core").action(async(packages,_options)=>{const record=readRecord(),modulesDir=join(process.cwd(),"node_modules"),wanted=(packages?.length??0)>0?packages.map(normalize):record?.packages??readdirSync(join(modulesDir,"@stacksjs"),{withFileTypes:!0}).filter((entry)=>entry.isSymbolicLink()).map((entry)=>`@stacksjs/${entry.name}`);let removed=0;const unlinked=new Set;for(const name of wanted){const target=join(modulesDir,name);if(!isSymlink(target))continue;fs.rmSync(target,{force:!0});unlinked.add(name);removed++}const survivors=(record?.packages??[]).filter((name)=>!unlinked.has(name));if(survivors.length>0)writeRecord({framework:record.framework,packages:survivors});else fs.rmSync(recordPath(),{force:!0});if(removed===0){log.info("Nothing was linked.");await log.flush();process.exit(ExitCode.Success)}log.info(`Unlinked ${removed} package${removed===1?"":"s"}; reinstalling the published copies...`);if(await Bun.spawn(["bun","install","--force"],{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){log.error("`bun install` failed. The links are gone; re-run the install once the failure is resolved.");process.exit(ExitCode.FatalError)}for(const name of survivors){const source=join(record.framework,"storage/framework/core",name.replace("@stacksjs/","")),target=join(modulesDir,name);if(!existsSync(source))continue;fs.rmSync(target,{recursive:!0,force:!0});fs.symlinkSync(source,target,"dir")}if(survivors.length>0)log.success(`Unlinked ${removed}; ${survivors.length} package${survivors.length===1?"":"s"} still linked.`);else log.success("This project is back on the published packages.");await log.flush();process.exit(ExitCode.Success)})}
|
package/dist/commands/lint.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import process from"node:process";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";async function runStyleAction(entry,label,options){const actions=await import("@stacksjs/actions"),{ok}=await actions[entry](options);if(!ok){log.error(`${label} reported failure`);process.exit(ExitCode.FatalError)}}async function runStxChecks(startTime){const{runStxLint}=await import("@stacksjs/actions"),report=await runStxLint(),out=[""];for(const r of report.results)if(r.status==="fail"){out.push(` FAIL ${r.label}`);out.push(` ${r.count} found, baseline ${r.baseline}${r.why?` (${r.why})`:""}`);for(const line of r.detail.slice(0,8))out.push(` ${line}`)}else if(r.status==="loosened")out.push(` DROP ${r.label}: ${r.count} < baseline ${r.baseline}
|
|
1
|
+
import process from"node:process";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";async function runStyleAction(entry,label,options){const actions=await import("@stacksjs/actions"),{ok}=await actions[entry](options);if(!ok){log.error(`${label} reported failure`);process.exit(ExitCode.FatalError)}}async function runStxChecks(startTime){const{runStxLint}=await import("@stacksjs/actions"),report=await runStxLint(),out=[""];for(const r of report.results)if(r.status==="fail"){out.push(` FAIL ${r.label}`);out.push(` ${r.count} found, baseline ${r.baseline}${r.why?` (${r.why})`:""}`);for(const line of r.detail.slice(0,8))out.push(` ${line}`)}else if(r.status==="loosened")out.push(` DROP ${r.label}: ${r.count} < baseline ${r.baseline} - lower it in config/lint.ts`);else out.push(` ok ${r.label}${r.baseline>0?` (${r.count}, held)`:""}`);if(report.distMissing)out.push(""," note: no build output found - the dist checks did not run. Run `./buddy build` first.");if(report.loosened>0){out.push(""," Current counts, for config/lint.ts:");for(const[id,count]of Object.entries(report.counts).sort(([a],[b])=>a.localeCompare(b)))out.push(` '${id}': ${count},`)}console.log(out.join(`
|
|
2
2
|
`));if(report.failed>0){log.error(`${report.failed} stx check(s) failed.`);await outro("stx checks failed",{startTime,useSeconds:!0});process.exit(ExitCode.FatalError)}if(report.loosened>0){log.warn(`No regressions, but ${report.loosened} baseline(s) are now stale.`);await outro("stx baselines stale",{startTime,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro("All stx checks pass",{startTime,useSeconds:!0})}export function lint(buddy){const descriptions={lint:"Automagically lints your project codebase",lintFix:"Automagically fixes all lint errors",format:"Format your project codebase",formatCheck:"Check formatting without making changes",project:"Target a specific project",stx:"Run the stx conformance checks instead of code style",verbose:"Enable verbose output"};buddy.command("lint",descriptions.lint).option("-f, --fix",descriptions.lintFix,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--stx",descriptions.stx,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy lint` ...",options);const startTime=await intro("buddy lint");if(options.stx){await runStxChecks(startTime);return}await runStyleAction(options.fix?"lintFix":"lintProject","lint");await outro("Linted your project",{startTime,useSeconds:!0})});buddy.command("lint:fix",descriptions.lintFix).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy lint:fix` ...",options);const startTime=await intro("buddy lint:fix");log.info("Fixing lint errors...");await runStyleAction("lintFix","lint:fix");await outro("Fixed lint errors",{startTime,useSeconds:!0})});buddy.command("format",descriptions.format).option("-w, --write","Write changes to files",{default:!1}).option("-c, --check",descriptions.formatCheck,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy format` ...",options);const startTime=await intro("buddy format");await runStyleAction("formatProject","format",options.check?{check:!0}:{write:!0});await outro("Formatted your project",{startTime,useSeconds:!0})});buddy.command("format:check",descriptions.formatCheck).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy format:check` ...",options);const startTime=await intro("buddy format:check");await runStyleAction("formatProject","format:check",{check:!0});await outro("Format check complete",{startTime,useSeconds:!0})});onUnknownSubcommand(buddy,"lint")}
|
package/dist/commands/mail.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
var {require}=import.meta;import{log,runCommand}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{getErrorMessage}from"@stacksjs/utils";import{CLI}from"@stacksjs/clapp";import{createHash,createHmac,randomBytes,timingSafeEqual}from"crypto";import{readFileSync,existsSync}from"node:fs";import{execSync,spawnSync}from"node:child_process";export function resolveDirectMailHost(env=process.env){let configured=env.MAIL_SERVER_HOST||(env.HCLOUD_TOKEN||env.HETZNER_API_TOKEN?env.MAIL_HOST:void 0);if(configured&&["127.0.0.1","localhost","mailpit"].includes(configured))configured=env.MAIL_DOMAIN?`mail.${env.MAIL_DOMAIN}`:void 0;if(!configured||configured==="127.0.0.1"||configured==="localhost")return;return configured.replace(/^https?:\/\//,"").split("/")[0]?.split(":")[0]}export function sanitizeLineCount(value){const count=Number.parseInt(value||"50",10);return String(Number.isFinite(count)?Math.min(5000,Math.max(1,count)):50)}function shellQuote(value){return`'${value.replace(/'/g,"'\\''")}'`}async function resolveOperationalMailHost(){if(process.env.MAIL_SERVER_HOST)return resolveDirectMailHost();const token=process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN;if(!token)return;const appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");try{const ip=(await(await fetch(`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent(`${appName}-production-app`)}`,{headers:{Authorization:`Bearer ${token}`}})).json()).servers?.[0]?.public_net?.ipv4?.ip;if(ip)return ip}catch{}const configured=resolveDirectMailHost();if(configured)return configured;const{email}=await import("@stacksjs/config");return email?.domain?`mail.${email.domain}`:void 0}const mailStorageRemote="/usr/local/sbin/mail-storage-external";function loadMailStorageAws(options){const profile=options.profile||process.env.AWS_PROFILE||"default",region=options.region||process.env.AWS_REGION||"us-east-1";process.env.AWS_PROFILE=profile;loadEnvFiles();if(existsSync(".env.production"))try{const{loadEnv}=require("@stacksjs/env");loadEnv({path:".env.production",env:"production",keysFile:".env.keys",overload:!0,quiet:!0})}catch{}const credentialsPath=`${process.env.HOME||process.env.USERPROFILE||""}/.aws/credentials`;if((!process.env.AWS_ACCESS_KEY_ID||!process.env.AWS_SECRET_ACCESS_KEY)&&existsSync(credentialsPath)){const values={};let inProfile=!1;for(const line of readFileSync(credentialsPath,"utf8").split(`
|
|
2
2
|
`)){const trimmed=line.trim();if(trimmed.startsWith("[")){inProfile=trimmed===`[${profile}]`;continue}if(!inProfile)continue;const separator=trimmed.indexOf("=");if(separator>0)values[trimmed.slice(0,separator).trim()]=trimmed.slice(separator+1).trim()}if(values.aws_access_key_id&&values.aws_secret_access_key){process.env.AWS_ACCESS_KEY_ID=values.aws_access_key_id;process.env.AWS_SECRET_ACCESS_KEY=values.aws_secret_access_key;if(values.aws_session_token)process.env.AWS_SESSION_TOKEN=values.aws_session_token;else delete process.env.AWS_SESSION_TOKEN}}return{profile,region}}function mailStorageSecretId(options){if(options.secret)return options.secret;if(process.env.MAIL_STORAGE_SECRET_ID)return process.env.MAIL_STORAGE_SECRET_ID;const app=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),environment=(process.env.APP_ENV||"production").toLowerCase().replace(/[^a-z0-9-]/g,"-");return`${app}/${environment}/mail-storage-key`}async function mailStorageHost(options){const host=options.host||await resolveOperationalMailHost();if(!host)throw Error("Could not resolve the mail host. Pass --host <hostname-or-ip>.");if(!/^[a-zA-Z0-9._:-]+$/.test(host))throw Error(`Unsafe mail host: ${host}`);return host}function runMailStorageSsh(host,command,input){const result=spawnSync("ssh",["-o","BatchMode=yes","-o","ConnectTimeout=15",`root@${host}`,command],{input,maxBuffer:33554432});if(result.status!==0){const stderr=Buffer.from(result.stderr||[]).toString("utf8").trim();throw Error(stderr||`SSH command failed with status ${result.status}`)}return Buffer.from(result.stdout||[])}function readMailRecoveryKey(){const result=spawnSync("/usr/bin/security",["find-generic-password","-a","mail-storage-recovery","-s","com.stacks.production.mail.luks","-w"],{maxBuffer:1048576,timeout:30000});if(result.status!==0)throw Error("macOS Keychain recovery-key read failed");const raw=Buffer.from(result.stdout||[]),text=raw.toString("ascii").trim(),key=/^[0-9a-f]{128}$/i.test(text)?Buffer.from(text,"hex"):raw.length===65&&raw[64]===10?Buffer.from(raw.subarray(0,64)):Buffer.from(raw);raw.fill(0);if(key.length!==64)throw Error("macOS Keychain returned an invalid recovery key");return key}async function waitForHetznerAction(token,actionId){const deadline=Date.now()+120000;while(Date.now()<deadline){const response=await fetch(`https://api.hetzner.cloud/v1/actions/${actionId}`,{headers:{Authorization:`Bearer ${token}`}}),data=await response.json();if(!response.ok)throw Error(data?.error?.message||`Hetzner action ${actionId} lookup failed`);if(data.action?.status==="success")return;if(data.action?.status==="error")throw Error(data.action?.error?.message||`Hetzner action ${actionId} failed`);await new Promise((resolve)=>setTimeout(resolve,1000))}throw Error(`Hetzner action ${actionId} timed out`)}async function ensureMailStorageVolume(options){loadEnvFiles();const token=process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN;if(!token)throw Error("HCLOUD_TOKEN or HETZNER_API_TOKEN is required.");const headers={Authorization:`Bearer ${token}`,"Content-Type":"application/json"},host=await mailStorageHost(options),server=(await(await fetch(`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent("stacks-production-app")}`,{headers})).json()).servers?.[0];if(!server)throw Error("Hetzner server stacks-production-app was not found.");const app=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),name=`${app}-production-mail`,requestedSize=Number.parseInt(options.size||"20",10);if(!Number.isFinite(requestedSize)||requestedSize<10||requestedSize>1024)throw Error("Mail volume size must be between 10 and 1024 GiB.");let volume=(await(await fetch(`https://api.hetzner.cloud/v1/volumes?name=${encodeURIComponent(name)}`,{headers})).json()).volumes?.[0];if(!volume){const createResponse=await fetch("https://api.hetzner.cloud/v1/volumes",{method:"POST",headers,body:JSON.stringify({name,size:requestedSize,server:server.id,automount:!1,labels:{application:app,environment:"production",purpose:"mail-storage"}})}),createData=await createResponse.json();if(!createResponse.ok)throw Error(createData?.error?.message||"Hetzner volume creation failed.");volume=createData.volume;if(createData.action?.id)await waitForHetznerAction(token,createData.action.id)}else if(!volume.server){const attachResponse=await fetch(`https://api.hetzner.cloud/v1/volumes/${volume.id}/actions/attach`,{method:"POST",headers,body:JSON.stringify({server:server.id,automount:!1})}),attachData=await attachResponse.json();if(!attachResponse.ok)throw Error(attachData?.error?.message||"Hetzner volume attach failed.");await waitForHetznerAction(token,attachData.action.id)}else if(volume.server!==server.id)throw Error(`Hetzner volume ${name} is attached to a different server.`);volume=(await(await fetch(`https://api.hetzner.cloud/v1/volumes/${volume.id}`,{headers})).json()).volume||volume;if(!volume.linux_device)throw Error(`Hetzner volume ${name} has no Linux device path yet.`);return{device:volume.linux_device,host,id:volume.id,name,size:volume.size}}async function ensureMailKmsKey(options){const{region}=loadMailStorageAws(options),{AWSClient}=await import("../../../cloud/src/imap/client"),{S3Client}=await import("../../../cloud/src/imap/s3"),{SecretsManagerClient}=await import("../../../cloud/src/imap/secrets-manager"),client=new AWSClient,request=async(target,body)=>client.request({service:"kms",region,method:"POST",path:"/",headers:{"Content-Type":"application/x-amz-json-1.1","X-Amz-Target":`TrentService.${target}`},body:JSON.stringify(body)}),alias="alias/stacks-production-mail";let keyId=(await request("ListAliases",{Limit:100})).Aliases?.find((item)=>item.AliasName===alias)?.TargetKeyId;if(!keyId){keyId=(await request("CreateKey",{Description:"Customer-managed key for production mail secrets and backups",KeyUsage:"ENCRYPT_DECRYPT",Origin:"AWS_KMS",Tags:[{TagKey:"Application",TagValue:"stacks"},{TagKey:"Environment",TagValue:"production"},{TagKey:"Purpose",TagValue:"mail-storage"}]})).KeyMetadata?.KeyId;if(!keyId)throw Error("AWS KMS did not return a key ID.");await request("CreateAlias",{AliasName:alias,TargetKeyId:keyId});await request("EnableKeyRotation",{KeyId:keyId,RotationPeriodInDays:90})}const arn=(await request("DescribeKey",{KeyId:keyId})).KeyMetadata?.Arn;if(!arn)throw Error("AWS KMS did not return a key ARN.");const secrets=new SecretsManagerClient(region);for(const secretId of["stacks/production/mail-storage-key","stacks/production/mail-restic"])try{await secrets.updateSecret({SecretId:secretId,KmsKeyId:arn})}catch(error){if(error?.code!=="ResourceNotFoundException")throw Error(`Could not re-encrypt secret ${secretId}: ${getErrorMessage(error)}`)}const s3=new S3Client(region);for(const bucket of["stacks-production-s3-backups","stacks-production-s3-email","stacks-production-email"])try{await s3.putBucketEncryption(bucket,"aws:kms",arn)}catch(error){throw Error(`Could not apply KMS encryption to ${bucket}: ${getErrorMessage(error)}`)}return{alias,arn}}async function configureMailRestic(options){const{region}=loadMailStorageAws(options),host=await mailStorageHost(options),bucket=options.bucket||"stacks-production-s3-backups";if(!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket))throw Error(`Unsafe S3 bucket name: ${bucket}`);const kms=await ensureMailKmsKey(options),{AWSClient}=await import("../../../cloud/src/imap/client"),{SecretsManagerClient}=await import("../../../cloud/src/imap/secrets-manager"),iam=new AWSClient,iamRequest=async(action,params={})=>iam.request({service:"iam",region:"us-east-1",method:"POST",path:"/",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({Action:action,Version:"2010-05-08",...params}).toString()}),userName="stacks-production-mail-backup";try{await iamRequest("GetUser",{UserName:userName})}catch(error){if(error?.code!=="NoSuchEntity")throw error;await iamRequest("CreateUser",{UserName:userName,"Tags.member.1.Key":"Application","Tags.member.1.Value":"stacks","Tags.member.2.Key":"Environment","Tags.member.2.Value":"production","Tags.member.3.Key":"Purpose","Tags.member.3.Value":"mail-backup"})}await iamRequest("PutUserPolicy",{UserName:userName,PolicyName:"mail-restic-backup",PolicyDocument:JSON.stringify({Version:"2012-10-17",Statement:[{Effect:"Allow",Action:["s3:ListBucket","s3:GetBucketLocation"],Resource:`arn:aws:s3:::${bucket}`,Condition:{StringLike:{"s3:prefix":["restic-mail","restic-mail/*"]}}},{Effect:"Allow",Action:["s3:GetObject","s3:PutObject","s3:DeleteObject","s3:AbortMultipartUpload","s3:ListMultipartUploadParts"],Resource:`arn:aws:s3:::${bucket}/restic-mail/*`},{Effect:"Allow",Action:["kms:Decrypt","kms:Encrypt","kms:GenerateDataKey","kms:DescribeKey"],Resource:kms.arn}]})});const listedMembers=(await iamRequest("ListAccessKeys",{UserName:userName})).ListAccessKeysResult?.AccessKeyMetadata?.member,oldKeys=listedMembers?Array.isArray(listedMembers)?listedMembers:[listedMembers]:[];if(oldKeys.length>0&&!options.force)throw Error(`IAM user ${userName} already has a key. Pass --force to rotate it.`);if(oldKeys.length>=2)for(const old of oldKeys)await iamRequest("DeleteAccessKey",{UserName:userName,AccessKeyId:old.AccessKeyId});const access=(await iamRequest("CreateAccessKey",{UserName:userName})).CreateAccessKeyResult?.AccessKey;if(!access?.AccessKeyId||!access?.SecretAccessKey)throw Error("AWS IAM did not return the Restic access key.");const password=randomBytes(48).toString("base64url"),repository=`s3:s3.amazonaws.com/${bucket}/restic-mail`,secretId="stacks/production/mail-restic",secretPayload=JSON.stringify({version:1,repository,password,accessKeyId:access.AccessKeyId,secretAccessKey:access.SecretAccessKey,kmsKeyArn:kms.arn,createdAt:new Date().toISOString()}),secrets=new SecretsManagerClient(region);try{await secrets.createSecret({Name:secretId,Description:"Restic credentials for production mail backups",KmsKeyId:kms.arn,SecretString:secretPayload,Tags:[{Key:"Application",Value:"stacks"},{Key:"Environment",Value:"production"},{Key:"Purpose",Value:"mail-backup"}]})}catch(error){if(error?.code!=="ResourceExistsException")throw error;await secrets.updateSecret({SecretId:secretId,KmsKeyId:kms.arn,SecretString:secretPayload})}const configDir="/var/lib/mail-storage/data/restic",passwordPath=`${configDir}/password`,envPayload=[`RESTIC_REPOSITORY=${repository}`,`RESTIC_PASSWORD_FILE=${passwordPath}`,`AWS_ACCESS_KEY_ID=${access.AccessKeyId}`,`AWS_SECRET_ACCESS_KEY=${access.SecretAccessKey}`,`AWS_DEFAULT_REGION=${region}`,""].join(`
|
|
3
3
|
`);runMailStorageSsh(host,`install -d -m 0700 ${configDir}; umask 077; dd of=${configDir}/restic.env status=none`,Buffer.from(envPayload));runMailStorageSsh(host,`umask 077; dd of=${passwordPath} status=none`,Buffer.from(`${password}
|
|
4
|
-
`));runMailStorageSsh(host,`set -a; . ${configDir}/restic.env; set +a; restic snapshots >/dev/null 2>&1 || restic init`);runMailStorageSsh(host,"systemctl enable --now mail-restic-backup.timer mail-storage-health.timer");runMailStorageSsh(host,"systemctl start mail-restic-backup.service");runMailStorageSsh(host,"/usr/local/sbin/mail-restic-restore-check");for(const old of oldKeys)if(old.AccessKeyId!==access.AccessKeyId)await iamRequest("DeleteAccessKey",{UserName:userName,AccessKeyId:old.AccessKeyId});return{repository,secretId}}function decodeMailStorageSecret(secretString){if(!secretString)throw Error("The mail storage secret has no SecretString value.");let parsed;try{parsed=JSON.parse(secretString)}catch{throw Error("The mail storage secret is not valid JSON.")}if(parsed.version!==1||parsed.encoding!=="base64"||typeof parsed.key!=="string")throw Error("The mail storage secret uses an unsupported format.");const key=Buffer.from(parsed.key,"base64");if(key.length<32)throw Error("The mail storage key is unexpectedly short.");return key}async function getMailStorageSecret(options){const{profile,region}=loadMailStorageAws(options),secretId=mailStorageSecretId(options),{SecretsManagerClient}=await import("../../../cloud/src/imap/secrets-manager"),value=await new SecretsManagerClient(region,profile).getSecretValue({SecretId:secretId});return{key:decodeMailStorageSecret(value.SecretString),secretId}}async function escrowMailStorageKey(options){const{profile,region}=loadMailStorageAws(options),host=await mailStorageHost(options),secretId=mailStorageSecretId(options),{SecretsManagerClient}=await import("../../../cloud/src/imap/secrets-manager"),secrets=new SecretsManagerClient(region,profile),key=runMailStorageSsh(host,"systemd-creds decrypt --name=mail-storage-key /etc/credstore.encrypted/mail-storage-key.cred -");if(key.length<32)throw Error("The machine-bound mail storage key is unexpectedly short.");let existing;try{const value=await secrets.getSecretValue({SecretId:secretId});existing=decodeMailStorageSecret(value.SecretString)}catch(error){if(error?.code!=="ResourceNotFoundException")throw error}if(existing&&!timingSafeEqual(existing,key)){existing.fill(0);if(!options.force){key.fill(0);throw Error(`Secret ${secretId} already contains a different key. Pass --force to replace it.`)}}existing?.fill(0);const payload={version:1,encoding:"base64",key:key.toString("base64"),image:"/var/lib/mail-storage.luks",mapper:"mail-storage",createdAt:new Date().toISOString()};if(existing)await secrets.putSecretValue({SecretId:secretId,SecretString:JSON.stringify(payload)});else try{await secrets.createSecret({Name:secretId,Description:`Externally escrowed LUKS2 key for ${host}`,SecretString:JSON.stringify(payload),Tags:[{Key:"Application",Value:process.env.APP_NAME||"stacks"},{Key:"Environment",Value:process.env.APP_ENV||"production"},{Key:"Purpose",Value:"mail-storage"}]})}catch(error){if(error?.code!=="ResourceExistsException")throw error;await secrets.putSecretValue({SecretId:secretId,SecretString:JSON.stringify(payload)})}const verify=await secrets.getSecretValue({SecretId:secretId}),verifiedKey=decodeMailStorageSecret(verify.SecretString),matches=verifiedKey.length===key.length&&timingSafeEqual(verifiedKey,key);verifiedKey.fill(0);key.fill(0);if(!matches)throw Error(`Secret ${secretId} did not verify after writing.`);return{secretId,host}}function addMailStorageOptions(command){return command.option("--host <host>","Mail server hostname or IP").option("--secret <id>","AWS Secrets Manager secret ID").option("--region <region>","AWS region",{default:"us-east-1"}).option("--profile <profile>","AWS credential profile",{default:"default"})}export function mailCommands(buddy){buddy.command("mail:provision","Provision this app's mail from config/email.ts onto the shared mail server (domain + DKIM + mailboxes + MX/SPF/DKIM/DMARC DNS). Reusable + idempotent; the same reconcile buddy deploy runs.").option("--ip <ip>","Mail server IP (defaults to the A record of config.email.domain)").action(async(options)=>{const environment=options.env||process.env.APP_ENV||"production";process.env.APP_ENV=environment;const envFile=`.env.${environment}`;if(existsSync(envFile))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:environment,keysFile:".env.keys",overload:!0,quiet:!0})}catch(err){log.warn(`Could not load ${envFile}: ${getErrorMessage(err)}`)}const{email:emailConfig}=await import("@stacksjs/config"),domain=emailConfig?.domain;if(!domain){log.error("config/email.ts has no `domain`
|
|
4
|
+
`));runMailStorageSsh(host,`set -a; . ${configDir}/restic.env; set +a; restic snapshots >/dev/null 2>&1 || restic init`);runMailStorageSsh(host,"systemctl enable --now mail-restic-backup.timer mail-storage-health.timer");runMailStorageSsh(host,"systemctl start mail-restic-backup.service");runMailStorageSsh(host,"/usr/local/sbin/mail-restic-restore-check");for(const old of oldKeys)if(old.AccessKeyId!==access.AccessKeyId)await iamRequest("DeleteAccessKey",{UserName:userName,AccessKeyId:old.AccessKeyId});return{repository,secretId}}function decodeMailStorageSecret(secretString){if(!secretString)throw Error("The mail storage secret has no SecretString value.");let parsed;try{parsed=JSON.parse(secretString)}catch{throw Error("The mail storage secret is not valid JSON.")}if(parsed.version!==1||parsed.encoding!=="base64"||typeof parsed.key!=="string")throw Error("The mail storage secret uses an unsupported format.");const key=Buffer.from(parsed.key,"base64");if(key.length<32)throw Error("The mail storage key is unexpectedly short.");return key}async function getMailStorageSecret(options){const{profile,region}=loadMailStorageAws(options),secretId=mailStorageSecretId(options),{SecretsManagerClient}=await import("../../../cloud/src/imap/secrets-manager"),value=await new SecretsManagerClient(region,profile).getSecretValue({SecretId:secretId});return{key:decodeMailStorageSecret(value.SecretString),secretId}}async function escrowMailStorageKey(options){const{profile,region}=loadMailStorageAws(options),host=await mailStorageHost(options),secretId=mailStorageSecretId(options),{SecretsManagerClient}=await import("../../../cloud/src/imap/secrets-manager"),secrets=new SecretsManagerClient(region,profile),key=runMailStorageSsh(host,"systemd-creds decrypt --name=mail-storage-key /etc/credstore.encrypted/mail-storage-key.cred -");if(key.length<32)throw Error("The machine-bound mail storage key is unexpectedly short.");let existing;try{const value=await secrets.getSecretValue({SecretId:secretId});existing=decodeMailStorageSecret(value.SecretString)}catch(error){if(error?.code!=="ResourceNotFoundException")throw error}if(existing&&!timingSafeEqual(existing,key)){existing.fill(0);if(!options.force){key.fill(0);throw Error(`Secret ${secretId} already contains a different key. Pass --force to replace it.`)}}existing?.fill(0);const payload={version:1,encoding:"base64",key:key.toString("base64"),image:"/var/lib/mail-storage.luks",mapper:"mail-storage",createdAt:new Date().toISOString()};if(existing)await secrets.putSecretValue({SecretId:secretId,SecretString:JSON.stringify(payload)});else try{await secrets.createSecret({Name:secretId,Description:`Externally escrowed LUKS2 key for ${host}`,SecretString:JSON.stringify(payload),Tags:[{Key:"Application",Value:process.env.APP_NAME||"stacks"},{Key:"Environment",Value:process.env.APP_ENV||"production"},{Key:"Purpose",Value:"mail-storage"}]})}catch(error){if(error?.code!=="ResourceExistsException")throw error;await secrets.putSecretValue({SecretId:secretId,SecretString:JSON.stringify(payload)})}const verify=await secrets.getSecretValue({SecretId:secretId}),verifiedKey=decodeMailStorageSecret(verify.SecretString),matches=verifiedKey.length===key.length&&timingSafeEqual(verifiedKey,key);verifiedKey.fill(0);key.fill(0);if(!matches)throw Error(`Secret ${secretId} did not verify after writing.`);return{secretId,host}}function addMailStorageOptions(command){return command.option("--host <host>","Mail server hostname or IP").option("--secret <id>","AWS Secrets Manager secret ID").option("--region <region>","AWS region",{default:"us-east-1"}).option("--profile <profile>","AWS credential profile",{default:"default"})}export function mailCommands(buddy){buddy.command("mail:provision","Provision this app's mail from config/email.ts onto the shared mail server (domain + DKIM + mailboxes + MX/SPF/DKIM/DMARC DNS). Reusable + idempotent; the same reconcile buddy deploy runs.").option("--ip <ip>","Mail server IP (defaults to the A record of config.email.domain)").action(async(options)=>{const environment=options.env||process.env.APP_ENV||"production";process.env.APP_ENV=environment;const envFile=`.env.${environment}`;if(existsSync(envFile))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:environment,keysFile:".env.keys",overload:!0,quiet:!0})}catch(err){log.warn(`Could not load ${envFile}: ${getErrorMessage(err)}`)}const{email:emailConfig}=await import("@stacksjs/config"),domain=emailConfig?.domain;if(!domain){log.error("config/email.ts has no `domain` - set it (e.g. domain: 'bughq.org') and add `mailboxes`.");process.exit(ExitCode.FatalError)}let ip=options.ip;if(!ip)try{ip=(await(await import("node:dns")).promises.resolve4(domain))[0]}catch{log.error(`Could not resolve an IP for ${domain}. Deploy the site first, or pass --ip <mail-server-ip>.`);process.exit(ExitCode.FatalError)}const{provisionMailTenant,reconcileMailDns}=await import("./deploy");log.info(`Provisioning mail for ${domain} on ${ip}...`);const res=await provisionMailTenant(ip,log);if(res)await reconcileMailDns(res,ip,log);log.success(`Mail provisioned for ${domain}. Add MAIL_PASSWORD_<LOCALPART> env vars to pin mailbox passwords.`);process.exit(ExitCode.Success)});addMailStorageOptions(buddy.command("mail:storage:status","Show encrypted mail storage and external-key status")).action(async(options)=>{loadEnvFiles();try{const host=await mailStorageHost(options),output=runMailStorageSsh(host,`${mailStorageRemote} status`).toString("utf8").trim();console.log(output);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to read mail storage status: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:kms:ensure","Create and apply the customer-managed production mail KMS key")).action(async(options)=>{try{const kms=await ensureMailKmsKey(options);console.log(`alias=${kms.alias}`);console.log(`arn=${kms.arn}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to configure mail KMS: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:volume:migrate","Create a dedicated Hetzner volume and migrate encrypted mail storage")).option("--size <gib>","Hetzner volume size in GiB",{default:"20"}).action(async(options)=>{let key,prepareAttempted=!1;try{const volume=await ensureMailStorageVolume(options);key=(await getMailStorageSecret(options)).key;runMailStorageSsh(volume.host,`for i in $(seq 1 60); do test -b ${shellQuote(volume.device)} && exit 0; sleep 1; done; exit 1`);prepareAttempted=!0;runMailStorageSsh(volume.host,["umask 077","key_file=$(mktemp /run/mail-volume-migrate-key.XXXXXX)",`trap 'shred -u "$key_file" 2>/dev/null || rm -f "$key_file"' EXIT`,'dd of="$key_file" bs=64 count=1 iflag=fullblock status=none',`MAIL_ENCRYPTED_IMAGE_SIZE=16G /usr/local/sbin/mail-volume-migrate prepare ${shellQuote(volume.device)}`,`${mailStorageRemote} unlock < "$key_file"`,"/usr/local/sbin/mail-volume-migrate finalize"].join(" && "),key);const status=runMailStorageSsh(volume.host,`${mailStorageRemote} status`).toString("utf8").trim();console.log(`volume=${volume.name}`);console.log(`volume_id=${volume.id}`);console.log(`volume_size_gib=${volume.size}`);console.log(status);process.exit(ExitCode.Success)}catch(error){if(prepareAttempted&&key){const host=await mailStorageHost(options);try{runMailStorageSsh(host,"/usr/local/sbin/mail-volume-migrate rollback")}catch(rollbackError){console.error(`Dedicated-volume rollback step failed: ${getErrorMessage(rollbackError)}`)}try{runMailStorageSsh(host,`${mailStorageRemote} unlock`,key)}catch(unlockError){console.error(`Mail storage re-unlock failed: ${getErrorMessage(unlockError)}`)}}console.error(`Failed to migrate mail storage volume: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});addMailStorageOptions(buddy.command("mail:storage:key:recovery:add","Add an independent LUKS recovery key to macOS Keychain")).action(async(options)=>{let currentKey,recoveryKey;try{const host=await mailStorageHost(options);currentKey=(await getMailStorageSecret(options)).key;recoveryKey=randomBytes(64);runMailStorageSsh(host,`${mailStorageRemote} add-recovery-key`,Buffer.concat([currentKey,recoveryKey]));const account="mail-storage-recovery",service="com.stacks.production.mail.luks",encoded=recoveryKey.toString("base64"),keychainWriter=`
|
|
5
5
|
import Foundation
|
|
6
6
|
import Security
|
|
7
7
|
|
|
@@ -57,7 +57,7 @@ var {require}=import.meta;import{log,runCommand}from"@stacksjs/cli";import{ExitC
|
|
|
57
57
|
else { exit(3) }
|
|
58
58
|
`;if(spawnSync("/usr/bin/swift",["-e",keychainWriter],{input:`${encoded}
|
|
59
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("../../../cloud/src/imap/client"),{S3Client}=await import("../../../cloud/src/imap/s3"),kms=await ensureMailKmsKey(options),s3=new S3Client(region);await s3.putBucketEncryption(bucket,"aws:kms",kms.arn);const uploadUrl=s3.generatePresignedPutUrl(bucket,objectKey,"application/octet-stream",600),remoteCommand=["set -eu","tmp_dir=$(mktemp -d /run/mail-storage-header.XXXXXX)",'tmp="$tmp_dir/header"',`trap 'shred -u "$tmp" 2>/dev/null || rm -f "$tmp"; rmdir "$tmp_dir"' EXIT`,'cryptsetup luksHeaderBackup /var/lib/mail-storage.luks --header-backup-file "$tmp"',`curl --fail-with-body --silent --show-error -H 'Content-Type: application/octet-stream' --upload-file "$tmp" ${shellQuote(uploadUrl)} 1>&2`].join("; ");runMailStorageSsh(host,remoteCommand);const object=await s3.headObject(bucket,objectKey);if(!object?.ContentLength||object.ContentLength<1048576)throw Error("The uploaded LUKS header size did not verify.");const head=await new AWSClient().request({service:"s3",region,method:"HEAD",path:`/${bucket}/${objectKey}`,returnHeaders:!0}),encryption=head?.headers?.["x-amz-server-side-encryption"];if(encryption!=="aws:kms")throw Error(`The LUKS header object reported unexpected encryption: ${encryption||"none"}`);const keyId=head?.headers?.["x-amz-server-side-encryption-aws-kms-key-id"];if(keyId!==kms.arn)throw Error(`The LUKS header object used an unexpected KMS key: ${keyId||"none"}`);console.log(`header=s3://${bucket}/${objectKey}`);console.log("encryption=aws:kms");console.log(`kms_key=${kms.arn}`);process.exit(ExitCode.Success)}catch(error){console.error(`Failed to back up the LUKS header: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});addMailStorageOptions(buddy.command("mail:storage:externalize","Move the LUKS key off-host and require external unlocks")).option("--force","Replace a different existing secret",{default:!1}).action(async(options)=>{let configured=!1,key;try{const escrow=await escrowMailStorageKey(options);key=(await getMailStorageSecret(options)).key;runMailStorageSsh(escrow.host,`${mailStorageRemote} configure-external`);configured=!0;runMailStorageSsh(escrow.host,`${mailStorageRemote} unlock`,key);runMailStorageSsh(escrow.host,`${mailStorageRemote} finalize-external`);const status=runMailStorageSsh(escrow.host,`${mailStorageRemote} status`).toString("utf8").trim();console.log(status);console.log(`External key: ${escrow.secretId}`);process.exit(ExitCode.Success)}catch(error){if(configured)try{const host=await mailStorageHost(options);runMailStorageSsh(host,`${mailStorageRemote} rollback-machine`)}catch(rollbackError){console.error(`Automatic rollback also failed: ${getErrorMessage(rollbackError)}`)}console.error(`Failed to externalize mail storage: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}finally{key?.fill(0)}});buddy.command("mail:user:add <email>","Add a mail user").option("--password <password>","User password (generated if not provided)").action(async(email,options)=>{log.info(`Adding mail user: ${email}`);try{const password=options.password||randomBytes(16).toString("base64").replace(/[+/=]/g,"").substring(0,16),passwordHash=createHash("sha256").update(password).digest("hex"),tableName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-mail-users`,{DynamoDBClient}=await import("@stacksjs/ts-cloud");await new DynamoDBClient(process.env.AWS_REGION||"us-east-1").putItem({TableName:tableName,Item:{email:{S:email.toLowerCase()},passwordHash:{S:passwordHash},createdAt:{S:new Date().toISOString()}}});log.success(`User ${email} added successfully!`);console.log("");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log(" MAIL CREDENTIALS");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");console.log(` Email: ${email}`);console.log(` Password: ${password}`);console.log("");console.log(" To connect with Mail.app, run:");console.log(" buddy mail:proxy");console.log("");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");if(!options.password)log.warn("Save this password! It will not be shown again.");process.exit(ExitCode.Success)}catch(error){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){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`);process.exit(ExitCode.Success)}catch(error){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){log.error("Mail API URL not found. Run `buddy deploy` first to create the mail infrastructure.");process.exit(ExitCode.FatalError)}console.log("");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log(" IMAP PROXY");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");console.log(` API URL: ${apiUrl}`);console.log(` Port: ${options.port}`);console.log("");console.log(" Configure Mail.app with:");console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");console.log(" Account Type: IMAP");console.log(" Incoming Server: localhost");console.log(` Incoming Port: ${options.port}`);console.log(" SSL: No (local connection)");console.log(" Outgoing Server: localhost");console.log(` Outgoing Port: ${options.port}`);console.log(" Username: your-email@stacksjs.com");console.log(" Password: your-password");console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");console.log("");process.env.MAIL_API_URL=apiUrl;process.env.IMAP_PORT=options.port;const{ImapProxy}=await import("../../mail-server/src/proxy/imap-proxy"),proxy=new ImapProxy(Number.parseInt(options.port,10),apiUrl);await proxy.start();process.on("SIGINT",()=>{console.log(`
|
|
60
|
-
Shutting down proxy...`);proxy.stop();process.exit(0)})}catch(error){log.error(`Failed to start proxy: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:test","Test mail API connection").action(async()=>{try{const{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),cfn=new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1"),stackName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-cloud`,mailApiOutput=((await cfn.describeStacks({stackName})).Stacks?.[0]?.Outputs||[]).find((o)=>o.OutputKey==="MailApiUrl");if(!mailApiOutput?.OutputValue){log.error("Mail API not deployed. Run `buddy deploy` first.");process.exit(ExitCode.FatalError)}const apiUrl=mailApiOutput.OutputValue;log.info(`Testing Mail API at ${apiUrl}`);const response=await fetch(`${apiUrl}/mailboxes`,{headers:{Authorization:`Basic ${Buffer.from("test@stacksjs.com:test").toString("base64")}`}});if(response.status===401)log.success("Mail API is responding (auth required)");else if(response.ok){log.success("Mail API is working!");const data=await response.json();console.log("Response:",JSON.stringify(data,null,2))}else log.warn(`Mail API returned status ${response.status}`);process.exit(ExitCode.Success)}catch(error){log.error(`Failed to test API: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:credentials [email]","Show SMTP credentials for email access").action(async(emailAddress)=>{loadEnvFiles();const domain=process.env.APP_DOMAIN||process.env.MAIL_DOMAIN||"stacksjs.com",mailHost=`mail.${domain}`,email=emailAddress||`chris@${domain}`,localPart=email.includes("@")?email.split("@")[0]:email,username=email,envKey=`MAIL_PASSWORD_${localPart.toUpperCase().replace(/[^A-Z0-9]/g,"_")}`,rawPassword=process.env[envKey],isCiphertext=typeof rawPassword==="string"&&rawPassword.replace(/^["']/,"").startsWith("encrypted:"),password=isCiphertext?void 0:rawPassword;console.log("");console.log("==========================================================");console.log(` EMAIL CREDENTIALS
|
|
60
|
+
Shutting down proxy...`);proxy.stop();process.exit(0)})}catch(error){log.error(`Failed to start proxy: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:test","Test mail API connection").action(async()=>{try{const{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),cfn=new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1"),stackName=`${(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-")}-cloud`,mailApiOutput=((await cfn.describeStacks({stackName})).Stacks?.[0]?.Outputs||[]).find((o)=>o.OutputKey==="MailApiUrl");if(!mailApiOutput?.OutputValue){log.error("Mail API not deployed. Run `buddy deploy` first.");process.exit(ExitCode.FatalError)}const apiUrl=mailApiOutput.OutputValue;log.info(`Testing Mail API at ${apiUrl}`);const response=await fetch(`${apiUrl}/mailboxes`,{headers:{Authorization:`Basic ${Buffer.from("test@stacksjs.com:test").toString("base64")}`}});if(response.status===401)log.success("Mail API is responding (auth required)");else if(response.ok){log.success("Mail API is working!");const data=await response.json();console.log("Response:",JSON.stringify(data,null,2))}else log.warn(`Mail API returned status ${response.status}`);process.exit(ExitCode.Success)}catch(error){log.error(`Failed to test API: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:credentials [email]","Show SMTP credentials for email access").action(async(emailAddress)=>{loadEnvFiles();const domain=process.env.APP_DOMAIN||process.env.MAIL_DOMAIN||"stacksjs.com",mailHost=`mail.${domain}`,email=emailAddress||`chris@${domain}`,localPart=email.includes("@")?email.split("@")[0]:email,username=email,envKey=`MAIL_PASSWORD_${localPart.toUpperCase().replace(/[^A-Z0-9]/g,"_")}`,rawPassword=process.env[envKey],isCiphertext=typeof rawPassword==="string"&&rawPassword.replace(/^["']/,"").startsWith("encrypted:"),password=isCiphertext?void 0:rawPassword;console.log("");console.log("==========================================================");console.log(` EMAIL CREDENTIALS - ${email}`);console.log("==========================================================");console.log("");console.log(` Email: ${email}`);console.log(` Host: ${mailHost}`);console.log(" Port: 587");console.log(" Security: STARTTLS");console.log(` Username: ${username}`);if(password)console.log(` Password: ${password}`);else if(isCiphertext)console.log(" Password: (encrypted - this machine has no private key for .env.production)");else console.log(` Password: (not set - add ${envKey} to .env.production)`);console.log("");console.log("==========================================================");console.log("");process.exit(ExitCode.Success)});buddy.command("mail:logs","Show mail server logs from production").option("-n, --lines <count>","Number of log lines to show",{default:"50"}).option("-f, --follow","Follow log output (poll every 5s)").option("--filter <pattern>","Filter logs by pattern (e.g. AUTH, LOGIN, error)").action(async(options)=>{loadEnvFiles();const directHost=await resolveOperationalMailHost();if(directHost){const lines=sanitizeLineCount(options.lines),filter=options.filter?options.filter.replace(/[^a-zA-Z0-9_.@\s-]/g,""):"",command=`journalctl -u mail --no-pager -n ${lines}${filter?` | grep -iE ${shellQuote(filter)}`:""}`;try{const output=execSync(`ssh -o BatchMode=yes -o ConnectTimeout=10 root@${shellQuote(directHost)} ${shellQuote(command)}`,{encoding:"utf8"});console.log(output);process.exit(ExitCode.Success)}catch(error){log.error(`Failed to fetch logs from ${directHost}: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}}const region=process.env.AWS_REGION||"us-east-1",appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");try{const{EC2Client,SSMClient}=await import("@stacksjs/ts-cloud"),ec2=new EC2Client(region),ssm=new SSMClient(region);log.info("Finding mail server...");const instanceId=(await ec2.describeInstances({Filters:[{Name:"tag:Name",Values:[`${appName}-mail-server`]},{Name:"instance-state-name",Values:["running"]}]})).Reservations?.[0]?.Instances?.[0]?.InstanceId;if(!instanceId){log.error("No running mail server found.");log.info(`Looked for instances tagged: ${appName}-mail-server`);process.exit(ExitCode.FatalError)}log.info(`Found instance: ${instanceId}`);const lines=sanitizeLineCount(options.lines),sanitizedFilter=options.filter?options.filter.replace(/[^a-zA-Z0-9_.@\s-]/g,""):"",filterCmd=sanitizedFilter?` | grep -iE '${sanitizedFilter}'`:"",fetchLogs=async()=>{const cmd=`journalctl -u smtp-server -u mail-server --no-pager -n ${lines} --since "10 minutes ago" 2>&1${filterCmd}`,result=await ssm.runShellCommand(instanceId,[cmd],{timeoutSeconds:30,maxWaitMs:30000,pollIntervalMs:2000});if(!result.success){log.error(`Failed to fetch logs: ${result.error||"Unknown error"}`);return!1}if(result.output){const logLines=result.output.trim().split(`
|
|
61
61
|
`);for(const line of logLines)if(line.includes("error")||line.includes("Error")||line.includes("AUTH failed"))console.log(` \x1B[31m${line}\x1B[0m`);else if(line.includes("warning")||line.includes("Warning"))console.log(` \x1B[33m${line}\x1B[0m`);else if(line.includes("AUTH success")||line.includes("LOGIN completed")||line.includes("authenticated"))console.log(` \x1B[32m${line}\x1B[0m`);else console.log(` ${line}`)}else console.log(" No log entries found.");return!0};console.log("");console.log("==========================================================");console.log(" MAIL SERVER LOGS");console.log("==========================================================");console.log("");await fetchLogs();if(options.follow){console.log("");log.info("Following logs (Ctrl+C to stop)...");let polling=!1;const poll=setInterval(()=>{if(polling)return;polling=!0;console.log("");fetchLogs().catch((e)=>log.error("Log polling failed:",e)).finally(()=>{polling=!1})},5000);await new Promise((resolve)=>{process.on("SIGINT",()=>{clearInterval(poll);resolve()})})}console.log("");process.exit(ExitCode.Success)}catch(error){log.error(`Failed to fetch logs: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:status","Show mail server status").action(async()=>{loadEnvFiles();const directHost=await resolveOperationalMailHost();if(directHost){const command='systemctl is-active mail; systemctl show mail -p NRestarts -p ActiveEnterTimestamp --value; ss -H -ltn | grep -E "(:(25|143|465|587|993))\\b"; systemctl is-enabled mail-health.timer; systemctl is-active mail-health.timer';try{const output=execSync(`ssh -o BatchMode=yes -o ConnectTimeout=10 root@${shellQuote(directHost)} ${shellQuote(command)}`,{encoding:"utf8"});console.log(output);process.exit(ExitCode.Success)}catch(error){log.error(`Mail server ${directHost} is unhealthy: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}}const region=process.env.AWS_REGION||"us-east-1",appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");try{const{EC2Client,SSMClient}=await import("@stacksjs/ts-cloud"),ec2=new EC2Client(region),ssm=new SSMClient(region),instanceId=(await ec2.describeInstances({Filters:[{Name:"tag:Name",Values:[`${appName}-mail-server`]},{Name:"instance-state-name",Values:["running"]}]})).Reservations?.[0]?.Instances?.[0]?.InstanceId;if(!instanceId){log.error("No running mail server found.");log.info(`Looked for instances tagged: ${appName}-mail-server`);process.exit(ExitCode.FatalError)}const result=await ssm.runShellCommand(instanceId,["echo === Service ===","(systemctl status smtp-server --no-pager 2>&1 || systemctl status mail-server --no-pager 2>&1) | head -15","echo === Ports ===",'ss -tlnp 2>&1 | grep -E "(25|587|465|993|143|110|995)" || echo "No mail ports listening"',"echo === Memory ===",`ps aux | grep -E "(smtp-server|bun|mail)" | grep -v grep | awk '{print $6/1024 "MB", $11}'`],{timeoutSeconds:30,maxWaitMs:30000,pollIntervalMs:2000});console.log("");console.log("==========================================================");console.log(" MAIL SERVER STATUS");console.log("==========================================================");console.log("");if(result.success&&result.output)for(const line of result.output.trim().split(`
|
|
62
62
|
`))console.log(` ${line}`);else log.error(`Failed: ${result.error}`);console.log("");process.exit(ExitCode.Success)}catch(error){log.error(`Failed to get status: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:port25:request","Request port 25 unblock for direct SMTP delivery").option("--provider <provider>","Cloud provider: aws or hetzner",{default:"aws"}).option("--instance-id <id>","AWS EC2 instance ID (auto-detected if not provided)").option("--server-id <id>","Hetzner server ID (auto-detected if not provided)").option("--elastic-ip <ip>","AWS Elastic IP address").option("--rdns <hostname>","Reverse DNS hostname (e.g. mail.stacksjs.com)").option("--use-case <text>","Description of email use case").action(async(options)=>{loadEnvFiles();const domain=process.env.APP_DOMAIN||process.env.MAIL_DOMAIN||"stacksjs.com",provider=options.provider.toLowerCase();if(provider==="aws")await requestAwsPort25(options,domain);else if(provider==="hetzner")await requestHetznerPort25(options,domain);else{log.error(`Unknown provider: ${provider}. Use 'aws' or 'hetzner'.`);process.exit(ExitCode.FatalError)}});buddy.command("mail:port25:status","Check outbound port 25 status on the mail server").option("--provider <provider>","Cloud provider: aws or hetzner",{default:"aws"}).action(async(options)=>{loadEnvFiles();const region=process.env.AWS_REGION||"us-east-1",appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-");if(options.provider==="hetzner"){const hetznerToken=process.env.HETZNER_API_TOKEN;if(!hetznerToken){log.error("HETZNER_API_TOKEN not set");process.exit(ExitCode.FatalError)}try{const ip=(await(await fetch("https://api.hetzner.cloud/v1/servers?label_selector=service=mail",{headers:{Authorization:`Bearer ${hetznerToken}`}})).json()).servers?.[0]?.public_net?.ipv4?.ip;if(!ip){const mailServer=(await(await fetch("https://api.hetzner.cloud/v1/servers",{headers:{Authorization:`Bearer ${hetznerToken}`}})).json()).servers?.find((s)=>s.name?.includes("mail"));if(mailServer){const serverIp=mailServer.public_net?.ipv4?.ip;log.info(`Found Hetzner server: ${mailServer.name} (${serverIp})`);const result=execSync(`ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@${serverIp} "timeout 5 bash -c 'echo QUIT > /dev/tcp/gmail-smtp-in.l.google.com/25' 2>&1 && echo PORT_25_OPEN || echo PORT_25_BLOCKED"`,{encoding:"utf-8"});printPort25Status(result.trim(),"Hetzner",serverIp)}else log.error("No mail server found on Hetzner")}else{const result=execSync(`ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@${ip} "timeout 5 bash -c 'echo QUIT > /dev/tcp/gmail-smtp-in.l.google.com/25' 2>&1 && echo PORT_25_OPEN || echo PORT_25_BLOCKED"`,{encoding:"utf-8"});printPort25Status(result.trim(),"Hetzner",ip)}}catch(error){log.error(`Failed: ${getErrorMessage(error)}`)}process.exit(ExitCode.Success)}try{const{EC2Client,SSMClient}=await import("@stacksjs/ts-cloud"),ec2=new EC2Client(region),ssm=new SSMClient(region),instance=(await ec2.describeInstances({Filters:[{Name:"tag:Name",Values:[`${appName}-mail-server`]},{Name:"instance-state-name",Values:["running"]}]})).Reservations?.[0]?.Instances?.[0],instanceId=instance?.InstanceId;if(!instanceId){log.error("No running mail server found.");process.exit(ExitCode.FatalError)}const publicIp=instance?.PublicIpAddress||"unknown";log.info(`Testing port 25 on ${instanceId} (${publicIp})...`);const result=await ssm.runShellCommand(instanceId,['timeout 5 bash -c "echo QUIT > /dev/tcp/gmail-smtp-in.l.google.com/25" 2>&1 && echo PORT_25_OPEN || echo PORT_25_BLOCKED'],{timeoutSeconds:15,maxWaitMs:20000,pollIntervalMs:2000});printPort25Status(result.output?.trim()||"PORT_25_BLOCKED","AWS",publicIp);process.exit(ExitCode.Success)}catch(error){log.error(`Failed: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:server","Start SMTP relay server").option("--port <port>","SMTP port",{default:"587"}).action(async(options)=>{loadEnvFiles();const domain=process.env.APP_DOMAIN||process.env.MAIL_DOMAIN||"stacksjs.com",port=Number.parseInt(options.port,10),mailboxes=["chris","blake","glenn"],users={},generated=[];for(const name of mailboxes){const envKey=`MAIL_PASSWORD_${name.toUpperCase()}`;let password=process.env[envKey];if(!password){password=randomBytes(16).toString("base64url");generated.push(`${envKey}=${password}`)}users[name]={password,email:`${name}@${domain}`}}if(generated.length>0){console.log("");log.warn("Generated missing passwords. Add these to .env.production:");for(const line of generated)console.log(` ${line}`)}console.log("");console.log("==========================================================");console.log(" SMTP RELAY SERVER");console.log("==========================================================");console.log("");console.log(` Host: mail.${domain}`);console.log(` Port: ${port}`);console.log(` Domain: ${domain}`);console.log("");console.log(" Mailboxes:");for(const[name,user]of Object.entries(users))console.log(` ${name} (${user.email})`);console.log("");console.log(" Relaying through AWS SES");console.log("==========================================================");console.log("");try{const{SmtpServer}=await import("../../../cloud/src/imap/smtp-server"),server=new SmtpServer({port,host:"0.0.0.0",domain,users,sentBucket:"stacks-production-email",sentPrefix:"sent/"});await server.start();await new Promise((resolve)=>{process.on("SIGINT",async()=>{console.log(`
|
|
63
63
|
Shutting down SMTP server...`);await server.stop();resolve()})});process.exit(0)}catch(error){log.error(`Failed to start SMTP server: ${getErrorMessage(error)}`);process.exit(ExitCode.FatalError)}});buddy.command("mail:preview","Open the dev-only Mailable preview UI in your browser").option("--no-open","Print the URL but skip the browser-open step",{default:!1}).option("--port <port>","Override the API port (default: from config.ports.api / PORT_API)").example("buddy mail:preview").example("buddy mail:preview --no-open").example("buddy mail:preview --port 4000").action(async(options)=>{const{config}=await import("@stacksjs/config"),portFromOption=options.port?Number(options.port):void 0,portFromEnv=process.env.PORT_API?Number(process.env.PORT_API):void 0,url=`http://localhost:${portFromOption||portFromEnv||config.ports?.api||3008}/_stacks/mail/preview`;log.info(`Mailable preview: ${url}`);log.info("(Make sure `./buddy dev:api` is running.)");if(options.open!==!1)try{execSync(`open "${url}" 2>/dev/null || xdg-open "${url}" 2>/dev/null || true`,{encoding:"utf-8"})}catch{}process.exit(ExitCode.Success)})}function loadEnvFiles(){const envPath=".env.production";if(existsSync(envPath))try{const{loadEnv}=require("@stacksjs/env");loadEnv({path:envPath,env:"production",keysFile:".env.keys",overload:!1,quiet:!0})}catch{const content=readFileSync(envPath,"utf-8");for(const line of content.split(`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{existsSync}from"node:fs";import{writeFile}from"node:fs/promises";import{join,resolve}from"node:path";import process from"node:process";import{log,onUnknownSubcommand}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{DRIVERS,renderReport,runMigrator}from"../migrators";export function migrateProject(buddy){buddy.command("migrate:project [target]","Port an existing project (Laravel, Rails) into a Stacks project.").option("--from <framework>",`Source framework
|
|
1
|
+
import{existsSync}from"node:fs";import{writeFile}from"node:fs/promises";import{join,resolve}from"node:path";import process from"node:process";import{log,onUnknownSubcommand}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{DRIVERS,renderReport,runMigrator}from"../migrators";export function migrateProject(buddy){buddy.command("migrate:project [target]","Port an existing project (Laravel, Rails) into a Stacks project.").option("--from <framework>",`Source framework - one of: ${Object.keys(DRIVERS).join(", ")}`,{default:"laravel"}).option("--source <path>","Path to the existing source project. Defaults to the current working directory.",{default:!1}).option("--dry-run","Show what would be translated without writing files.",{default:!1}).option("--verbose","Print the per-entry table after the summary.",{default:!1}).action(async(target,options)=>{const from=options.from??"laravel";if(!(from in DRIVERS)){log.error(`Unknown source framework: '${from}'. Supported: ${Object.keys(DRIVERS).join(", ")}.`);process.exit(ExitCode.FatalError)}const sourcePath=options.source?resolve(process.cwd(),String(options.source)):process.cwd();if(!existsSync(sourcePath)){log.error(`Source project not found: ${sourcePath}`);process.exit(ExitCode.FatalError)}const targetPath=target?resolve(process.cwd(),target):process.cwd();log.info(`Migrating ${from} project: ${sourcePath} \u2192 ${targetPath}`);if(options.dryRun)log.info("(dry run - no files will be written)");try{const report=await runMigrator({source:sourcePath,target:targetPath,from,dryRun:options.dryRun===!0}),translated=report.entries.filter((e)=>e.status==="translated").length,copied=report.entries.filter((e)=>e.status==="copied").length,skipped=report.entries.filter((e)=>e.status==="skipped").length,failed=report.entries.filter((e)=>e.status==="failed").length;log.info(`Translated: ${translated} | Copied: ${copied} | Skipped: ${skipped} | Failed: ${failed}`);if(options.verbose)for(const e of report.entries){const arrow=e.target?` \u2192 ${e.target}`:"",note=e.note?` - ${e.note}`:"";log.info(` [${e.status}] ${e.source}${arrow}${note}`)}if(!options.dryRun){const reportPath=join(targetPath,"MIGRATION_REPORT.md");await writeFile(reportPath,renderReport(report));log.success(`Report written to ${reportPath}`)}process.exit(ExitCode.Success)}catch(err){log.error(`Migration failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}});onUnknownSubcommand(buddy,"migrate:project")}
|
package/dist/commands/migrate.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
import{closeSync,existsSync,mkdirSync,openSync,readdirSync,readFileSync,rmSync}from"node:fs";import{relative}from"node:path";import process from"node:process";import{confirm,intro,log,onUnknownSubcommand,outro,text}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{hasTTY,isCI}from"@stacksjs/env";import{appPath,frameworkPath,frameworkRuntimePath,projectPath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{preflightDatabase}from"../database-preflight";import{DDL_CONSTRAINT_OVERRIDE_ENV,DIALECT_OVERRIDE_ENV,auditDdlConstraints,auditMigrationCorpus,dialectCapabilities,formatDdlConstraintError,formatMigrationDialectError,relativeMigrationDirectory,resolveMigrationDirectory,stripSqlNoise}from"@stacksjs/database";import{resultFailed}from"../result";let _runAction;async function runAction(...args){if(!_runAction)_runAction=(await import("@stacksjs/actions")).runAction;return _runAction(...args)}function acquireMigrationLock(){const lockDir=frameworkRuntimePath(),lockFile=`${lockDir}/migrations.lock`;try{if(!existsSync(lockDir))mkdirSync(lockDir,{recursive:!0});const fd=openSync(lockFile,"wx");try{closeSync(fd)}catch{}return{acquired:!0,release:()=>{try{rmSync(lockFile,{force:!0})}catch{}}}}catch{return{acquired:!1,release:()=>{}}}}async function ensureDatabaseOrExit(){try{const{ensureDatabaseReady}=await import("@stacksjs/database");await ensureDatabaseReady()}catch(error){log.syncError(error instanceof Error?error.message:String(error));process.exit(ExitCode.FatalError)}}function readMigrateMarker(){const file=frameworkRuntimePath("last-migrate-result.json");if(!existsSync(file))return null;try{const raw=readFileSync(file,"utf8"),parsed=JSON.parse(raw),n=typeof parsed.appliedCount==="number"?parsed.appliedCount:null;if(n===null||!Number.isFinite(n))return null;return{appliedCount:Math.max(0,Math.floor(n))}}catch{return null}finally{try{rmSync(file,{force:!0})}catch{}}}function countModelFiles(dir){if(!existsSync(dir))return 0;let count=0;const entries=readdirSync(dir,{withFileTypes:!0});for(const entry of entries)if(entry.isDirectory())count+=countModelFiles(`${dir}/${entry.name}`);else if(entry.name.endsWith(".ts")&&!entry.name.startsWith(".")&&!entry.name.startsWith("index"))count++;return count}function validateModelsExist(){const userModelsPath=appPath("Models"),defaultModelsPath=frameworkPath("defaults/app/Models"),userModelCount=countModelFiles(userModelsPath),defaultModelCount=countModelFiles(defaultModelsPath);if(userModelCount===0&&defaultModelCount===0)return{valid:!1,error:"No models found. Please create models in app/Models or ensure framework defaults exist."};return{valid:!0}}export function validateMigrationDialect(cwd=process.cwd(),options={}){const driver=String(options.driver||process.env.DB_CONNECTION||"sqlite").toLowerCase(),dir=resolveMigrationDirectory(driver,{cwd}),relativeDir=relativeMigrationDirectory(dir,cwd);if(process.env[DIALECT_OVERRIDE_ENV]!=="1"){const caps=dialectCapabilities(driver),target=caps.wire==="mysql"?"mysql":caps.wire==="postgres"?"postgres":"sqlite";if(driver==="sqlite"||driver==="postgres"||caps.wire==="mysql"){const audit=auditMigrationCorpus({dir,target});if(!audit.empty&&audit.incompatible.length>0)return{valid:!1,error:formatMigrationDialectError(audit,target,relativeDir)}}}if(process.env[DDL_CONSTRAINT_OVERRIDE_ENV]!=="1"){const constraints=auditDdlConstraints({dir,dialect:driver});if(!constraints.empty&&constraints.violations.length>0)return{valid:!1,error:formatDdlConstraintError(constraints,driver,relativeDir)}}return{valid:!0}}async function reportSchemaDrift(){try{const{auditSchemaDrift,formatSchemaDrift}=await import("@stacksjs/database"),drift=await auditSchemaDrift();if(drift.skipped||drift.clean)return;log.warn(await formatSchemaDrift(drift))}catch(err){log.debug(`[migrate] schema drift check skipped: ${err instanceof Error?err.message:String(err)}`)}}async function reportMissingForeignKeys(){try{const{auditForeignKeys}=await import("@stacksjs/database"),result=await auditForeignKeys();if(result.missing.length===0)return;const sample=result.missing.slice(0,5).map((fk)=>` \u2022 ${fk.fromTable}.${fk.fromColumn} \u2192 ${fk.toTable}.${fk.toColumn} (${fk.model})`).join(`
|
|
2
2
|
`),more=result.missing.length>5?`
|
|
3
|
-
+ ${result.missing.length-5} more
|
|
3
|
+
+ ${result.missing.length-5} more - run \`./buddy doctor\` for the full list.`:"";log.warn(`${result.missing.length} of ${result.declared.length} declared foreign keys are missing from the live schema:
|
|
4
4
|
${sample}${more}
|
|
5
|
-
|
|
5
|
+
The model-backed create migrations may be stale for this database - run \`./buddy migrate:fresh\` to regenerate and replay them from the model attributes (this resets data).`)}catch(err){log.debug(`[migrate] FK integrity check skipped: ${err instanceof Error?err.message:String(err)}`)}}async function reportFkOrphans(){try{const{findFkOrphans}=await import("@stacksjs/database"),result=await findFkOrphans();if(!result.supported||result.total===0)return;const sample=result.orphans.slice(0,5).map((o)=>` \u2022 ${o.table}.${o.column} \u2192 ${o.parent} (${o.count} row${o.count===1?"":"s"})`).join(`
|
|
6
6
|
`),more=result.orphans.length>5?`
|
|
7
|
-
+ ${result.orphans.length-5} more
|
|
7
|
+
+ ${result.orphans.length-5} more - run \`./buddy doctor\` for the full list.`:"",first=result.orphans[0];log.warn(`${result.total} row${result.total===1?"":"s"} violate foreign keys (orphaned parents), now that SQLite enforces \`foreign_keys = ON\`:
|
|
8
8
|
${sample}${more}
|
|
9
|
-
|
|
9
|
+
These were written under the old \`foreign_keys = OFF\` default (#1951). Review and clean up manually - e.g. DELETE FROM ${first.table} WHERE ${first.column} IS NOT NULL AND ${first.column} NOT IN (SELECT id FROM ${first.parent}). migrate never deletes data.`)}catch(err){log.debug(`[migrate] FK orphan scan skipped: ${err instanceof Error?err.message:String(err)}`)}}function describeOp(op){switch(op.kind){case"drop_table":return`drop table "${op.table}" (all rows lost)`;case"drop_column":return`drop column "${op.table}"."${op.column}" (column data lost)`;case"modify_column":return`change type of "${op.table}"."${op.column}" (possible data loss)`;case"rebuild_table":return`rebuild table "${op.table}" (type/constraint change)`;case"rename_column":return`rename "${op.table}"."${op.from}" \u2192 "${op.to}"`;case"rename_table":return`rename table "${op.from}" \u2192 "${op.to}"`;default:return`${op.kind} on "${op.table}"${op.column?`."${op.column}"`:""}`}}async function confirmDestructiveMigrations(opts){let operations=[];try{const{previewPendingMigrations}=await import("@stacksjs/database");operations=await previewPendingMigrations({fromDb:opts.fromDb,applyRenames:opts.applyRenames})}catch(error){log.debug(`Migration preview unavailable: ${error instanceof Error?error.message:String(error)}`);return!0}const renames=operations.filter((o)=>o.kind==="rename_column"||o.kind==="rename_table");for(const r of renames)log.info(`Detected ${describeOp(r)} - applying as a rename (data preserved). Use --no-rename to drop + add instead.`);const destructive=operations.filter((o)=>o.destructive);if(destructive.length===0)return!0;log.warn(`This migration includes ${destructive.length} potentially destructive change${destructive.length===1?"":"s"}:`);for(const op of destructive)log.warn(` \u2022 ${describeOp(op)}`);if(opts.force)return!0;if(isCI||!hasTTY){log.error("Refusing to apply destructive changes in a non-interactive environment. Re-run with --force to proceed.");return!1}return confirm({message:"Apply these destructive changes?",initial:!1})}function parseGuardBool(raw){if(raw==null||raw==="")return;const v=raw.toLowerCase().trim();if(v==="1"||v==="true"||v==="yes"||v==="on")return!0;if(v==="0"||v==="false"||v==="no"||v==="off")return!1;return}function parseFreshGuard(raw){const v=raw?.toLowerCase().trim();return v==="allow"||v==="confirm"||v==="disabled"?v:void 0}async function resolveMigrationGuards(){const isProd=/^prod/i.test(process.env.APP_ENV||"local");let cfg={};try{const{awaitConfig}=await import("@stacksjs/config");cfg=(await awaitConfig()).database?.safety??{}}catch(error){log.debug(`[migrate] safety config unavailable, using defaults: ${error instanceof Error?error.message:String(error)}`)}const confirmMigrate=parseGuardBool(process.env.DB_MIGRATE_CONFIRM)??cfg.confirmMigrate??!0,migrateFresh=parseFreshGuard(process.env.DB_MIGRATE_FRESH)??cfg.migrateFresh??(isProd?"disabled":"allow");return{confirmMigrate,migrateFresh}}function currentDatabaseLabel(){if((process.env.DB_CONNECTION||"sqlite").toLowerCase()==="sqlite")return process.env.DB_DATABASE_PATH||"database/stacks.sqlite";return process.env.DB_DATABASE||"stacks"}export function migrate(buddy){const descriptions={migrate:"Migrates your database",fresh:"Drop all tables and re-run every migration (destroys all data)",project:"Target a specific project",verbose:"Enable verbose output",auth:"Also migrate auth tables (oauth_clients, oauth_access_tokens, oauth_refresh_tokens, password_resets)",force:"Apply destructive changes (drop column/table, lossy type change) without confirmation",fromDb:"Diff against the live database schema instead of the snapshot (self-heal drift)",noRename:"Treat renamed columns as drop + add instead of a data-preserving rename"};buddy.command("migrate",descriptions.migrate).alias("db:migrate").option("-d, --diff","Show the SQL that would be run",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("-a, --auth",descriptions.auth,{default:!0}).option("--no-auth","Skip auth/oauth table migrations").option("-f, --force",descriptions.force,{default:!1}).option("--create-database","Create the database if it does not exist, without asking",{default:!1}).option("--from-db",descriptions.fromDb,{default:!1}).option("--no-rename",descriptions.noRename).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy migrate` ...",options);const perf=await intro("buddy migrate"),validation=validateModelsExist();if(!validation.valid){console.error(`
|
|
10
10
|
\u274C Error: ${validation.error}
|
|
11
11
|
`);process.exit(ExitCode.FatalError)}const dialectCheck=validateMigrationDialect();if(!dialectCheck.valid){console.error(`
|
|
12
12
|
\u274C Error: ${dialectCheck.error}
|
|
13
|
-
`);process.exit(ExitCode.FatalError)}const applyRenames=options.rename===!1?!1:void 0;if(options.fromDb)process.env.STACKS_MIGRATE_FROM_DB="1";if(applyRenames===!1)process.env.STACKS_MIGRATE_NO_RENAME="1";if(options.diff){try{const{previewPendingMigrations}=await import("@stacksjs/database"),ops=await previewPendingMigrations({fromDb:options.fromDb,applyRenames});if(ops.length===0)log.info("No pending schema changes
|
|
13
|
+
`);process.exit(ExitCode.FatalError)}const applyRenames=options.rename===!1?!1:void 0;if(options.fromDb)process.env.STACKS_MIGRATE_FROM_DB="1";if(applyRenames===!1)process.env.STACKS_MIGRATE_NO_RENAME="1";if(options.diff){try{const{previewPendingMigrations}=await import("@stacksjs/database"),ops=await previewPendingMigrations({fromDb:options.fromDb,applyRenames});if(ops.length===0)log.info("No pending schema changes - your models match the database.");else{log.info(`${ops.length} pending change${ops.length===1?"":"s"}:`);for(const op of ops)log.info(` \u2022 ${describeOp(op)}${op.destructive?" [destructive]":""}`)}}catch(error){await log.error("Failed to preview migrations:",error)}await outro("Diff complete - no changes applied.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}await preflightDatabase({createDatabase:options.createDatabase,command:"migrate"});if((await resolveMigrationGuards()).confirmMigrate&&!options.force)if(isCI||!hasTTY)log.debug("[migrate] confirmMigrate guard skipped - non-interactive environment.");else{const APP_ENV=process.env.APP_ENV||"local";await log.flush();if(!await confirm({message:`Run migrations against the ${APP_ENV} database "${currentDatabaseLabel()}"?`,initial:!0})){await outro("Migration cancelled - no changes applied.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}await ensureDatabaseOrExit();const lock=acquireMigrationLock();if(!lock.acquired){log.syncError("Another migration is already running (storage/framework/runtime/migrations.lock exists). Wait for it to finish, or remove the lockfile if it is stale.");process.exit(ExitCode.FatalError)}if(!await confirmDestructiveMigrations({force:options.force,fromDb:options.fromDb,applyRenames})){lock.release();await outro("Migration cancelled - no changes applied.",{startTime:perf,useSeconds:!0});process.exit(isCI||!hasTTY?ExitCode.FatalError:ExitCode.Success)}if(options.auth!==!1){log.debug("Migrating auth tables...");try{const{migrateAuthTables}=await import("@stacksjs/database"),authResult=await migrateAuthTables({verbose:options.verbose});if(!authResult.success)log.error(`Failed to migrate auth tables: ${authResult.error}`)}catch(error){log.error("Failed to migrate auth tables:",error)}}const result=await runAction(Action.Migrate,options).finally(()=>lock.release());if(resultFailed(result))log.error("Model migrations failed - applying notification/RBAC table guarantees before exiting.");if(options.auth!==!1)try{const{ensureUtcDatetimeColumns,migrateNotificationTables,migrateRbacTables,migrateTraitTables}=await import("@stacksjs/database"),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}`);const datetimeResult=await ensureUtcDatetimeColumns({verbose:options.verbose});if(!datetimeResult.success)log.error(`Failed to convert TIMESTAMP columns to DATETIME: ${datetimeResult.error}`)}catch(error){log.error("Failed to migrate notification/RBAC/trait tables:",error)}if(resultFailed(result)){await outro("While running the migrate command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}if(options.auth!==!1)try{const{ensureUsersAuthColumns,sqlHelpers}=await import("@stacksjs/database"),driver=process.env.DB_CONNECTION||"sqlite";await ensureUsersAuthColumns(sqlHelpers(driver),{verbose:options.verbose})}catch(error){log.error("Failed to ensure users auth columns post-migration:",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)}await reportMissingForeignKeys();await reportSchemaDrift();await reportFkOrphans();const APP_ENV=process.env.APP_ENV||"local",marker=readMigrateMarker(),authSuffix=options.auth!==!1?" (including auth tables)":"",outroMessage=marker==null?`Migrated your ${APP_ENV} database.${authSuffix}`:marker.appliedCount===0?`Nothing to migrate - your ${APP_ENV} database is already up to date.${authSuffix}`:`Applied ${marker.appliedCount} migration${marker.appliedCount===1?"":"s"} to your ${APP_ENV} database.${authSuffix}`;await outro(outroMessage,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:fresh",descriptions.fresh).alias("db:fresh").option("-d, --diff","Show the SQL that would be run",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("-s, --seed","Run database seeders after migration",{default:!1}).option("-a, --auth",descriptions.auth,{default:!0}).option("--no-auth","Skip auth/oauth table migrations").option("--create-database","Create the database if it does not exist, without asking",{default:!1}).option("-f, --force",'Skip the drop-database confirmation (only honored when the migrateFresh guard is "allow")',{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy migrate:fresh` ...",options);const perf=await intro("buddy migrate:fresh"),validation=validateModelsExist();if(!validation.valid){console.error(`
|
|
14
14
|
\u274C Error: ${validation.error}
|
|
15
15
|
`);process.exit(ExitCode.FatalError)}const dialectCheck=validateMigrationDialect();if(!dialectCheck.valid){console.error(`
|
|
16
16
|
\u274C Error: ${dialectCheck.error}
|
|
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
|
|
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:dns",descriptions.migrate).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy migrate:dns` ...",options);const perf=await intro("buddy migrate:dns"),result=await runAction(Action.MigrateDns,{...options});if(resultFailed(result)){await outro("While running the migrate:dns command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}const APP_URL=process.env.APP_URL||"undefined";await outro(`Migrated your ${APP_URL} DNS.`,{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
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(`
|
|
23
23
|
DB_CONNECTION is already "${target}". Nothing to switch.
|
|
@@ -26,7 +26,7 @@ ${sample}${more}
|
|
|
26
26
|
Regenerate them against ${target} before migrating, or this switch will fail on the first file.`:"",sqliteFkNote=current==="sqlite"&&alterCount>0?`
|
|
27
27
|
(These were skipped on SQLite per stacksjs/stacks#1916 and survive on disk for replay.)`:"",boolNote=current==="sqlite"&&(target==="postgres"||target==="mysql"||target==="vitess")?`
|
|
28
28
|
\u2022 Booleans land as 0/1 on SQLite; ${target} stores them as ${target==="postgres"?"true/false":"0/1 (compatible)"}.`:"",tzNote=target==="postgres"?`
|
|
29
|
-
\u2022 PostgreSQL uses timestamptz (with TZ) where SQLite/MySQL store plain TIMESTAMP. Existing rows do NOT auto-upgrade
|
|
29
|
+
\u2022 PostgreSQL uses timestamptz (with TZ) where SQLite/MySQL store plain TIMESTAMP. Existing rows do NOT auto-upgrade - they ride the column's stored type.`:"",envExtras=target!=="sqlite"?", plus DB_HOST / DB_PORT / DB_USERNAME / DB_PASSWORD / DB_DATABASE":"",missingNote=missingEnv.length>0?`
|
|
30
30
|
\u26A0 Missing env vars for ${target}: ${missingEnv.join(", ")}`:"";console.log(`
|
|
31
31
|
Switch plan: ${current} \u2192 ${target}
|
|
32
32
|
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
@@ -39,7 +39,7 @@ ${sample}${more}
|
|
|
39
39
|
Next steps:
|
|
40
40
|
1. Update .env: DB_CONNECTION=${target}${envExtras}
|
|
41
41
|
2. (Optional) Export data from the current ${current} database.
|
|
42
|
-
3. ${switchBlocked?`Regenerate the migration files for ${target} FIRST
|
|
42
|
+
3. ${switchBlocked?`Regenerate the migration files for ${target} FIRST - \`./buddy migrate\` will refuse until then.`:"Run `./buddy migrate` (or `migrate:fresh` to start clean)."}
|
|
43
43
|
4. The post-migrate FK audit will report any constraints that didn't replay.
|
|
44
44
|
`);await outro("Plan rendered. Re-run after updating .env to actually switch.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:regenerate [dialect]","Rebuild database/migrations from your models for a given dialect").option("--dry-run","Show what would change without writing anything",{default:!1}).option("-f, --force","Regenerate even though the database already has migrations recorded",{default:!1}).option("--replace-unmarked","Also delete migrations carrying no @generated marker (pre-marker corpora only)",{default:!1}).option("--only-existing-tables","Rebuild exactly the tables already in the corpus, for changing dialect without adopting the framework schema",{default:!1}).action(async(dialect,options)=>{const perf=await intro("buddy migrate:regenerate"),target=(dialect||process.env.DB_CONNECTION||"sqlite").toLowerCase();if(!new Set(["sqlite","mysql","vitess","postgres","singlestore"]).has(target)){log.syncError(`Unknown dialect "${target}". Allowed: sqlite, mysql, vitess, postgres, singlestore.`);process.exit(ExitCode.FatalError)}const{countAppliedMigrations,regenerateMigrationCorpus}=await import("@stacksjs/database");let applied=0;try{applied=await countAppliedMigrations()}catch{applied=0}if(applied>0&&!options.force&&!options.dryRun){log.syncError(`This database already has ${applied} migration(s) recorded.`);log.syncError("Regenerating renumbers every file, and the migrations table keys on the filename,");log.syncError("so already-applied migrations would look pending and run a second time.");log.syncError(" Point at an empty database, or re-run with --force if you know it is safe.");process.exit(ExitCode.FatalError)}const plan=await regenerateMigrationCorpus({dialect:target,dryRun:!0,replaceUnmarked:options.replaceUnmarked,onlyExistingTables:options.onlyExistingTables});if(resultFailed(plan)){log.syncError(plan.error.message);process.exit(ExitCode.FatalError)}const{files,removed,preserved,preservedOutOfScope,models,modelRoots,unrebuildable}=plan.value,outOfScope=new Set(preservedOutOfScope),protectedHistory=preserved.filter((f)=>!outOfScope.has(f)),protectedHistoryBlock=protectedHistory.length===0?"":`
|
|
45
45
|
\u2022 ${protectedHistory.length} protected history file(s) will be KEPT:
|
|
@@ -73,11 +73,11 @@ ${unrebuildable.map((t)=>` ${t}`).join(`
|
|
|
73
73
|
\u2022 ${removed.length} existing file(s) will be removed${protectedHistoryBlock}${outOfScopeBlock}${unrebuildableBlock}
|
|
74
74
|
\u2022 These files are tracked in git, so review with \`git diff\` afterwards
|
|
75
75
|
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
76
|
-
`);if(options.dryRun){await outro("Dry run. Nothing was written.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(!isCI&&hasTTY&&process.stdin.isTTY){await log.flush();if(!await confirm({message:`Replace ${removed.length} migration file(s) with ${files.length} generated for ${target}?`,initial:!1})){await outro("Cancelled. Nothing was written.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}const result=await regenerateMigrationCorpus({dialect:target,replaceUnmarked:options.replaceUnmarked});if(resultFailed(result)){log.syncError(result.error.message);process.exit(ExitCode.FatalError)}log.success(`Wrote ${result.value.files.length} ${target} migration file(s) to database/migrations.`);if(applied>0){const{reconcileMigrationLedger}=await import("@stacksjs/database"),fixed=await reconcileMigrationLedger();if(fixed.remapped.length>0)log.info(`Repointed ${fixed.remapped.length} ledger row(s) at their renumbered file.`);if(fixed.recorded.length>0)log.info(`Recorded ${fixed.recorded.length} migration(s) already present in the schema.`);if(fixed.skipped.length>0)log.warn(`${fixed.skipped.length} ledger entr(ies) need a look
|
|
76
|
+
`);if(options.dryRun){await outro("Dry run. Nothing was written.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(!isCI&&hasTTY&&process.stdin.isTTY){await log.flush();if(!await confirm({message:`Replace ${removed.length} migration file(s) with ${files.length} generated for ${target}?`,initial:!1})){await outro("Cancelled. Nothing was written.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}const result=await regenerateMigrationCorpus({dialect:target,replaceUnmarked:options.replaceUnmarked});if(resultFailed(result)){log.syncError(result.error.message);process.exit(ExitCode.FatalError)}log.success(`Wrote ${result.value.files.length} ${target} migration file(s) to database/migrations.`);if(applied>0){const{reconcileMigrationLedger}=await import("@stacksjs/database"),fixed=await reconcileMigrationLedger();if(fixed.remapped.length>0)log.info(`Repointed ${fixed.remapped.length} ledger row(s) at their renumbered file.`);if(fixed.recorded.length>0)log.info(`Recorded ${fixed.recorded.length} migration(s) already present in the schema.`);if(fixed.skipped.length>0)log.warn(`${fixed.skipped.length} ledger entr(ies) need a look - run \`./buddy migrate:status\`.`)}log.info("Review the change with `git diff`, then run `./buddy migrate`.");await outro("Regenerated.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:status","Compare database/migrations, the migrations ledger, and the live schema").option("--reconcile","Repair the ledger where the schema proves what happened",{default:!1}).option("--include-partial","With --reconcile, also record half-applied migrations",{default:!1}).option("--json","Emit the audit as JSON",{default:!1}).action(async(options)=>{const perf=options.json?void 0:await intro("buddy migrate:status"),{auditMigrationLedger,reconcileMigrationLedger}=await import("@stacksjs/database"),audit=await auditMigrationLedger();if(options.json){console.log(JSON.stringify(audit,(_k,v)=>v instanceof Set?[...v]:v,2));process.exit(audit.drift?ExitCode.FatalError:ExitCode.Success)}if(!audit.supported){log.info(`Dialect "${audit.dialect}" is not audited. Nothing to compare.`);await outro("Skipped.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const{counts,entries,orphans}=audit,list=(status)=>entries.filter((e)=>e.status===status).map((e)=>e.file),report=[],section=(heading,files)=>{if(files.length===0)return;if(heading)report.push(` ${heading}`);for(const file of files.slice(0,8))report.push(` ${file}`);if(files.length>8)report.push(` \u2026 +${files.length-8} more`);report.push("")};report.push("");report.push(` Migration status: ${audit.dialect}`);report.push(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");report.push(` ${entries.length} file(s) on disk \xB7 ${audit.recordedCount} recorded in the ledger`);report.push("");if(counts.applied>0)report.push(` ${counts.applied} applied - recorded, and present in the schema.`,"");if(counts.unverifiable>0)report.push(` ${counts.unverifiable} unverifiable - data migrations with no schema trace to check.`,"");section(`${counts.pending} pending - not applied yet, will run on the next \`buddy migrate\`:`,list("pending"));if(counts.stranded>0){report.push(` ${counts.stranded} STRANDED - already applied to the schema, but missing from the ledger.`);report.push(" These re-run on the next `buddy migrate`, which is unsafe for anything not idempotent.");section("",list("stranded"))}section(`${counts.partial} PARTIAL - some effects present, some missing. Needs a human:`,list("partial"));section(`${counts.reverted} REVERTED - recorded as applied, but the effects are gone from the schema:`,list("reverted"));if(orphans.length>0)section(`${orphans.length} orphaned ledger row(s) - recorded, but no such file on disk:`,orphans.map((o)=>`${o.migration}${o.renamedTo?` -> renumbered to ${o.renamedTo}`:" (no counterpart; migration deleted?)"}`));console.log(report.join(`
|
|
77
77
|
`));if(!audit.drift){await outro("Ledger matches the corpus and the schema.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(!options.reconcile){console.log(`
|
|
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.skipped.length>0){log.warn(`Left ${fixed.skipped.length} entr(ies) alone:`);console.log(fixed.skipped.slice(0,8).map((s)=>` ${s.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.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)log.info("Nothing could be repaired automatically.");await outro("Reconciled.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"migrate")}
|
package/dist/commands/publish.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import{existsSync,mkdirSync,realpathSync}from"node:fs";import{cp,readdir,stat}from"node:fs/promises";import{homedir}from"node:os";import{dirname,join,resolve}from"node:path";import process from"node:process";import{italic,log,onUnknownSubcommand}from"@stacksjs/cli";import{path}from"@stacksjs/path";import{fs,globSync}from"@stacksjs/storage";import{pruneVendoredCoreFromWorkflows,splitFrameworkTypecheckScript}from"../workflow-prune";import{ExitCode}from"@stacksjs/types";export function publish(buddy){const descriptions={command:"Publish a Stacks default into your userland (app/) directory so you can customize it",model:"Publish a default model from storage/framework/defaults/app/Models/ to app/Models/",controller:"Publish a default controller from storage/framework/defaults/app/Controllers/ to app/Controllers/",middleware:"Publish a default middleware from storage/framework/defaults/app/Middleware/ to app/Middleware/",action:"Publish a default action from storage/framework/defaults/app/Actions/ to app/Actions/",core:"Publish a framework package source from node_modules/@stacksjs/<pkg>/ into storage/framework/core/<pkg>/ for editing",unpublishCore:"Drop a vendored storage/framework/core/<pkg>/ and go back to the installed @stacksjs/<pkg>",all:"Unvendor the whole framework: remove storage/framework/core and resolve every @stacksjs package from the `stacks` dependency in package.json",publishAll:"Vendor the whole framework: copy storage/framework/core out of a local Stacks checkout and wire it up as a Bun workspace",frameworkPath:"The Stacks checkout to vendor from (defaults to $STACKS_FRAMEWORK_PATH, ../stacks, then ~/Code/stacks)",coreStatus:"Report whether this project runs on a vendored storage/framework/core or on the published packages",name:"The name of the resource to publish (e.g. Cart, User)",pkg:"The name of the framework package (e.g. router, orm, faker
|
|
1
|
+
import{existsSync,mkdirSync,realpathSync}from"node:fs";import{cp,readdir,stat}from"node:fs/promises";import{homedir}from"node:os";import{dirname,join,resolve}from"node:path";import process from"node:process";import{italic,log,onUnknownSubcommand}from"@stacksjs/cli";import{path}from"@stacksjs/path";import{fs,globSync}from"@stacksjs/storage";import{pruneVendoredCoreFromWorkflows,splitFrameworkTypecheckScript}from"../workflow-prune";import{ExitCode}from"@stacksjs/types";export function publish(buddy){const descriptions={command:"Publish a Stacks default into your userland (app/) directory so you can customize it",model:"Publish a default model from storage/framework/defaults/app/Models/ to app/Models/",controller:"Publish a default controller from storage/framework/defaults/app/Controllers/ to app/Controllers/",middleware:"Publish a default middleware from storage/framework/defaults/app/Middleware/ to app/Middleware/",action:"Publish a default action from storage/framework/defaults/app/Actions/ to app/Actions/",core:"Publish a framework package source from node_modules/@stacksjs/<pkg>/ into storage/framework/core/<pkg>/ for editing",unpublishCore:"Drop a vendored storage/framework/core/<pkg>/ and go back to the installed @stacksjs/<pkg>",all:"Unvendor the whole framework: remove storage/framework/core and resolve every @stacksjs package from the `stacks` dependency in package.json",publishAll:"Vendor the whole framework: copy storage/framework/core out of a local Stacks checkout and wire it up as a Bun workspace",frameworkPath:"The Stacks checkout to vendor from (defaults to $STACKS_FRAMEWORK_PATH, ../stacks, then ~/Code/stacks)",coreStatus:"Report whether this project runs on a vendored storage/framework/core or on the published packages",name:"The name of the resource to publish (e.g. Cart, User)",pkg:"The name of the framework package (e.g. router, orm, faker - without @stacksjs/ prefix)",force:"Overwrite an existing userland file",forceUnpublish:"Delete the vendored source even when it has uncommitted changes",verbose:"Enable verbose output"};buddy.command("publish:model <name>",descriptions.model).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"model",name,defaultsDir:path.frameworkPath("defaults/app/Models"),userDir:path.userModelsPath(),force:!!options.force})});buddy.command("publish:controller <name>",descriptions.controller).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"controller",name,defaultsDir:path.frameworkPath("defaults/app/Controllers"),userDir:path.userControllersPath(),force:!!options.force})});buddy.command("publish:middleware <name>",descriptions.middleware).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"middleware",name,defaultsDir:path.frameworkPath("defaults/app/Middleware"),userDir:path.userMiddlewarePath(),force:!!options.force})});buddy.command("publish:action <name>",descriptions.action).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"action",name,defaultsDir:path.frameworkPath("defaults/app/Actions"),userDir:path.userActionsPath(),force:!!options.force})});buddy.command("publish:core [pkg]",descriptions.core).option("--all",descriptions.publishAll,{default:!1}).option("--path <path>",descriptions.frameworkPath).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy publish:core router").example("buddy publish:core --all").example("buddy publish:core --all --path ../stacks").action(async(pkg,options)=>{if(options.all){await vendorFramework(options.path,!!options.force);return}if(!pkg){log.error("Usage: buddy publish:core <pkg> (or --all to vendor the whole framework as a workspace)");process.exit(ExitCode.FatalError)}await publishCorePackage(pkg,!!options.force)});buddy.command("core:status",descriptions.coreStatus).alias("publish:core:status").option("--verbose",descriptions.verbose,{default:!1}).example("buddy core:status").action(async()=>{await reportCoreStatus()});buddy.command("unpublish:core [pkg]",descriptions.unpublishCore).option("--all",descriptions.all,{default:!1}).option("--force",descriptions.forceUnpublish,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(pkg,options)=>{if(options.all){await unvendorFramework(!!options.force);return}if(!pkg){log.error("Usage: buddy unpublish:core <pkg> (or --all to move the whole framework to node_modules)");process.exit(ExitCode.FatalError)}await unpublishCorePackage(pkg,!!options.force)});buddy.command("publish [resource] [name]",descriptions.command).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(resource,name,options)=>{if(!resource||!name){log.error("Usage: buddy publish:<resource> <Name> (e.g. buddy publish:model Cart)");process.exit(ExitCode.FatalError)}const handler={model:()=>publishResource({kind:"model",name,defaultsDir:path.frameworkPath("defaults/app/Models"),userDir:path.userModelsPath(),force:!!options.force}),controller:()=>publishResource({kind:"controller",name,defaultsDir:path.frameworkPath("defaults/app/Controllers"),userDir:path.userControllersPath(),force:!!options.force}),middleware:()=>publishResource({kind:"middleware",name,defaultsDir:path.frameworkPath("defaults/app/Middleware"),userDir:path.userMiddlewarePath(),force:!!options.force}),action:()=>publishResource({kind:"action",name,defaultsDir:path.frameworkPath("defaults/app/Actions"),userDir:path.userActionsPath(),force:!!options.force}),core:()=>publishCorePackage(name,!!options.force)}[resource.toLowerCase()];if(!handler){log.error(`Unknown publishable resource: ${italic(resource)}`);log.info("Available: model, controller, middleware, action, core");process.exit(ExitCode.FatalError)}await handler()});onUnknownSubcommand(buddy,"publish")}async function publishCorePackage(pkg,force){const shortName=pkg.replace(/^@stacksjs\//,"").replace(/^core\//,""),fail=(msg,hint)=>{process.stderr.write(`${msg}
|
|
2
2
|
`);if(hint)process.stderr.write(` ${hint}
|
|
3
|
-
`);process.exit(ExitCode.FatalError)};if(!shortName||shortName.includes("/")||shortName.includes(".."))fail(`Invalid package name: ${pkg}`,"Use a short name like `router` or the fully qualified `@stacksjs/router`.");const sourceDir=resolve(process.cwd(),"node_modules","@stacksjs",shortName),targetDir=path.frameworkPath(`core/${shortName}`);try{if(!(await stat(sourceDir)).isDirectory())fail(`${sourceDir} is not a directory.`)}catch{fail(`Could not find @stacksjs/${shortName} in node_modules.`,"Run `bun install` first, or check the package name.")}if(existsSync(targetDir)&&!force)fail(`Already published: ${targetDir.replace(`${process.cwd()}/`,"")}`,"Pass --force to overwrite.");mkdirSync(dirname(targetDir),{recursive:!0});const SKIP=new Set(["node_modules","dist",".bun",".cache","tsconfig.tsbuildinfo"]);let copied=0;await copyTreeFiltered(sourceDir,targetDir,SKIP,()=>copied++);log.success(`Published @stacksjs/${shortName} \u2192 ${italic(targetDir.replace(`${process.cwd()}/`,""))} (${copied} files)`);log.info("Edit freely
|
|
4
|
-
`);const bunfigPath=resolve(process.cwd(),"bunfig.toml");let rewrittenPreloads=0;if(existsSync(bunfigPath)){const bunfig=await fs.promises.readFile(bunfigPath,"utf-8"),next=bunfig.replace(/(["'])@stacksjs\/([\w-]+)\/([^"']+?)\.js\1/g,(match,quote,pkgName,subpath)=>{if(!provided.has(`@stacksjs/${pkgName}`))return match;rewrittenPreloads++;return`${quote}./storage/framework/core/${pkgName}/${subpath}.ts${quote}`});if(next!==bunfig)await fs.promises.writeFile(bunfigPath,next)}const tsconfigPath=resolve(process.cwd(),"tsconfig.json");let rewroteTsconfig=!1;if(existsSync(tsconfigPath)){const raw=await fs.promises.readFile(tsconfigPath,"utf-8"),next=raw.replace(/"extends"\s*:\s*"\.\/storage\/framework\/tsconfig\.app\.json"/,'"extends": "./storage/framework/core/tsconfig.json"');if(next!==raw){await fs.promises.writeFile(tsconfigPath,next);rewroteTsconfig=!0}}log.success(`Vendored ${copied} files into ${italic(rel(coreDir))}`);log.info(`package.json now depends on ${depName}@workspace:*`);if(repointed>0)log.info(`Repointed ${repointed} version range${repointed===1?"":"s"} to workspace:*`);if(addedGlobs>0)log.info(`Added ${addedGlobs} workspace glob${addedGlobs===1?"":"s"} for the vendored packages`);if(rewrittenPreloads>0)log.info(`Rewrote ${rewrittenPreloads} bunfig.toml preload path${rewrittenPreloads===1?"":"s"} to the vendored source`);if(rewroteTsconfig)log.info("tsconfig.json now extends storage/framework/core/tsconfig.json");log.info("Linking the workspace...");if(await Bun.spawn(["bun","install"],{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){log.error("`bun install` failed. The files are in place; re-run the install once the failure is resolved.");process.exit(ExitCode.FatalError)}log.success("This project now runs on the vendored framework
|
|
3
|
+
`);process.exit(ExitCode.FatalError)};if(!shortName||shortName.includes("/")||shortName.includes(".."))fail(`Invalid package name: ${pkg}`,"Use a short name like `router` or the fully qualified `@stacksjs/router`.");const sourceDir=resolve(process.cwd(),"node_modules","@stacksjs",shortName),targetDir=path.frameworkPath(`core/${shortName}`);try{if(!(await stat(sourceDir)).isDirectory())fail(`${sourceDir} is not a directory.`)}catch{fail(`Could not find @stacksjs/${shortName} in node_modules.`,"Run `bun install` first, or check the package name.")}if(existsSync(targetDir)&&!force)fail(`Already published: ${targetDir.replace(`${process.cwd()}/`,"")}`,"Pass --force to overwrite.");mkdirSync(dirname(targetDir),{recursive:!0});const SKIP=new Set(["node_modules","dist",".bun",".cache","tsconfig.tsbuildinfo"]);let copied=0;await copyTreeFiltered(sourceDir,targetDir,SKIP,()=>copied++);log.success(`Published @stacksjs/${shortName} \u2192 ${italic(targetDir.replace(`${process.cwd()}/`,""))} (${copied} files)`);log.info("Edit freely - local changes win over the installed package.")}async function copyTreeFiltered(sourceDir,targetDir,skip,onFile){mkdirSync(targetDir,{recursive:!0});const entries=await readdir(sourceDir,{withFileTypes:!0});for(const entry of entries){if(skip.has(entry.name))continue;const src=`${sourceDir}/${entry.name}`,dst=`${targetDir}/${entry.name}`;if(entry.isDirectory()){await copyTreeFiltered(src,dst,skip,onFile);continue}await cp(src,dst,{force:!0,dereference:!0});onFile()}}async function publishResource(ctx){const{kind,name,defaultsDir,userDir,force}=ctx,fileName=name.endsWith(".ts")?name:`${name}.ts`,matches=globSync([`${defaultsDir}/**/${fileName}`],{absolute:!0});if(!matches.length){log.error(`Could not find default ${kind}: ${italic(fileName)}`);log.info(`Looked under: ${italic(defaultsDir)}`);process.exit(ExitCode.FatalError)}if(matches.length>1){log.warn(`Multiple defaults match ${italic(fileName)}; using the first:`);for(const m of matches)log.info(` ${m}`)}const sourcePath=matches[0];if(!sourcePath)throw Error(`Could not resolve default ${kind}: ${fileName}`);const targetPath=`${userDir.replace(/\/$/,"")}/${fileName}`;if(existsSync(targetPath)&&!force){log.error(`Already exists: ${italic(targetPath)}`);log.info("Pass --force to overwrite.");process.exit(ExitCode.FatalError)}mkdirSync(dirname(targetPath),{recursive:!0});await fs.promises.copyFile(sourcePath,targetPath);const carried=await carryRelativeImports(sourcePath,targetPath);log.success(`Published ${kind} ${italic(name)} \u2192 ${italic(targetPath.replace(`${process.cwd()}/`,""))}`);for(const file of carried)log.info(` + ${italic(file.replace(`${process.cwd()}/`,""))} (imported by it)`)}export async function carryRelativeImports(sourcePath,targetPath,seen=new Set){if(seen.has(sourcePath))return[];seen.add(sourcePath);const source=await fs.promises.readFile(sourcePath,"utf-8"),written=[],root=realpathSync(process.cwd()),targetDir=realpathSync(dirname(targetPath)),specifiers=[...source.matchAll(/from\s+['"](\.[^'"]+)['"]/g)].map((match)=>match[1]);for(const specifier of new Set(specifiers)){if(!specifier)continue;const candidates=specifier.endsWith(".ts")?[specifier]:[`${specifier}.ts`,`${specifier}/index.ts`];for(const candidate of candidates){const from=resolve(dirname(sourcePath),candidate),to=resolve(targetDir,candidate);if(!existsSync(from))continue;if(!to.startsWith(`${root}/`))break;if(!existsSync(to)){mkdirSync(dirname(to),{recursive:!0});await fs.promises.copyFile(from,to);written.push(to)}written.push(...await carryRelativeImports(from,to,seen));break}}return written}async function vendorFramework(explicitPath,force){const coreDir=path.frameworkPath("core"),rel=(p)=>p.replace(`${process.cwd()}/`,"");if(existsSync(coreDir)&&!force){log.info(`Already vendored: ${italic(rel(coreDir))}`);log.info("Pass --force to replace it with a fresh copy of the checkout.");return}const framework=resolveFrameworkCheckout(explicitPath);if(!framework){log.error("No Stacks checkout found to vendor from.");log.info("Pass one with `--path <dir>`, or set STACKS_FRAMEWORK_PATH.");log.info("A checkout is required: the published packages ship `dist` only, so a copy of them would not be editable.");process.exit(ExitCode.FatalError)}const sourceCore=join(framework,"storage/framework/core"),corePkgPath=resolve(sourceCore,"package.json");if(!existsSync(corePkgPath)){log.error(`${sourceCore} has no package.json - that does not look like a Stacks checkout.`);process.exit(ExitCode.FatalError)}const depName=JSON.parse(await fs.promises.readFile(corePkgPath,"utf-8")).name??"stacks";log.info(`Vendoring the framework from ${italic(framework)}...`);if(existsSync(coreDir))await fs.promises.rm(coreDir,{recursive:!0,force:!0});const SKIP=new Set(["node_modules",".bun",".cache","tsconfig.tsbuildinfo"]);let copied=0;await copyTreeFiltered(sourceCore,coreDir,SKIP,()=>copied++);const rootPkgPath=resolve(process.cwd(),"package.json"),rootPkg=JSON.parse(await fs.promises.readFile(rootPkgPath,"utf-8")),provided=new Set([depName]);for(const entry of await readdir(coreDir,{withFileTypes:!0})){if(!entry.isDirectory())continue;if(existsSync(resolve(coreDir,entry.name,"package.json")))provided.add(`@stacksjs/${entry.name}`)}let repointed=0;for(const field of["dependencies","devDependencies"]){const deps=rootPkg[field];if(!deps)continue;for(const name of Object.keys(deps)){if(!provided.has(name)||deps[name].startsWith("workspace:"))continue;deps[name]="workspace:*";repointed++}}if(!rootPkg.dependencies?.[depName]&&!rootPkg.devDependencies?.[depName])rootPkg.dependencies={...rootPkg.dependencies,[depName]:"workspace:*"};const workspaces=rootPkg.workspaces??[];let addedGlobs=0;for(const glob of["storage/framework/core","storage/framework/core/*"]){if(workspaces.some((existing)=>existing.replace(/^\.\//,"").replace(/\/$/,"")===glob))continue;workspaces.push(glob);addedGlobs++}rootPkg.workspaces=workspaces;await fs.promises.writeFile(rootPkgPath,`${JSON.stringify(rootPkg,null,2)}
|
|
4
|
+
`);const bunfigPath=resolve(process.cwd(),"bunfig.toml");let rewrittenPreloads=0;if(existsSync(bunfigPath)){const bunfig=await fs.promises.readFile(bunfigPath,"utf-8"),next=bunfig.replace(/(["'])@stacksjs\/([\w-]+)\/([^"']+?)\.js\1/g,(match,quote,pkgName,subpath)=>{if(!provided.has(`@stacksjs/${pkgName}`))return match;rewrittenPreloads++;return`${quote}./storage/framework/core/${pkgName}/${subpath}.ts${quote}`});if(next!==bunfig)await fs.promises.writeFile(bunfigPath,next)}const tsconfigPath=resolve(process.cwd(),"tsconfig.json");let rewroteTsconfig=!1;if(existsSync(tsconfigPath)){const raw=await fs.promises.readFile(tsconfigPath,"utf-8"),next=raw.replace(/"extends"\s*:\s*"\.\/storage\/framework\/tsconfig\.app\.json"/,'"extends": "./storage/framework/core/tsconfig.json"');if(next!==raw){await fs.promises.writeFile(tsconfigPath,next);rewroteTsconfig=!0}}log.success(`Vendored ${copied} files into ${italic(rel(coreDir))}`);log.info(`package.json now depends on ${depName}@workspace:*`);if(repointed>0)log.info(`Repointed ${repointed} version range${repointed===1?"":"s"} to workspace:*`);if(addedGlobs>0)log.info(`Added ${addedGlobs} workspace glob${addedGlobs===1?"":"s"} for the vendored packages`);if(rewrittenPreloads>0)log.info(`Rewrote ${rewrittenPreloads} bunfig.toml preload path${rewrittenPreloads===1?"":"s"} to the vendored source`);if(rewroteTsconfig)log.info("tsconfig.json now extends storage/framework/core/tsconfig.json");log.info("Linking the workspace...");if(await Bun.spawn(["bun","install"],{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){log.error("`bun install` failed. The files are in place; re-run the install once the failure is resolved.");process.exit(ExitCode.FatalError)}log.success("This project now runs on the vendored framework - edits under storage/framework/core are live.");log.info("Go back to the published packages any time with `buddy unpublish:core --all`.")}function resolveFrameworkCheckout(explicit){const candidates=[explicit,process.env.STACKS_FRAMEWORK_PATH,resolve(process.cwd(),"../stacks"),join(homedir(),"Code/stacks")].filter(Boolean);for(const candidate of candidates){const full=resolve(candidate);if(full===resolve(process.cwd()))continue;if(existsSync(join(full,"storage/framework/core/package.json")))return full}return null}async function reportCoreStatus(){const coreDir=path.frameworkPath("core"),rel=(p)=>p.replace(`${process.cwd()}/`,""),vendored=existsSync(resolve(coreDir,"package.json")),rootPkgPath=resolve(process.cwd(),"package.json"),rootPkg=existsSync(rootPkgPath)?JSON.parse(await fs.promises.readFile(rootPkgPath,"utf-8")):{},declared=rootPkg.dependencies?.stacks??rootPkg.devDependencies?.stacks;if(vendored){const corePkg=JSON.parse(await fs.promises.readFile(resolve(coreDir,"package.json"),"utf-8")),packages=(await readdir(coreDir,{withFileTypes:!0})).filter((entry)=>entry.isDirectory()&&existsSync(resolve(coreDir,entry.name,"package.json")));log.info(`Layout: vendored - ${italic(rel(coreDir))} (${packages.length} packages, v${corePkg.version??"unknown"})`);log.info(`Declared: stacks@${declared??"(not declared)"}`);if(declared&&!declared.startsWith("workspace:"))log.warn(`The vendored copy is not linked: stacks is declared as ${declared}, not workspace:*. Run \`buddy publish:core --all --force\` to relink, or \`buddy unpublish:core --all\` to remove it.`);log.info("Move to the published packages with `buddy unpublish:core --all`.");return}const installed=resolve(process.cwd(),"node_modules/@stacksjs/buddy/package.json"),installedVersion=existsSync(installed)?JSON.parse(await fs.promises.readFile(installed,"utf-8")).version:void 0;log.info("Layout: published packages - no storage/framework/core in this project");log.info(`Declared: stacks@${declared??"(not declared)"}`);log.info(`Installed: ${installedVersion?`v${installedVersion}`:"(run bun install)"}`);log.info("Vendor the framework for local development with `buddy publish:core --all`.")}async function unpublishCorePackage(pkg,force){const shortName=normalizeCoreName(pkg),targetDir=path.frameworkPath(`core/${shortName}`),rel=(p)=>p.replace(`${process.cwd()}/`,"");if(!existsSync(targetDir)){log.info(`Not vendored: ${italic(rel(targetDir))} - nothing to do.`);return}const installed=resolve(process.cwd(),"node_modules","@stacksjs",shortName);if(!existsSync(installed)&&!force){log.error(`@stacksjs/${shortName} is not installed, so removing the vendored copy would leave nothing to resolve.`);log.info(`Run \`bun add @stacksjs/${shortName}\` first, or pass --force to remove it anyway.`);process.exit(ExitCode.FatalError)}await assertNoUncommittedChanges(targetDir,force);await fs.promises.rm(targetDir,{recursive:!0,force:!0});log.success(`Unpublished ${italic(rel(targetDir))} - @stacksjs/${shortName} now resolves from node_modules.`)}async function unvendorFramework(force){const coreDir=path.frameworkPath("core"),rel=(p)=>p.replace(`${process.cwd()}/`,"");if(!existsSync(coreDir)){log.info("No storage/framework/core in this project - already on the installed packages.");return}const corePkgPath=resolve(coreDir,"package.json");if(!existsSync(corePkgPath)){log.error(`${rel(coreDir)} has no package.json, so its version cannot be determined.`);log.info("Unvendor the packages individually with `buddy unpublish:core <pkg>` instead.");process.exit(ExitCode.FatalError)}const corePkg=JSON.parse(await fs.promises.readFile(corePkgPath,"utf-8")),version=corePkg.version;if(!version){log.error(`${rel(corePkgPath)} has no version field.`);process.exit(ExitCode.FatalError)}await assertNoUncommittedChanges(coreDir,force);const depName=corePkg.name??"stacks",range=`^${await resolvePublishedVersion(depName,version)}`,rootPkgPath=resolve(process.cwd(),"package.json"),rootPkgRaw=await fs.promises.readFile(rootPkgPath,"utf-8"),rootPkg=JSON.parse(rootPkgRaw),provided=new Set([depName,...Object.keys(corePkg.dependencies??{}).filter((name)=>name.startsWith("@stacksjs/"))]);for(const entry of await readdir(coreDir,{withFileTypes:!0}))if(entry.isDirectory())provided.add(`@stacksjs/${entry.name}`);let repointed=0;const repointWorkspaceRanges=(pkg)=>{let touched=!1;for(const field of["dependencies","devDependencies","peerDependencies"]){const deps=pkg[field];if(!deps)continue;for(const[name,spec]of Object.entries(deps)){if(!spec.startsWith("workspace:")||!provided.has(name))continue;deps[name]=range;touched=!0;repointed++}}return touched};repointWorkspaceRanges(rootPkg);if(!rootPkg.dependencies?.[depName]&&!rootPkg.devDependencies?.[depName])rootPkg.dependencies={...rootPkg.dependencies,[depName]:range};let rewrittenScripts=0;for(const[name,script]of Object.entries(rootPkg.scripts??{})){if(!/storage\/framework\/core\/buddy\/src\/cli\.ts/.test(script))continue;rootPkg.scripts[name]=script.replace(/\bbunx?\s+(?:--bun\s+)?\.?\/?storage\/framework\/core\/buddy\/src\/cli\.ts/g,"./buddy");rewrittenScripts++}if(Array.isArray(rootPkg.workspaces)){rootPkg.workspaces=rootPkg.workspaces.filter((glob)=>!isCoreWorkspaceGlob(glob));if(rootPkg.workspaces.length===0)delete rootPkg.workspaces}await fs.promises.writeFile(rootPkgPath,`${JSON.stringify(rootPkg,null,2)}
|
|
5
5
|
`);for(const glob of rootPkg.workspaces??[])for(const memberPkgPath of globSync(`${glob.replace(/\/$/,"")}/package.json`,{cwd:process.cwd(),absolute:!0})){const raw=await fs.promises.readFile(memberPkgPath,"utf-8"),memberPkg=JSON.parse(raw);if(repointWorkspaceRanges(memberPkg))await fs.promises.writeFile(memberPkgPath,`${JSON.stringify(memberPkg,null,2)}
|
|
6
6
|
`)}const bunfigPath=resolve(process.cwd(),"bunfig.toml");let rewrittenPreloads=0;if(existsSync(bunfigPath)){const bunfig=await fs.promises.readFile(bunfigPath,"utf-8"),next=bunfig.replace(/(["'])\.?\/?storage\/framework\/core\/([\w-]+)\/([^"']+?)\.ts\1/g,(_match,quote,pkgName,subpath)=>{rewrittenPreloads++;return`${quote}@stacksjs/${pkgName}/${subpath}.js${quote}`});if(next!==bunfig)await fs.promises.writeFile(bunfigPath,next)}const tsconfigPath=resolve(process.cwd(),"tsconfig.json");let rewroteTsconfig=!1,rewroteTypecheck=!1;if(existsSync(tsconfigPath)){const raw=await fs.promises.readFile(tsconfigPath,"utf-8"),next=raw.replace(/"extends"\s*:\s*"\.\/storage\/framework\/core\/tsconfig\.json"/,'"extends": "./storage/framework/tsconfig.app.json"');if(next!==raw){await fs.promises.writeFile(tsconfigPath,next);rewroteTsconfig=!0}}const splitTypecheck=splitFrameworkTypecheckScript(rootPkg.scripts??{});if(splitTypecheck){rootPkg.scripts=splitTypecheck;rewroteTypecheck=!0;await fs.promises.writeFile(rootPkgPath,`${JSON.stringify(rootPkg,null,2)}
|
|
7
|
-
`)}const prunedWorkflows=await pruneVendoredCoreFromWorkflows(process.cwd());await fs.promises.rm(coreDir,{recursive:!0,force:!0});const scopedDir=resolve(process.cwd(),"node_modules/@stacksjs");let danglingRemoved=0;if(existsSync(scopedDir))for(const entry of await readdir(scopedDir,{withFileTypes:!0})){if(!entry.isSymbolicLink())continue;const link=resolve(scopedDir,entry.name);if(existsSync(link))continue;await fs.promises.rm(link,{force:!0});danglingRemoved++}log.success(`Removed ${italic(rel(coreDir))}`);log.info(`package.json now depends on ${depName}@${range}`);if(repointed>0)log.info(`Repointed ${repointed} workspace: range${repointed===1?"":"s"} to ${range}`);if(danglingRemoved>0)log.info(`Removed ${danglingRemoved} node_modules symlink${danglingRemoved===1?"":"s"} left pointing into it`);if(rewrittenScripts>0)log.info(`Repointed ${rewrittenScripts} package.json script${rewrittenScripts===1?"":"s"} to ./buddy`);if(rewrittenPreloads>0)log.info(`Rewrote ${rewrittenPreloads} bunfig.toml preload path${rewrittenPreloads===1?"":"s"} to package specifiers`);if(rewroteTsconfig)log.info("tsconfig.json now extends storage/framework/tsconfig.app.json");if(rewroteTypecheck)log.info("`typecheck` now checks this app as well as the framework files it still ships");for(const pruned of prunedWorkflows){const parts=[pruned.removedJobs.length>0?`${pruned.removedJobs.length} job${pruned.removedJobs.length===1?"":"s"} (${pruned.removedJobs.join(", ")})`:"",pruned.removedSteps>0?`${pruned.removedSteps} step${pruned.removedSteps===1?"":"s"}`:""].filter(Boolean);log.info(`${pruned.file}: removed ${parts.join(" and ")} that ran against the vendored core`)}log.info("Installing the published packages...");if(await Bun.spawn(["bun","install"],{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){log.error("`bun install` failed. package.json and bunfig.toml were updated; re-run the install once the failure is resolved.");process.exit(ExitCode.FatalError)}log.success("This project now runs on the published Stacks packages.");log.info("Vendor an individual package again any time with `buddy publish:core <pkg>`.")}async function resolvePublishedVersion(depName,vendored){let published;try{published=await fetchPublishedVersions(depName)}catch(error){log.warn(`Could not reach the npm registry to check ${depName} versions (${error instanceof Error?error.message:String(error)}).`);log.info(`Pinning the vendored version, ${depName}@^${vendored}.`);return vendored}if(published.versions.has(vendored))return vendored;if(!published.latest){log.warn(`${depName}@${vendored} is not published and the registry reports no latest version.`);return vendored}log.warn(`${depName}@${vendored} is not published yet
|
|
7
|
+
`)}const prunedWorkflows=await pruneVendoredCoreFromWorkflows(process.cwd());await fs.promises.rm(coreDir,{recursive:!0,force:!0});const scopedDir=resolve(process.cwd(),"node_modules/@stacksjs");let danglingRemoved=0;if(existsSync(scopedDir))for(const entry of await readdir(scopedDir,{withFileTypes:!0})){if(!entry.isSymbolicLink())continue;const link=resolve(scopedDir,entry.name);if(existsSync(link))continue;await fs.promises.rm(link,{force:!0});danglingRemoved++}log.success(`Removed ${italic(rel(coreDir))}`);log.info(`package.json now depends on ${depName}@${range}`);if(repointed>0)log.info(`Repointed ${repointed} workspace: range${repointed===1?"":"s"} to ${range}`);if(danglingRemoved>0)log.info(`Removed ${danglingRemoved} node_modules symlink${danglingRemoved===1?"":"s"} left pointing into it`);if(rewrittenScripts>0)log.info(`Repointed ${rewrittenScripts} package.json script${rewrittenScripts===1?"":"s"} to ./buddy`);if(rewrittenPreloads>0)log.info(`Rewrote ${rewrittenPreloads} bunfig.toml preload path${rewrittenPreloads===1?"":"s"} to package specifiers`);if(rewroteTsconfig)log.info("tsconfig.json now extends storage/framework/tsconfig.app.json");if(rewroteTypecheck)log.info("`typecheck` now checks this app as well as the framework files it still ships");for(const pruned of prunedWorkflows){const parts=[pruned.removedJobs.length>0?`${pruned.removedJobs.length} job${pruned.removedJobs.length===1?"":"s"} (${pruned.removedJobs.join(", ")})`:"",pruned.removedSteps>0?`${pruned.removedSteps} step${pruned.removedSteps===1?"":"s"}`:""].filter(Boolean);log.info(`${pruned.file}: removed ${parts.join(" and ")} that ran against the vendored core`)}log.info("Installing the published packages...");if(await Bun.spawn(["bun","install"],{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){log.error("`bun install` failed. package.json and bunfig.toml were updated; re-run the install once the failure is resolved.");process.exit(ExitCode.FatalError)}log.success("This project now runs on the published Stacks packages.");log.info("Vendor an individual package again any time with `buddy publish:core <pkg>`.")}async function resolvePublishedVersion(depName,vendored){let published;try{published=await fetchPublishedVersions(depName)}catch(error){log.warn(`Could not reach the npm registry to check ${depName} versions (${error instanceof Error?error.message:String(error)}).`);log.info(`Pinning the vendored version, ${depName}@^${vendored}.`);return vendored}if(published.versions.has(vendored))return vendored;if(!published.latest){log.warn(`${depName}@${vendored} is not published and the registry reports no latest version.`);return vendored}log.warn(`${depName}@${vendored} is not published yet - the vendored copy is ahead of npm.`);log.info(`Pinning the newest published version instead, ${depName}@^${published.latest}.`);return published.latest}async function fetchPublishedVersions(depName){const response=await fetch(`https://registry.npmjs.org/${depName.replace("/","%2F")}`,{headers:{accept:"application/vnd.npm.install-v1+json"}});if(!response.ok)throw Error(`registry responded ${response.status}`);const packument=await response.json();return{latest:packument["dist-tags"]?.latest,versions:new Set(Object.keys(packument.versions??{}))}}function normalizeCoreName(pkg){const shortName=pkg.replace(/^@stacksjs\//,"").replace(/^core\//,"");if(!shortName||shortName.includes("/")||shortName.includes("..")){process.stderr.write(`Invalid package name: ${pkg}
|
|
8
8
|
`);process.stderr.write(" Use a short name like `router` or the fully qualified `@stacksjs/router`.\n");process.exit(ExitCode.FatalError)}return shortName}function isCoreWorkspaceGlob(glob){const normalized=glob.replace(/^\.\//,"").replace(/\/$/,"");return normalized==="storage/framework/core"||normalized.startsWith("storage/framework/core/")}async function assertNoUncommittedChanges(dir,force){if(force)return;try{const proc=Bun.spawn(["git","status","--porcelain","--",dir],{cwd:process.cwd(),stdout:"pipe",stderr:"ignore"}),output=await new Response(proc.stdout).text();if(await proc.exited!==0)return;const changed=output.split(`
|
|
9
9
|
`).filter(Boolean);if(changed.length===0)return;log.error(`${changed.length} uncommitted change${changed.length===1?"":"s"} under ${italic(dir.replace(`${process.cwd()}/`,""))}:`);for(const line of changed.slice(0,10))log.info(` ${line}`);if(changed.length>10)log.info(` ... and ${changed.length-10} more`);log.info("Commit or stash them first, or pass --force to delete them anyway.");process.exit(ExitCode.FatalError)}catch{}}
|