@stacksjs/buddy 0.74.27 → 0.74.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/commands/add.js +1 -1
- package/dist/commands/build.js +1 -1
- package/dist/commands/cloud.d.ts +91 -0
- package/dist/commands/cloud.js +7 -3
- package/dist/commands/deploy.d.ts +10 -0
- package/dist/commands/deploy.js +3 -3
- package/dist/commands/email.js +1 -1
- package/dist/commands/generate.d.ts +13 -1
- package/dist/commands/generate.js +1 -1
- package/dist/commands/link.js +1 -1
- package/dist/commands/lint.js +1 -1
- package/dist/commands/make.js +1 -1
- package/dist/commands/migrate.js +3 -3
- package/dist/commands/seed.js +1 -1
- package/dist/commands/share.js +1 -1
- package/dist/commands/stacks.js +1 -1
- package/dist/commands/tinker.js +1 -1
- package/dist/commands/types.js +1 -1
- package/dist/lazy-commands.js +1 -1
- package/dist/production-server.js +1 -1
- package/package.json +55 -55
package/dist/commands/deploy.js
CHANGED
|
@@ -30,12 +30,12 @@ for (const entry of units) {
|
|
|
30
30
|
}
|
|
31
31
|
console.log(JSON.stringify(ports))
|
|
32
32
|
`.trim(),encoded=Buffer.from(probe).toString("base64"),{sshExecOrThrow}=await import("@stacksjs/ts-cloud"),target=toSshTarget(where),line=(await sshExecOrThrow(target.host,`/usr/local/bin/bun -e "eval(Buffer.from('${encoded}','base64').toString())"`,remoteExecOptions(target,10))).trim().split(`
|
|
33
|
-
`).at(-1)||"{}",livePorts=JSON.parse(line),result=reconcilePartialDeployManagementDashboards(tsCloudConfig,livePorts);for(const siteName of result.preserved)log.info(`Partial deploy: preserving the live management dashboard service '${siteName}' on port ${livePorts[siteName]}`);for(const siteName of result.removed)log.info(`Partial deploy: omitting management dashboard route '${siteName}' because no active service owns it`)}export function resolvePersistedAttachTargetBox(tsCloudConfig,environment,cwd=process.cwd()){const owner=tsCloudConfig.cloud?.attachTo;if(!owner)return null;const stackName=tsCloudConfig.project?.stackName||`${tsCloudConfig.project?.slug||"app"}-${environment}`,statePath=join(cwd,"storage","cloud","state",`${stackName}.json`);if(!existsSync(statePath))return null;try{const state=JSON.parse(readFileSync(statePath,"utf8")),isSshPin=state.provider==="ssh";if(state.stackName!==stackName||!isSshPin&&typeof state.serverId!=="number"||typeof state.publicIp!=="string"||!state.publicIp.trim())return null;return{...typeof state.serverId==="number"?{serverId:state.serverId}:{},serverName:typeof state.serverName==="string"&&state.serverName?state.serverName:isSshPin?String(state.publicIp):`${owner}-${environment}-app`,publicIp:state.publicIp,publicIpv6:typeof state.publicIpv6==="string"?state.publicIpv6:void 0}}catch{return null}}export function scrubLoopbackSitePortsForFirewall(tsCloudConfig){const sites=tsCloudConfig?.sites;if(!sites)return tsCloudConfig;const loopbackHosts=new Set(["127.0.0.1","::1","localhost"]),scrubbed={};for(const[siteName,site]of Object.entries(sites)){const host=String(site?.env?.HOST??"").toLowerCase();if(site&&typeof site.port==="number"&&!site.domain&&loopbackHosts.has(host)){const rest={...site};delete rest.port;scrubbed[siteName]=rest}else scrubbed[siteName]=site}return{...tsCloudConfig,sites:scrubbed}}function githubDeploymentsEnabled(){return process.env.GITHUB_ACTIONS!=="true"&&process.env.TS_CLOUD_GITHUB_DEPLOYMENTS!=="0"}async function ghCliAvailable(){try{const{execSync}=await import("node:child_process");execSync("gh --version",{stdio:"ignore"});return!0}catch{return!1}}async function resolveSiteGithubSource(root){try{const{execSync}=await import("node:child_process"),run=(cmd)=>execSync(cmd,{cwd:root,stdio:["ignore","pipe","ignore"]}).toString().trim(),match=run("git config --get remote.origin.url").match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);if(!match?.[1])return null;return{repo:match[1],ref:run("git rev-parse HEAD")}}catch{return null}}async function setGithubDeploymentStatus(record,state){try{const{execSync}=await import("node:child_process"),body=JSON.stringify({state,environment:record.environment,...record.environmentUrl?{environment_url:record.environmentUrl}:{},description:state==="success"?"Deployed":state==="failure"?"Deploy failed":"Deploying"});execSync(`gh api -X POST repos/${record.repo}/deployments/${record.id}/statuses --input -`,{input:body,stdio:["pipe","ignore","ignore"]})}catch(err){log.warn(`GitHub deployment status (${state}) skipped for ${record.repo}: ${getErrorMessage(err)}`)}}async function startGithubDeployment(source,environment,environmentUrl){try{const{execSync}=await import("node:child_process"),body=JSON.stringify({ref:source.ref,environment,description:`buddy deploy (${environment})`,auto_merge:!1,required_contexts:[],production_environment:environment==="production"}),out=execSync(`gh api -X POST repos/${source.repo}/deployments --input - --jq '.id'`,{input:body,stdio:["pipe","pipe","ignore"]}).toString().trim(),id=Number(out);if(!out||!Number.isInteger(id)||id<=0)return null;const record={repo:source.repo,id,environment,environmentUrl};await setGithubDeploymentStatus(record,"in_progress");return record}catch(err){log.warn(`GitHub deployment record skipped for ${source.repo}: ${getErrorMessage(err)}`);return null}}async function startGithubDeployments(args){const{sites,onlySite,environment,resolveSiteKind}=args,records=[];if(!githubDeploymentsEnabled()||!await ghCliAvailable())return records;const seen=new Set;for(const[siteName,site]of Object.entries(sites)){if(!site||onlySite&&siteName!==onlySite)continue;const kind=resolveSiteKind(site);if(kind==="bucket"||kind==="redirect")continue;const source=await resolveSiteGithubSource(site.root||".");if(!source)continue;const key=`${source.repo}@${source.ref}`;if(seen.has(key))continue;seen.add(key);const record=await startGithubDeployment(source,environment,site.domain?`https://${site.domain}`:void 0);if(record){records.push(record);log.info(`GitHub deployment ${record.repo}#${record.id} \u2192 ${environment}`)}}return records}async function runSshDeploy(args){const{tsCloudConfig,environment,verbose,docker,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,onlySite,persistedAttachBox,provider,sshTarget}=args,startTime=performance.now(),targetLabel=deployTargetLabel(provider,sshTarget?.profile)
|
|
33
|
+
`).at(-1)||"{}",livePorts=JSON.parse(line),result=reconcilePartialDeployManagementDashboards(tsCloudConfig,livePorts);for(const siteName of result.preserved)log.info(`Partial deploy: preserving the live management dashboard service '${siteName}' on port ${livePorts[siteName]}`);for(const siteName of result.removed)log.info(`Partial deploy: omitting management dashboard route '${siteName}' because no active service owns it`)}export function resolvePersistedAttachTargetBox(tsCloudConfig,environment,cwd=process.cwd()){const owner=tsCloudConfig.cloud?.attachTo;if(!owner)return null;const stackName=tsCloudConfig.project?.stackName||`${tsCloudConfig.project?.slug||"app"}-${environment}`,statePath=join(cwd,"storage","cloud","state",`${stackName}.json`);if(!existsSync(statePath))return null;try{const state=JSON.parse(readFileSync(statePath,"utf8")),isSshPin=state.provider==="ssh";if(state.stackName!==stackName||!isSshPin&&typeof state.serverId!=="number"||typeof state.publicIp!=="string"||!state.publicIp.trim())return null;return{...typeof state.serverId==="number"?{serverId:state.serverId}:{},serverName:typeof state.serverName==="string"&&state.serverName?state.serverName:isSshPin?String(state.publicIp):`${owner}-${environment}-app`,publicIp:state.publicIp,publicIpv6:typeof state.publicIpv6==="string"?state.publicIpv6:void 0}}catch{return null}}export function scrubLoopbackSitePortsForFirewall(tsCloudConfig){const sites=tsCloudConfig?.sites;if(!sites)return tsCloudConfig;const loopbackHosts=new Set(["127.0.0.1","::1","localhost"]),scrubbed={};for(const[siteName,site]of Object.entries(sites)){const host=String(site?.env?.HOST??"").toLowerCase();if(site&&typeof site.port==="number"&&!site.domain&&loopbackHosts.has(host)){const rest={...site};delete rest.port;scrubbed[siteName]=rest}else scrubbed[siteName]=site}return{...tsCloudConfig,sites:scrubbed}}function githubDeploymentsEnabled(){return process.env.GITHUB_ACTIONS!=="true"&&process.env.TS_CLOUD_GITHUB_DEPLOYMENTS!=="0"}async function ghCliAvailable(){try{const{execSync}=await import("node:child_process");execSync("gh --version",{stdio:"ignore"});return!0}catch{return!1}}async function resolveSiteGithubSource(root){try{const{execSync}=await import("node:child_process"),run=(cmd)=>execSync(cmd,{cwd:root,stdio:["ignore","pipe","ignore"]}).toString().trim(),match=run("git config --get remote.origin.url").match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);if(!match?.[1])return null;return{repo:match[1],ref:run("git rev-parse HEAD")}}catch{return null}}async function setGithubDeploymentStatus(record,state){try{const{execSync}=await import("node:child_process"),body=JSON.stringify({state,environment:record.environment,...record.environmentUrl?{environment_url:record.environmentUrl}:{},description:state==="success"?"Deployed":state==="failure"?"Deploy failed":"Deploying"});execSync(`gh api -X POST repos/${record.repo}/deployments/${record.id}/statuses --input -`,{input:body,stdio:["pipe","ignore","ignore"]})}catch(err){log.warn(`GitHub deployment status (${state}) skipped for ${record.repo}: ${getErrorMessage(err)}`)}}async function startGithubDeployment(source,environment,environmentUrl){try{const{execSync}=await import("node:child_process"),body=JSON.stringify({ref:source.ref,environment,description:`buddy deploy (${environment})`,auto_merge:!1,required_contexts:[],production_environment:environment==="production"}),out=execSync(`gh api -X POST repos/${source.repo}/deployments --input - --jq '.id'`,{input:body,stdio:["pipe","pipe","ignore"]}).toString().trim(),id=Number(out);if(!out||!Number.isInteger(id)||id<=0)return null;const record={repo:source.repo,id,environment,environmentUrl};await setGithubDeploymentStatus(record,"in_progress");return record}catch(err){log.warn(`GitHub deployment record skipped for ${source.repo}: ${getErrorMessage(err)}`);return null}}async function startGithubDeployments(args){const{sites,onlySite,environment,resolveSiteKind}=args,records=[];if(!githubDeploymentsEnabled()||!await ghCliAvailable())return records;const seen=new Set;for(const[siteName,site]of Object.entries(sites)){if(!site||onlySite&&siteName!==onlySite)continue;const kind=resolveSiteKind(site);if(kind==="bucket"||kind==="redirect")continue;const source=await resolveSiteGithubSource(site.root||".");if(!source)continue;const key=`${source.repo}@${source.ref}`;if(seen.has(key))continue;seen.add(key);const record=await startGithubDeployment(source,environment,site.domain?`https://${site.domain}`:void 0);if(record){records.push(record);log.info(`GitHub deployment ${record.repo}#${record.id} \u2192 ${environment}`)}}return records}async function runSshDeploy(args){const{tsCloudConfig,environment,verbose,docker,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,onlySite,persistedAttachBox,provider,sshTarget}=args,startTime=performance.now(),targetLabel=deployTargetLabel(provider,sshTarget?.profile),host=sshTarget?`${sshTarget.user}@${sshTarget.host}${sshTarget.port===22?"":`:${sshTarget.port}`}`:`${tsCloudConfig.hetzner?.location||process.env.HCLOUD_LOCATION||"fsn1"} \xB7 ${tsCloudConfig.infrastructure?.compute?.size||"small"}`;console.log("");console.log(`\uD83D\uDE80 Deploy \u2192 ${targetLabel}`);if(verbose){console.log("");log.info(`Project: ${tsCloudConfig.project?.slug}`);log.info(`Environment: ${environment}`);if(sshTarget)log.info(`Host: ${host}`);else{log.info(`Location: ${tsCloudConfig.hetzner?.location||process.env.HCLOUD_LOCATION||"fsn1"}`);log.info(`Size: ${tsCloudConfig.infrastructure?.compute?.size||"small"}`)}}else console.log(` ${tsCloudConfig.project?.slug} \u2192 ${environment} \xB7 ${host}`);console.log("");try{if(shouldInjectManagementDashboard(tsCloudConfig)&&typeof ensureManagementDashboard==="function")ensureManagementDashboard(tsCloudConfig,{cwd:process.cwd(),logger:{info:(m)=>log.info(m),warn:(m)=>log.warn(m)}});else if(tsCloudConfig.cloud?.attachTo)log.info(`Management dashboard: using the '${tsCloudConfig.cloud.attachTo}' server owner's dashboard`)}catch(err){log.warn(`Management dashboard injection skipped: ${getErrorMessage(err)}`)}const driver=createCloudDriver({config:tsCloudConfig,provider});if(!driver.provisionComputeInfrastructure){log.error(`The ${provider} driver does not support compute provisioning (update @stacksjs/ts-cloud).`);process.exit(ExitCode.FatalError)}const attachTo=tsCloudConfig.cloud?.attachTo;let ip,ipv6;if(attachTo){let lookup,box=persistedAttachBox;if(!box&&provider==="ssh"&&sshTarget)box={publicIp:sshTarget.host,serverName:sshTarget.host};if(!box){lookup=await resolveAttachTargetBox(attachTo,environment,tsCloudConfig);box=lookup.box}if(box&&!box.publicIpv6&&provider!=="ssh"){const resolved=await resolveAttachTargetBox(attachTo,environment,tsCloudConfig);if(resolved.box?.publicIpv6)box={...box,publicIpv6:resolved.box.publicIpv6}}if(!box?.publicIp){log.error(describeAttachLookupFailure(attachTo,environment,lookup?.failure));process.exit(ExitCode.FatalError)}ip=box.publicIp;ipv6=box.publicIpv6;if((tsCloudConfig.project?.slug||"app")===attachTo){log.error(`This project's slug is '${attachTo}', which is the slug of the box it attaches to.`);log.error(`A tenant's deploy owns /etc/rpx/sites.d/<slug>.json, so deploying would overwrite '${attachTo}'s own gateway fragment and take its sites down.`);log.info(`Set a distinct project.slug in config/cloud.ts (e.g. '${p.projectPath().split("/").pop()}') and deploy again.`);process.exit(ExitCode.FatalError)}log.info(`Attaching to '${attachTo}' box '${box.serverName}' (${ip}) - skipping provisioning`);const guardTarget=sshTarget?{...sshTarget,host:ip}:ip;await assertFragmentIsOurs(guardTarget,tsCloudConfig,log);await assertPortsAreFree(guardTarget,tsCloudConfig,log);const compute=(tsCloudConfig.infrastructure??={}).compute??={};compute.webServer="rpx";const wantsPublicTls=dnsPublishingAllowed({provider,publicIp:ip,sites:tsCloudConfig.sites});compute.proxy={...wantsPublicTls?{onDemandTls:!0}:{},...compute.proxy??{},engine:"rpx"};const stackName=tsCloudConfig.project?.stackName||`${tsCloudConfig.project?.slug||"app"}-${environment}`,stateDir=join(process.cwd(),"storage","cloud","state");mkdirSync(stateDir,{recursive:!0});const attachPin=provider==="ssh"&&sshTarget?sshStatePin({stackName,target:{...sshTarget,host:ip},lanIp:ip}):{stackName,serverId:box.serverId,serverName:box.serverName,publicIp:ip,...ipv6?{publicIpv6:ipv6}:{},sshUser:"root",deployStoragePath:"/var/ts-cloud/staging"};writeFileSync(join(stateDir,`${stackName}.json`),`${JSON.stringify(attachPin,null,2)}
|
|
34
34
|
`)}else{log.info(provider==="ssh"?`Adopting ${sshTarget?.host??"host"} (preflight, then bootstrap if needed)...`:"Provisioning Hetzner compute infrastructure...");const outputs=await driver.provisionComputeInfrastructure({config:scrubLoopbackSitePortsForFirewall(tsCloudConfig),environment});ip=outputs.appPublicIp;ipv6=outputs.appPublicIpv6;log.success(provider==="ssh"?"Host ready":"Hetzner compute infrastructure ready");if(outputs.appInstanceId&&provider!=="ssh")log.info(`Server ID: ${outputs.appInstanceId}`);if(provider==="ssh"&&sshTarget&&ip)try{const stackName=tsCloudConfig.project?.stackName||`${tsCloudConfig.project?.slug||"app"}-${environment}`,dir=join(process.cwd(),"storage","cloud","state"),statePath=join(dir,`${stackName}.json`);let existing=null;try{existing=existsSync(statePath)?JSON.parse(readFileSync(statePath,"utf8")):null}catch{existing=null}const pin=mergeSshStatePin(existing,sshStatePin({stackName,target:sshTarget,deployStoragePath:outputs.deployStoragePath}));mkdirSync(dir,{recursive:!0});writeFileSync(statePath,`${JSON.stringify(pin,null,2)}
|
|
35
35
|
`)}catch(err){log.warn(`Could not record the ssh host pin: ${getErrorMessage(err)}`)}}if(ip)log.info(provider==="ssh"?`Host: ${ip}`:`Server IP: ${ip}`);if(!ip){log.error("The deploy target has no reachable address - cannot deploy over SSH.");process.exit(ExitCode.FatalError)}await waitForRemoteReady(sshTarget?{...sshTarget,host:ip}:ip);if(onlySite&&!attachTo)await reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig,sshTarget?{...sshTarget,host:ip}:ip);const{execSync}=await import("node:child_process"),{tmpdir}=await import("node:os"),sites=applyEnvironmentToSites(tsCloudConfig.sites||{},environment,tsCloudConfig),slug=tsCloudConfig.project?.slug||"app";let sha;try{sha=execSync("git rev-parse --short HEAD",{stdio:["ignore","pipe","ignore"]}).toString().trim()}catch{sha=Date.now().toString(36)}const tarExcludes=["node_modules","pantry",".git",".github",".cache","bin","dist",relative(p.projectPath(),p.stxPath()).replace(/\/$/,""),".stx",relative(p.projectPath(),p.cloudStatePath()).replace(/\/$/,""),".ts-cloud",relative(p.projectPath(),p.frameworkRuntimePath()).replace(/\/$/,""),"tmp","temp",".DS_Store","*.log",".env",".env.local",".env.keys",".env.production.bak",".env.production.plain",...encryptedEnvFileNames(p.projectPath()),"*.sqlite","*.sqlite-wal","*.sqlite-shm"];if(onlySite&&!sites[onlySite]){log.error(`--site '${onlySite}' is not a configured site. Available: ${Object.keys(sites).join(", ")}`);process.exit(ExitCode.FatalError)}const tarballs=new Map;for(const[siteName,site]of Object.entries(sites)){if(!site)continue;if(onlySite&&siteName!==onlySite)continue;const kind=resolveSiteKind(site);if(kind==="bucket")continue;if(kind==="redirect")continue;if(kind==="server-static"&&site.build){log.info(`Building static site '${siteName}': ${site.build}`);execSync(site.build,{stdio:verbose?"inherit":"pipe"})}const root=site.root||".",tarballPath=join(tmpdir(),`${slug}-${siteName}-${sha}.tar.gz`),siteExcludes=Array.isArray(site.exclude)?site.exclude.filter((entry)=>typeof entry==="string"&&entry.length>0):[],excludeArgs=[...tarExcludes,...siteExcludes].flatMap((pattern)=>[`--exclude='${pattern}'`,`--exclude='*/${pattern}'`]);if(siteExcludes.length>0)log.info(`Excluding server-owned paths: ${siteExcludes.join(", ")}`);log.info(`Packaging ${root} \u2192 ${tarballPath}...`);execSync(`tar czf "${tarballPath}" ${excludeArgs.join(" ")} -C "${root}" .`,{stdio:verbose?"inherit":"pipe",env:{...process.env,COPYFILE_DISABLE:"1"}});const sizeMb=Math.max(1,Math.round(statSync(tarballPath).size/1048576));log.info(`Release tarball: ~${sizeMb} MB`);tarballs.set(siteName,tarballPath)}if(docker)await buildContainerImageWithPantry({slug,sites,verbose});const resolvedDeployEnv=await resolveDeployEnvValues(environment,tsCloudConfig),sitesWithResolvedEnv=applyPreMigrationBackup(applyScheduledWork(applyPersistentStatePaths(applyAutomaticMigrations(mergeSiteDeployEnv(sites,resolvedDeployEnv)),slug),p.projectPath("app/Scheduler.ts")),projectDatabaseTarget(slug,"backups")),apiProblem=apiDeploymentProblem(sitesWithResolvedEnv,existsSync(p.projectPath("routes/api.ts")));if(apiProblem){log.error(apiProblem);throw Error("Refusing to deploy: the API would not be reachable.")}const{validateMigrationDialect}=await import("./migrate");for(const driver of siteDatabaseDrivers(sitesWithResolvedEnv)){const dialect=validateMigrationDialect(p.projectPath(),{driver});if(!dialect.valid){log.error(dialect.error??`The committed migrations cannot run on ${driver}.`);throw Error(`Refusing to deploy: the migrations cannot run on ${driver}.`)}}for(const[envKey,envValue]of Object.entries(resolvedDeployEnv))if(process.env[envKey]===void 0)process.env[envKey]=envValue;const githubDeployments=await startGithubDeployments({sites,onlySite,environment,resolveSiteKind});log.info(onlySite?`Shipping site '${onlySite}' to the server...`:"Shipping release to the server...");const deployConfig=onlySite?{...tsCloudConfig,sites:{[onlySite]:sitesWithResolvedEnv[onlySite]}}:{...tsCloudConfig,sites:sitesWithResolvedEnv},ok=await deployAllComputeSites({config:deployConfig,managementDashboard:!onlySite,rpxConfig:{...tsCloudConfig,sites:sitesWithResolvedEnv},environment,driver,sha,runtime:tsCloudConfig.infrastructure?.compute?.runtime||"bun",tarballForSite:(siteName)=>{const path=tarballs.get(siteName);if(!path)throw Error(`Missing tarball for site '${siteName}'`);return path},logger:{info:(m)=>log.info(m),warn:(m)=>log.warn(m),error:(m)=>log.error(m),step:(m)=>log.info(m),success:(m)=>log.success(m)}}),dnsAllowed=dnsPublishingAllowed({provider,publicIp:ip,sites});if(ok&&!dnsAllowed&&provider==="ssh"){const reachable=lanUrls(onlySite?{[onlySite]:sites[onlySite]}:sites,sshTarget??hetznerTarget(ip),tsCloudConfig.ssh?.lan?.hostname);log.info("Private host: skipping DNS, TLS issuance, CDN and mail reconciliation.");log.info(`Reachable on the local network at ${reachable.join(", ")}`);log.info("To publish a domain, give the host a routable address and set ssh.publicIp, or set TS_CLOUD_SSH_PUBLISH_DNS=1.")}let publishedDns=[];if(ok&&dnsAllowed){const autoWww=tsCloudConfig.infrastructure?.compute?.proxy?.autoWww;publishedDns=await reconcileHetznerDns(onlySite?{[onlySite]:sites[onlySite]}:sites,ip,log,ipv6,autoWww)}if(ok&&publishedDns.length>0){log.info(`Issuing TLS for ${publishedDns.length} newly published record(s)...`);try{const{execSync}=await import("node:child_process"),unit=`rpx-cert-renew-${tsCloudConfig.project?.slug||"app"}.service`,certSshArgs=sshCliArgs(sshTarget?{...sshTarget,host:ip}:hetznerTarget(ip),{connectTimeoutSec:20}),out=execSync(`ssh ${certSshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`systemctl start ${unit} 2>&1 || true
|
|
36
36
|
systemctl is-active ${unit} >/dev/null 2>&1 && echo TLSUNIT:running || echo TLSUNIT:done
|
|
37
37
|
journalctl -u ${unit} -n 20 --no-pager 2>/dev/null | grep -E 'Certificate written|Skipping|error|Error' | tail -5 || true`,encoding:"utf8",stdio:["pipe","pipe","pipe"]});for(const line of out.split(`
|
|
38
|
-
`))if(/Certificate written|Skipping/.test(line))log.info(` ${line.replace(/^.*?\]:\s*/,"").trim()}`);log.success("TLS issued and gateway reloaded for the new record(s)")}catch(err){log.warn(`TLS issuance after DNS failed: ${err?.message||err}`);log.warn(` Until it succeeds, ${publishedDns.join(", ")} serves a fallback certificate.`)}}if(ok&&dnsAllowed)await reconcileConfigDns(onlySite?{[onlySite]:sites[onlySite]}:sites,log);if(ok&&dnsAllowed)await reconcileCloudflareCdnForDeploy(tsCloudConfig,ip,ipv6,log);if(ok&&dnsAllowed){const mailOwner=mailServerOwnerFromConfig(emailConfig);let mailIp=ip;if(mailOwner){const mailLookup=await resolveAttachTargetBox(mailOwner,environment,tsCloudConfig);if(mailLookup.box?.publicIp){mailIp=mailLookup.box.publicIp;log.info(`Mail: reconciling on '${mailOwner}' box '${mailLookup.box.serverName}' (${mailIp})`)}else{mailIp=void 0;log.warn(`Mail: ${describeAttachLookupFailure(mailOwner,environment,mailLookup.failure)}`);log.warn("Mail: skipping mail reconciliation; the application deploy remains live.")}}const mailRes=mailIp?await provisionMailTenant(mailIp,log):null;if(mailOwner&&mailIp&&mailIp!==ip)await cleanupDetachedMailHealth(ip,log);if(mailRes)await reconcileMailDns(mailRes,mailIp,log)}for(const record of githubDeployments)await setGithubDeploymentStatus(record,ok?"success":"failure");console.log("");if(ok){const liveAt=dnsAllowed?publishedDns[0]?`https://${publishedDns[0]}`:`http://${ip}:3000`:lanUrls(onlySite?{[onlySite]:sites[onlySite]}:sites,sshTarget??hetznerTarget(ip),tsCloudConfig.ssh?.lan?.hostname).join(", ");await outro(`Deployed to ${targetLabel}. Your site is live at ${liveAt}`,{startTime,useSeconds:!0});log.info(`Coming-soon page: http://${ip}:3000 (bypass with ?secret=\u2026)`)}else{await outro(`${targetLabel} deploy reported a failure - see the per-instance output above.`,{startTime,useSeconds:!0});process.exit(ExitCode.FatalError)}}function resolveMailboxes(mailboxes,domain,generatePassword=!1){return resolveMailboxesWithSkipped(mailboxes,domain,generatePassword).boxes}function generateMailboxPassword(){return Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString("base64url")}function resolveMailboxesWithSkipped(mailboxes,domain,generatePassword=!1){if(!Array.isArray(mailboxes))return{boxes:[],skipped:[]};const out=[],skipped=[];for(const entry of mailboxes){let raw,explicitPw;if(typeof entry==="string")raw=entry;else if(entry&&typeof entry==="object"){raw=entry.email??entry.username;explicitPw=entry.password;if(entry.generate===!0)generatePassword=!0}if(!raw||typeof raw!=="string")continue;const localPart=(raw.includes("@")?raw.split("@")[0]??"":raw).trim();if(!localPart)continue;const address=`${localPart}@${domain}`,envKey=`MAIL_PASSWORD_${localPart.toUpperCase().replace(/[^A-Z0-9]/g,"_")}`,envPw=explicitPw||process.env[envKey];if(!envPw){if(generatePassword){out.push({address,localPart:localPart.toUpperCase(),password:generateMailboxPassword(),generated:!0});continue}skipped.push(address);continue}out.push({address,localPart:localPart.toUpperCase(),password:envPw,generated:!1})}return{boxes:out,skipped}}export function hasExplicitEmailConfig(projectRoot=p.projectPath()){return existsSync(join(projectRoot,"config","email.ts"))}export function mailServerOwnerFromConfig(config){const owner=config?.server?.attachTo;return typeof owner==="string"&&owner.trim()?owner.trim():void 0}async function cleanupDetachedMailHealth(ip,logger){const{execSync}=await import("node:child_process"),sshArgs=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=20",`root@${ip}`],script=`set -e
|
|
38
|
+
`))if(/Certificate written|Skipping/.test(line))log.info(` ${line.replace(/^.*?\]:\s*/,"").trim()}`);log.success("TLS issued and gateway reloaded for the new record(s)")}catch(err){log.warn(`TLS issuance after DNS failed: ${err?.message||err}`);log.warn(` Until it succeeds, ${publishedDns.join(", ")} serves a fallback certificate.`)}}if(ok&&dnsAllowed)await reconcileConfigDns(onlySite?{[onlySite]:sites[onlySite]}:sites,log);if(ok&&dnsAllowed)await reconcileCloudflareCdnForDeploy(tsCloudConfig,ip,ipv6,log);if(ok&&dnsAllowed){const mailOwner=mailServerOwnerFromConfig(emailConfig);let mailIp=ip;if(mailOwner){const mailLookup=await resolveAttachTargetBox(mailOwner,environment,tsCloudConfig);if(mailLookup.box?.publicIp){mailIp=mailLookup.box.publicIp;log.info(`Mail: reconciling on '${mailOwner}' box '${mailLookup.box.serverName}' (${mailIp})`)}else{mailIp=void 0;log.warn(`Mail: ${describeAttachLookupFailure(mailOwner,environment,mailLookup.failure)}`);log.warn("Mail: skipping mail reconciliation; the application deploy remains live.")}}const mailRes=mailIp?await provisionMailTenant(mailIp,log):null;if(mailOwner&&mailIp&&mailIp!==ip)await cleanupDetachedMailHealth(ip,log);if(mailRes)await reconcileMailDns(mailRes,mailIp,log)}for(const record of githubDeployments)await setGithubDeploymentStatus(record,ok?"success":"failure");console.log("");if(ok){const liveAt=dnsAllowed?publishedDns[0]?`https://${publishedDns[0]}`:`http://${ip}:3000`:lanUrls(onlySite?{[onlySite]:sites[onlySite]}:sites,sshTarget??hetznerTarget(ip),tsCloudConfig.ssh?.lan?.hostname).join(", ");await outro(`Deployed to ${targetLabel}. Your site is live at ${liveAt}`,{startTime,useSeconds:!0});log.info(`Coming-soon page: http://${ip}:3000 (bypass with ?secret=\u2026)`)}else{await outro(`${targetLabel} deploy reported a failure - see the per-instance output above.`,{startTime,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}}function resolveMailboxes(mailboxes,domain,generatePassword=!1){return resolveMailboxesWithSkipped(mailboxes,domain,generatePassword).boxes}function generateMailboxPassword(){return Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString("base64url")}function resolveMailboxesWithSkipped(mailboxes,domain,generatePassword=!1){if(!Array.isArray(mailboxes))return{boxes:[],skipped:[]};const out=[],skipped=[];for(const entry of mailboxes){let raw,explicitPw;if(typeof entry==="string")raw=entry;else if(entry&&typeof entry==="object"){raw=entry.email??entry.username;explicitPw=entry.password;if(entry.generate===!0)generatePassword=!0}if(!raw||typeof raw!=="string")continue;const localPart=(raw.includes("@")?raw.split("@")[0]??"":raw).trim();if(!localPart)continue;const address=`${localPart}@${domain}`,envKey=`MAIL_PASSWORD_${localPart.toUpperCase().replace(/[^A-Z0-9]/g,"_")}`,envPw=explicitPw||process.env[envKey];if(!envPw){if(generatePassword){out.push({address,localPart:localPart.toUpperCase(),password:generateMailboxPassword(),generated:!0});continue}skipped.push(address);continue}out.push({address,localPart:localPart.toUpperCase(),password:envPw,generated:!1})}return{boxes:out,skipped}}export function hasExplicitEmailConfig(projectRoot=p.projectPath()){return existsSync(join(projectRoot,"config","email.ts"))}export function mailServerOwnerFromConfig(config){const owner=config?.server?.attachTo;return typeof owner==="string"&&owner.trim()?owner.trim():void 0}async function cleanupDetachedMailHealth(ip,logger){const{execSync}=await import("node:child_process"),sshArgs=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=20",`root@${ip}`],script=`set -e
|
|
39
39
|
if systemctl list-unit-files --type=service --no-legend | awk '{print $1}' | grep -qx mail.service; then
|
|
40
40
|
exit 0
|
|
41
41
|
fi
|
|
@@ -402,5 +402,5 @@ EOF
|
|
|
402
402
|
systemctl daemon-reload
|
|
403
403
|
systemctl enable --now mail-health.timer >/dev/null 2>&1
|
|
404
404
|
# 6) Restart only when the startup-read env actually changed (domain or DKIM key).
|
|
405
|
-
if [ "$ENV_CHANGED" = 1 ]; then systemctl restart mail 2>/dev/null || true; echo "MAILTENANT:env-changed+restarted,forwards=$FWD_STATE"; else echo "MAILTENANT:current,forwards=$FWD_STATE"; fi`;try{const out=execSync(`ssh ${sshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:script,encoding:"utf8",stdio:["pipe","pipe","pipe"]}),line=(out.match(/MAILTENANT:[^\n]*/)||[])[0]||"MAILTENANT:done",mailHostFromOut=(out.match(/MAILHOST:([^\n]*)/)||[])[1]?.trim(),mailHost=mailHostFromOut||`mail.${domain}`,dkimPubB64=(out.match(/DKIMPUB:([^\n]*)/)||[])[1]?.trim()||void 0,dkimSelector=(out.match(/DKIMSEL:([^\n]*)/)||[])[1]?.trim()||void 0,dkimGlobalKey=(out.match(/DKIMGLOBAL:([^\n]*)/)||[])[1]?.trim();if(dkimGlobalKey)logger.info(`Mail: ${domain} signs with the server's global DKIM key (${dkimGlobalKey}) \u2014 rotate that one.`);const dkimStaleKey=(out.match(/DKIMSTALE:([^\n]*)/)||[])[1]?.trim();if(dkimStaleKey)logger.warn(`Mail: ${dkimStaleKey} is an unused DKIM key left by an earlier deploy \u2014 nothing signs with it. Remove it with: rm ${dkimStaleKey}`);const madeAddrs=new Set([...out.matchAll(/MADE:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])),migratedAddrs=new Set([...out.matchAll(/MIGRATED:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])),created=boxes.filter((b)=>madeAddrs.has(b.address)).map((b)=>({address:b.address,password:b.password}));logger.success(`Mail routing reconciled (${line.replace("MAILTENANT:","")})`);const failed=[...out.matchAll(/FAIL:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[]);if(failed.length)logger.warn(`Mail: the server refused ${failed.length} mailbox(es): ${failed.join(", ")}`);const forwardError=(out.match(/FWDERR:([^\n]*)/)||[])[1]?.trim();if(forwardError)logger.warn(`Mail: the forward rules were not merged: ${forwardError}`);const certHost=(out.match(/CERTHOST:([^\n]*)/)||[])[1]?.trim();if(certHost)logger.success(`Mail: ${certHost} added to the mail certificate - clients can use it as the server name`);const certError=(out.match(/CERTFAIL:([^\n]*)/)||[])[1]?.trim();if(certError)logger.warn(`Mail: could not issue a certificate for mail.${domain} (clients should use ${mailHostFromOut||"the shared mail host"}): ${certError}`);const seen=new Set([...madeAddrs,...migratedAddrs,...[...out.matchAll(/EXISTS:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])]),unaccounted=boxes.filter((b)=>!seen.has(b.address)).map((b)=>b.address);if(unaccounted.length)logger.warn(`Mail: ${unaccounted.length} declared mailbox(es) were not reconciled: ${unaccounted.join(", ")}`);if(created.length){logger.info(`Mail: created ${created.length} mailbox(es) - credentials below (save them; shown once):`);for(const b of created)logger.info(` ${b.address} ${b.password}`)}if(migratedAddrs.size)logger.success(`Mail: migrated ${migratedAddrs.size} legacy mailbox username(s) to isolated full addresses`);return domain?{domain,mailHost,dkimPubB64,dkimSelector,created}:null}catch(err){logger.warn(`Mail routing reconcile skipped: ${getErrorMessage(err)}`);return null}}const DNS_PROVIDER_CREDENTIALS={porkbun:["PORKBUN_API_KEY","PORKBUN_SECRET_KEY"],cloudflare:["CLOUDFLARE_API_TOKEN"],godaddy:["GODADDY_API_KEY","GODADDY_API_SECRET"],route53:["AWS_ACCESS_KEY_ID (or AWS_PROFILE)"]};export function declaredDnsProvider(config){const provider=config?.tsCloud?.infrastructure?.dns?.provider??config?.infrastructure?.dns?.provider;return typeof provider==="string"&&provider?provider.toLowerCase():void 0}export function dnsProviderConfigsFromEnv(declared){const configs=[];if(process.env.PORKBUN_API_KEY&&process.env.PORKBUN_SECRET_KEY)configs.push({provider:"porkbun",apiKey:process.env.PORKBUN_API_KEY,secretKey:process.env.PORKBUN_SECRET_KEY});if(process.env.CLOUDFLARE_API_TOKEN)configs.push({provider:"cloudflare",apiToken:process.env.CLOUDFLARE_API_TOKEN});if(process.env.GODADDY_API_KEY&&process.env.GODADDY_API_SECRET)configs.push({provider:"godaddy",apiKey:process.env.GODADDY_API_KEY,apiSecret:process.env.GODADDY_API_SECRET,environment:process.env.GODADDY_ENVIRONMENT});if(process.env.AWS_ACCESS_KEY_ID||process.env.AWS_PROFILE)configs.push({provider:"route53"});if(!declared)return configs;return configs.filter((config)=>config.provider===declared)}export function declaredDnsProviderProblem(declared,configs){if(!declared||configs.length>0)return;const needed=DNS_PROVIDER_CREDENTIALS[declared];if(!needed)return`config/cloud.ts declares the DNS provider '${declared}', which is not one this deploy knows how to drive (${Object.keys(DNS_PROVIDER_CREDENTIALS).join(", ")}).`;return`config/cloud.ts declares '${declared}' as the DNS provider for this project, but ${needed.join(" and ")} ${needed.length>1?"are":"is"} not set in this environment. Set ${needed.length>1?"them":"it"} in .env.production (\`buddy env:set\`) so the records land at the registrar that actually administers the zone. Refusing to try another provider: writing DNS into the wrong zone is not better than writing none.`}async function resolveZoneDnsProvider(domain,providerConfigs,logger){if(providerConfigs.length===0)return;const{createDnsProvider,detectDnsProvider}=await import("@stacksjs/ts-cloud"),provider=await detectDnsProvider(domain,providerConfigs).catch((err)=>{logger.warn(` DNS: ignoring a configured provider for ${domain} - its credentials were rejected (${err?.message||err})`);return});if(provider)return provider;let nameservers=[];try{const{resolveNs}=await import("node:dns/promises");nameservers=await resolveNs(domain)}catch{}const providerName=dnsProviderNameFromNameservers(nameservers),providerConfig=providerConfigs.find((config)=>config.provider===providerName);return providerConfig?createDnsProvider(providerConfig):void 0}export function resolveDmarcPolicy(policy){return policy==="none"||policy==="quarantine"||policy==="reject"?policy:"quarantine"}export function zoneFqdn(name,zone){const apex=zone.replace(/\.$/,"").toLowerCase(),n=String(name??"").replace(/\.$/,"").toLowerCase();if(!n||n==="@")return apex;return n===apex||n.endsWith(`.${apex}`)?n:`${n}.${apex}`}export function selectRecordsAt(records,fqdn,type,zone){const target=zoneFqdn(fqdn,zone);return records.filter((r)=>String(r.type).toUpperCase()===type.toUpperCase()&&zoneFqdn(r.name,zone)===target)}export function findMailDnsAnomalies(records,expectations,zone){const problems=[];for(const expectation of expectations){const at=selectRecordsAt(records,expectation.fqdn,expectation.type,zone),ours=expectation.owns?at.filter((record)=>expectation.owns(txtContent(record))):at;if(ours.length===0)problems.push(`${expectation.label}: nothing published at ${expectation.fqdn}`);else if(ours.length>1)problems.push(`${expectation.label}: ${ours.length} records at ${expectation.fqdn}, expected 1 - receivers treat a duplicated ${expectation.type} here as unconfigured, so remove the stale one`)}return problems}export function txtContent(record){return String(record.content??record.value??"").replace(/^"|"$/g,"")}export function planTxtReplacement(existing,content,owns){const ours=existing.filter((record)=>owns(txtContent(record)));if(ours.length===1&&txtContent(ours[0])===content)return{remove:[],create:!1};return{remove:ours,create:!0}}export async function reconcileMailDns(res,ip,logger){const{domain,dkimPubB64}=res;let{mailHost}=res;const dkimName=`${res.dkimSelector||"mail"}._domainkey`;try{const dns=await import("node:dns"),own=`mail.${domain}`;if(own!==mailHost&&(await dns.promises.resolve4(own)).includes(ip))mailHost=own}catch{}const spf=`v=spf1 ip4:${ip} ~all`,cfg=emailConfig||{},firstBox=resolveMailboxes(cfg.mailboxes,domain)[0]?.address,fromAddress=typeof cfg.from?.address==="string"&&cfg.from.address.includes("@")?cfg.from.address:void 0,dmarcCfg=cfg.server?.dmarc||{},rua=dmarcCfg.reportTo||fromAddress||firstBox||`chris@${domain}`,dmarc=`v=DMARC1; p=${resolveDmarcPolicy(dmarcCfg.policy)}; rua=mailto:${rua}`,dkim=dkimPubB64?`v=DKIM1; k=rsa; p=${dkimPubB64}`:void 0,byHand=(reason)=>{logger.warn(`Mail DNS not published for ${domain}: ${reason}`);logger.info("Add these records by hand, or point a configured provider at the zone:");logger.info(` MX @ 10 ${mailHost}`);logger.info(` TXT @ ${spf}`);if(dkim)logger.info(` TXT ${dkimName.padEnd(17)}${dkim}`);logger.info(` TXT _dmarc ${dmarc}`);logger.info(` A mail ${ip}`)},declared=declaredDnsProvider(await loadTsCloudConfig(process.env.APP_ENV||"production").catch(()=>{return})),providerConfigs=dnsProviderConfigsFromEnv(declared),declaredProblem=declaredDnsProviderProblem(declared,providerConfigs);if(declaredProblem){logger.warn(` DNS: ${declaredProblem}`);return byHand(`the declared provider '${declared}' has no credentials in this environment`)}if(providerConfigs.length===0)return byHand("no DNS provider credentials are configured");const provider=await resolveZoneDnsProvider(domain,providerConfigs,logger);if(!provider)return byHand("no configured DNS provider administers this zone");const apex=domain.toLowerCase(),dkimFqdn=`${dkimName}.${domain}`,dmarcFqdn=`_dmarc.${domain}`,mailFqdn=`mail.${domain}`,recordsAt=async(fqdn,type)=>{const res=await provider.listRecords(domain);return selectRecordsAt(res?.success?res.records||[]:[],fqdn,type,domain)},replaceTxt=async(fqdn,content,owns)=>{const existing=await recordsAt(fqdn,"TXT"),{remove,create}=planTxtReplacement(existing,content,owns);if(!create)return;for(const record of remove){const removed=await provider.deleteRecord(domain,{...record,name:fqdn,type:"TXT"});if(removed&&removed.success===!1)throw Error(`TXT ${fqdn}: could not remove the record being replaced (${removed.message||"provider refused the delete"})`)}const created=await provider.createRecord(domain,{name:fqdn,type:"TXT",content,ttl:600});if(!created?.success)throw Error(`TXT ${fqdn}: ${created?.message||"provider rejected the record"}`)};try{const mxRecords=await recordsAt(apex,"MX"),staleMx=mxRecords.filter((r)=>String(r.content??r.value??"").replace(/\.$/,"").toLowerCase()!==mailHost.toLowerCase());for(const record of staleMx){logger.warn(` Mail DNS: replacing an existing MX for ${domain} \u2192 ${record.content??record.value}`);await provider.deleteRecord(domain,{...record,name:apex,type:"MX"})}if(mxRecords.length===staleMx.length){const created=await provider.createRecord(domain,{name:apex,type:"MX",content:mailHost,ttl:600,priority:10});if(!created?.success)throw Error(`MX ${domain}: ${created?.message||"provider rejected the record"}`)}await replaceTxt(apex,spf,(existing)=>existing.toLowerCase().startsWith("v=spf1"));if(dkim)await replaceTxt(dkimFqdn,dkim,()=>!0);await replaceTxt(dmarcFqdn,dmarc,(existing)=>existing.toLowerCase().startsWith("v=dmarc1"));const mailA=await provider.upsertRecord(domain,{name:mailFqdn,type:"A",content:ip,ttl:600});if(!mailA?.success)throw Error(`A ${mailFqdn}: ${mailA?.message||"provider rejected the record"}`);const verifyRes=await provider.listRecords(domain),anomalies=verifyRes?.success?findMailDnsAnomalies(verifyRes.records||[],[{label:"MX",fqdn:apex,type:"MX"},{label:"SPF",fqdn:apex,type:"TXT",owns:(content)=>content.toLowerCase().startsWith("v=spf1")},...dkim?[{label:"DKIM",fqdn:dkimFqdn,type:"TXT"}]:[],{label:"DMARC",fqdn:dmarcFqdn,type:"TXT",owns:(content)=>content.toLowerCase().startsWith("v=dmarc1")}],domain):[];for(const anomaly of anomalies)logger.warn(` Mail DNS: ${anomaly}`);logger.success(`Mail DNS published for ${domain} via ${provider.name} (MX\u2192${mailHost}, SPF, DKIM at ${dkimName}, DMARC, ${mailFqdn}\u2192${ip})`)}catch(err){logger.warn(`Mail DNS reconcile skipped for ${domain}: ${getErrorMessage(err)}`)}}export function configDnsDomains(sites){const domains=new Set;for(const site of Object.values(sites))if(!site?.redirect&&site?.domain&&typeof site.domain==="string")domains.add(site.domain.replace(/^www\./,""));const all=[...domains];return all.filter((domain)=>!all.some((other)=>other!==domain&&domain.endsWith(`.${other}`)))}async function reconcileCloudflareCdnForDeploy(tsCloudConfig,ip,ipv6,logger){let resolveCloudflareCdnPlan,reconcileCloudflareCdn,CloudflareProvider,normalizePublicIpv6;try{({resolveCloudflareCdnPlan,reconcileCloudflareCdn,CloudflareProvider,normalizePublicIpv6}=await import("@stacksjs/ts-cloud"))}catch{return}if(typeof resolveCloudflareCdnPlan!=="function"||typeof reconcileCloudflareCdn!=="function"){if(tsCloudConfig?.infrastructure?.compute?.proxy?.cdn?.provider==="cloudflare")logger.warn("Cloudflare CDN: installed @stacksjs/ts-cloud is too old to manage it \u2014 upgrade to ^0.9.4.");return}const{plan,errors}=resolveCloudflareCdnPlan(tsCloudConfig);for(const error of errors)logger.warn(`Cloudflare CDN: ${error}`);if(!plan)return;if(!ip){logger.warn("Cloudflare CDN: no box IP resolved \u2014 skipping.");return}logger.info(`Cloudflare CDN: reconciling ${plan.hosts.length} host(s) on ${plan.zone}...`);try{const provider=new CloudflareProvider(plan.apiToken,{zoneId:plan.zoneId,accountId:plan.accountId}),report=await reconcileCloudflareCdn({provider,zone:plan.zone,hosts:plan.hosts,ipv4:ip,ipv6:typeof normalizePublicIpv6==="function"?normalizePublicIpv6(ipv6):ipv6,proxied:plan.proxied,settings:plan.settings,cache:plan.cache,originGuard:plan.originGuard,purge:plan.purge,skipOriginProbe:plan.skipOriginProbe});for(const record of report.records)logger.success(` ${record.host} ${record.type} \u2192 ${record.content}${record.proxied?" (proxied)":" (DNS-only)"}`);for(const setting of report.settingsChanged)logger.info(` ${setting.id}: ${JSON.stringify(setting.from)} \u2192 ${JSON.stringify(setting.to)}`);if(report.cacheRules>0)logger.success(` ${report.cacheRules} cache rule(s) applied`);if(report.originGuard)logger.success(" origin guard header applied");if(report.purged)logger.success(" edge cache purged");for(const deferred of report.deferredProxy||[])logger.warn(` ${deferred.host} left DNS-only: ${deferred.reason} \u2014 re-run the deploy once TLS is issued.`);for(const warning of report.warnings)logger.warn(` ${warning}`)}catch(err){logger.warn(`Cloudflare CDN: ${err?.message||err}`)}}export function dnsProviderNameFromNameservers(nameservers){const normalized=nameservers.map((name)=>name.toLowerCase().replace(/\.$/,""));if(normalized.some((name)=>name.endsWith(".porkbun.com")))return"porkbun";if(normalized.some((name)=>name.endsWith(".ns.cloudflare.com")))return"cloudflare";if(normalized.some((name)=>/(^|\.)awsdns-\d+\.(?:com|net|org|co\.uk)$/.test(name)))return"route53";if(normalized.some((name)=>name.endsWith(".domaincontrol.com")))return"godaddy";return null}async function reconcileConfigDns(sites,logger){const projectDnsConfig=await loadProjectDnsConfig(dnsConfig);if(["a","aaaa","cname","mx","txt"].reduce((total,key)=>total+(Array.isArray(projectDnsConfig?.[key])?projectDnsConfig[key].length:0),0)===0)return;for(const domain of configDnsDomains(sites))try{const result=await syncDnsConfig(domain,projectDnsConfig);if(!result.provider)continue;if(result.created||result.failed)logger.info(`DNS (config/dns.ts) ${domain}: ${result.created} created, ${result.kept} kept${result.failed?`, ${result.failed} failed`:""}`);for(const failure of result.failures)logger.warn(` ${failure.record.type} ${failure.record.name} \u2192 ${failure.record.content}: ${failure.reason}`);for(const skipped of result.skipped)logger.info(` skipped ${skipped.record.type} ${skipped.record.name}: ${skipped.reason}`)}catch(err){logger.warn(`DNS (config/dns.ts) reconcile for ${domain} failed: ${err instanceof Error?err.message:String(err)}`)}}async function reconcileHetznerDns(sites,ip,logger,ipv6,autoWww){const published=[],{gatewayHostnames}=await import("@stacksjs/ts-cloud"),hostnames=gatewayHostnames(sites,{autoWww}),byBase=new Map;for(const fqdn of hostnames){const base=fqdn.replace(/^www\./,""),group=byBase.get(base)??new Set;group.add(fqdn);byBase.set(base,group)}if(byBase.size===0)return published;const declared=declaredDnsProvider(await loadTsCloudConfig(process.env.APP_ENV||"production").catch(()=>{return})),providerConfigs=dnsProviderConfigsFromEnv(declared),declaredProblem=declaredDnsProviderProblem(declared,providerConfigs);if(declaredProblem)logger.warn(`DNS: ${declaredProblem}`);if(providerConfigs.length===0){if(!declaredProblem)logger.warn("DNS: no DNS provider credentials found (PORKBUN_API_KEY/\u2026); skipping DNS reconciliation.");for(const fqdn of hostnames)logger.info(` Point manually: A ${fqdn} \u2192 ${ip}`);return published}const{reconcileAddressRecords}=await import("@stacksjs/ts-cloud");logger.info("Reconciling DNS records...");const resolveA=async(fqdn)=>{try{const{resolve4}=await import("node:dns/promises");return await resolve4(fqdn)}catch{return[]}};for(const[domain,group]of byBase){const fqdns=[...group].sort();try{const provider=await resolveZoneDnsProvider(domain,providerConfigs,logger);if(!provider){for(const fqdn of fqdns){const current=await resolveA(fqdn);if(current.includes(ip))logger.success(` DNS: ${fqdn} \u2192 ${ip} (externally managed, already correct)`);else if(current.length===0)logger.warn(` DNS: ${fqdn} does not resolve and no configured provider manages ${domain} - create it manually: A ${fqdn} \u2192 ${ip}`);else logger.warn(` DNS: ${fqdn} resolves to ${current.join(", ")} but this deploy targets ${ip}, and no configured provider manages ${domain} - update it manually: A ${fqdn} \u2192 ${ip}`)}continue}for(const fqdn of fqdns){const report=await reconcileAddressRecords({provider,zone:domain,fqdn,ipv4:ip,ipv6});for(const record of report.published){logger.success(` DNS: ${record.fqdn} \u2192 ${record.content} (${provider.name})`);published.push(record.fqdn)}for(const warning of report.warnings)logger.warn(` DNS: ${warning}`)}}catch(err){logger.warn(` DNS: ${domain} reconciliation failed: ${err?.message||err}`)}}return published}async function buildContainerImageWithPantry(args){const{slug,sites,verbose}=args,{execSync}=await import("node:child_process");let cli;for(const candidate of["pantry","ts-pantry"])try{execSync(`command -v ${candidate}`,{stdio:"pipe"});cli=candidate;break}catch{}if(!cli){log.warn("pantry CLI not found on PATH - skipping container image build. Install pantry to enable `--docker`.");return}const dockerfile="storage/framework/Dockerfile",canPush=Boolean(process.env.PANTRY_REGISTRY_TOKEN||process.env.PANTRY_TOKEN);for(const[siteName,site]of Object.entries(sites)){if(!site?.start)continue;const tag=`${slug}-${siteName}:latest`;log.info(`[${siteName}] building image ${tag} with pantry (native, no Docker daemon)...`);const flags=["build",".","-t",tag,"-f",dockerfile,"--run-mode","skip"];if(canPush)flags.push("--push");execSync(`${cli} ${flags.map((f)=>f.includes(" ")?`'${f}'`:f).join(" ")}`,{stdio:verbose?"inherit":"pipe",maxBuffer:536870912});log.success(`[${siteName}] image built${canPush?" + pushed to the pantry registry":""}`)}}export function deploy(buddy){const descriptions={deploy:"Deploy your project",project:"Target a specific project",production:"Deploy to production",development:"Deploy to development",staging:"Deploy to staging",yes:"Confirm all prompts by default",domain:"Specify a domain to deploy to",verbose:"Enable verbose output"};buddy.command("deploy [env]",descriptions.deploy).option("--domain <domain>",descriptions.domain,{default:void 0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--prod",descriptions.production,{default:!1}).option("--dev",descriptions.development,{default:!1}).option("--yes",descriptions.yes,{default:!1}).option("--site <name>","Deploy only this one site to the existing server (multi-tenant surgical add)",{default:void 0}).option("--staging",descriptions.staging,{default:!1}).option("--docker","Also build an OCI image with pantry (native, no Docker daemon) and push it to the pantry registry",{default:!1}).option("-J, --json","Emit a machine-readable deployment preview",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(withDeployNotification(async(envArg,options)=>{log.debug("Running `buddy deploy` ...",options);const askedForDryRun=process.argv.includes("--dry-run")||options.dryRun===!0,optionEnvironment=options.env,deployEnvName=resolveDeploymentEnvironment({positional:envArg,option:optionEnvironment,staging:options.staging,development:options.dev}),deployEnv=deployEnvName;try{applyDeploymentDomainOverride({},options.domain)}catch(error){log.error(getErrorMessage(error));process.exit(ExitCode.InvalidArgument)}if(askedForDryRun){process.env.APP_ENV=deployEnvName;const envFile=deployEnvName==="production"?".env.production":`.env.${deployEnvName}`;if(existsSync(p.projectPath(envFile)))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:deployEnvName,keysFile:".env.keys",overload:!0,quiet:!0})}catch(error){log.debug(`Could not load ${envFile} for preview: ${getErrorMessage(error)}`)}const tsCloudConfig=await loadTsCloudConfig(deployEnvName),{resolveSiteKind}=await loadTsCloudDeployApi(),unbacked=tsCloudConfig?findUnbackedManagedServices(tsCloudConfig,await hasOffsiteBackupDestination()):[];let plan;try{plan=createDeploymentPreview({config:tsCloudConfig,environment:deployEnvName,site:options.site,domain:options.domain,docker:options.docker===!0,fallbackProjectName:app.name,fallbackProjectSlug:app.name?.toLowerCase().replace(/[^a-z0-9]+/g,"-")||"app",fallbackProvider:"aws",fallbackMode:cloudConfig.mode||"server",fallbackRegion:process.env.AWS_REGION||"us-east-1",resolveSiteKind,applyEnvironmentToSites,warnings:unbacked.length>0?[unbackedDataMessage(unbacked)]:[]})}catch(error){log.error(getErrorMessage(error));process.exit(ExitCode.InvalidArgument)}if(options.json||process.argv.includes("--json"))console.log(`${deploymentPreviewJsonPrefix}${JSON.stringify(plan)}`);else process.stdout.write(formatDeploymentPreview(plan));return}await ensureDeployPrerequisites(options.verbose===!0,deployEnvName);process.env.APP_ENV=deployEnvName;{const envFile=deployEnvName==="production"?".env.production":`.env.${deployEnvName}`;if(existsSync(p.projectPath(envFile)))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:deployEnvName,keysFile:".env.keys",overload:!0,quiet:!0})}catch(err){log.warn(`Could not load ${envFile}: ${getErrorMessage(err)}`)}}const tsCloudConfig=await loadTsCloudConfig(deployEnvName);if(tsCloudConfig&&isSshPipelineProvider(resolveProvider(tsCloudConfig))){await deployOverSsh(applyDeploymentDomainOverride(tsCloudConfig,options.domain),deployEnv,options);return}if(process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY)delete process.env.AWS_PROFILE;const startTime=performance.now();console.log("");console.log("\uD83D\uDE80 Deploy");console.log("");let productionUrl;if(deployEnv==="production"||deployEnv==="prod"){productionUrl=(await resolveDeployEnvValues("production",tsCloudConfig)).APP_URL?.trim()||void 0;if(productionUrl)log.debug("Using APP_URL from .env.production:",productionUrl)}const envUrl=env.APP_URL,domain=options.domain||productionUrl||envUrl||app.url;if(deployEnvName==="production"&&!options.yes)await confirmProductionDeployment();if(!domain){log.info("No domain found in your .env.production or ./config/app.ts");log.info("Please ensure your domain is properly configured.");log.info("For more info, check out the docs or join our Discord.");process.exit(ExitCode.FatalError)}log.info(`Deploying to ${italic(domain)} (${deployEnv})`);await checkIfAwsIsBootstrapped(options);options.domain=await configureDomain(domain,options,startTime);const result=await runAction(Action.Deploy,options);if(resultFailed(result)){await outro("While running the `buddy deploy`, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Project deployed.",{startTime,useSeconds:!0})}));buddy.command("deploy:rollback [site]","Roll back a deployment to a preserved release").option("--env <environment>","Environment to roll back",{default:"production"}).option("--to <release>","Preserved release id to activate",{default:void 0}).option("--dry-run","Preview the rollback without changing the active release",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(site,options)=>{const exitCode=await runDeployRollback(site,options);if(exitCode!==ExitCode.Success)process.exit(exitCode)});onUnknownSubcommand(buddy,"deploy")}async function confirmProductionDeployment(){if(!process.stdin.isTTY){log.error("Refusing to deploy to production from a non-interactive shell without confirmation.");log.info(" \u27A1\uFE0F Re-run with `--yes` to confirm (e.g. in CI): `buddy deploy --prod --yes`");process.exit(ExitCode.InvalidArgument)}if(!await prompts.confirm({message:"Are you sure you want to deploy to production?",initial:!0})){log.info("Aborting deployment...");process.exit(ExitCode.InvalidArgument)}}async function configureDomain(domain,options,startTime){log.debug("Configuring domain...",domain);if(!domain){log.info("We could not identify a domain to deploy to.");log.warn("Please set your .env or ./config/app.ts properly.");log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");console.log("");log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");console.log("");process.exit(ExitCode.FatalError)}if(domain.includes("localhost")){log.info("You are deploying to a local environment.");log.warn("Please set your .env or ./config/app.ts properly. The domain we are deploying cannot be a `localhost` domain.");log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");console.log("");log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");console.log("");process.exit(ExitCode.FatalError)}if(await hasUserDomainBeenAddedToCloud(domain)){log.info("Domain is properly configured");log.info("Your cloud is deploying...");log.info(`${italic("This may take a while...")}`);return domain}console.log("");log.info(` \uD83D\uDC4B It appears to be your first ${italic(domain)} deployment.`);console.log("");log.info(italic("Let\u2019s ensure it is all connected properly."));log.info(italic("One moment..."));console.log("");const result=await addDomain({...options,deploy:!0,startTime});if(resultFailed(result)){await outro("While running the `buddy deploy`, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Added your domain.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}async function promptAndSaveCredentials(){const accessKeyId=await prompts.text({message:"AWS Access Key ID:",validate:(value)=>value.length>0?!0:"Access Key ID is required"});if(!accessKeyId){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const secretAccessKey=await prompts.password({message:"AWS Secret Access Key:",validate:(value)=>value.length>0?!0:"Secret Access Key is required"});if(!secretAccessKey){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const region=await prompts.text({message:"AWS Region:",initial:"us-east-1"});if(!region){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const{setEnv}=await import("@stacksjs/env");await setEnv("AWS_ACCESS_KEY_ID",accessKeyId,{file:".env.production"});await setEnv("AWS_SECRET_ACCESS_KEY",secretAccessKey,{file:".env.production"});await setEnv("AWS_REGION",region||"us-east-1",{file:".env.production"});process.env.AWS_ACCESS_KEY_ID=accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=secretAccessKey;process.env.AWS_REGION=region||"us-east-1";log.success("AWS credentials saved securely to .env.production");console.log("")}function loadAwsCredentialsFromEnv(){const environment=process.env.APP_ENV||process.env.NODE_ENV||"production",envFiles=[p.projectPath(`.env.${environment}`),p.projectPath(".env")];for(const envPath of envFiles){if(!existsSync(envPath))continue;try{const lines=readFileSync(envPath,"utf-8").split(`
|
|
405
|
+
if [ "$ENV_CHANGED" = 1 ]; then systemctl restart mail 2>/dev/null || true; echo "MAILTENANT:env-changed+restarted,forwards=$FWD_STATE"; else echo "MAILTENANT:current,forwards=$FWD_STATE"; fi`;try{const out=execSync(`ssh ${sshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:script,encoding:"utf8",stdio:["pipe","pipe","pipe"]}),line=(out.match(/MAILTENANT:[^\n]*/)||[])[0]||"MAILTENANT:done",mailHostFromOut=(out.match(/MAILHOST:([^\n]*)/)||[])[1]?.trim(),mailHost=mailHostFromOut||`mail.${domain}`,dkimPubB64=(out.match(/DKIMPUB:([^\n]*)/)||[])[1]?.trim()||void 0,dkimSelector=(out.match(/DKIMSEL:([^\n]*)/)||[])[1]?.trim()||void 0,dkimGlobalKey=(out.match(/DKIMGLOBAL:([^\n]*)/)||[])[1]?.trim();if(dkimGlobalKey)logger.info(`Mail: ${domain} signs with the server's global DKIM key (${dkimGlobalKey}) \u2014 rotate that one.`);const dkimStaleKey=(out.match(/DKIMSTALE:([^\n]*)/)||[])[1]?.trim();if(dkimStaleKey)logger.warn(`Mail: ${dkimStaleKey} is an unused DKIM key left by an earlier deploy \u2014 nothing signs with it. Remove it with: rm ${dkimStaleKey}`);const madeAddrs=new Set([...out.matchAll(/MADE:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])),migratedAddrs=new Set([...out.matchAll(/MIGRATED:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])),created=boxes.filter((b)=>madeAddrs.has(b.address)).map((b)=>({address:b.address,password:b.password}));logger.success(`Mail routing reconciled (${line.replace("MAILTENANT:","")})`);const failed=[...out.matchAll(/FAIL:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[]);if(failed.length)logger.warn(`Mail: the server refused ${failed.length} mailbox(es): ${failed.join(", ")}`);const forwardError=(out.match(/FWDERR:([^\n]*)/)||[])[1]?.trim();if(forwardError)logger.warn(`Mail: the forward rules were not merged: ${forwardError}`);const certHost=(out.match(/CERTHOST:([^\n]*)/)||[])[1]?.trim();if(certHost)logger.success(`Mail: ${certHost} added to the mail certificate - clients can use it as the server name`);const certError=(out.match(/CERTFAIL:([^\n]*)/)||[])[1]?.trim();if(certError)logger.warn(`Mail: could not issue a certificate for mail.${domain} (clients should use ${mailHostFromOut||"the shared mail host"}): ${certError}`);const seen=new Set([...madeAddrs,...migratedAddrs,...[...out.matchAll(/EXISTS:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])]),unaccounted=boxes.filter((b)=>!seen.has(b.address)).map((b)=>b.address);if(unaccounted.length)logger.warn(`Mail: ${unaccounted.length} declared mailbox(es) were not reconciled: ${unaccounted.join(", ")}`);if(created.length){logger.info(`Mail: created ${created.length} mailbox(es) - credentials below (save them; shown once):`);for(const b of created)logger.info(` ${b.address} ${b.password}`)}if(migratedAddrs.size)logger.success(`Mail: migrated ${migratedAddrs.size} legacy mailbox username(s) to isolated full addresses`);return domain?{domain,mailHost,dkimPubB64,dkimSelector,created}:null}catch(err){logger.warn(`Mail routing reconcile skipped: ${getErrorMessage(err)}`);return null}}const DNS_PROVIDER_CREDENTIALS={porkbun:["PORKBUN_API_KEY","PORKBUN_SECRET_KEY"],cloudflare:["CLOUDFLARE_API_TOKEN"],godaddy:["GODADDY_API_KEY","GODADDY_API_SECRET"],route53:["AWS_ACCESS_KEY_ID (or AWS_PROFILE)"]};export function declaredDnsProvider(config){const provider=config?.tsCloud?.infrastructure?.dns?.provider??config?.infrastructure?.dns?.provider;return typeof provider==="string"&&provider?provider.toLowerCase():void 0}export function dnsProviderConfigsFromEnv(declared){const configs=[];if(process.env.PORKBUN_API_KEY&&process.env.PORKBUN_SECRET_KEY)configs.push({provider:"porkbun",apiKey:process.env.PORKBUN_API_KEY,secretKey:process.env.PORKBUN_SECRET_KEY});if(process.env.CLOUDFLARE_API_TOKEN)configs.push({provider:"cloudflare",apiToken:process.env.CLOUDFLARE_API_TOKEN});if(process.env.GODADDY_API_KEY&&process.env.GODADDY_API_SECRET)configs.push({provider:"godaddy",apiKey:process.env.GODADDY_API_KEY,apiSecret:process.env.GODADDY_API_SECRET,environment:process.env.GODADDY_ENVIRONMENT});if(process.env.AWS_ACCESS_KEY_ID||process.env.AWS_PROFILE)configs.push({provider:"route53"});if(!declared)return configs;return configs.filter((config)=>config.provider===declared)}export function declaredDnsProviderProblem(declared,configs){if(!declared||configs.length>0)return;const needed=DNS_PROVIDER_CREDENTIALS[declared];if(!needed)return`config/cloud.ts declares the DNS provider '${declared}', which is not one this deploy knows how to drive (${Object.keys(DNS_PROVIDER_CREDENTIALS).join(", ")}).`;return`config/cloud.ts declares '${declared}' as the DNS provider for this project, but ${needed.join(" and ")} ${needed.length>1?"are":"is"} not set in this environment. Set ${needed.length>1?"them":"it"} in .env.production (\`buddy env:set\`) so the records land at the registrar that actually administers the zone. Refusing to try another provider: writing DNS into the wrong zone is not better than writing none.`}async function resolveZoneDnsProvider(domain,providerConfigs,logger){if(providerConfigs.length===0)return;const{createDnsProvider,detectDnsProvider}=await import("@stacksjs/ts-cloud"),provider=await detectDnsProvider(domain,providerConfigs).catch((err)=>{logger.warn(` DNS: ignoring a configured provider for ${domain} - its credentials were rejected (${err?.message||err})`);return});if(provider)return provider;let nameservers=[];try{const{resolveNs}=await import("node:dns/promises");nameservers=await resolveNs(domain)}catch{}const providerName=dnsProviderNameFromNameservers(nameservers),providerConfig=providerConfigs.find((config)=>config.provider===providerName);return providerConfig?createDnsProvider(providerConfig):void 0}export function resolveDmarcPolicy(policy){return policy==="none"||policy==="quarantine"||policy==="reject"?policy:"quarantine"}export function zoneFqdn(name,zone){const apex=zone.replace(/\.$/,"").toLowerCase(),n=String(name??"").replace(/\.$/,"").toLowerCase();if(!n||n==="@")return apex;return n===apex||n.endsWith(`.${apex}`)?n:`${n}.${apex}`}export function selectRecordsAt(records,fqdn,type,zone){const target=zoneFqdn(fqdn,zone);return records.filter((r)=>String(r.type).toUpperCase()===type.toUpperCase()&&zoneFqdn(r.name,zone)===target)}export function findMailDnsAnomalies(records,expectations,zone){const problems=[];for(const expectation of expectations){const at=selectRecordsAt(records,expectation.fqdn,expectation.type,zone),ours=expectation.owns?at.filter((record)=>expectation.owns(txtContent(record))):at;if(ours.length===0)problems.push(`${expectation.label}: nothing published at ${expectation.fqdn}`);else if(ours.length>1)problems.push(`${expectation.label}: ${ours.length} records at ${expectation.fqdn}, expected 1 - receivers treat a duplicated ${expectation.type} here as unconfigured, so remove the stale one`)}return problems}export function txtContent(record){return String(record.content??record.value??"").replace(/^"|"$/g,"")}export function planTxtReplacement(existing,content,owns){const ours=existing.filter((record)=>owns(txtContent(record)));if(ours.length===1&&txtContent(ours[0])===content)return{remove:[],create:!1};return{remove:ours,create:!0}}export async function reconcileMailDns(res,ip,logger){const{domain,dkimPubB64}=res;let{mailHost}=res;const dkimName=`${res.dkimSelector||"mail"}._domainkey`;try{const dns=await import("node:dns"),own=`mail.${domain}`;if(own!==mailHost&&(await dns.promises.resolve4(own)).includes(ip))mailHost=own}catch{}const spf=`v=spf1 ip4:${ip} ~all`,cfg=emailConfig||{},firstBox=resolveMailboxes(cfg.mailboxes,domain)[0]?.address,fromAddress=typeof cfg.from?.address==="string"&&cfg.from.address.includes("@")?cfg.from.address:void 0,dmarcCfg=cfg.server?.dmarc||{},rua=dmarcCfg.reportTo||fromAddress||firstBox||`chris@${domain}`,dmarc=`v=DMARC1; p=${resolveDmarcPolicy(dmarcCfg.policy)}; rua=mailto:${rua}`,dkim=dkimPubB64?`v=DKIM1; k=rsa; p=${dkimPubB64}`:void 0,byHand=(reason)=>{logger.warn(`Mail DNS not published for ${domain}: ${reason}`);logger.info("Add these records by hand, or point a configured provider at the zone:");logger.info(` MX @ 10 ${mailHost}`);logger.info(` TXT @ ${spf}`);if(dkim)logger.info(` TXT ${dkimName.padEnd(17)}${dkim}`);logger.info(` TXT _dmarc ${dmarc}`);logger.info(` A mail ${ip}`)},declared=declaredDnsProvider(await loadTsCloudConfig(process.env.APP_ENV||"production").catch(()=>{return})),providerConfigs=dnsProviderConfigsFromEnv(declared),declaredProblem=declaredDnsProviderProblem(declared,providerConfigs);if(declaredProblem){logger.warn(` DNS: ${declaredProblem}`);return byHand(`the declared provider '${declared}' has no credentials in this environment`)}if(providerConfigs.length===0)return byHand("no DNS provider credentials are configured");const provider=await resolveZoneDnsProvider(domain,providerConfigs,logger);if(!provider)return byHand("no configured DNS provider administers this zone");const apex=domain.toLowerCase(),dkimFqdn=`${dkimName}.${domain}`,dmarcFqdn=`_dmarc.${domain}`,mailFqdn=`mail.${domain}`,recordsAt=async(fqdn,type)=>{const res=await provider.listRecords(domain);return selectRecordsAt(res?.success?res.records||[]:[],fqdn,type,domain)},replaceTxt=async(fqdn,content,owns)=>{const existing=await recordsAt(fqdn,"TXT"),{remove,create}=planTxtReplacement(existing,content,owns);if(!create)return;for(const record of remove){const removed=await provider.deleteRecord(domain,{...record,name:fqdn,type:"TXT"});if(removed&&removed.success===!1)throw Error(`TXT ${fqdn}: could not remove the record being replaced (${removed.message||"provider refused the delete"})`)}const created=await provider.createRecord(domain,{name:fqdn,type:"TXT",content,ttl:600});if(!created?.success)throw Error(`TXT ${fqdn}: ${created?.message||"provider rejected the record"}`)};try{const mxRecords=await recordsAt(apex,"MX"),staleMx=mxRecords.filter((r)=>String(r.content??r.value??"").replace(/\.$/,"").toLowerCase()!==mailHost.toLowerCase());for(const record of staleMx){logger.warn(` Mail DNS: replacing an existing MX for ${domain} \u2192 ${record.content??record.value}`);await provider.deleteRecord(domain,{...record,name:apex,type:"MX"})}if(mxRecords.length===staleMx.length){const created=await provider.createRecord(domain,{name:apex,type:"MX",content:mailHost,ttl:600,priority:10});if(!created?.success)throw Error(`MX ${domain}: ${created?.message||"provider rejected the record"}`)}await replaceTxt(apex,spf,(existing)=>existing.toLowerCase().startsWith("v=spf1"));if(dkim)await replaceTxt(dkimFqdn,dkim,()=>!0);await replaceTxt(dmarcFqdn,dmarc,(existing)=>existing.toLowerCase().startsWith("v=dmarc1"));const mailA=await provider.upsertRecord(domain,{name:mailFqdn,type:"A",content:ip,ttl:600});if(!mailA?.success)throw Error(`A ${mailFqdn}: ${mailA?.message||"provider rejected the record"}`);const verifyRes=await provider.listRecords(domain),anomalies=verifyRes?.success?findMailDnsAnomalies(verifyRes.records||[],[{label:"MX",fqdn:apex,type:"MX"},{label:"SPF",fqdn:apex,type:"TXT",owns:(content)=>content.toLowerCase().startsWith("v=spf1")},...dkim?[{label:"DKIM",fqdn:dkimFqdn,type:"TXT"}]:[],{label:"DMARC",fqdn:dmarcFqdn,type:"TXT",owns:(content)=>content.toLowerCase().startsWith("v=dmarc1")}],domain):[];for(const anomaly of anomalies)logger.warn(` Mail DNS: ${anomaly}`);logger.success(`Mail DNS published for ${domain} via ${provider.name} (MX\u2192${mailHost}, SPF, DKIM at ${dkimName}, DMARC, ${mailFqdn}\u2192${ip})`)}catch(err){logger.warn(`Mail DNS reconcile skipped for ${domain}: ${getErrorMessage(err)}`)}}export function configDnsDomains(sites){const domains=new Set;for(const site of Object.values(sites))if(!site?.redirect&&site?.domain&&typeof site.domain==="string")domains.add(site.domain.replace(/^www\./,""));const all=[...domains];return all.filter((domain)=>!all.some((other)=>other!==domain&&domain.endsWith(`.${other}`)))}async function reconcileCloudflareCdnForDeploy(tsCloudConfig,ip,ipv6,logger){let resolveCloudflareCdnPlan,reconcileCloudflareCdn,CloudflareProvider,normalizePublicIpv6;try{({resolveCloudflareCdnPlan,reconcileCloudflareCdn,CloudflareProvider,normalizePublicIpv6}=await import("@stacksjs/ts-cloud"))}catch{return}if(typeof resolveCloudflareCdnPlan!=="function"||typeof reconcileCloudflareCdn!=="function"){if(tsCloudConfig?.infrastructure?.compute?.proxy?.cdn?.provider==="cloudflare")logger.warn("Cloudflare CDN: installed @stacksjs/ts-cloud is too old to manage it \u2014 upgrade to ^0.9.4.");return}const{plan,errors}=resolveCloudflareCdnPlan(tsCloudConfig);for(const error of errors)logger.warn(`Cloudflare CDN: ${error}`);if(!plan)return;if(!ip){logger.warn("Cloudflare CDN: no box IP resolved \u2014 skipping.");return}logger.info(`Cloudflare CDN: reconciling ${plan.hosts.length} host(s) on ${plan.zone}...`);try{const provider=new CloudflareProvider(plan.apiToken,{zoneId:plan.zoneId,accountId:plan.accountId}),report=await reconcileCloudflareCdn({provider,zone:plan.zone,hosts:plan.hosts,ipv4:ip,ipv6:typeof normalizePublicIpv6==="function"?normalizePublicIpv6(ipv6):ipv6,proxied:plan.proxied,settings:plan.settings,cache:plan.cache,originGuard:plan.originGuard,purge:plan.purge,skipOriginProbe:plan.skipOriginProbe});for(const record of report.records)logger.success(` ${record.host} ${record.type} \u2192 ${record.content}${record.proxied?" (proxied)":" (DNS-only)"}`);for(const setting of report.settingsChanged)logger.info(` ${setting.id}: ${JSON.stringify(setting.from)} \u2192 ${JSON.stringify(setting.to)}`);if(report.cacheRules>0)logger.success(` ${report.cacheRules} cache rule(s) applied`);if(report.originGuard)logger.success(" origin guard header applied");if(report.purged)logger.success(" edge cache purged");for(const deferred of report.deferredProxy||[])logger.warn(` ${deferred.host} left DNS-only: ${deferred.reason} \u2014 re-run the deploy once TLS is issued.`);for(const warning of report.warnings)logger.warn(` ${warning}`)}catch(err){logger.warn(`Cloudflare CDN: ${err?.message||err}`)}}export function dnsProviderNameFromNameservers(nameservers){const normalized=nameservers.map((name)=>name.toLowerCase().replace(/\.$/,""));if(normalized.some((name)=>name.endsWith(".porkbun.com")))return"porkbun";if(normalized.some((name)=>name.endsWith(".ns.cloudflare.com")))return"cloudflare";if(normalized.some((name)=>/(^|\.)awsdns-\d+\.(?:com|net|org|co\.uk)$/.test(name)))return"route53";if(normalized.some((name)=>name.endsWith(".domaincontrol.com")))return"godaddy";return null}async function reconcileConfigDns(sites,logger){const projectDnsConfig=await loadProjectDnsConfig(dnsConfig);if(["a","aaaa","cname","mx","txt"].reduce((total,key)=>total+(Array.isArray(projectDnsConfig?.[key])?projectDnsConfig[key].length:0),0)===0)return;for(const domain of configDnsDomains(sites))try{const result=await syncDnsConfig(domain,projectDnsConfig);if(!result.provider)continue;if(result.created||result.failed)logger.info(`DNS (config/dns.ts) ${domain}: ${result.created} created, ${result.kept} kept${result.failed?`, ${result.failed} failed`:""}`);for(const failure of result.failures)logger.warn(` ${failure.record.type} ${failure.record.name} \u2192 ${failure.record.content}: ${failure.reason}`);for(const skipped of result.skipped)logger.info(` skipped ${skipped.record.type} ${skipped.record.name}: ${skipped.reason}`)}catch(err){logger.warn(`DNS (config/dns.ts) reconcile for ${domain} failed: ${err instanceof Error?err.message:String(err)}`)}}export async function reconcileHetznerDns(sites,ip,logger,ipv6,autoWww){const published=[],{gatewayHostnames}=await import("@stacksjs/ts-cloud"),hostnames=gatewayHostnames(sites,{autoWww}),byBase=new Map;for(const fqdn of hostnames){const base=fqdn.replace(/^www\./,""),group=byBase.get(base)??new Set;group.add(fqdn);byBase.set(base,group)}if(byBase.size===0)return published;const declared=declaredDnsProvider(await loadTsCloudConfig(process.env.APP_ENV||"production").catch(()=>{return})),providerConfigs=dnsProviderConfigsFromEnv(declared),declaredProblem=declaredDnsProviderProblem(declared,providerConfigs);if(declaredProblem)logger.warn(`DNS: ${declaredProblem}`);if(providerConfigs.length===0){if(!declaredProblem)logger.warn("DNS: no DNS provider credentials found (PORKBUN_API_KEY/\u2026); skipping DNS reconciliation.");for(const fqdn of hostnames)logger.info(` Point manually: A ${fqdn} \u2192 ${ip}`);return published}const{reconcileAddressRecords}=await import("@stacksjs/ts-cloud");logger.info("Reconciling DNS records...");const resolveA=async(fqdn)=>{try{const{resolve4}=await import("node:dns/promises");return await resolve4(fqdn)}catch{return[]}};for(const[domain,group]of byBase){const fqdns=[...group].sort();try{const provider=await resolveZoneDnsProvider(domain,providerConfigs,logger);if(!provider){for(const fqdn of fqdns){const current=await resolveA(fqdn);if(current.includes(ip))logger.success(` DNS: ${fqdn} \u2192 ${ip} (externally managed, already correct)`);else if(current.length===0)logger.warn(` DNS: ${fqdn} does not resolve and no configured provider manages ${domain} - create it manually: A ${fqdn} \u2192 ${ip}`);else logger.warn(` DNS: ${fqdn} resolves to ${current.join(", ")} but this deploy targets ${ip}, and no configured provider manages ${domain} - update it manually: A ${fqdn} \u2192 ${ip}`)}continue}for(const fqdn of fqdns){const report=await reconcileAddressRecords({provider,zone:domain,fqdn,ipv4:ip,ipv6});for(const record of report.published){logger.success(` DNS: ${record.fqdn} \u2192 ${record.content} (${provider.name})`);published.push(record.fqdn)}for(const warning of report.warnings)logger.warn(` DNS: ${warning}`)}}catch(err){logger.warn(` DNS: ${domain} reconciliation failed: ${err?.message||err}`)}}return published}async function buildContainerImageWithPantry(args){const{slug,sites,verbose}=args,{execSync}=await import("node:child_process");let cli;for(const candidate of["pantry","ts-pantry"])try{execSync(`command -v ${candidate}`,{stdio:"pipe"});cli=candidate;break}catch{}if(!cli){log.warn("pantry CLI not found on PATH - skipping container image build. Install pantry to enable `--docker`.");return}const dockerfile="storage/framework/Dockerfile",canPush=Boolean(process.env.PANTRY_REGISTRY_TOKEN||process.env.PANTRY_TOKEN);for(const[siteName,site]of Object.entries(sites)){if(!site?.start)continue;const tag=`${slug}-${siteName}:latest`;log.info(`[${siteName}] building image ${tag} with pantry (native, no Docker daemon)...`);const flags=["build",".","-t",tag,"-f",dockerfile,"--run-mode","skip"];if(canPush)flags.push("--push");execSync(`${cli} ${flags.map((f)=>f.includes(" ")?`'${f}'`:f).join(" ")}`,{stdio:verbose?"inherit":"pipe",maxBuffer:536870912});log.success(`[${siteName}] image built${canPush?" + pushed to the pantry registry":""}`)}}export function deploy(buddy){const descriptions={deploy:"Deploy your project",project:"Target a specific project",production:"Deploy to production",development:"Deploy to development",staging:"Deploy to staging",yes:"Confirm all prompts by default",domain:"Specify a domain to deploy to",verbose:"Enable verbose output"};buddy.command("deploy [env]",descriptions.deploy).option("--domain <domain>",descriptions.domain,{default:void 0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--prod",descriptions.production,{default:!1}).option("--dev",descriptions.development,{default:!1}).option("--yes",descriptions.yes,{default:!1}).option("--site <name>","Deploy only this one site to the existing server (multi-tenant surgical add)",{default:void 0}).option("--staging",descriptions.staging,{default:!1}).option("--docker","Also build an OCI image with pantry (native, no Docker daemon) and push it to the pantry registry",{default:!1}).option("-J, --json","Emit a machine-readable deployment preview",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(withDeployNotification(async(envArg,options)=>{log.debug("Running `buddy deploy` ...",options);const askedForDryRun=process.argv.includes("--dry-run")||options.dryRun===!0,optionEnvironment=options.env,deployEnvName=resolveDeploymentEnvironment({positional:envArg,option:optionEnvironment,staging:options.staging,development:options.dev}),deployEnv=deployEnvName;try{applyDeploymentDomainOverride({},options.domain)}catch(error){log.error(getErrorMessage(error));process.exit(ExitCode.InvalidArgument)}if(askedForDryRun){process.env.APP_ENV=deployEnvName;const envFile=deployEnvName==="production"?".env.production":`.env.${deployEnvName}`;if(existsSync(p.projectPath(envFile)))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:deployEnvName,keysFile:".env.keys",overload:!0,quiet:!0})}catch(error){log.debug(`Could not load ${envFile} for preview: ${getErrorMessage(error)}`)}const tsCloudConfig=await loadTsCloudConfig(deployEnvName),{resolveSiteKind}=await loadTsCloudDeployApi(),unbacked=tsCloudConfig?findUnbackedManagedServices(tsCloudConfig,await hasOffsiteBackupDestination()):[];let plan;try{plan=createDeploymentPreview({config:tsCloudConfig,environment:deployEnvName,site:options.site,domain:options.domain,docker:options.docker===!0,fallbackProjectName:app.name,fallbackProjectSlug:app.name?.toLowerCase().replace(/[^a-z0-9]+/g,"-")||"app",fallbackProvider:"aws",fallbackMode:cloudConfig.mode||"server",fallbackRegion:process.env.AWS_REGION||"us-east-1",resolveSiteKind,applyEnvironmentToSites,warnings:unbacked.length>0?[unbackedDataMessage(unbacked)]:[]})}catch(error){log.error(getErrorMessage(error));process.exit(ExitCode.InvalidArgument)}if(options.json||process.argv.includes("--json"))console.log(`${deploymentPreviewJsonPrefix}${JSON.stringify(plan)}`);else process.stdout.write(formatDeploymentPreview(plan));return}await ensureDeployPrerequisites(options.verbose===!0,deployEnvName);process.env.APP_ENV=deployEnvName;{const envFile=deployEnvName==="production"?".env.production":`.env.${deployEnvName}`;if(existsSync(p.projectPath(envFile)))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:deployEnvName,keysFile:".env.keys",overload:!0,quiet:!0})}catch(err){log.warn(`Could not load ${envFile}: ${getErrorMessage(err)}`)}}const tsCloudConfig=await loadTsCloudConfig(deployEnvName);if(tsCloudConfig&&isSshPipelineProvider(resolveProvider(tsCloudConfig))){await deployOverSsh(applyDeploymentDomainOverride(tsCloudConfig,options.domain),deployEnv,options);return}if(process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY)delete process.env.AWS_PROFILE;const startTime=performance.now();console.log("");console.log("\uD83D\uDE80 Deploy");console.log("");let productionUrl;if(deployEnv==="production"||deployEnv==="prod"){productionUrl=(await resolveDeployEnvValues("production",tsCloudConfig)).APP_URL?.trim()||void 0;if(productionUrl)log.debug("Using APP_URL from .env.production:",productionUrl)}const envUrl=env.APP_URL,domain=options.domain||productionUrl||envUrl||app.url;if(deployEnvName==="production"&&!options.yes)await confirmProductionDeployment();if(!domain){log.info("No domain found in your .env.production or ./config/app.ts");log.info("Please ensure your domain is properly configured.");log.info("For more info, check out the docs or join our Discord.");process.exit(ExitCode.FatalError)}log.info(`Deploying to ${italic(domain)} (${deployEnv})`);await checkIfAwsIsBootstrapped(options);options.domain=await configureDomain(domain,options,startTime);const result=await runAction(Action.Deploy,options);if(resultFailed(result)){await outro("While running the `buddy deploy`, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Project deployed.",{startTime,useSeconds:!0})}));buddy.command("deploy:rollback [site]","Roll back a deployment to a preserved release").option("--env <environment>","Environment to roll back",{default:"production"}).option("--to <release>","Preserved release id to activate",{default:void 0}).option("--dry-run","Preview the rollback without changing the active release",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(site,options)=>{const exitCode=await runDeployRollback(site,options);if(exitCode!==ExitCode.Success)process.exit(exitCode)});onUnknownSubcommand(buddy,"deploy")}async function confirmProductionDeployment(){if(!process.stdin.isTTY){log.error("Refusing to deploy to production from a non-interactive shell without confirmation.");log.info(" \u27A1\uFE0F Re-run with `--yes` to confirm (e.g. in CI): `buddy deploy --prod --yes`");process.exit(ExitCode.InvalidArgument)}if(!await prompts.confirm({message:"Are you sure you want to deploy to production?",initial:!0})){log.info("Aborting deployment...");process.exit(ExitCode.InvalidArgument)}}async function configureDomain(domain,options,startTime){log.debug("Configuring domain...",domain);if(!domain){log.info("We could not identify a domain to deploy to.");log.warn("Please set your .env or ./config/app.ts properly.");log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");console.log("");log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");console.log("");process.exit(ExitCode.FatalError)}if(domain.includes("localhost")){log.info("You are deploying to a local environment.");log.warn("Please set your .env or ./config/app.ts properly. The domain we are deploying cannot be a `localhost` domain.");log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");console.log("");log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");console.log("");process.exit(ExitCode.FatalError)}if(await hasUserDomainBeenAddedToCloud(domain)){log.info("Domain is properly configured");log.info("Your cloud is deploying...");log.info(`${italic("This may take a while...")}`);return domain}console.log("");log.info(` \uD83D\uDC4B It appears to be your first ${italic(domain)} deployment.`);console.log("");log.info(italic("Let\u2019s ensure it is all connected properly."));log.info(italic("One moment..."));console.log("");const result=await addDomain({...options,deploy:!0,startTime});if(resultFailed(result)){await outro("While running the `buddy deploy`, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Added your domain.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}async function promptAndSaveCredentials(){const accessKeyId=await prompts.text({message:"AWS Access Key ID:",validate:(value)=>value.length>0?!0:"Access Key ID is required"});if(!accessKeyId){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const secretAccessKey=await prompts.password({message:"AWS Secret Access Key:",validate:(value)=>value.length>0?!0:"Secret Access Key is required"});if(!secretAccessKey){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const region=await prompts.text({message:"AWS Region:",initial:"us-east-1"});if(!region){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const{setEnv}=await import("@stacksjs/env");await setEnv("AWS_ACCESS_KEY_ID",accessKeyId,{file:".env.production"});await setEnv("AWS_SECRET_ACCESS_KEY",secretAccessKey,{file:".env.production"});await setEnv("AWS_REGION",region||"us-east-1",{file:".env.production"});process.env.AWS_ACCESS_KEY_ID=accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=secretAccessKey;process.env.AWS_REGION=region||"us-east-1";log.success("AWS credentials saved securely to .env.production");console.log("")}function loadAwsCredentialsFromEnv(){const environment=process.env.APP_ENV||process.env.NODE_ENV||"production",envFiles=[p.projectPath(`.env.${environment}`),p.projectPath(".env")];for(const envPath of envFiles){if(!existsSync(envPath))continue;try{const lines=readFileSync(envPath,"utf-8").split(`
|
|
406
406
|
`);let accessKeyId,secretAccessKey,region,accountId;for(const line of lines){const trimmed=line.trim();if(trimmed.startsWith("#")||!trimmed.includes("="))continue;const[key,...valueParts]=trimmed.split("="),value=valueParts.join("=").trim();if(key==="AWS_ACCESS_KEY_ID"&&value)accessKeyId=value;else if(key==="AWS_SECRET_ACCESS_KEY"&&value)secretAccessKey=value;else if(key==="AWS_REGION"&&value)region=value;else if(key==="AWS_ACCOUNT_ID"&&value)accountId=value}if(accessKeyId&&secretAccessKey){log.debug(`Found AWS credentials in ${envPath}`);return{accessKeyId,secretAccessKey,region,accountId}}}catch(error){log.debug(`Failed to read ${envPath} file:`,error)}}return{}}async function checkIfAwsIsBootstrapped(options){let handlingAlreadyExists=!1;try{log.info("Ensuring AWS cloud stack exists...");let hasCredentials=process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY;if(!hasCredentials){const envCredentials=loadAwsCredentialsFromEnv();if(envCredentials.accessKeyId&&envCredentials.secretAccessKey){process.env.AWS_ACCESS_KEY_ID=envCredentials.accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=envCredentials.secretAccessKey;if(envCredentials.region&&!process.env.AWS_REGION)process.env.AWS_REGION=envCredentials.region;if(envCredentials.accountId&&!process.env.AWS_ACCOUNT_ID)process.env.AWS_ACCOUNT_ID=envCredentials.accountId;hasCredentials=!0;const environment=process.env.APP_ENV||process.env.NODE_ENV||"production";log.success(`Using AWS credentials from .env.${environment}`)}}if(!hasCredentials){const fileCredentials=loadAwsCredentialsFromFile();if(fileCredentials.accessKeyId&&fileCredentials.secretAccessKey){process.env.AWS_ACCESS_KEY_ID=fileCredentials.accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=fileCredentials.secretAccessKey;if(fileCredentials.region&&!process.env.AWS_REGION)process.env.AWS_REGION=fileCredentials.region;hasCredentials=!0;log.success("Using AWS credentials from ~/.aws/credentials")}}if(!hasCredentials){log.info("AWS credentials not found in .env or ~/.aws/credentials.");log.info("You can either:");log.info(" 1. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in .env.production");log.info(" 2. Add credentials to ~/.aws/credentials");log.info(" 3. Configure them interactively below");console.log("");if(options?.yes){log.info("Skipping credential setup (--yes flag provided)");process.exit(ExitCode.FatalError)}const setupCredentials=await prompts.confirm({message:"Would you like to configure AWS credentials now?",initial:!0});log.debug("setupCredentials response:",setupCredentials,typeof setupCredentials);if(setupCredentials===void 0||setupCredentials===!1){if(setupCredentials===void 0){console.log("");log.info("Deployment cancelled");process.exit(ExitCode.Success)}console.log("");log.info("Skipping cloud infrastructure check");log.info("You can configure AWS credentials later by running: buddy configure:aws");return!0}await promptAndSaveCredentials()}else log.success("AWS credentials found");const appName=(process.env.APP_NAME||app.name||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),stackName=`${appName}-cloud`,{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),cfnClient=new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1");let stackExists=!1,needsEmailUpdate=!1;try{const stack=(await cfnClient.describeStacks({stackName})).Stacks?.[0];if(stack){stackExists=!0;log.success("Cloud stack exists");const{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),resources=await new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1").listStackResources(stackName),hasEmailBucket=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="EmailBucket"),hasOutboundLambda=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="OutboundEmailLambda"),hasConversionLambda=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="EmailConversionLambda"),hasNotificationTopic=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="EmailNotificationTopic"),hasMailApiLambda=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="MailApiLambda"),hasMailUsersTable=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="MailUsersTable"),hasMailServerInstance=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="MailServerInstance"),currentEmailDomain=stack.Outputs?.find((o)=>o.OutputKey==="EmailDomain")?.OutputValue,configuredDomain=(emailConfig?.from?.address?.includes("@")?emailConfig.from.address.split("@")[1]:void 0)||"stacksjs.com";if(!hasEmailBucket&&emailConfig?.server?.scan!==void 0){log.info("Email infrastructure not found in stack, will update...");needsEmailUpdate=!0}else if(currentEmailDomain&¤tEmailDomain!==configuredDomain){log.info(`Email domain changed: ${currentEmailDomain} -> ${configuredDomain}, will update...`);needsEmailUpdate=!0}else if(hasEmailBucket&&(!hasOutboundLambda||!hasConversionLambda||!hasNotificationTopic)){log.info("Email infrastructure incomplete, will update...");needsEmailUpdate=!0}else if(hasEmailBucket&&(!hasMailApiLambda||!hasMailUsersTable)){log.info("Mail API infrastructure missing, will update...");needsEmailUpdate=!0}else if(hasEmailBucket&&!hasMailServerInstance&&emailConfig?.server?.enabled){log.info("Mail server EC2 instance missing, will update...");needsEmailUpdate=!0}const currentMode=(stack.Outputs||[]).find((o)=>o.OutputKey==="MailServerMode")?.OutputValue,configuredMode=emailConfig?.server?.mode||"serverless";if(currentMode&¤tMode!==configuredMode){log.info(`Mail server mode changed: ${currentMode} -> ${configuredMode}, will update...`);needsEmailUpdate=!0}if(hasMailServerInstance&&emailConfig?.server?.enabled){if(process.env.FORCE_MAIL_UPDATE==="true"){log.info("Forcing mail server update...");needsEmailUpdate=!0}}if(!needsEmailUpdate)return!0}}catch(error){const caught=error&&typeof error==="object"?error:{message:String(error)};log.debug(`Stack not found: ${getErrorMessage(error)}`)}if(!stackExists)log.info("Cloud stack not found, will be created by deploy action");return!0}catch(err){if(!handlingAlreadyExists){log.error("Error checking cloud infrastructure");log.error(`Error: ${getErrorMessage(err)}`);if(options?.verbose)console.error(err)}process.exit(ExitCode.FatalError)}}
|
package/dist/commands/email.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{readFileSync,existsSync}from"node:fs";import process from"node:process";import{email as emailConfig}from"@stacksjs/config";import{ExitCode}from"@stacksjs/types";import{onUnknownSubcommand}from"@stacksjs/cli";import{getErrorMessage}from"@stacksjs/utils";import{reprocessInboundEmails}from"../email-reprocess";const TIMEOUT_MS=30000;async function withTimeout(promise,ms=TIMEOUT_MS){let timeoutId;const timeoutPromise=new Promise((_,reject)=>{timeoutId=setTimeout(()=>reject(Error(`Operation timed out after ${ms}ms`)),ms)});try{return await Promise.race([promise,timeoutPromise])}finally{clearTimeout(timeoutId)}}let _awsCredsLoaded=!1;async function validateS3Bucket(bucket,region){try{const{S3Client}=await import("@stacksjs/ts-cloud"),s3=new S3Client(region),list=s3.listObjects;if(typeof list!=="function")return{ok:!0};await withTimeout(list.call(s3,{bucket,maxKeys:1}),5000);return{ok:!0}}catch(err){const message=err instanceof Error?err.message:String(err);if(/NoSuchBucket/i.test(message))return{ok:!1,reason:`Bucket '${bucket}' does not exist in region '${region}'.`,hint:"Run `buddy deploy` to create email infrastructure, or pass --bucket <name> if you know the correct bucket."};if(/AccessDenied|Forbidden/i.test(message))return{ok:!1,reason:`Access denied to bucket '${bucket}'.`,hint:"Check that AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (or your IAM role) have s3:ListBucket permission for this bucket."};if(/region/i.test(message)||/PermanentRedirect/i.test(message))return{ok:!1,reason:`Bucket '${bucket}' is not in region '${region}'.`,hint:"Set AWS_REGION to the bucket's actual region, or pass the right bucket for this region."};return{ok:!1,reason:message,hint:"Check AWS connectivity and credentials."}}}async function loadAwsCredentials(){if(_awsCredsLoaded)return;_awsCredsLoaded=!0;const envPath=".env.production";if(!existsSync(envPath))return;const{parse}=await import("@stacksjs/env"),content=readFileSync(envPath,"utf-8"),{parsed}=parse(content);for(const[key,value]of Object.entries(parsed))if(process.env[key]===void 0)process.env[key]=value}const descriptions={email:"Email server management commands",verify:"Check domain verification status",test:"Send a test email",list:"List configured mailboxes",logs:"View email processing logs",status:"Show email server status",inbox:"View inbox emails from S3",reprocess:"Reprocess raw emails from S3 into mailbox structure"};export function email(buddy){buddy.command("email",descriptions.email).
|
|
1
|
+
import{readFileSync,existsSync}from"node:fs";import process from"node:process";import{email as emailConfig}from"@stacksjs/config";import{ExitCode}from"@stacksjs/types";import{onUnknownSubcommand}from"@stacksjs/cli";import{getErrorMessage}from"@stacksjs/utils";import{reprocessInboundEmails}from"../email-reprocess";const TIMEOUT_MS=30000;async function withTimeout(promise,ms=TIMEOUT_MS){let timeoutId;const timeoutPromise=new Promise((_,reject)=>{timeoutId=setTimeout(()=>reject(Error(`Operation timed out after ${ms}ms`)),ms)});try{return await Promise.race([promise,timeoutPromise])}finally{clearTimeout(timeoutId)}}let _awsCredsLoaded=!1;async function validateS3Bucket(bucket,region){try{const{S3Client}=await import("@stacksjs/ts-cloud"),s3=new S3Client(region),list=s3.listObjects;if(typeof list!=="function")return{ok:!0};await withTimeout(list.call(s3,{bucket,maxKeys:1}),5000);return{ok:!0}}catch(err){const message=err instanceof Error?err.message:String(err);if(/NoSuchBucket/i.test(message))return{ok:!1,reason:`Bucket '${bucket}' does not exist in region '${region}'.`,hint:"Run `buddy deploy` to create email infrastructure, or pass --bucket <name> if you know the correct bucket."};if(/AccessDenied|Forbidden/i.test(message))return{ok:!1,reason:`Access denied to bucket '${bucket}'.`,hint:"Check that AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (or your IAM role) have s3:ListBucket permission for this bucket."};if(/region/i.test(message)||/PermanentRedirect/i.test(message))return{ok:!1,reason:`Bucket '${bucket}' is not in region '${region}'.`,hint:"Set AWS_REGION to the bucket's actual region, or pass the right bucket for this region."};return{ok:!1,reason:message,hint:"Check AWS connectivity and credentials."}}}async function loadAwsCredentials(){if(_awsCredsLoaded)return;_awsCredsLoaded=!0;const envPath=".env.production";if(!existsSync(envPath))return;const{parse}=await import("@stacksjs/env"),content=readFileSync(envPath,"utf-8"),{parsed}=parse(content);for(const[key,value]of Object.entries(parsed))if(process.env[key]===void 0)process.env[key]=value}const descriptions={email:"Email server management commands",verify:"Check domain verification status",test:"Send a test email",list:"List configured mailboxes",logs:"View email processing logs",status:"Show email server status",inbox:"View inbox emails from S3",reprocess:"Reprocess raw emails from S3 into mailbox structure"};export function email(buddy){buddy.command("email",descriptions.email).action(async()=>{console.log(`
|
|
2
2
|
\uD83D\uDCE7 Email Server Commands
|
|
3
3
|
`);console.log(" buddy email:verify - Check domain verification status");console.log(" buddy email:test - Send a test email");console.log(" buddy email:list - List configured mailboxes");console.log(" buddy email:inbox - View inbox emails from S3");console.log(" buddy email:reprocess - Reprocess raw emails into mailbox structure");console.log(" buddy email:logs - View email processing logs");console.log(" buddy email:status - Show email server status");console.log("")});buddy.command("email:verify",descriptions.verify).action(async()=>{console.log(`
|
|
4
4
|
\uD83D\uDCE7 Checking Email Domain Verification...
|
|
@@ -1,2 +1,14 @@
|
|
|
1
|
-
import type { CLI } from '@stacksjs/types';
|
|
1
|
+
import type { CLI, GeneratorOptions } from '@stacksjs/types';
|
|
2
|
+
/**
|
|
3
|
+
* Generate types, then refresh the database schema augmentation.
|
|
4
|
+
*
|
|
5
|
+
* Shared because two commands spell this: `generate:types` and `types:generate`.
|
|
6
|
+
* They used to be two implementations - the second called only `generateTypes`
|
|
7
|
+
* and stopped - and `generate:types` also claimed `types:generate` as an alias,
|
|
8
|
+
* so which behaviour you got depended on which registration the CLI resolved
|
|
9
|
+
* first. It resolved the lesser one, which meant `project-setup` and anyone
|
|
10
|
+
* typing `types:generate` regenerated types and left `database/types.d.ts`
|
|
11
|
+
* stale.
|
|
12
|
+
*/
|
|
13
|
+
export declare function runTypeGeneration(options: GeneratorOptions & { watch?: boolean }): Promise<void>;
|
|
2
14
|
export declare function generate(buddy: CLI): void;
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import process from"node:process";import{generateComponentMeta,generateCoreSymlink,generateIdeHelpers,generateLibEntries,generateOpenApiSpec,generatePantryConfig,generateProjectImages,generateTypes,generateVsCodeCustomData,generateWebTypes,invoke as startGenerationProcess,watchTypes}from"@stacksjs/actions";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{frameworkPath,projectPath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{reportFailure,resultFailed}from"../result";export function generate(buddy){const descriptions={command:"Automagically build any of your libraries/packages for production use. Select any of the following packages",types:"Generate your TypeScript types",entries:"Generate your function & Component Library Entry Points",webTypes:"Generate web-types.json for IDEs",customData:"Generate VS Code custom data (custom-elements.json) for IDEs",ideHelpers:"Generate IDE helpers",componentMeta:"Generate component meta information",coreSymlink:"Generate symlink of the core framework to the project root",pantry:"Generate the pantry configuration file",openApi:"Generate the OpenAPI specification",images:"Generate every image declared in config/images.ts",og:"Generate the social cards used by link previews",appStore:"Generate the App Store screenshot set",appIcons:"Generate the app icon and favicon sets",select:"What are you trying to generate?",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("generate",descriptions.command).option("-t, --types",descriptions.types).option("-e, --entries",descriptions.entries).option("-w, --web-types",descriptions.webTypes).option("-c, --custom-data",descriptions.customData).option("-i, --ide-helpers",descriptions.ideHelpers).option("-c, --component-meta",descriptions.componentMeta).option("-p, --pantry",descriptions.pantry).option("-o, --openapi",descriptions.openApi).option("--images",descriptions.images).option("-p, --project [project]",descriptions.project,{default:!1}).option("--core-symlink",descriptions.coreSymlink).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate` ...",options);await startGenerationProcess(options);process.exit(ExitCode.Success)});buddy.command("generate:types",descriptions.types).option("-p, --project [project]",descriptions.project,{default:!1}).option("-w, --watch","Re-run on changes to models/ and config/",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).
|
|
1
|
+
import process from"node:process";import{generateComponentMeta,generateCoreSymlink,generateIdeHelpers,generateLibEntries,generateOpenApiSpec,generatePantryConfig,generateProjectImages,generateTypes,generateVsCodeCustomData,generateWebTypes,invoke as startGenerationProcess,watchTypes}from"@stacksjs/actions";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{frameworkPath,projectPath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{reportFailure,resultFailed}from"../result";export async function runTypeGeneration(options){await generateTypes(options);try{const{buildDatabaseSchema}=await import("@stacksjs/orm");await buildDatabaseSchema()}catch(err){log.warn(`[generate:db-types] skipped: ${err.message}`)}if(options.watch)await watchTypes(options)}export function generate(buddy){const descriptions={command:"Automagically build any of your libraries/packages for production use. Select any of the following packages",types:"Generate your TypeScript types",entries:"Generate your function & Component Library Entry Points",webTypes:"Generate web-types.json for IDEs",customData:"Generate VS Code custom data (custom-elements.json) for IDEs",ideHelpers:"Generate IDE helpers",componentMeta:"Generate component meta information",coreSymlink:"Generate symlink of the core framework to the project root",pantry:"Generate the pantry configuration file",openApi:"Generate the OpenAPI specification",images:"Generate every image declared in config/images.ts",og:"Generate the social cards used by link previews",appStore:"Generate the App Store screenshot set",appIcons:"Generate the app icon and favicon sets",select:"What are you trying to generate?",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("generate",descriptions.command).option("-t, --types",descriptions.types).option("-e, --entries",descriptions.entries).option("-w, --web-types",descriptions.webTypes).option("-c, --custom-data",descriptions.customData).option("-i, --ide-helpers",descriptions.ideHelpers).option("-c, --component-meta",descriptions.componentMeta).option("-p, --pantry",descriptions.pantry).option("-o, --openapi",descriptions.openApi).option("--images",descriptions.images).option("-p, --project [project]",descriptions.project,{default:!1}).option("--core-symlink",descriptions.coreSymlink).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate` ...",options);await startGenerationProcess(options);process.exit(ExitCode.Success)});buddy.command("generate:types",descriptions.types).option("-p, --project [project]",descriptions.project,{default:!1}).option("-w, --watch","Re-run on changes to models/ and config/",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:types` ...",options);await runTypeGeneration(options)});buddy.command("generate:db-types","Refresh database/types.d.ts for db.selectFrom autocomplete (stacksjs/stacks#1923)").option("--dry-run","Print the would-be file content without writing",{default:!1}).option("--framework","Write the framework's own FrameworkSchema instead of the app's DatabaseSchema",{default:!1}).action(async(options)=>{const{buildDatabaseSchema}=await import("@stacksjs/orm"),result=await buildDatabaseSchema(options.framework?{dryRun:options.dryRun,target:"framework",outFile:frameworkPath("core/database/src/framework-schema.ts"),migrationsDir:projectPath("database/migrations")}:{dryRun:options.dryRun});if(options.dryRun)console.log(result.content);for(const e of result.errors)log.warn(`[generate:db-types] ${e.file}: ${e.error}`);log.info(`[generate:db-types] resolved ${result.tables.length} table(s)`)});buddy.command("generate:vschema","Derive a Vitess VSchema from your models (writes database/vschema.json)").option("--dry-run","Print the VSchema without writing it",{default:!1}).option("--out [path]","Where to write the VSchema",{default:"database/vschema.json"}).action(async(options)=>{const{generateVSchema}=await import("@stacksjs/actions"),result=await generateVSchema({dryRun:options.dryRun,out:options.out});if(!result.ok){console.error(`
|
|
2
2
|
\u274C ${result.error}
|
|
3
3
|
`);process.exit(ExitCode.FatalError)}console.log(result.report);if(options.dryRun)console.log(JSON.stringify(result.vschema,null,2));else log.success(`Wrote ${result.path} (${result.tableCount} tables)`)});buddy.command("generate:entries",descriptions.entries).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:entries` ...",options);await generateLibEntries(options)});buddy.command("generate:web-types",descriptions.webTypes).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:web-types` ...",options);await generateWebTypes()});buddy.command("generate:vscode-custom-data",descriptions.customData).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:vscode-custom-data` ...",options);await generateVsCodeCustomData()});buddy.command("generate:ide-helpers",descriptions.ideHelpers).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:ide-helpers` ...",options);await generateIdeHelpers()});buddy.command("generate:component-meta",descriptions.componentMeta).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:component-meta` ...",options);await generateComponentMeta()});buddy.command("generate:pantry-config",descriptions.pantry).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:pantry-config` ...",options);await generatePantryConfig()});buddy.command("generate:openapi-spec",descriptions.openApi).alias("generate:openapi").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:openapi-spec` ...",options);const perf=await intro("buddy generate:openapi-spec");await generateOpenApiSpec();await outro("Generated OpenAPI specification",{startTime:perf,useSeconds:!0})});buddy.command("generate:migrations","Generate Migrations").action(async(options)=>{log.debug("Running `buddy generate:migrations` ...",options);const{generateMigrations}=await import("@stacksjs/database"),result=await generateMigrations();if(resultFailed(result))reportFailure(result,"generateMigrations failed")});buddy.command("generate:core-symlink","Symlink `.framework` -> storage/framework. A shortcut for core developers.").action(async(options)=>{log.debug("Running `buddy generate:core-symlink` ...",options);await generateCoreSymlink()});buddy.command("generate:images",descriptions.images).alias("images:generate").option("--social","Only build the social cards").option("--app-store","Only build the App Store screenshots").option("--app-icons","Only build the app icons and favicons").option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:images` ...",options);const perf=await intro("buddy generate:images"),only=[];if(options.social)only.push("social");if(options.appStore)only.push("app-store");if(options.appIcons)only.push("app-icons");await generateProjectImages({only,verbose:options.verbose});await outro("Generated images",{startTime:perf,useSeconds:!0})});buddy.command("generate:og",descriptions.og).alias("generate:social").option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:og` ...",options);const perf=await intro("buddy generate:og");await generateProjectImages({only:["social"],verbose:options.verbose});await outro("Generated social cards",{startTime:perf,useSeconds:!0})});buddy.command("generate:app-store",descriptions.appStore).alias("generate:screenshots").option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:app-store` ...",options);const perf=await intro("buddy generate:app-store");await generateProjectImages({only:["app-store"],verbose:options.verbose});await outro("Generated App Store screenshots",{startTime:perf,useSeconds:!0})});buddy.command("generate:app-icons",descriptions.appIcons).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy generate:app-icons` ...",options);const perf=await intro("buddy generate:app-icons");await generateProjectImages({only:["app-icons"],verbose:options.verbose});await outro("Generated app icons",{startTime:perf,useSeconds:!0})});onUnknownSubcommand(buddy,"generate")}
|
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){await log.error("No framework checkout found.");log.info("Pass one with `--path <dir>` or set STACKS_FRAMEWORK_PATH.");await log.flush();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();await log.flush();process.exit(ExitCode.Success)});buddy.command("unlink:core [...packages]","Go back to the installed @stacksjs/* packages
|
|
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){await log.error("No framework checkout found.");log.info("Pass one with `--path <dir>` or set STACKS_FRAMEWORK_PATH.");await log.flush();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();await log.flush();process.exit(ExitCode.Success)});buddy.command("unlink:core [...packages]","Go back to the installed @stacksjs/* packages (all of them, unless you name some)").example("buddy unlink:core").action(async(packages)=>{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){await 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
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){await 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
|
-
`));if(report.failed>0){await 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")}
|
|
2
|
+
`));if(report.failed>0){await log.error(`${report.failed} stx check(s) failed.`);await outro("stx checks failed",{startTime,useSeconds:!0,type:"error"});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,type:"error"});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/make.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import process from"node:process";import{createFactory,createMiddleware,createMigration,createModel,createNotification,createPage,invoke,makeAction,makeCertificate,makeCommand,makeComponent,makeDatabase,makeFunction,makeJob,makeLanguage,makeMail,makePage,makePolicy,makeQueueTable,makeResource,makeStack,setDryRun}from"@stacksjs/actions";import{intro,italic,onUnknownSubcommand,outro}from"@stacksjs/cli";import{log}from"@stacksjs/logging";import{ExitCode}from"@stacksjs/types";export function make(buddy){const descriptions={action:"Create a new action",command:"Create a new CLI command",model:"Create a new model",middleware:"Create a new middleware",component:"Create a new component",page:"Create a new page",function:"Create a new function",job:"Create a new job",language:"Create a new language",database:"Create a new database",migration:"Create a new migration",factory:"Create a new factory",notification:"Create a new notification",mail:"Create a new Mailable + companion stx template",policy:"Create a new authorization policy",resource:"Create a new API resource",name:"The name of the action",queue:"Make queue migration",queueTable:"Create the queue jobs table migration",stack:"Create a new stack",certificate:"Create a new SSL Certificate",select:"What are you trying to make?",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("make [make]","The make command").option("-a, --action [action]",descriptions.action,{default:!1}).option("-c, --component [component]",descriptions.component,{default:!1}).option("-d, --database [database]",descriptions.database,{default:!1}).option("-f, --factory [factory]",descriptions.factory,{default:!1}).option("-fn, --function [function]",descriptions.function,{default:!1}).option("-l, --language [language]",descriptions.language,{default:!1}).option("-m, --model [model]",descriptions.model,{default:!1}).option("-mw, --middleware [middleware]",descriptions.middleware,{default:!1}).option("-p, --page [page]",descriptions.page,{default:!1}).option("-mg, --migration [migration]",descriptions.migration,{default:!1}).option("-n, --notification [notification]",descriptions.notification,{default:!1}).option("-qt, --queue-table",descriptions.queue,{default:!1}).option("-s, --stack [stack]",descriptions.stack,{default:!1}).option("--dry-run","Preview the files that would be generated without writing",{default:!1}).option("--with-validation","Include a validation rules block in the generated stub",{default:!1}).option("--with-auth","Include auth-aware boilerplate in the generated stub",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(make,options)=>{log.debug("Running `buddy make` ...",options);if(!buddy.args[0]){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}setDryRun(Boolean(options.dryRun||options["dry-run"]));if(make){options.name=buddy.args[1]??make;switch(make){case"action":await makeAction(options);break;case"certificate":await makeCertificate();break;case"command":await makeCommand(options);break;case"component":await makeComponent(options);break;case"database":makeDatabase(options);break;case"function":await makeFunction(options);break;case"job":await makeJob(options);break;case"language":await makeLanguage(options);break;case"mail":await makeMail(options);break;case"migration":await createMigration(options);break;case"middleware":await createMiddleware(options);break;case"model":await createModel(options);break;case"page":await createPage(options);break;case"notification":await createNotification(options);break;case"policy":await makePolicy({name:options.name,model:typeof options.model==="string"?options.model:void 0,register:options.register});break;case"resource":await makeResource({name:options.name,model:typeof options.model==="string"?options.model:void 0});break;case"queue-table":await makeQueueTable();break;case"stack":await makeStack(options);break;case"factory":await createFactory(options);break;default:{console.error(`Unknown make subcommand: ${make}`);console.error("Valid subcommands: action, certificate, command, component, database, factory, function, job, language, mail, middleware, migration, model, notification, page, policy, queue-table, resource, stack");process.exit(ExitCode.InvalidArgument)}}}await invoke(options);process.exit(ExitCode.Success)});buddy.command("make:action [name]",descriptions.action).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--dry-run","Preview the file without writing",{default:!1}).option("--with-validation","Generate a stub that calls validate()",{default:!1}).option("--with-auth","Generate a stub that requires an authenticated user",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.info("Running `buddy make:action` ...");log.debug("Running `buddy make:action` ...",name,options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");await log.flush();process.exit(ExitCode.FatalError)}setDryRun(Boolean(options.dryRun||options["dry-run"]));await makeAction(options)});buddy.command("make:certificate",descriptions.certificate).alias("make:cert").example("buddy make:certificate").action(async(options)=>{log.debug("Running `buddy make:certificate` ...",options);await makeCertificate()});buddy.command("scaffold:crud [name]","Generate model, migration, and CRUD actions").alias("make:crud").alias("make:scaffold").option("-n, --name [name]","Resource name (PascalCase)",{default:!1}).option("-f, --fields [fields]","Comma-separated field list, e.g. title:string,body:text,published:boolean",{default:""}).option("--dry-run","Preview the generated files without writing",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy scaffold:crud Post --fields=title:string,body:text,published:boolean").action(async(name,options)=>{name=name??options.name;if(!name){console.error("scaffold:crud requires a resource name. Example: buddy scaffold:crud Post --fields=title:string,body:text");process.exit(ExitCode.FatalError)}const{scaffoldCrud}=await import("@stacksjs/actions");setDryRun(Boolean(options.dryRun||options["dry-run"]));try{await scaffoldCrud(name,options)}catch(err){console.error("scaffold:crud failed:",err);process.exit(ExitCode.FatalError)}});buddy.command("make:command [name]",descriptions.command).option("-n, --name [name]",descriptions.name,{default:!1}).option("-s, --signature [signature]","The command signature (CLI name)",{default:!1}).option("-d, --description [description]","The command description",{default:!1}).option("--register","Also add an entry to app/Commands.ts (optional - commands are auto-discovered)",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy make:command SendEmails").example("buddy make:command SendEmails --signature=send-emails").action(async(name,options)=>{log.debug("Running `buddy make:command` ...",options);const perf=await intro("buddy make:command");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a command name.");console.error("Example: buddy make:command SendEmails");process.exit(ExitCode.FatalError)}if(!await makeCommand(options)){await outro("While running the make:command command, there was an issue",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} command.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:component [name]",descriptions.component).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:component` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makeComponent(options)});buddy.command("make:database [name]",descriptions.database).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action((name,options)=>{log.debug("Running `buddy make:database` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a database name via the `--name` option, or as the command\u2019s argument.");console.error("Example: `buddy make:database my-cool-database`");console.error("Or: `buddy make:database --name=my-cool-database`");console.error("Read more about the documentation here: https://stacksjs.com/docs/make/database");process.exit(ExitCode.FatalError)}makeDatabase(options)});buddy.command("make:factory [name]",descriptions.factory).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:factory` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await createFactory(options)});buddy.command("make:function [name]",descriptions.function).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:function` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makeFunction(options)});buddy.command("make:lang [name]",descriptions.language).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:lang` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makeLanguage(options)});buddy.command("make:migration [name]",descriptions.migration).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:migration` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a migration name");process.exit(ExitCode.FatalError)}await createMigration(options)});buddy.command("make:model [name]",descriptions.model).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:model` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a model name");process.exit(ExitCode.FatalError)}await createModel(options)});buddy.command("make:mail [name]",descriptions.mail).option("-n, --name [name]",descriptions.name,{default:!1}).option("-f, --force","Overwrite existing files",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy make:mail OrderShipped").example("buddy make:mail welcome-back // PascalCases to WelcomeBack, kebab-cases to welcome-back").example("buddy make:mail Welcome --force // overwrite existing files").action(async(name,options)=>{log.debug("Running `buddy make:mail` ...",options);const perf=await intro("buddy make:mail");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name (e.g. `buddy make:mail OrderShipped`).");process.exit(ExitCode.FatalError)}await makeMail(options);await outro(`Created your ${italic(name)} mailable.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:notification [name]",descriptions.notification).option("-n, --name [name]",descriptions.name,{default:!1}).option("-e, --email","Is it an email notification?",{default:!0}).option("-c, --chat","Is it a chat notification?",{default:!1}).option("-s, --sms","Is it a SMS notification?",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:notification` ...",options);const perf=await intro("buddy make:notification");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}if(!await createNotification(options)){await outro("While running the make:notification command, there was an issue",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} notification.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:policy [name]",descriptions.policy).option("-n, --name [name]",descriptions.name,{default:!1}).option("-m, --model [model]","The model this policy is for",{default:!1}).option("--no-register","Do not register in Gates.ts",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy make:policy PostPolicy").example("buddy make:policy CommentPolicy --model=Comment").action(async(name,options)=>{log.debug("Running `buddy make:policy` ...",options);const perf=await intro("buddy make:policy");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a policy name.");console.error("Example: buddy make:policy PostPolicy");process.exit(ExitCode.FatalError)}if(!await makePolicy(options)){await outro("While running the make:policy command, there was an issue",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} policy.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:resource [name]",descriptions.resource).option("-n, --name [name]",descriptions.name,{default:!1}).option("-m, --model [model]","The model this resource is for",{default:!1}).option("-c, --collection","Create a collection resource",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy make:resource UserResource").example("buddy make:resource PostResource --model=Post").example("buddy make:resource PostCollection --collection").action(async(name,options)=>{log.debug("Running `buddy make:resource` ...",options);const perf=await intro("buddy make:resource");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a resource name.");console.error("Example: buddy make:resource UserResource");process.exit(ExitCode.FatalError)}if(!await makeResource(options)){await outro("While running the make:resource command, there was an issue",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} resource.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:queue-table",descriptions.queueTable).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy make queue:table` ...",options);await makeQueueTable()});buddy.command("make:stack [name]",descriptions.stack).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:stack` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makeStack(options)});buddy.command("make:view [name]",descriptions.page).alias("make:page [name]").option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:view` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makePage(options)});buddy.command("make:job [name]",descriptions.job).option("-n, --name [name]",descriptions.name,{default:!1}).option("-q, --queue [queue]","The queue to dispatch to",{default:"default"}).option("-c, --class","Create a class-based job",{default:!1}).option("-t, --tries [tries]","Number of retry attempts",{default:3}).option("-b, --backoff [backoff]","Backoff delay in seconds",{default:3}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:job` ...",options);const perf=await intro("buddy make:job");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a job name.");console.error("Example: buddy make:job SendWelcomeEmail");process.exit(ExitCode.FatalError)}if(!await makeJob(options)){await outro("While running the make:job command, there was an issue",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} job.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"make")}
|
|
1
|
+
import process from"node:process";import{createFactory,createMiddleware,createMigration,createModel,createNotification,createPage,invoke,makeAction,makeCertificate,makeCommand,makeComponent,makeDatabase,makeFunction,makeJob,makeLanguage,makeMail,makePage,makePolicy,makeQueueTable,makeResource,makeStack,setDryRun}from"@stacksjs/actions";import{intro,italic,onUnknownSubcommand,outro}from"@stacksjs/cli";import{log}from"@stacksjs/logging";import{ExitCode}from"@stacksjs/types";export function make(buddy){const descriptions={action:"Create a new action",command:"Create a new CLI command",model:"Create a new model",middleware:"Create a new middleware",component:"Create a new component",page:"Create a new page",function:"Create a new function",job:"Create a new job",language:"Create a new language",database:"Create a new database",migration:"Create a new migration",factory:"Create a new factory",notification:"Create a new notification",mail:"Create a new Mailable + companion stx template",policy:"Create a new authorization policy",resource:"Create a new API resource",name:"The name of the action",queue:"Make queue migration",queueTable:"Create the queue jobs table migration",stack:"Create a new stack",certificate:"Create a new SSL Certificate",select:"What are you trying to make?",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("make [make]","The make command").option("-a, --action [action]",descriptions.action,{default:!1}).option("-c, --component [component]",descriptions.component,{default:!1}).option("-d, --database [database]",descriptions.database,{default:!1}).option("-f, --factory [factory]",descriptions.factory,{default:!1}).option("-fn, --function [function]",descriptions.function,{default:!1}).option("-l, --language [language]",descriptions.language,{default:!1}).option("-m, --model [model]",descriptions.model,{default:!1}).option("-mw, --middleware [middleware]",descriptions.middleware,{default:!1}).option("-p, --page [page]",descriptions.page,{default:!1}).option("-mg, --migration [migration]",descriptions.migration,{default:!1}).option("-n, --notification [notification]",descriptions.notification,{default:!1}).option("-qt, --queue-table",descriptions.queue,{default:!1}).option("-s, --stack [stack]",descriptions.stack,{default:!1}).option("--dry-run","Preview the files that would be generated without writing",{default:!1}).option("--with-validation","Include a validation rules block in the generated stub",{default:!1}).option("--with-auth","Include auth-aware boilerplate in the generated stub",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(make,options)=>{log.debug("Running `buddy make` ...",options);if(!buddy.args[0]){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}setDryRun(Boolean(options.dryRun||options["dry-run"]));if(make){options.name=buddy.args[1]??make;switch(make){case"action":await makeAction(options);break;case"certificate":await makeCertificate();break;case"command":await makeCommand(options);break;case"component":await makeComponent(options);break;case"database":makeDatabase(options);break;case"function":await makeFunction(options);break;case"job":await makeJob(options);break;case"language":await makeLanguage(options);break;case"mail":await makeMail(options);break;case"migration":await createMigration(options);break;case"middleware":await createMiddleware(options);break;case"model":await createModel(options);break;case"page":await createPage(options);break;case"notification":await createNotification(options);break;case"policy":await makePolicy({name:options.name,model:typeof options.model==="string"?options.model:void 0,register:options.register});break;case"resource":await makeResource({name:options.name,model:typeof options.model==="string"?options.model:void 0});break;case"queue-table":await makeQueueTable();break;case"stack":await makeStack(options);break;case"factory":await createFactory(options);break;default:{console.error(`Unknown make subcommand: ${make}`);console.error("Valid subcommands: action, certificate, command, component, database, factory, function, job, language, mail, middleware, migration, model, notification, page, policy, queue-table, resource, stack");process.exit(ExitCode.InvalidArgument)}}}await invoke(options);process.exit(ExitCode.Success)});buddy.command("make:action [name]",descriptions.action).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--dry-run","Preview the file without writing",{default:!1}).option("--with-validation","Generate a stub that calls validate()",{default:!1}).option("--with-auth","Generate a stub that requires an authenticated user",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.info("Running `buddy make:action` ...");log.debug("Running `buddy make:action` ...",name,options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");await log.flush();process.exit(ExitCode.FatalError)}setDryRun(Boolean(options.dryRun||options["dry-run"]));await makeAction(options)});buddy.command("make:certificate",descriptions.certificate).alias("make:cert").example("buddy make:certificate").action(async(options)=>{log.debug("Running `buddy make:certificate` ...",options);await makeCertificate()});buddy.command("scaffold:crud [name]","Generate model, migration, and CRUD actions").alias("make:crud").alias("make:scaffold").option("-n, --name [name]","Resource name (PascalCase)",{default:!1}).option("-f, --fields [fields]","Comma-separated field list, e.g. title:string,body:text,published:boolean",{default:""}).option("--dry-run","Preview the generated files without writing",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy scaffold:crud Post --fields=title:string,body:text,published:boolean").action(async(name,options)=>{name=name??options.name;if(!name){console.error("scaffold:crud requires a resource name. Example: buddy scaffold:crud Post --fields=title:string,body:text");process.exit(ExitCode.FatalError)}const{scaffoldCrud}=await import("@stacksjs/actions");setDryRun(Boolean(options.dryRun||options["dry-run"]));try{await scaffoldCrud(name,options)}catch(err){console.error("scaffold:crud failed:",err);process.exit(ExitCode.FatalError)}});buddy.command("make:command [name]",descriptions.command).option("-n, --name [name]",descriptions.name,{default:!1}).option("-s, --signature [signature]","The command signature (CLI name)",{default:!1}).option("-d, --description [description]","The command description",{default:!1}).option("--register","Also add an entry to app/Commands.ts (optional - commands are auto-discovered)",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy make:command SendEmails").example("buddy make:command SendEmails --signature=send-emails").action(async(name,options)=>{log.debug("Running `buddy make:command` ...",options);const perf=await intro("buddy make:command");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a command name.");console.error("Example: buddy make:command SendEmails");process.exit(ExitCode.FatalError)}if(!await makeCommand(options)){await outro("While running the make:command command, there was an issue",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} command.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:component [name]",descriptions.component).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:component` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makeComponent(options)});buddy.command("make:database [name]",descriptions.database).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action((name,options)=>{log.debug("Running `buddy make:database` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a database name via the `--name` option, or as the command\u2019s argument.");console.error("Example: `buddy make:database my-cool-database`");console.error("Or: `buddy make:database --name=my-cool-database`");console.error("Read more about the documentation here: https://stacksjs.com/docs/make/database");process.exit(ExitCode.FatalError)}makeDatabase(options)});buddy.command("make:factory [name]",descriptions.factory).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:factory` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await createFactory(options)});buddy.command("make:function [name]",descriptions.function).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:function` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makeFunction(options)});buddy.command("make:lang [name]",descriptions.language).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:lang` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makeLanguage(options)});buddy.command("make:migration [name]",descriptions.migration).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:migration` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a migration name");process.exit(ExitCode.FatalError)}await createMigration(options)});buddy.command("make:model [name]",descriptions.model).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:model` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a model name");process.exit(ExitCode.FatalError)}await createModel(options)});buddy.command("make:mail [name]",descriptions.mail).option("-n, --name [name]",descriptions.name,{default:!1}).option("-f, --force","Overwrite existing files",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy make:mail OrderShipped").example("buddy make:mail welcome-back // PascalCases to WelcomeBack, kebab-cases to welcome-back").example("buddy make:mail Welcome --force // overwrite existing files").action(async(name,options)=>{log.debug("Running `buddy make:mail` ...",options);const perf=await intro("buddy make:mail");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name (e.g. `buddy make:mail OrderShipped`).");process.exit(ExitCode.FatalError)}await makeMail(options);await outro(`Created your ${italic(name)} mailable.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:notification [name]",descriptions.notification).option("-n, --name [name]",descriptions.name,{default:!1}).option("-e, --email","Is it an email notification?",{default:!0}).option("-c, --chat","Is it a chat notification?",{default:!1}).option("-s, --sms","Is it a SMS notification?",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:notification` ...",options);const perf=await intro("buddy make:notification");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}if(!await createNotification(options)){await outro("While running the make:notification command, there was an issue",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} notification.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:policy [name]",descriptions.policy).option("-n, --name [name]",descriptions.name,{default:!1}).option("-m, --model [model]","The model this policy is for",{default:!1}).option("--no-register","Do not register in Gates.ts",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy make:policy PostPolicy").example("buddy make:policy CommentPolicy --model=Comment").action(async(name,options)=>{log.debug("Running `buddy make:policy` ...",options);const perf=await intro("buddy make:policy");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a policy name.");console.error("Example: buddy make:policy PostPolicy");process.exit(ExitCode.FatalError)}if(!await makePolicy(options)){await outro("While running the make:policy command, there was an issue",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} policy.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:resource [name]",descriptions.resource).option("-n, --name [name]",descriptions.name,{default:!1}).option("-m, --model [model]","The model this resource is for",{default:!1}).option("-c, --collection","Create a collection resource",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy make:resource UserResource").example("buddy make:resource PostResource --model=Post").example("buddy make:resource PostCollection --collection").action(async(name,options)=>{log.debug("Running `buddy make:resource` ...",options);const perf=await intro("buddy make:resource");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a resource name.");console.error("Example: buddy make:resource UserResource");process.exit(ExitCode.FatalError)}if(!await makeResource(options)){await outro("While running the make:resource command, there was an issue",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} resource.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("make:queue-table",descriptions.queueTable).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy make queue:table` ...",options);await makeQueueTable()});buddy.command("make:stack [name]",descriptions.stack).option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:stack` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makeStack(options)});buddy.command("make:view [name]",descriptions.page).alias("make:page [name]").option("-n, --name [name]",descriptions.name,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:view` ...",options);name=name??options.name;options.name=name;if(!name){console.error("You need to specify a name. Read more about the documentation here.");process.exit(ExitCode.FatalError)}await makePage(options)});buddy.command("make:job [name]",descriptions.job).option("-n, --name [name]",descriptions.name,{default:!1}).option("-q, --queue [queue]","The queue to dispatch to",{default:"default"}).option("-c, --class","Create a class-based job",{default:!1}).option("-t, --tries [tries]","Number of retry attempts",{default:3}).option("-b, --backoff [backoff]","Backoff delay in seconds",{default:3}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy make:job` ...",options);const perf=await intro("buddy make:job");name=name??options.name;options.name=name;if(!name){console.error("You need to specify a job name.");console.error("Example: buddy make:job SendWelcomeEmail");process.exit(ExitCode.FatalError)}if(!await makeJob(options)){await outro("While running the make:job command, there was an issue",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}await outro(`Created your ${italic(name)} job.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"make")}
|
package/dist/commands/migrate.js
CHANGED
|
@@ -17,9 +17,9 @@ These were written under the old \`foreign_keys = OFF\` default (#1951). Review
|
|
|
17
17
|
`);process.exit(ExitCode.FatalError)}const guards=await resolveMigrationGuards(),dbLabel=currentDatabaseLabel(),APP_ENV=process.env.APP_ENV||"local";if(guards.migrateFresh==="disabled"){await log.error(`\`buddy migrate:fresh\` is disabled by your migration safety guards (it DROPS every table).
|
|
18
18
|
Target: ${APP_ENV} database "${dbLabel}"
|
|
19
19
|
To allow it, set database.safety.migrateFresh to 'allow' in config/database.ts,
|
|
20
|
-
or run once with: DB_MIGRATE_FRESH=allow ./buddy migrate:fresh`);await outro('migrate:fresh refused - the migrateFresh guard is set to "disabled".',{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}if(!(guards.migrateFresh==="allow"&&options.force===!0)){if(isCI||!hasTTY){const hint=guards.migrateFresh==="confirm"?'Guard is "confirm": migrate:fresh must be run interactively.':"Re-run with --force to drop the database non-interactively.";await log.error(`Refusing to drop the ${APP_ENV} database "${dbLabel}" in a non-interactive environment. ${hint}`);await outro("migrate:fresh cancelled.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}log.warn(`This will DROP ALL TABLES in the ${APP_ENV} database "${dbLabel}" and rebuild them from scratch. All data will be lost.`);await log.flush();if((await text({message:`Type the database name "${dbLabel}" to confirm (blank to cancel):`})).trim()!==dbLabel){await outro("migrate:fresh cancelled - confirmation did not match.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}await preflightDatabase({createDatabase:options.createDatabase,command:"migrate:fresh"});await ensureDatabaseOrExit();const result=await runAction(Action.MigrateFresh,options);if(resultFailed(result))log.error("Model migrations failed - applying auth/notification/RBAC table guarantees before exiting.");if(options.auth!==!1){log.debug("Migrating auth tables...");try{const{migrateAuthTables,migrateNotificationTables,migrateRbacTables,migrateTraitTables}=await import("@stacksjs/database"),authResult=await migrateAuthTables({verbose:options.verbose});if(!authResult.success)log.error(`Failed to migrate auth tables: ${authResult.error}`);const notifResult=await migrateNotificationTables({verbose:options.verbose});if(!notifResult.success)log.error(`Failed to migrate notification tables: ${notifResult.error}`);const rbacResult=await migrateRbacTables({verbose:options.verbose});if(!rbacResult.success)log.error(`Failed to migrate RBAC tables: ${rbacResult.error}`);const traitResult=await migrateTraitTables({verbose:options.verbose});if(!traitResult.success)log.error(`Failed to migrate polymorphic trait tables: ${traitResult.error}`)}catch(error){log.error("Failed to migrate auth/notification/RBAC/trait tables:",error)}}try{const{ensureUuidColumns,sqlHelpers}=await import("@stacksjs/database"),driver=process.env.DB_CONNECTION||"sqlite";await ensureUuidColumns(sqlHelpers(driver),{verbose:options.verbose})}catch(error){log.error("Failed to ensure uuid columns post-migration:",error)}if(resultFailed(result)){await outro("While running the migrate:fresh command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await reportMissingForeignKeys();await reportSchemaDrift();if(options.seed){log.info("Running database seeders...");try{const{runApplicationSeeders,seed}=await import("@stacksjs/database"),seedResult=await seed({verbose:options.verbose,fresh:!0}),applicationSeedResult=await runApplicationSeeders({verbose:options.verbose}),failures=seedResult.failed+applicationSeedResult.failed;if(failures>0){log.warn(`Seeding completed with ${failures} failure(s)`);for(const r of seedResult.results)if(!r.success)log.error(` - ${r.model}: ${r.error}`);for(const r of applicationSeedResult.results)if(!r.success)log.error(` - ${r.seeder}: ${r.error}`)}else log.success(`Seeded ${seedResult.successful} model(s) and ran ${applicationSeedResult.successful} application seeder(s)`)}catch(error){log.error("Failed to run seeders:",error)}}const parts=[];if(options.auth!==!1)parts.push("auth tables");if(options.seed)parts.push("seeded");const suffix=parts.length>0?` & ${parts.join(" & ")}`:"",marker=readMigrateMarker(),countPhrase=marker==null?"":marker.appliedCount===0?" (0 applied - no migration files found?)":` (${marker.appliedCount} migration${marker.appliedCount===1?"":"s"} applied)`;await outro(`All tables dropped successfully & migrated successfully${countPhrase}${suffix}`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:
|
|
20
|
+
or run once with: DB_MIGRATE_FRESH=allow ./buddy migrate:fresh`);await outro('migrate:fresh refused - the migrateFresh guard is set to "disabled".',{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}if(!(guards.migrateFresh==="allow"&&options.force===!0)){if(isCI||!hasTTY){const hint=guards.migrateFresh==="confirm"?'Guard is "confirm": migrate:fresh must be run interactively.':"Re-run with --force to drop the database non-interactively.";await log.error(`Refusing to drop the ${APP_ENV} database "${dbLabel}" in a non-interactive environment. ${hint}`);await outro("migrate:fresh cancelled.",{startTime:perf,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}log.warn(`This will DROP ALL TABLES in the ${APP_ENV} database "${dbLabel}" and rebuild them from scratch. All data will be lost.`);await log.flush();if((await text({message:`Type the database name "${dbLabel}" to confirm (blank to cancel):`})).trim()!==dbLabel){await outro("migrate:fresh cancelled - confirmation did not match.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}await preflightDatabase({createDatabase:options.createDatabase,command:"migrate:fresh"});await ensureDatabaseOrExit();const result=await runAction(Action.MigrateFresh,options);if(resultFailed(result))log.error("Model migrations failed - applying auth/notification/RBAC table guarantees before exiting.");if(options.auth!==!1){log.debug("Migrating auth tables...");try{const{migrateAuthTables,migrateNotificationTables,migrateRbacTables,migrateTraitTables}=await import("@stacksjs/database"),authResult=await migrateAuthTables({verbose:options.verbose});if(!authResult.success)log.error(`Failed to migrate auth tables: ${authResult.error}`);const notifResult=await migrateNotificationTables({verbose:options.verbose});if(!notifResult.success)log.error(`Failed to migrate notification tables: ${notifResult.error}`);const rbacResult=await migrateRbacTables({verbose:options.verbose});if(!rbacResult.success)log.error(`Failed to migrate RBAC tables: ${rbacResult.error}`);const traitResult=await migrateTraitTables({verbose:options.verbose});if(!traitResult.success)log.error(`Failed to migrate polymorphic trait tables: ${traitResult.error}`)}catch(error){log.error("Failed to migrate auth/notification/RBAC/trait tables:",error)}}try{const{ensureUuidColumns,sqlHelpers}=await import("@stacksjs/database"),driver=process.env.DB_CONNECTION||"sqlite";await ensureUuidColumns(sqlHelpers(driver),{verbose:options.verbose})}catch(error){log.error("Failed to ensure uuid columns post-migration:",error)}if(resultFailed(result)){await outro("While running the migrate:fresh command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await reportMissingForeignKeys();await reportSchemaDrift();if(options.seed){log.info("Running database seeders...");try{const{runApplicationSeeders,seed}=await import("@stacksjs/database"),seedResult=await seed({verbose:options.verbose,fresh:!0}),applicationSeedResult=await runApplicationSeeders({verbose:options.verbose}),failures=seedResult.failed+applicationSeedResult.failed;if(failures>0){log.warn(`Seeding completed with ${failures} failure(s)`);for(const r of seedResult.results)if(!r.success)log.error(` - ${r.model}: ${r.error}`);for(const r of applicationSeedResult.results)if(!r.success)log.error(` - ${r.seeder}: ${r.error}`)}else log.success(`Seeded ${seedResult.successful} model(s) and ran ${applicationSeedResult.successful} application seeder(s)`)}catch(error){log.error("Failed to run seeders:",error)}}const parts=[];if(options.auth!==!1)parts.push("auth tables");if(options.seed)parts.push("seeded");const suffix=parts.length>0?` & ${parts.join(" & ")}`:"",marker=readMigrateMarker(),countPhrase=marker==null?"":marker.appliedCount===0?" (0 applied - no migration files found?)":` (${marker.appliedCount} migration${marker.appliedCount===1?"":"s"} applied)`;await outro(`All tables dropped successfully & migrated successfully${countPhrase}${suffix}`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:switch <driver>","Pre-flight check + plan for switching DB_CONNECTION between sqlite / mysql / vitess / postgres").action(async(driver)=>{log.debug(`Running \`buddy migrate:switch ${driver}\` ...`);const perf=await intro("buddy migrate:switch"),target=driver.toLowerCase();if(!new Set(["sqlite","mysql","vitess","postgres"]).has(target)){console.log(`
|
|
21
21
|
Unknown target driver "${driver}". Allowed: sqlite, mysql, vitess, postgres.
|
|
22
|
-
`);await outro("Aborted.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}const current=(process.env.DB_CONNECTION||"sqlite").toLowerCase();if(current===target){console.log(`
|
|
22
|
+
`);await outro("Aborted.",{startTime:perf,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}const current=(process.env.DB_CONNECTION||"sqlite").toLowerCase();if(current===target){console.log(`
|
|
23
23
|
DB_CONNECTION is already "${target}". Nothing to switch.
|
|
24
24
|
`);await outro("No-op.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const missingEnv=({sqlite:["DB_DATABASE"],mysql:["DB_HOST","DB_PORT","DB_USERNAME","DB_PASSWORD","DB_DATABASE"],vitess:["DB_HOST","DB_PORT","DB_USERNAME","DB_PASSWORD","DB_DATABASE"],postgres:["DB_HOST","DB_PORT","DB_USERNAME","DB_PASSWORD","DB_DATABASE"]}[target]??[]).filter((k)=>!process.env[k]);let alterCount=0,uniqueIdxCount=0;try{const migrationsDir=resolveMigrationDirectory(target);if(existsSync(migrationsDir)){const files=readdirSync(migrationsDir).filter((f)=>f.endsWith(".sql"));for(const f of files){const content=stripSqlNoise(readFileSync(`${migrationsDir}/${f}`,"utf-8")).toLowerCase();if(/alter\s+table[\s\S]*add\s+constraint/.test(content))alterCount++;if(/^\s*create\s+unique\s+index/m.test(content))uniqueIdxCount++}}}catch{}const auditTarget=target==="vitess"?"mysql":target,switchAudit=auditMigrationCorpus({dir:resolveMigrationDirectory(target),target:auditTarget}),switchBlocked=switchAudit.incompatible.length>0,blockedFiles=new Set(switchAudit.incompatible.map((m)=>m.file)).size,dialectNote=switchBlocked?`
|
|
25
25
|
\u26D4 ${blockedFiles} of ${switchAudit.total} migration files are ${switchAudit.inferred??"another dialect"}-flavoured and will NOT run on ${target}.
|
|
@@ -78,6 +78,6 @@ ${unrebuildable.map((t)=>` ${t}`).join(`
|
|
|
78
78
|
Repair with: ./buddy migrate:status --reconcile
|
|
79
79
|
That repoints renumbered ledger rows and records migrations the schema
|
|
80
80
|
already proves. It never runs SQL from a migration file.
|
|
81
|
-
`);await outro("Drift detected.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}const fixed=await reconcileMigrationLedger({includePartial:options.includePartial});if(fixed.remapped.length>0)log.success(`Repointed ${fixed.remapped.length} ledger row(s) at their renumbered file.`);if(fixed.recorded.length>0)log.success(`Recorded ${fixed.recorded.length} migration(s) the schema already reflects.`);if(fixed.pruned.length)log.success(`Pruned ${fixed.pruned.length} duplicate ledger row(s) left by a renumbering.`);if(fixed.skipped.length>0){log.warn(`Left ${fixed.skipped.length} entr(ies) alone:`);console.log(fixed.skipped.slice(0,8).map((s)=>` ${s.file} - ${s.reason}`).join(`
|
|
81
|
+
`);await outro("Drift detected.",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}const fixed=await reconcileMigrationLedger({includePartial:options.includePartial});if(fixed.remapped.length>0)log.success(`Repointed ${fixed.remapped.length} ledger row(s) at their renumbered file.`);if(fixed.recorded.length>0)log.success(`Recorded ${fixed.recorded.length} migration(s) the schema already reflects.`);if(fixed.pruned.length)log.success(`Pruned ${fixed.pruned.length} duplicate ledger row(s) left by a renumbering.`);if(fixed.skipped.length>0){log.warn(`Left ${fixed.skipped.length} entr(ies) alone:`);console.log(fixed.skipped.slice(0,8).map((s)=>` ${s.file} - ${s.reason}`).join(`
|
|
82
82
|
`)+(fixed.skipped.length>8?`
|
|
83
83
|
\u2026 +${fixed.skipped.length-8} more`:""))}if(fixed.remapped.length===0&&fixed.recorded.length===0&&fixed.pruned.length===0)log.info("Nothing could be repaired automatically.");await outro("Reconciled.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"migrate")}
|
package/dist/commands/seed.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import process from"node:process";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";export function seed(buddy){const descriptions={seed:"Seed your database",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("seed",descriptions.seed).alias("db:seed").option("-p, --project [project]",descriptions.project,{default:!1}).option("--only [models]","Comma-separated list of models to seed",{default:""}).option("--except [models]","Comma-separated list of models to skip",{default:""}).option("--only-seeders [seeders]","Comma-separated list of application seeder classes to run",{default:""}).option("--except-seeders [seeders]","Comma-separated list of application seeder classes to skip",{default:""}).option("--skip-models","Skip model-factory seeding",{default:!1}).option("--skip-application-seeders","Skip application seeders",{default:!1}).option("--include-defaults","Also seed the framework's built-in models",{default:!1}).option("--allow-protected","Seed auth/oauth models even on a non-fresh DB (will invalidate live tokens)",{default:!1}).option("--fresh","Truncate tables before seeding",{default:!1}).option("--append","Add rows to tables that already have some, instead of skipping them",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy seed` ...",options);const perf=await intro("buddy seed"),{injectGlobalAutoImports}=await import("@stacksjs/server");await injectGlobalAutoImports();const{runApplicationSeeders,seed:seedDatabase}=await import("@stacksjs/database"),list=(value)=>value?value.split(",").map((entry)=>entry.trim()).filter(Boolean):void 0,summary=options.skipModels?{total:0,successful:0,failed:0,results:[],duration:0}:await seedDatabase({verbose:options.verbose,fresh:options.fresh,append:options.append,only:list(options.only),except:list(options.except),includeDefaults:options.includeDefaults,allowProtected:options.allowProtected}),applicationSummary=options.skipApplicationSeeders?{total:0,successful:0,failed:0,results:[],duration:0}:await runApplicationSeeders({verbose:options.verbose,only:list(options.onlySeeders),except:list(options.exceptSeeders)}),APP_ENV=process.env.APP_ENV||"local";if(summary.total===0&&applicationSummary.total===0){await outro("No matching model or application seeders were found.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const failures=summary.failed+applicationSummary.failed;await outro(`Seeded your ${APP_ENV} database. ${summary.successful}/${summary.total} model(s) and ${applicationSummary.successful}/${applicationSummary.total} application seeder(s) completed${failures>0?`, ${failures} failed`:""}.`,{startTime:perf,useSeconds:!0});process.exit(failures>0?ExitCode.FatalError:ExitCode.Success)});buddy.command("seed:roles","Seed default RBAC role packs (admin, dev, client)").alias("roles:seed").action(async()=>{const perf=await intro("buddy seed:roles");try{const{seedDefaultRoles}=await import("@stacksjs/auth"),result=await seedDefaultRoles();if(result.created.length===0&&result.skipped.length>0)await outro(`All ${result.skipped.length} default role packs already exist - nothing to do.`,{startTime:perf,useSeconds:!0});else{const createdNames=result.created.map((r)=>r.name).join(", ");await outro(`Created ${result.created.length} role pack(s): ${createdNames}. Skipped ${result.skipped.length} existing.`,{startTime:perf,useSeconds:!0})}process.exit(ExitCode.Success)}catch(err){await outro("Failed to seed default roles. Most often: migrations haven't run yet (try `./buddy migrate` first).",{startTime:perf,useSeconds:!0},err);process.exit(ExitCode.FatalError)}});onUnknownSubcommand(buddy,"seed")}
|
|
1
|
+
import process from"node:process";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";export function seed(buddy){const descriptions={seed:"Seed your database",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("seed",descriptions.seed).alias("db:seed").option("-p, --project [project]",descriptions.project,{default:!1}).option("--only [models]","Comma-separated list of models to seed",{default:""}).option("--except [models]","Comma-separated list of models to skip",{default:""}).option("--only-seeders [seeders]","Comma-separated list of application seeder classes to run",{default:""}).option("--except-seeders [seeders]","Comma-separated list of application seeder classes to skip",{default:""}).option("--skip-models","Skip model-factory seeding",{default:!1}).option("--skip-application-seeders","Skip application seeders",{default:!1}).option("--include-defaults","Also seed the framework's built-in models",{default:!1}).option("--allow-protected","Seed auth/oauth models even on a non-fresh DB (will invalidate live tokens)",{default:!1}).option("--fresh","Truncate tables before seeding",{default:!1}).option("--append","Add rows to tables that already have some, instead of skipping them",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy seed` ...",options);const perf=await intro("buddy seed"),{injectGlobalAutoImports}=await import("@stacksjs/server");await injectGlobalAutoImports();const{runApplicationSeeders,seed:seedDatabase}=await import("@stacksjs/database"),list=(value)=>value?value.split(",").map((entry)=>entry.trim()).filter(Boolean):void 0,summary=options.skipModels?{total:0,successful:0,failed:0,results:[],duration:0}:await seedDatabase({verbose:options.verbose,fresh:options.fresh,append:options.append,only:list(options.only),except:list(options.except),includeDefaults:options.includeDefaults,allowProtected:options.allowProtected}),applicationSummary=options.skipApplicationSeeders?{total:0,successful:0,failed:0,results:[],duration:0}:await runApplicationSeeders({verbose:options.verbose,only:list(options.onlySeeders),except:list(options.exceptSeeders)}),APP_ENV=process.env.APP_ENV||"local";if(summary.total===0&&applicationSummary.total===0){await outro("No matching model or application seeders were found.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const failures=summary.failed+applicationSummary.failed;await outro(`Seeded your ${APP_ENV} database. ${summary.successful}/${summary.total} model(s) and ${applicationSummary.successful}/${applicationSummary.total} application seeder(s) completed${failures>0?`, ${failures} failed`:""}.`,{startTime:perf,useSeconds:!0,type:failures>0?"error":"success"});process.exit(failures>0?ExitCode.FatalError:ExitCode.Success)});buddy.command("seed:roles","Seed default RBAC role packs (admin, dev, client)").alias("roles:seed").action(async()=>{const perf=await intro("buddy seed:roles");try{const{seedDefaultRoles}=await import("@stacksjs/auth"),result=await seedDefaultRoles();if(result.created.length===0&&result.skipped.length>0)await outro(`All ${result.skipped.length} default role packs already exist - nothing to do.`,{startTime:perf,useSeconds:!0});else{const createdNames=result.created.map((r)=>r.name).join(", ");await outro(`Created ${result.created.length} role pack(s): ${createdNames}. Skipped ${result.skipped.length} existing.`,{startTime:perf,useSeconds:!0})}process.exit(ExitCode.Success)}catch(err){await outro("Failed to seed default roles. Most often: migrations haven't run yet (try `./buddy migrate` first).",{startTime:perf,useSeconds:!0},err);process.exit(ExitCode.FatalError)}});onUnknownSubcommand(buddy,"seed")}
|