@stacksjs/buddy 0.74.11 → 0.74.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/clean.js +1 -1
- package/dist/commands/cloud.js +1 -1
- package/dist/commands/configure.js +1 -1
- package/dist/commands/create.js +1 -1
- package/dist/commands/db.js +1 -1
- package/dist/commands/deploy.js +6 -6
- package/dist/commands/dns.js +2 -2
- package/dist/commands/domains.js +1 -1
- package/dist/commands/fresh.js +1 -1
- package/dist/commands/link.js +1 -1
- package/dist/commands/mail.js +6 -6
- package/dist/commands/maintenance.js +1 -1
- package/dist/commands/make.js +1 -1
- package/dist/commands/publish.js +4 -4
- package/dist/commands/server.js +3 -3
- package/dist/commands/stacks.js +1 -1
- package/dist/commands/upgrade.js +1 -1
- package/dist/database-preflight.js +1 -1
- package/package.json +53 -53
package/dist/commands/clean.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{hasTTY,isCI}from"@stacksjs/env";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function clean(buddy){const descriptions={clean:"Removes all node_modules & lock files",project:"Target a specific project",force:"Skip the confirmation prompt (required in CI/non-interactive shells)",verbose:"Enable verbose output"};buddy.command("clean",descriptions.clean).option("-p, --project [project]",descriptions.project,{default:!1}).option("-f, --force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy clean` ...",options);if(options.dryRun){const perf=await intro("buddy clean");log.info("Dry run: would remove dependency directories, generated framework builds, and lockfiles.");await outro("Clean preview complete",{startTime:perf,useSeconds:!0,message:"No files were removed"});return}const skipConfirm=options.force===!0||Boolean(buddy.isForce)||Boolean(buddy.isNoInteraction);if(!skipConfirm&&(isCI||!hasTTY||!process.stdin.isTTY)){log.syncError("Refusing to run `buddy clean` from a non-interactive shell without confirmation.");log.fatal(" \u27A1\uFE0F Re-run with `--force` to proceed: `buddy clean --force`")}if(!skipConfirm){const{confirm}=await import("@stacksjs/cli");if(!await confirm({message:"This will remove all node_modules and lock files. Continue?",initial:!1})){log.info("Clean cancelled");process.exit(ExitCode.Success)}}const perf=await intro("buddy clean"),result=await runAction(Action.Clean,options);if(resultFailed(result)){await outro("While running the clean command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Cleaned up",{startTime:perf,useSeconds:!0,message:"Cleaned up"});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"clean")}
|
|
1
|
+
import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{hasTTY,isCI}from"@stacksjs/env";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function clean(buddy){const descriptions={clean:"Removes all node_modules & lock files",project:"Target a specific project",force:"Skip the confirmation prompt (required in CI/non-interactive shells)",verbose:"Enable verbose output"};buddy.command("clean",descriptions.clean).option("-p, --project [project]",descriptions.project,{default:!1}).option("-f, --force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy clean` ...",options);if(options.dryRun){const perf=await intro("buddy clean");log.info("Dry run: would remove dependency directories, generated framework builds, and lockfiles.");await outro("Clean preview complete",{startTime:perf,useSeconds:!0,message:"No files were removed"});return}const skipConfirm=options.force===!0||Boolean(buddy.isForce)||Boolean(buddy.isNoInteraction);if(!skipConfirm&&(isCI||!hasTTY||!process.stdin.isTTY)){log.syncError("Refusing to run `buddy clean` from a non-interactive shell without confirmation.");log.fatal(" \u27A1\uFE0F Re-run with `--force` to proceed: `buddy clean --force`")}if(!skipConfirm){const{confirm}=await import("@stacksjs/cli");if(!await confirm({message:"This will remove all node_modules and lock files. Continue?",initial:!1})){log.info("Clean cancelled");await log.flush();process.exit(ExitCode.Success)}}const perf=await intro("buddy clean"),result=await runAction(Action.Clean,options);if(resultFailed(result)){await outro("While running the clean command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Cleaned up",{startTime:perf,useSeconds:!0,message:"Cleaned up"});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"clean")}
|
package/dist/commands/cloud.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import{existsSync,readdirSync,readFileSync}from"node:fs";import{join}from"node:path";import process from"node:process";import{intro,italic,log,onUnknownSubcommand,outro,prompts,runCommand,text,underline}from"@stacksjs/cli";import{addJumpBox,deleteCdkRemnants,deleteIamUsers,deleteJumpBox,deleteLogGroups,deleteParameterStore,deleteStacksBuckets,deleteStacksFunctions,deleteSubnets,deleteVpcs,getCloudFrontDistributionId,getJumpBoxInstanceId}from"@stacksjs/cloud";import{hasTTY,isCI}from"@stacksjs/env";import{path as p}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";import{isSshPipelineProvider,sshFleetFromConfigAndState}from"./deploy-ssh-target";async function createTemporaryCdkRole(roleName){const{AWSClient}=await import("@stacksjs/ts-cloud"),client=new AWSClient,trustPolicy={Version:"2012-10-17",Statement:[{Effect:"Allow",Principal:{Service:"cloudformation.amazonaws.com"},Action:"sts:AssumeRole"}]};try{try{const getRoleParams=new URLSearchParams({Action:"GetRole",RoleName:roleName,Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:getRoleParams.toString()});log.debug(`Role ${roleName} already exists`);return}catch(e){if(!e.message?.includes("NoSuchEntity")&&!e.message?.includes("cannot be found"))throw e}log.info("Creating temporary IAM role to enable stack deletion...");const createRoleParams=new URLSearchParams({Action:"CreateRole",RoleName:roleName,AssumeRolePolicyDocument:JSON.stringify(trustPolicy),Description:"Temporary role to allow CloudFormation to delete stuck stack",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:createRoleParams.toString()});log.success("Created IAM role");log.info("Attaching permissions...");const attachPolicyParams=new URLSearchParams({Action:"AttachRolePolicy",RoleName:roleName,PolicyArn:"arn:aws:iam::aws:policy/AdministratorAccess",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:attachPolicyParams.toString()});log.success("IAM role ready for stack deletion");log.info("Waiting for IAM role to propagate...");await new Promise((resolve)=>setTimeout(resolve,1e4))}catch(error){if(error.message?.includes("EntityAlreadyExists"))log.debug("Role already exists");else throw error}}async function deleteTemporaryCdkRole(roleName){const{AWSClient}=await import("@stacksjs/ts-cloud"),client=new AWSClient;try{const detachPolicyParams=new URLSearchParams({Action:"DetachRolePolicy",RoleName:roleName,PolicyArn:"arn:aws:iam::aws:policy/AdministratorAccess",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:detachPolicyParams.toString()});const deleteRoleParams=new URLSearchParams({Action:"DeleteRole",RoleName:roleName,Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:deleteRoleParams.toString()});log.success("Cleaned up temporary IAM role")}catch(error){log.debug(`Could not clean up temporary role: ${error.message}`)}}function isResultError(result){return resultFailed(result)}function getResultError(result){if(!result||typeof result!=="object")return"Unknown error";return String(result.error||"Unknown error")}function getResultValue(result){if(!result||typeof result!=="object")return;return result.value}async function refuse(...messages){for(const message of messages.slice(0,-1))await log.error(message);return log.exit(messages[messages.length-1],ExitCode.FatalError)}async function assertFleetProvider(tsCloudConfig,command){const provider=tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws";if(isSshPipelineProvider(provider))return;await log.info("The on-box half (the rpx gateway registry) is provider-independent; the server listing is not yet.");await log.exit(`\`buddy ${command}\` can list Hetzner and ssh servers, and this project's provider is '${provider}'.`,ExitCode.FatalError)}function readSshStatePins(cwd=process.cwd()){const dir=join(cwd,"storage","cloud","state");if(!existsSync(dir))return[];const pins=[];for(const file of readdirSync(dir)){if(!file.endsWith(".json"))continue;try{pins.push(JSON.parse(readFileSync(join(dir,file),"utf8")))}catch{}}return pins}async function listFleet(tsCloudConfig){if((tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER)==="ssh"){const servers=sshFleetFromConfigAndState(tsCloudConfig,readSshStatePins());return{servers,problem:servers.length===0?"No ssh hosts are configured. Add one under `ssh.hosts` in config/cloud.ts, or set TS_CLOUD_SSH_HOST.":void 0}}const{HetznerClient,resolveHetznerApiToken,toInventoryServer}=await import("@stacksjs/ts-cloud"),apiToken=resolveHetznerApiToken(tsCloudConfig);if(!apiToken)return{servers:[],problem:"No Hetzner API token, so no servers were looked up. Set HCLOUD_TOKEN (or HETZNER_API_TOKEN, or hetzner.apiToken in config/cloud.ts)."};try{return{servers:(await new HetznerClient({apiToken}).listServers()).map((server)=>toInventoryServer(server)).sort((a,b)=>a.name.localeCompare(b.name))}}catch(error){const status=Number(error?.status),where=Number.isFinite(status)&&status>0?`returned HTTP ${status}`:"could not be reached";return{servers:[],problem:`The Hetzner API ${where}, so the server list is incomplete. ${error?.message??String(error)}`+(status===401||status===403?" That is an auth failure, not an empty project: check the token is valid for this Hetzner project.":"")}}}async function declaredSitesFor(tsCloudConfig,slug){const{resolveSiteKind,siteInstallBase}=await import("@stacksjs/ts-cloud");return Object.entries(tsCloudConfig?.sites??{}).map(([name,site])=>{const domain=typeof site?.domain==="string"&&site.domain.trim()?site.domain.trim():void 0,port=Number(site?.port);return{name,kind:resolveSiteKind(site),domain,path:typeof site?.path==="string"&&site.path.trim()?site.path.trim():"/",port:Number.isFinite(port)&&port>0?port:void 0,installBase:siteInstallBase(slug,name),loopbackOnly:!domain}})}function describeAttachEdits(slug,owner,edit,dryRun){const lines=[" Two edits make the attach real, in two different repositories:",""];if(edit.state==="refused"){lines.push(` 1. config/cloud.ts here: could not edit it (${edit.reason})`);lines.push(` Add \`attachTo: '${owner}'\` to the \`cloud\` block by hand.`)}else if(edit.state==="already-set")lines.push(` 1. config/cloud.ts here: already sets attachTo: '${owner}'. Nothing to do.`);else lines.push(` 1. config/cloud.ts here: ${dryRun?`would set attachTo: '${owner}' (--dry-run, not written)`:`set attachTo: '${owner}'`}`);lines.push("");lines.push(` 2. In the '${owner}' project's own repository, which this command cannot edit:`);lines.push(` add '${slug}' to the \`tenants\` array in its config/cloud.ts.`);lines.push(` Without it, that project's deploy ships ${slug.toUpperCase()}_* keys from its`);lines.push(" env files into this project's .env instead of dropping them.");lines.push("");lines.push(" Then `buddy deploy` from here puts these sites on that box.");return lines}export function cloud(buddy){const descriptions={cloud:"Interact with the Stacks Cloud",ssh:"SSH into the Stacks Cloud",add:"Add a resource to the Stacks Cloud",remove:"Remove the Stacks Cloud. In case it fails, try again",optimizeCost:"Remove certain resources that may be re-applied at a later time",cleanUp:"Remove all resources that were retained during the cloud deletion",invalidateCache:"Invalidate the CloudFront cache",diff:"Show the diff of the current, undeployed cloud changes ",dashboard:"Run the local Stacks Cloud management cockpit (servers, sites, deploys)",sites:"List every server and what each one is hosting, across projects",attach:"Attach this project to a server another project owns, after checking it is safe",attachServer:"Server to attach to, by provider name or by owning project slug",dryRun:"Print the plan and change nothing",sitesEnv:"Environment to take the inventory for",remote:"Skip the SSH read of each box's gateway registry (co-tenants are then not listed)",json:"Emit the inventory as JSON",host:"Host to bind the dashboard to",port:"Port to bind the dashboard to",env:"Environment to manage",paths:"The paths to invalidate",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("cloud",descriptions.cloud).option("--ssh",descriptions.ssh,{default:!1}).option("--connect",descriptions.ssh,{default:!1}).option("--invalidate-cache",descriptions.invalidateCache,{default:!1}).option("--paths [paths]",descriptions.paths).option("--diff",descriptions.diff,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud` ...",options);const startTime=performance.now();if(options.ssh||options.connect){const jumpBoxId=await getJumpBoxInstanceId(),result=await runCommand(`aws ssm start-session --target ${jumpBoxId}`,{...options,cwd:p.projectPath(),stdin:"pipe"});if(isResultError(result)){await outro("While running the cloud command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(options.invalidateCache){if(!await prompts.confirm("Would you like to invalidate the CDN (CloudFront) cache?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Invalidating the CloudFront cache...");const{AWSCloudFrontClient}=await import("@stacksjs/ts-cloud"),cloudfront=new AWSCloudFrontClient,distributionId=await getCloudFrontDistributionId();try{const invalidationId=await cloudfront.invalidateAll(distributionId);log.success(`Invalidation created: ${invalidationId}`);log.info("Status: pending")}catch(err){log.error(`Failed to invalidate CloudFront cache: ${err.message}`)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(options.diff){try{const{InfrastructureGenerator}=await import("@stacksjs/ts-cloud"),{CloudFormationClient}=await import("@stacksjs/ts-cloud/aws"),{tsCloud:cloudConfig}=await import("~/config/cloud"),environment=process.env.APP_ENV||process.env.NODE_ENV||"production",newTemplate=new InfrastructureGenerator({config:cloudConfig,environment}).generate().toJSON(),stackName=`${cloudConfig.project?.slug||"stacks"}-${environment}`,cfn=new CloudFormationClient(process.env.AWS_REGION||"us-east-1");let currentTemplate="{}";try{currentTemplate=(await cfn.getTemplate(stackName)).TemplateBody}catch{log.info("No deployed stack found. Showing full template as diff.")}if(currentTemplate===newTemplate)log.info("No changes detected.");else{log.info("Changes detected between deployed and local template:");log.info(`Current template: ${currentTemplate.length} bytes`);log.info(`New template: ${newTemplate.length} bytes`)}}catch(error){log.error(`Failed to compute diff: ${error.message}`)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Not implemented yet. Read more about `buddy cloud` here: https://stacksjs.com/docs/cloud");process.exit(ExitCode.Success)});buddy.command("cloud:add",descriptions.add).option("--jump-box","Remove the jump-box",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:add` ...",options);const startTime=await intro("buddy cloud:add");if(options.jumpBox){if(!await prompts.confirm("Would you like to add a jump-box to your cloud?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("The jump-box is getting added to your cloud resources...");log.info("This takes a few moments, please be patient.");await new Promise((resolve)=>setTimeout(resolve,2000));const result=await addJumpBox();if(isResultError(result)){await outro("While running the cloud:add command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}log.info(italic("View the jump-box in the AWS console:"));log.info(underline("https://us-east-1.console.aws.amazon.com/ec2/home?region=us-east-1#Instances:instanceState=running"));log.info(italic("Once it finished initializing, you may SSH into it:"));log.info(underline("buddy cloud --ssh"));await outro("Your jump-box was added.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("This functionality is not yet implemented.");process.exit(ExitCode.Success)});buddy.command("cloud:remove",descriptions.remove).alias("cloud:destroy").alias("cloud:rm").alias("undeploy").option("--jump-box","Remove the jump-box",{default:!1}).option("--force","Force deletion of stack in bad state",{default:!1}).option("--yes","Skip confirmation prompts",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:remove` ...",options);const startTime=await intro("buddy cloud:remove"),environment=process.env.APP_ENV||process.env.NODE_ENV||"production";if(!options.yes&&(isCI||!hasTTY||!process.stdin.isTTY)){log.syncError(`Refusing to remove the "${environment}" cloud infrastructure from a non-interactive shell without confirmation.`);log.syncError(" \u27A1\uFE0F Re-run with `--yes` to confirm (e.g. in CI): `buddy cloud:remove --yes`");await outro("cloud:remove cancelled.",{startTime,useSeconds:!0});await log.flush();process.exit(ExitCode.FatalError)}if(options.jumpBox){if(!options.yes){if(!await prompts.confirm("Would you like to remove your jump-box for now?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}}const result=await deleteJumpBox();if(isResultError(result)){await outro("While removing your jump-box, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Your jump-box was removed.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(!options.yes){log.warning(`This will permanently delete the "${environment}" cloud infrastructure (compute, storage, CDN, and DNS managed by Stacks). This cannot be undone.`);if((await text({message:`Type the environment name "${environment}" to confirm (blank to cancel):`})).trim()!==environment){await outro("cloud:remove cancelled - confirmation did not match.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}}console.log("");console.log("Removing cloud infrastructure...");console.log(` ${italic("This typically takes 2-5 minutes.")}`);console.log("");if(!process.env.AWS_ACCESS_KEY_ID||!process.env.AWS_SECRET_ACCESS_KEY){const{existsSync,readFileSync}=await import("node:fs"),{projectPath}=await import("@stacksjs/path"),envFiles=[projectPath(`.env.${environment}`),projectPath(".env")];for(const envPath of envFiles)if(existsSync(envPath)){const lines=readFileSync(envPath,"utf-8").split(`
|
|
1
|
+
import{existsSync,readdirSync,readFileSync}from"node:fs";import{join}from"node:path";import process from"node:process";import{intro,italic,log,onUnknownSubcommand,outro,prompts,runCommand,text,underline}from"@stacksjs/cli";import{addJumpBox,deleteCdkRemnants,deleteIamUsers,deleteJumpBox,deleteLogGroups,deleteParameterStore,deleteStacksBuckets,deleteStacksFunctions,deleteSubnets,deleteVpcs,getCloudFrontDistributionId,getJumpBoxInstanceId}from"@stacksjs/cloud";import{hasTTY,isCI}from"@stacksjs/env";import{path as p}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";import{isSshPipelineProvider,sshFleetFromConfigAndState}from"./deploy-ssh-target";async function createTemporaryCdkRole(roleName){const{AWSClient}=await import("@stacksjs/ts-cloud"),client=new AWSClient,trustPolicy={Version:"2012-10-17",Statement:[{Effect:"Allow",Principal:{Service:"cloudformation.amazonaws.com"},Action:"sts:AssumeRole"}]};try{try{const getRoleParams=new URLSearchParams({Action:"GetRole",RoleName:roleName,Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:getRoleParams.toString()});log.debug(`Role ${roleName} already exists`);return}catch(e){if(!e.message?.includes("NoSuchEntity")&&!e.message?.includes("cannot be found"))throw e}log.info("Creating temporary IAM role to enable stack deletion...");const createRoleParams=new URLSearchParams({Action:"CreateRole",RoleName:roleName,AssumeRolePolicyDocument:JSON.stringify(trustPolicy),Description:"Temporary role to allow CloudFormation to delete stuck stack",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:createRoleParams.toString()});log.success("Created IAM role");log.info("Attaching permissions...");const attachPolicyParams=new URLSearchParams({Action:"AttachRolePolicy",RoleName:roleName,PolicyArn:"arn:aws:iam::aws:policy/AdministratorAccess",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:attachPolicyParams.toString()});log.success("IAM role ready for stack deletion");log.info("Waiting for IAM role to propagate...");await new Promise((resolve)=>setTimeout(resolve,1e4))}catch(error){if(error.message?.includes("EntityAlreadyExists"))log.debug("Role already exists");else throw error}}async function deleteTemporaryCdkRole(roleName){const{AWSClient}=await import("@stacksjs/ts-cloud"),client=new AWSClient;try{const detachPolicyParams=new URLSearchParams({Action:"DetachRolePolicy",RoleName:roleName,PolicyArn:"arn:aws:iam::aws:policy/AdministratorAccess",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:detachPolicyParams.toString()});const deleteRoleParams=new URLSearchParams({Action:"DeleteRole",RoleName:roleName,Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:deleteRoleParams.toString()});log.success("Cleaned up temporary IAM role")}catch(error){log.debug(`Could not clean up temporary role: ${error.message}`)}}function isResultError(result){return resultFailed(result)}function getResultError(result){if(!result||typeof result!=="object")return"Unknown error";return String(result.error||"Unknown error")}function getResultValue(result){if(!result||typeof result!=="object")return;return result.value}async function refuse(...messages){for(const message of messages.slice(0,-1))await log.error(message);return log.exit(messages[messages.length-1],ExitCode.FatalError)}async function assertFleetProvider(tsCloudConfig,command){const provider=tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws";if(isSshPipelineProvider(provider))return;await log.info("The on-box half (the rpx gateway registry) is provider-independent; the server listing is not yet.");await log.exit(`\`buddy ${command}\` can list Hetzner and ssh servers, and this project's provider is '${provider}'.`,ExitCode.FatalError)}function readSshStatePins(cwd=process.cwd()){const dir=join(cwd,"storage","cloud","state");if(!existsSync(dir))return[];const pins=[];for(const file of readdirSync(dir)){if(!file.endsWith(".json"))continue;try{pins.push(JSON.parse(readFileSync(join(dir,file),"utf8")))}catch{}}return pins}async function listFleet(tsCloudConfig){if((tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER)==="ssh"){const servers=sshFleetFromConfigAndState(tsCloudConfig,readSshStatePins());return{servers,problem:servers.length===0?"No ssh hosts are configured. Add one under `ssh.hosts` in config/cloud.ts, or set TS_CLOUD_SSH_HOST.":void 0}}const{HetznerClient,resolveHetznerApiToken,toInventoryServer}=await import("@stacksjs/ts-cloud"),apiToken=resolveHetznerApiToken(tsCloudConfig);if(!apiToken)return{servers:[],problem:"No Hetzner API token, so no servers were looked up. Set HCLOUD_TOKEN (or HETZNER_API_TOKEN, or hetzner.apiToken in config/cloud.ts)."};try{return{servers:(await new HetznerClient({apiToken}).listServers()).map((server)=>toInventoryServer(server)).sort((a,b)=>a.name.localeCompare(b.name))}}catch(error){const status=Number(error?.status),where=Number.isFinite(status)&&status>0?`returned HTTP ${status}`:"could not be reached";return{servers:[],problem:`The Hetzner API ${where}, so the server list is incomplete. ${error?.message??String(error)}`+(status===401||status===403?" That is an auth failure, not an empty project: check the token is valid for this Hetzner project.":"")}}}async function declaredSitesFor(tsCloudConfig,slug){const{resolveSiteKind,siteInstallBase}=await import("@stacksjs/ts-cloud");return Object.entries(tsCloudConfig?.sites??{}).map(([name,site])=>{const domain=typeof site?.domain==="string"&&site.domain.trim()?site.domain.trim():void 0,port=Number(site?.port);return{name,kind:resolveSiteKind(site),domain,path:typeof site?.path==="string"&&site.path.trim()?site.path.trim():"/",port:Number.isFinite(port)&&port>0?port:void 0,installBase:siteInstallBase(slug,name),loopbackOnly:!domain}})}function describeAttachEdits(slug,owner,edit,dryRun){const lines=[" Two edits make the attach real, in two different repositories:",""];if(edit.state==="refused"){lines.push(` 1. config/cloud.ts here: could not edit it (${edit.reason})`);lines.push(` Add \`attachTo: '${owner}'\` to the \`cloud\` block by hand.`)}else if(edit.state==="already-set")lines.push(` 1. config/cloud.ts here: already sets attachTo: '${owner}'. Nothing to do.`);else lines.push(` 1. config/cloud.ts here: ${dryRun?`would set attachTo: '${owner}' (--dry-run, not written)`:`set attachTo: '${owner}'`}`);lines.push("");lines.push(` 2. In the '${owner}' project's own repository, which this command cannot edit:`);lines.push(` add '${slug}' to the \`tenants\` array in its config/cloud.ts.`);lines.push(` Without it, that project's deploy ships ${slug.toUpperCase()}_* keys from its`);lines.push(" env files into this project's .env instead of dropping them.");lines.push("");lines.push(" Then `buddy deploy` from here puts these sites on that box.");return lines}export function cloud(buddy){const descriptions={cloud:"Interact with the Stacks Cloud",ssh:"SSH into the Stacks Cloud",add:"Add a resource to the Stacks Cloud",remove:"Remove the Stacks Cloud. In case it fails, try again",optimizeCost:"Remove certain resources that may be re-applied at a later time",cleanUp:"Remove all resources that were retained during the cloud deletion",invalidateCache:"Invalidate the CloudFront cache",diff:"Show the diff of the current, undeployed cloud changes ",dashboard:"Run the local Stacks Cloud management cockpit (servers, sites, deploys)",sites:"List every server and what each one is hosting, across projects",attach:"Attach this project to a server another project owns, after checking it is safe",attachServer:"Server to attach to, by provider name or by owning project slug",dryRun:"Print the plan and change nothing",sitesEnv:"Environment to take the inventory for",remote:"Skip the SSH read of each box's gateway registry (co-tenants are then not listed)",json:"Emit the inventory as JSON",host:"Host to bind the dashboard to",port:"Port to bind the dashboard to",env:"Environment to manage",paths:"The paths to invalidate",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("cloud",descriptions.cloud).option("--ssh",descriptions.ssh,{default:!1}).option("--connect",descriptions.ssh,{default:!1}).option("--invalidate-cache",descriptions.invalidateCache,{default:!1}).option("--paths [paths]",descriptions.paths).option("--diff",descriptions.diff,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud` ...",options);const startTime=performance.now();if(options.ssh||options.connect){const jumpBoxId=await getJumpBoxInstanceId(),result=await runCommand(`aws ssm start-session --target ${jumpBoxId}`,{...options,cwd:p.projectPath(),stdin:"pipe"});if(isResultError(result)){await outro("While running the cloud command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(options.invalidateCache){if(!await prompts.confirm("Would you like to invalidate the CDN (CloudFront) cache?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Invalidating the CloudFront cache...");const{AWSCloudFrontClient}=await import("@stacksjs/ts-cloud"),cloudfront=new AWSCloudFrontClient,distributionId=await getCloudFrontDistributionId();try{const invalidationId=await cloudfront.invalidateAll(distributionId);log.success(`Invalidation created: ${invalidationId}`);log.info("Status: pending")}catch(err){log.error(`Failed to invalidate CloudFront cache: ${err.message}`)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(options.diff){try{const{InfrastructureGenerator}=await import("@stacksjs/ts-cloud"),{CloudFormationClient}=await import("@stacksjs/ts-cloud/aws"),{tsCloud:cloudConfig}=await import("~/config/cloud"),environment=process.env.APP_ENV||process.env.NODE_ENV||"production",newTemplate=new InfrastructureGenerator({config:cloudConfig,environment}).generate().toJSON(),stackName=`${cloudConfig.project?.slug||"stacks"}-${environment}`,cfn=new CloudFormationClient(process.env.AWS_REGION||"us-east-1");let currentTemplate="{}";try{currentTemplate=(await cfn.getTemplate(stackName)).TemplateBody}catch{log.info("No deployed stack found. Showing full template as diff.")}if(currentTemplate===newTemplate)log.info("No changes detected.");else{log.info("Changes detected between deployed and local template:");log.info(`Current template: ${currentTemplate.length} bytes`);log.info(`New template: ${newTemplate.length} bytes`)}}catch(error){log.error(`Failed to compute diff: ${error.message}`)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Not implemented yet. Read more about `buddy cloud` here: https://stacksjs.com/docs/cloud");await log.flush();process.exit(ExitCode.Success)});buddy.command("cloud:add",descriptions.add).option("--jump-box","Remove the jump-box",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:add` ...",options);const startTime=await intro("buddy cloud:add");if(options.jumpBox){if(!await prompts.confirm("Would you like to add a jump-box to your cloud?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("The jump-box is getting added to your cloud resources...");log.info("This takes a few moments, please be patient.");await new Promise((resolve)=>setTimeout(resolve,2000));const result=await addJumpBox();if(isResultError(result)){await outro("While running the cloud:add command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}log.info(italic("View the jump-box in the AWS console:"));log.info(underline("https://us-east-1.console.aws.amazon.com/ec2/home?region=us-east-1#Instances:instanceState=running"));log.info(italic("Once it finished initializing, you may SSH into it:"));log.info(underline("buddy cloud --ssh"));await outro("Your jump-box was added.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("This functionality is not yet implemented.");await log.flush();process.exit(ExitCode.Success)});buddy.command("cloud:remove",descriptions.remove).alias("cloud:destroy").alias("cloud:rm").alias("undeploy").option("--jump-box","Remove the jump-box",{default:!1}).option("--force","Force deletion of stack in bad state",{default:!1}).option("--yes","Skip confirmation prompts",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:remove` ...",options);const startTime=await intro("buddy cloud:remove"),environment=process.env.APP_ENV||process.env.NODE_ENV||"production";if(!options.yes&&(isCI||!hasTTY||!process.stdin.isTTY)){log.syncError(`Refusing to remove the "${environment}" cloud infrastructure from a non-interactive shell without confirmation.`);log.syncError(" \u27A1\uFE0F Re-run with `--yes` to confirm (e.g. in CI): `buddy cloud:remove --yes`");await outro("cloud:remove cancelled.",{startTime,useSeconds:!0});await log.flush();process.exit(ExitCode.FatalError)}if(options.jumpBox){if(!options.yes){if(!await prompts.confirm("Would you like to remove your jump-box for now?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}}const result=await deleteJumpBox();if(isResultError(result)){await outro("While removing your jump-box, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Your jump-box was removed.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(!options.yes){log.warning(`This will permanently delete the "${environment}" cloud infrastructure (compute, storage, CDN, and DNS managed by Stacks). This cannot be undone.`);if((await text({message:`Type the environment name "${environment}" to confirm (blank to cancel):`})).trim()!==environment){await outro("cloud:remove cancelled - confirmation did not match.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}}console.log("");console.log("Removing cloud infrastructure...");console.log(` ${italic("This typically takes 2-5 minutes.")}`);console.log("");if(!process.env.AWS_ACCESS_KEY_ID||!process.env.AWS_SECRET_ACCESS_KEY){const{existsSync,readFileSync}=await import("node:fs"),{projectPath}=await import("@stacksjs/path"),envFiles=[projectPath(`.env.${environment}`),projectPath(".env")];for(const envPath of envFiles)if(existsSync(envPath)){const lines=readFileSync(envPath,"utf-8").split(`
|
|
2
2
|
`);for(const line of lines){const trimmed=line.trim();if(trimmed.startsWith("#")||!trimmed.includes("="))continue;const[key,...valueParts]=trimmed.split("="),value=valueParts.join("=").replace(/^["']|["']$/g,"");if(key==="AWS_ACCESS_KEY_ID"||key==="AWS_SECRET_ACCESS_KEY"||key==="AWS_REGION"||key==="AWS_ACCOUNT_ID")process.env[key]=value}break}}delete process.env.AWS_PROFILE;try{const{undeployStack}=await import("../../../actions/deploy"),region=process.env.AWS_REGION||"us-east-1";await undeployStack({environment,region,verbose:options.verbose});await outro("Cloud infrastructure removed",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}catch(error){console.log("");console.error("\u2717 Failed to remove cloud infrastructure");const errorStr=String(error.message||error);if(errorStr.includes("security token")||errorStr.includes("credentials")){console.log("");console.error(" AWS credentials are invalid or expired");console.log(" Check your AWS credentials in .env.production:");console.log(" - AWS_ACCESS_KEY_ID");console.log(" - AWS_SECRET_ACCESS_KEY")}else if(errorStr.includes("region")||errorStr.includes("AWS_REGION")){console.log("");console.error(" AWS Region not configured");console.log(" Add AWS_REGION to your .env.production file")}else if(errorStr.includes("AccessDenied")){console.log("");console.error(" Access denied");console.log(" Your AWS credentials may not have permission to delete stacks")}else console.error(` ${errorStr}`);console.log("");console.log("Troubleshooting:");console.log(" ./buddy cloud:cleanup - Clean up resources manually");console.log(" --verbose - Show detailed error information");console.log("");if(options.verbose)console.error("Error details:",error);await outro("Failed to remove infrastructure",{startTime,useSeconds:!0});process.exit(ExitCode.FatalError)}});buddy.command("cloud:optimize-cost",descriptions.optimizeCost).option("--jump-box","Remove the jump-box",{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:optimize-cost` ...",options);const startTime=await intro("buddy cloud:optimize-cost");if(options.jumpBox){if(!await prompts.confirm("Would you like to remove your jump-box to optimize your costs?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}await deleteJumpBox();await outro("Your jump-box was removed & cost optimizations are applied.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}await outro("No cost optimization was applied",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:cleanup",descriptions.cleanUp).alias("cloud:clean-up").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:cleanup` ...",options);const startTime=await intro("buddy cloud:cleanup");delete process.env.AWS_PROFILE;log.info("Cleaning up your cloud resources will take a while to complete. Please be patient.");await new Promise((resolve)=>setTimeout(resolve,2000));const cleanupSteps=[{label:"jump-boxes",fn:deleteJumpBox,ignoreErrors:["Jump-box not found"]},{label:"retained S3 buckets",fn:deleteStacksBuckets},{label:"retained Lambda functions",fn:deleteStacksFunctions,ignoreErrors:["No stacks functions found"]},{label:"remaining Stacks logs",fn:deleteLogGroups},{label:"stored parameters",fn:deleteParameterStore},{label:"VPCs",fn:deleteVpcs},{label:"Subnets",fn:deleteSubnets},{label:"CDK remnants",fn:deleteCdkRemnants},{label:"IAM users",fn:deleteIamUsers}],errors=[];for(const step of cleanupSteps){log.info(`Removing any ${step.label}...`);try{const result=await step.fn();if(isResultError(result)){const errMsg=getResultError(result);if(!step.ignoreErrors?.includes(errMsg)){log.warn(`${step.label} cleanup issue: ${errMsg}`);errors.push({label:step.label,error:errMsg})}}else{const value=getResultValue(result);if(value)log.info(String(value))}}catch(e){const errMsg=e.message||"AWS SDK error";log.warn(`${step.label} cleanup skipped: ${errMsg}`);errors.push({label:step.label,error:errMsg})}}if(errors.length>0){log.warn(`Cleanup completed with ${errors.length} issue(s):`);for(const{label,error}of errors)log.warn(` - ${label}: ${error}`)}await outro("AWS resources have been removed",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:invalidate-cache",descriptions.invalidateCache).option("--paths [paths]",descriptions.paths,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:invalidate-cache` ...",options);const startTime=await intro("buddy cloud:invalidate-cache");if(!await prompts.confirm("Would you like to invalidate the CloudFront cache?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Invalidating the CloudFront cache...");const distributionId=await getCloudFrontDistributionId();if(!distributionId){await outro("Could not resolve CloudFront distribution ID",{startTime,useSeconds:!0},"Ensure your cloud stack is deployed before invalidating cache.");process.exit(ExitCode.FatalError)}const paths=options.paths?String(options.paths):"/*",result=await runCommand(`aws cloudfront create-invalidation --distribution-id ${distributionId} --paths ${paths}`,{...options,cwd:p.projectPath(),stdin:"pipe"});if(isResultError(result)){await outro("While running the cloud command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:diff",descriptions.diff).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:diff` ...",options);const startTime=await intro("buddy cloud:diff");try{const{InfrastructureGenerator}=await import("@stacksjs/ts-cloud"),{CloudFormationClient}=await import("@stacksjs/ts-cloud/aws"),{tsCloud:cloudConfig}=await import("~/config/cloud"),environment=process.env.APP_ENV||process.env.NODE_ENV||"production",newTemplate=new InfrastructureGenerator({config:cloudConfig,environment}).generate().toJSON(),stackName=`${cloudConfig.project?.slug||"stacks"}-${environment}`,cfn=new CloudFormationClient(process.env.AWS_REGION||"us-east-1");let currentTemplate="{}";try{currentTemplate=(await cfn.getTemplate(stackName)).TemplateBody}catch{log.info("No deployed stack found. Showing full template as diff.")}if(currentTemplate===newTemplate)log.info("No changes detected.");else{log.info("Changes detected between deployed and local template:");log.info(`Current template: ${currentTemplate.length} bytes`);log.info(`New template: ${newTemplate.length} bytes`)}}catch(error){await outro("While running the cloud diff command, there was an issue",{startTime,useSeconds:!0},error.message);process.exit(ExitCode.FatalError)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:dashboard",descriptions.dashboard).alias("cloud:cockpit").option("--host [host]",descriptions.host,{default:"127.0.0.1"}).option("--port [port]",descriptions.port,{default:"7676"}).option("--env [env]",descriptions.env).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:dashboard` ...",options);const startTime=await intro("buddy cloud:dashboard"),tsCloud=await import("@stacksjs/ts-cloud");if(typeof tsCloud.startLocalDashboardServer!=="function"){await outro("The installed @stacksjs/ts-cloud does not provide the local cockpit yet",{startTime,useSeconds:!0},"Update your dependencies (requires @stacksjs/ts-cloud >= 0.5.27).");process.exit(ExitCode.FatalError)}try{const server=await tsCloud.startLocalDashboardServer({host:options.host?String(options.host):void 0,port:options.port?Number(options.port):void 0,environment:options.env,verbose:!!options.verbose});log.success(`Stacks Cloud cockpit running at ${underline(server.url)}`);log.info(italic("Manage servers, sites, SSH keys and deploys. Press Ctrl+C to stop."));await new Promise(()=>{})}catch(error){await outro("While starting the cloud dashboard, there was an issue",{startTime,useSeconds:!0},error?.message??String(error));process.exit(ExitCode.FatalError)}});buddy.command("cloud:sites",descriptions.sites).option("--env [env]",descriptions.sitesEnv).option("--no-remote",descriptions.remote).option("-J, --json",descriptions.json,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:sites` ...",options);const{formatInventory,probeHostRoutes}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy"),environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0),slug=tsCloudConfig?.project?.slug||"app";await assertFleetProvider(tsCloudConfig,"cloud:sites");const listing=await listFleet(tsCloudConfig);if(listing.problem)await log.error(listing.problem);const declared=await declaredSitesFor(tsCloudConfig,slug),probes=options.remote===!1?[]:await probeFleet(listing.servers),inventory={slug,servers:listing.servers,probes,declared};if(options.json)console.log(JSON.stringify({environment,...inventory},null,2));else for(const line of formatInventory(inventory))console.log(line);process.exit(listing.problem&&listing.servers.length===0?ExitCode.FatalError:ExitCode.Success);async function probeFleet(servers){const{sshExec}=await import("@stacksjs/ts-cloud"),probes=[];for(let index=0;index<servers.length;index+=6)probes.push(...await Promise.all(servers.slice(index,index+6).map((server)=>probeHostRoutes(server,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})))));return probes}});buddy.command("cloud:attach",descriptions.attach).option("--server <server>",descriptions.attachServer).option("--env [env]",descriptions.sitesEnv).option("--dry-run",descriptions.dryRun,{default:!1}).option("-J, --json",descriptions.json,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:attach` ...",options);const{attachConflicts,attachIsViable,attachPreconditions,formatAttachPlan,probeHostRoutes,resolveAttachTarget,setAttachToInCloudConfig,sshExec}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy");if(!options.server)return await refuse("Which server? Pass --server <name|owner-slug>. `buddy cloud:sites` lists them.");const environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0),slug=tsCloudConfig?.project?.slug||"app";await assertFleetProvider(tsCloudConfig,"cloud:attach");const listing=await listFleet(tsCloudConfig);if(listing.problem)return await refuse(listing.problem);const target=resolveAttachTarget(listing.servers,String(options.server),environment);if("problem"in target)return await refuse(`${target.problem} \`buddy cloud:sites\` lists what is there.`);const server=target.server,preconditions=attachPreconditions(slug,server);if(preconditions.length>0)return await refuse(...preconditions);const declared=await declaredSitesFor(tsCloudConfig,slug),probe=await probeHostRoutes(server,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})),plan={slug,owner:server.project,server,declared,conflicts:probe.unavailable?[]:attachConflicts(slug,declared,probe.routes),registryRead:!probe.unavailable,registryProblem:probe.unavailable},viable=attachIsViable(plan),edit=viable?await applyAttachToConfig(plan.owner,Boolean(options.dryRun)):void 0;if(options.json)console.log(JSON.stringify({environment,...plan,edit},null,2));else{for(const line of formatAttachPlan(plan))console.log(line);if(edit)console.log(["",...describeAttachEdits(plan.slug,plan.owner,edit,Boolean(options.dryRun))].join(`
|
|
3
3
|
`))}process.exit(viable?ExitCode.Success:ExitCode.FatalError);async function applyAttachToConfig(owner,dryRun){const configPath=p.projectPath("config/cloud.ts"),{readFileSync,writeFileSync}=await import("node:fs"),before=readFileSync(configPath,"utf8");try{const after=setAttachToInCloudConfig({configText:before,owner});if(after===before)return{state:"already-set"};if(dryRun)return{state:"would-write"};writeFileSync(configPath,after);return{state:"written"}}catch(error){return{state:"refused",reason:error instanceof Error?error.message:String(error)}}}});onUnknownSubcommand(buddy,"cloud")}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import process from"node:process";import{log,onUnknownSubcommand,outro,runCommand}from"@stacksjs/cli";import{path as p}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function configure(buddy){const descriptions={configure:"Configure options",aws:"Configure the AWS connection",project:"Target a specific project",profile:"The AWS profile to use",verbose:"Enable verbose output"};buddy.command("configure",descriptions.configure).option("--aws",descriptions.aws,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy configure` ...",options);if(options?.aws){await configureAws(options);process.exit(ExitCode.Success)}log.info("Not implemented yet. Please use the --aws flag to configure AWS.");process.exit(ExitCode.Success)});buddy.command("configure:aws",descriptions.aws).option("-p, --project [project]",descriptions.project,{default:!1}).option("--profile",descriptions.profile,{default:process.env.AWS_PROFILE}).option("--verbose",descriptions.verbose,{default:!1}).option("--access-key-id","The AWS access key").option("--secret-access-key","The AWS secret access key").option("--region","The AWS region").option("--output","The AWS output format").option("--quiet","Suppress output").action(async(options)=>{log.debug("Running `buddy configure:aws` ...",options);await configureAws(options);process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"configure")}async function configureAws(options){const startTime=performance.now(),awsAccessKeyId=options?.accessKeyId??process.env.AWS_ACCESS_KEY_ID,awsSecretAccessKey=options?.secretAccessKey??process.env.AWS_SECRET_ACCESS_KEY,defaultRegion="us-east-1",defaultOutputFormat=options?.output??"json",profile=process.env.AWS_PROFILE??options?.profile,command=profile?`aws configure --profile ${profile}`:"aws configure",input=`${awsAccessKeyId}
|
|
1
|
+
import process from"node:process";import{log,onUnknownSubcommand,outro,runCommand}from"@stacksjs/cli";import{path as p}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function configure(buddy){const descriptions={configure:"Configure options",aws:"Configure the AWS connection",project:"Target a specific project",profile:"The AWS profile to use",verbose:"Enable verbose output"};buddy.command("configure",descriptions.configure).option("--aws",descriptions.aws,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy configure` ...",options);if(options?.aws){await configureAws(options);process.exit(ExitCode.Success)}log.info("Not implemented yet. Please use the --aws flag to configure AWS.");await log.flush();process.exit(ExitCode.Success)});buddy.command("configure:aws",descriptions.aws).option("-p, --project [project]",descriptions.project,{default:!1}).option("--profile",descriptions.profile,{default:process.env.AWS_PROFILE}).option("--verbose",descriptions.verbose,{default:!1}).option("--access-key-id","The AWS access key").option("--secret-access-key","The AWS secret access key").option("--region","The AWS region").option("--output","The AWS output format").option("--quiet","Suppress output").action(async(options)=>{log.debug("Running `buddy configure:aws` ...",options);await configureAws(options);process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"configure")}async function configureAws(options){const startTime=performance.now(),awsAccessKeyId=options?.accessKeyId??process.env.AWS_ACCESS_KEY_ID,awsSecretAccessKey=options?.secretAccessKey??process.env.AWS_SECRET_ACCESS_KEY,defaultRegion="us-east-1",defaultOutputFormat=options?.output??"json",profile=process.env.AWS_PROFILE??options?.profile,command=profile?`aws configure --profile ${profile}`:"aws configure",input=`${awsAccessKeyId}
|
|
2
2
|
${awsSecretAccessKey}
|
|
3
3
|
${defaultRegion}
|
|
4
4
|
${defaultOutputFormat}
|
package/dist/commands/create.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{chmodSync,cpSync,existsSync,readFileSync,readdirSync,rmSync,writeFileSync}from"node:fs";import process from"node:process";import{runAction}from"@stacksjs/actions";import{bold,cyan,dim,intro,log,onUnknownSubcommand,runCommand}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{resolve}from"@stacksjs/path";import{isFolder}from"@stacksjs/storage";import{ExitCode}from"@stacksjs/types";import{uninstallAllFeatures}from"./features";import{ensurePantryDependencies,ensurePantryInstalled}from"./setup";import{resultFailed}from"../result";import{fetchPublishedVersions}from"../registry";export function create(buddy){const descriptions={name:"The name of the project",command:"Create a new Stacks project",ui:"Are you building a UI?",components:"Are you building UI components?",webComponents:"Automagically built optimized custom elements/web components?",views:"How about views?",functions:"Are you developing functions/composables?",api:"Are you building an API?",database:"Do you need a database?",notifications:"Do you need notifications? e.g. email, SMS, push or chat notifications",cache:"Do you need caching?",email:"Do you need email?",project:"Target a specific project",minimal:"Skip optional feature bundles (cms, commerce, dashboard, marketing, monitoring, realtime, queue) - bare-bones API/SPA starter that can re-add them later via `./buddy <feature>:install`.",withCore:"Keep the framework vendored in `storage/framework/core` as a Bun workspace, for working ON Stacks. Apps that only work WITH Stacks want the default, which resolves every @stacksjs/* package from npm.",verbose:"Enable verbose output"};buddy.command("new [name]",descriptions.command).alias("create [name]").option("-n, --name [name]",descriptions.name,{default:!1}).option("-u, --ui",descriptions.ui,{default:!0}).option("-c, --components",descriptions.components,{default:!0}).option("-w, --web-components",descriptions.webComponents,{default:!0}).option("-p, --views",descriptions.views,{default:!0}).option("-f, --functions",descriptions.functions,{default:!0}).option("-a, --api",descriptions.api,{default:!0}).option("-d, --database",descriptions.database,{default:!0}).option("-ca, --cache",descriptions.cache,{default:!1}).option("-e, --email",descriptions.email,{default:!1}).option("-P, --project [project]",descriptions.project,{default:!1}).option("-m, --minimal",descriptions.minimal,{default:!1}).option("--with-core",descriptions.withCore,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy new <name>` ...",options);const startTime=await intro("buddy new");name=name??options.name;const path=resolve(process.cwd(),name);isFolderCheck(path);await onlineCheck();const result=await download(name,path,options);if(resultFailed(result)){await log.error(result.error);process.exit(ExitCode.FatalError)}ensureExecutableScripts(path);applyAppVcsTemplate(path);applyAppConfigTemplate(path);removeFrameworkTests(path);await ensureEnv(path,options);if(options.minimal)await stripFeatures(path);await install(path,options);if(!options.withCore)await unvendorCore(path,options);if(startTime){const time=performance.now()-startTime;log.success(dim(`[${time.toFixed(2)}ms] Completed`))}log.info(bold("Welcome to the Stacks Framework! \u269B\uFE0F"));log.info(`Get started: ${cyan(`cd ${name}`)} and then ${cyan("./buddy dev")}`);log.info(`Run ${cyan("./buddy doctor")} anytime to check your setup`);log.info("To learn more, visit https://stacksjs.com");process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"new")}function isFolderCheck(path){if(!isFolder(path))return;if(readdirSync(path).filter((entry)=>entry!==".git").length===0)return;console.error(`Path ${path} already exists`);process.exit(ExitCode.FatalError)}async function onlineCheck(){if(await isOnline())return;log.info("It appears you are disconnected from the internet.");log.info("Creating a new project requires a brief internet connection to download the template and install dependencies.");process.exit(ExitCode.FatalError)}async function isOnline(){try{return(await fetch("https://github.com",{method:"HEAD",signal:AbortSignal.timeout(3000)})).ok}catch{return!1}}export async function templateRef(fetchVersions=fetchPublishedVersions){try{const{latest}=await fetchVersions("stacks");return latest?`v${latest}`:null}catch{return null}}export function templateSpec(ref){return ref?`gh:stacksjs/stacks#${ref}`:"gh:stacksjs/stacks"}async function download(name,path,_options){log.info("Setting up your stack.");const ref=await templateRef();try{const{downloadTemplate}=await import("@stacksjs/gitit");try{await downloadTemplate(templateSpec(ref),{dir:name,force:!0})}catch(error){if(!ref)throw error;log.warn(`No ${ref} tag to scaffold from (${error instanceof Error?error.message:String(error)}).`);log.info("Falling back to the default branch. If this app does not typecheck, that gap is why.");await downloadTemplate(templateSpec(null),{dir:name,force:!0})}log.success(`Successfully scaffolded your project at ${cyan(path)}`);return{isErr:!1}}catch(error){return{isErr:!0,error:error instanceof Error?error.message:String(error)}}}function applyAppVcsTemplate(path){const source=resolve(path,"storage/framework/defaults/vcs/github"),destination=resolve(path,".github");if(!existsSync(source)){log.warn("No app CI template found at storage/framework/defaults/vcs/github - leaving .github as downloaded.");return}log.info("Installing app-shaped GitHub workflows...");try{rmSync(destination,{recursive:!0,force:!0});cpSync(source,destination,{recursive:!0});log.success("App CI installed")}catch(error){log.warn(`Could not install the app CI template: ${error instanceof Error?error.message:String(error)}`)}}function applyAppConfigTemplate(path){const source=resolve(path,"storage/framework/defaults/scaffold/config"),destination=resolve(path,"config");if(!existsSync(source)){log.warn("No app config template found at storage/framework/defaults/scaffold/config - leaving config as downloaded.");return}const slug=path.replace(/\/+$/,"").split("/").pop()||"stacks-app",displayName=slug.split(/[-_\s]+/).filter(Boolean).map((part)=>`${part.charAt(0).toUpperCase()}${part.slice(1)}`).join(" ");log.info("Installing app-safe infrastructure configuration...");try{for(const file of readdirSync(source)){if(!file.endsWith(".ts"))continue;const rendered=readFileSync(resolve(source,file),"utf8").replaceAll("__APP_NAME__",displayName).replaceAll("__APP_SLUG__",slug);writeFileSync(resolve(destination,file),rendered)}log.success("App-safe infrastructure configuration installed")}catch(error){log.warn(`Could not install the app config template: ${error instanceof Error?error.message:String(error)}`)}}function removeFrameworkTests(path){const tests=resolve(path,"tests");if(!existsSync(tests))return;log.info("Removing the framework's own test suite...");for(const entry of readdirSync(tests)){if(entry==="setup.ts")continue;rmSync(resolve(tests,entry),{recursive:!0,force:!0})}log.success("App starts with a clean test suite")}function ensureExecutableScripts(path){for(const script of["buddy","bootstrap"])try{chmodSync(resolve(path,script),493)}catch{}}async function ensureEnv(path,_options){log.info("Ensuring your environment is ready...");await ensurePantryInstalled();await ensurePantryDependencies(path);log.success("Environment is ready")}async function install(path,options){log.info("Installing & setting up Stacks");log.info("Copying .env.example \u2192 .env");let result=await runCommand("cp .env.example .env",{...options,cwd:path});if(resultFailed(result)){await log.error(result.error);process.exit(ExitCode.FatalError)}log.info("Removing template-encrypted env files...");const{rm}=await import("node:fs/promises");for(const stale of[".env.development",".env.staging",".env.production",".env.keys"])await rm(`${path}/${stale}`,{force:!0});log.info("Generating application key...");const keyResult=await runAction(Action.KeyGenerate,{...options,cwd:path});if(resultFailed(keyResult)){await log.error(keyResult.error);process.exit(ExitCode.FatalError)}if(existsSync(resolve(path,".git")))log.info("Existing git repository detected, skipping git init");else{log.info("Initializing git repository...");result=await runCommand("git init",{...options,cwd:path});if(resultFailed(result)){await log.error(result.error);process.exit(ExitCode.FatalError)}}log.success("Installed & set-up \uD83D\uDE80")}async function unvendorCore(path,options){log.info("Resolving the framework from npm (pass --with-core to keep it vendored)...");const result=await runCommand("./buddy unpublish:core --all --force",{...options,cwd:path});if(resultFailed(result)){const reason=result.error instanceof Error?result.error.stack??result.error.message:String(result.error??"").trim();log.error("Could not resolve the framework from npm.");log.error(reason.length>0?reason:"The step failed without reporting a reason.");log.error("");log.error(`The project at ${path} is half converted: the vendored framework has been`);log.error("removed and the published packages are not in place yet, so ./buddy cannot");log.error("boot there. Finish it by hand with:");log.error("");await log.error(` cd ${path} && rm -f node_modules/stacks && bun install`);await log.error("");await log.error("Or start over with `--with-core` to keep the framework vendored.");process.exit(ExitCode.FatalError)}log.success("Framework resolved from npm")}async function stripFeatures(path){log.info("Stripping optional feature bundles (--minimal)...");const results=await uninstallAllFeatures({root:path});let strippedAny=!1;for(const{feature,configOutcome,filesRemoved}of results)if(configOutcome==="flipped"||filesRemoved.length>0){strippedAny=!0;const fileSummary=filesRemoved.length>0?` (${filesRemoved.length} path${filesRemoved.length===1?"":"s"})`:"";log.info(` - ${feature}${fileSummary}`)}if(!strippedAny)log.info(" \u2192 no feature scaffolding present; nothing to strip.");else log.success("Minimal skeleton ready - run `./buddy <feature>:install` to add features back.")}
|
|
1
|
+
import{chmodSync,cpSync,existsSync,readFileSync,readdirSync,rmSync,writeFileSync}from"node:fs";import process from"node:process";import{runAction}from"@stacksjs/actions";import{bold,cyan,dim,intro,log,onUnknownSubcommand,runCommand}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{resolve}from"@stacksjs/path";import{isFolder}from"@stacksjs/storage";import{ExitCode}from"@stacksjs/types";import{uninstallAllFeatures}from"./features";import{ensurePantryDependencies,ensurePantryInstalled}from"./setup";import{resultFailed}from"../result";import{fetchPublishedVersions}from"../registry";export function create(buddy){const descriptions={name:"The name of the project",command:"Create a new Stacks project",ui:"Are you building a UI?",components:"Are you building UI components?",webComponents:"Automagically built optimized custom elements/web components?",views:"How about views?",functions:"Are you developing functions/composables?",api:"Are you building an API?",database:"Do you need a database?",notifications:"Do you need notifications? e.g. email, SMS, push or chat notifications",cache:"Do you need caching?",email:"Do you need email?",project:"Target a specific project",minimal:"Skip optional feature bundles (cms, commerce, dashboard, marketing, monitoring, realtime, queue) - bare-bones API/SPA starter that can re-add them later via `./buddy <feature>:install`.",withCore:"Keep the framework vendored in `storage/framework/core` as a Bun workspace, for working ON Stacks. Apps that only work WITH Stacks want the default, which resolves every @stacksjs/* package from npm.",verbose:"Enable verbose output"};buddy.command("new [name]",descriptions.command).alias("create [name]").option("-n, --name [name]",descriptions.name,{default:!1}).option("-u, --ui",descriptions.ui,{default:!0}).option("-c, --components",descriptions.components,{default:!0}).option("-w, --web-components",descriptions.webComponents,{default:!0}).option("-p, --views",descriptions.views,{default:!0}).option("-f, --functions",descriptions.functions,{default:!0}).option("-a, --api",descriptions.api,{default:!0}).option("-d, --database",descriptions.database,{default:!0}).option("-ca, --cache",descriptions.cache,{default:!1}).option("-e, --email",descriptions.email,{default:!1}).option("-P, --project [project]",descriptions.project,{default:!1}).option("-m, --minimal",descriptions.minimal,{default:!1}).option("--with-core",descriptions.withCore,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy new <name>` ...",options);const startTime=await intro("buddy new");name=name??options.name;const path=resolve(process.cwd(),name);isFolderCheck(path);await onlineCheck();const result=await download(name,path,options);if(resultFailed(result)){await log.error(result.error);process.exit(ExitCode.FatalError)}ensureExecutableScripts(path);applyAppVcsTemplate(path);applyAppConfigTemplate(path);removeFrameworkTests(path);await ensureEnv(path,options);if(options.minimal)await stripFeatures(path);await install(path,options);if(!options.withCore)await unvendorCore(path,options);if(startTime){const time=performance.now()-startTime;log.success(dim(`[${time.toFixed(2)}ms] Completed`))}log.info(bold("Welcome to the Stacks Framework! \u269B\uFE0F"));log.info(`Get started: ${cyan(`cd ${name}`)} and then ${cyan("./buddy dev")}`);log.info(`Run ${cyan("./buddy doctor")} anytime to check your setup`);log.info("To learn more, visit https://stacksjs.com");await log.flush();process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"new")}function isFolderCheck(path){if(!isFolder(path))return;if(readdirSync(path).filter((entry)=>entry!==".git").length===0)return;console.error(`Path ${path} already exists`);process.exit(ExitCode.FatalError)}async function onlineCheck(){if(await isOnline())return;log.info("It appears you are disconnected from the internet.");log.info("Creating a new project requires a brief internet connection to download the template and install dependencies.");await log.flush();process.exit(ExitCode.FatalError)}async function isOnline(){try{return(await fetch("https://github.com",{method:"HEAD",signal:AbortSignal.timeout(3000)})).ok}catch{return!1}}export async function templateRef(fetchVersions=fetchPublishedVersions){try{const{latest}=await fetchVersions("stacks");return latest?`v${latest}`:null}catch{return null}}export function templateSpec(ref){return ref?`gh:stacksjs/stacks#${ref}`:"gh:stacksjs/stacks"}async function download(name,path,_options){log.info("Setting up your stack.");const ref=await templateRef();try{const{downloadTemplate}=await import("@stacksjs/gitit");try{await downloadTemplate(templateSpec(ref),{dir:name,force:!0})}catch(error){if(!ref)throw error;log.warn(`No ${ref} tag to scaffold from (${error instanceof Error?error.message:String(error)}).`);log.info("Falling back to the default branch. If this app does not typecheck, that gap is why.");await downloadTemplate(templateSpec(null),{dir:name,force:!0})}log.success(`Successfully scaffolded your project at ${cyan(path)}`);return{isErr:!1}}catch(error){return{isErr:!0,error:error instanceof Error?error.message:String(error)}}}function applyAppVcsTemplate(path){const source=resolve(path,"storage/framework/defaults/vcs/github"),destination=resolve(path,".github");if(!existsSync(source)){log.warn("No app CI template found at storage/framework/defaults/vcs/github - leaving .github as downloaded.");return}log.info("Installing app-shaped GitHub workflows...");try{rmSync(destination,{recursive:!0,force:!0});cpSync(source,destination,{recursive:!0});log.success("App CI installed")}catch(error){log.warn(`Could not install the app CI template: ${error instanceof Error?error.message:String(error)}`)}}function applyAppConfigTemplate(path){const source=resolve(path,"storage/framework/defaults/scaffold/config"),destination=resolve(path,"config");if(!existsSync(source)){log.warn("No app config template found at storage/framework/defaults/scaffold/config - leaving config as downloaded.");return}const slug=path.replace(/\/+$/,"").split("/").pop()||"stacks-app",displayName=slug.split(/[-_\s]+/).filter(Boolean).map((part)=>`${part.charAt(0).toUpperCase()}${part.slice(1)}`).join(" ");log.info("Installing app-safe infrastructure configuration...");try{for(const file of readdirSync(source)){if(!file.endsWith(".ts"))continue;const rendered=readFileSync(resolve(source,file),"utf8").replaceAll("__APP_NAME__",displayName).replaceAll("__APP_SLUG__",slug);writeFileSync(resolve(destination,file),rendered)}log.success("App-safe infrastructure configuration installed")}catch(error){log.warn(`Could not install the app config template: ${error instanceof Error?error.message:String(error)}`)}}function removeFrameworkTests(path){const tests=resolve(path,"tests");if(!existsSync(tests))return;log.info("Removing the framework's own test suite...");for(const entry of readdirSync(tests)){if(entry==="setup.ts")continue;rmSync(resolve(tests,entry),{recursive:!0,force:!0})}log.success("App starts with a clean test suite")}function ensureExecutableScripts(path){for(const script of["buddy","bootstrap"])try{chmodSync(resolve(path,script),493)}catch{}}async function ensureEnv(path,_options){log.info("Ensuring your environment is ready...");await ensurePantryInstalled();await ensurePantryDependencies(path);log.success("Environment is ready")}async function install(path,options){log.info("Installing & setting up Stacks");log.info("Copying .env.example \u2192 .env");let result=await runCommand("cp .env.example .env",{...options,cwd:path});if(resultFailed(result)){await log.error(result.error);process.exit(ExitCode.FatalError)}log.info("Removing template-encrypted env files...");const{rm}=await import("node:fs/promises");for(const stale of[".env.development",".env.staging",".env.production",".env.keys"])await rm(`${path}/${stale}`,{force:!0});log.info("Generating application key...");const keyResult=await runAction(Action.KeyGenerate,{...options,cwd:path});if(resultFailed(keyResult)){await log.error(keyResult.error);process.exit(ExitCode.FatalError)}if(existsSync(resolve(path,".git")))log.info("Existing git repository detected, skipping git init");else{log.info("Initializing git repository...");result=await runCommand("git init",{...options,cwd:path});if(resultFailed(result)){await log.error(result.error);process.exit(ExitCode.FatalError)}}log.success("Installed & set-up \uD83D\uDE80")}async function unvendorCore(path,options){log.info("Resolving the framework from npm (pass --with-core to keep it vendored)...");const result=await runCommand("./buddy unpublish:core --all --force",{...options,cwd:path});if(resultFailed(result)){const reason=result.error instanceof Error?result.error.stack??result.error.message:String(result.error??"").trim();log.error("Could not resolve the framework from npm.");log.error(reason.length>0?reason:"The step failed without reporting a reason.");log.error("");log.error(`The project at ${path} is half converted: the vendored framework has been`);log.error("removed and the published packages are not in place yet, so ./buddy cannot");log.error("boot there. Finish it by hand with:");log.error("");await log.error(` cd ${path} && rm -f node_modules/stacks && bun install`);await log.error("");await log.error("Or start over with `--with-core` to keep the framework vendored.");process.exit(ExitCode.FatalError)}log.success("Framework resolved from npm")}async function stripFeatures(path){log.info("Stripping optional feature bundles (--minimal)...");const results=await uninstallAllFeatures({root:path});let strippedAny=!1;for(const{feature,configOutcome,filesRemoved}of results)if(configOutcome==="flipped"||filesRemoved.length>0){strippedAny=!0;const fileSummary=filesRemoved.length>0?` (${filesRemoved.length} path${filesRemoved.length===1?"":"s"})`:"";log.info(` - ${feature}${fileSummary}`)}if(!strippedAny)log.info(" \u2192 no feature scaffolding present; nothing to strip.");else log.success("Minimal skeleton ready - run `./buddy <feature>:install` to add features back.")}
|
package/dist/commands/db.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{existsSync,mkdirSync,readdirSync,rmSync,statSync}from"node:fs";import{isAbsolute,join,resolve}from"node:path";import process from"node:process";import{confirmOrNull,intro,log,outro}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{backupFileName,backupObjectKey,describeCommand,dumpCommand,dumpSqlite,isBackupFileName,prunableBackups,resolveBackupDestination,resolveBackupTarget,parseBackupDestination,restoreCommand,restoreSqlite,toolFailureDetail,withoutPassword}from"../database-backup";const DEFAULT_BACKUP_DIR="storage/backups/database",DEFAULT_RETAIN=7;export async function backupTarget(){const{config,overridesReady}=await import("@stacksjs/config");await overridesReady.catch(()=>{});return resolveBackupTarget(config?.database)}export async function backupDestination(override){if(override?.trim())return parseBackupDestination(override);const{config,overridesReady}=await import("@stacksjs/config");await overridesReady.catch(()=>{});return resolveBackupDestination(config?.database,process.env)}async function uploadBackup(destination,file,fileName){const{Storage}=await import("@stacksjs/storage"),key=backupObjectKey(destination,fileName),diskName=destination.kind==="disk"?destination.target:"s3",disk=Storage.disk(diskName);if(typeof disk.putStream!=="function")throw TypeError(`The '${diskName}' disk cannot stream uploads, so a database dump cannot be copied to it. Use an S3-backed disk for \`backups.destination\`.`);await disk.putStream(key,Bun.file(file).stream());return destination.kind==="disk"?`disk://${destination.target}/${key}`:`s3://${destination.target}/${key}`}function backupDir(out){const dir=out?.trim()||DEFAULT_BACKUP_DIR;return isAbsolute(dir)?dir:resolve(process.cwd(),dir)}function existingBackups(dir){if(!existsSync(dir))return[];return readdirSync(dir).filter(isBackupFileName).sort()}function sqliteDatabaseExists(target){const path=sqliteFile(target);return existsSync(path)&&statSync(path).size>0}function sqliteFile(target){return isAbsolute(target.database)?target.database:resolve(process.cwd(),target.database)}async function runTool(command,password){const proc=Bun.spawn([command.bin,...command.args],{env:{...process.env,...command.env},stdout:"pipe",stderr:"pipe"}),[code,stderr]=await Promise.all([proc.exited,new Response(proc.stderr).text()]);if(code===0)return;const detail=toolFailureDetail(stderr,command.bin)||`exit code ${code}`;throw Error(withoutPassword(`${command.bin}: ${detail}`,password))}function prune(dir,retain){const removed=prunableBackups(existingBackups(dir),retain);for(const name of removed)rmSync(join(dir,name),{force:!0});return removed}export function db(buddy){buddy.command("db:backup","Dump the application database to a file").option("--out [dir]",`Where to write the dump (default: ${DEFAULT_BACKUP_DIR})`).option("--retain [count]","How many dumps to keep",{default:String(DEFAULT_RETAIN)}).option("--before-migrations","Deploy mode: succeed quietly when there is no database yet",{default:!1}).option("--destination [uri]","Copy the dump offsite: s3://bucket/prefix or disk://name/prefix").option("--verbose","Enable verbose output",{default:!1}).example("buddy db:backup").example("buddy db:backup --out /var/backups/app --retain 30").example("buddy db:backup --destination disk://backups/daily").action(async(options)=>{const perf=await intro("buddy db:backup");try{const target=await backupTarget();if(!target){await outro("No dumpable database is configured; nothing to back up.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(target.engine==="sqlite"&&!sqliteDatabaseExists(target)){const message=`No database at ${target.database} yet; nothing to back up.`;if(options.beforeMigrations){await outro(message,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}log.warn(message);await outro("Nothing backed up.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const dir=backupDir(options.out);mkdirSync(dir,{recursive:!0});const name=backupFileName(target,new Date),destination=join(dir,name),command=dumpCommand(target,destination);if(command){if(options.verbose)log.info(describeCommand(command));await runTool(command,target.password)}else await dumpSqlite(sqliteFile(target),destination);const size=existsSync(destination)?statSync(destination).size:0;log.success(`Wrote ${destination} (${(size/1024).toFixed(1)} KB)`);const retain=Number.parseInt(String(options.retain??DEFAULT_RETAIN),10),removed=prune(dir,retain);if(removed.length)log.info(`Pruned ${removed.length} older dump(s), keeping ${retain}.`);const offsite=await backupDestination(options.destination);if(offsite){const uploaded=await uploadBackup(offsite,destination,name);log.success(`Copied to ${uploaded}`)}else log.info("This dump is on the same disk as the database. Set `backups.destination` in config/database.ts (or DB_BACKUP_DESTINATION) to copy it off the box.");await outro("Database backed up",{startTime:perf,useSeconds:!0})}catch(error){await log.error("Database backup failed:",error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("db:backups","List the database dumps that have been taken").option("--out [dir]",`Where dumps are kept (default: ${DEFAULT_BACKUP_DIR})`).example("buddy db:backups").action(async(options)=>{const dir=backupDir(options.out),backups=existingBackups(dir);if(!backups.length){log.info(`No dumps in ${dir}.`);process.exit(ExitCode.Success)}log.info(`${backups.length} dump(s) in ${dir}, oldest first:`);for(const name of backups){const size=statSync(join(dir,name)).size;log.info(` ${name} ${(size/1024).toFixed(1)} KB`)}process.exit(ExitCode.Success)});buddy.command("db:restore [file]","Restore the application database from a dump").option("--out [dir]",`Where dumps are kept (default: ${DEFAULT_BACKUP_DIR})`).option("--force","Skip the confirmation prompt",{default:!1}).option("--verbose","Enable verbose output",{default:!1}).example("buddy db:restore").example("buddy db:restore 2026-08-13T09-00-00-000.sqlite.sqlite --force").action(async(file,options)=>{const perf=await intro("buddy db:restore");try{const target=await backupTarget();if(!target){await log.error("No restorable database is configured.");process.exit(ExitCode.FatalError)}const dir=backupDir(options.out),backups=existingBackups(dir),name=file??backups[backups.length-1];if(!name){await log.error(`No dumps found in ${dir}.`);process.exit(ExitCode.FatalError)}const source=isAbsolute(name)?name:join(dir,name);if(!existsSync(source)){await log.error(`No such dump: ${source}`);process.exit(ExitCode.FatalError)}if(!options.force){if(await confirmOrNull({message:`Replace ${target.engine} database "${target.database}" with ${name}? The current contents are lost.`,initial:!1})!==!0){await outro("Restore cancelled.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}const command=restoreCommand(target,source);if(command){if(options.verbose)log.info(describeCommand(command));await runTool(command,target.password)}else{const displaced=await restoreSqlite(source,sqliteFile(target),Date.now());if(displaced)log.info(`The database that was there is kept at ${displaced}`)}log.success(`Restored ${target.database} from ${name}`);await outro("Database restored",{startTime:perf,useSeconds:!0})}catch(error){await log.error("Database restore failed:",error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)})}
|
|
1
|
+
import{existsSync,mkdirSync,readdirSync,rmSync,statSync}from"node:fs";import{isAbsolute,join,resolve}from"node:path";import process from"node:process";import{confirmOrNull,intro,log,outro}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{backupFileName,backupObjectKey,describeCommand,dumpCommand,dumpSqlite,isBackupFileName,prunableBackups,resolveBackupDestination,resolveBackupTarget,parseBackupDestination,restoreCommand,restoreSqlite,toolFailureDetail,withoutPassword}from"../database-backup";const DEFAULT_BACKUP_DIR="storage/backups/database",DEFAULT_RETAIN=7;export async function backupTarget(){const{config,overridesReady}=await import("@stacksjs/config");await overridesReady.catch(()=>{});return resolveBackupTarget(config?.database)}export async function backupDestination(override){if(override?.trim())return parseBackupDestination(override);const{config,overridesReady}=await import("@stacksjs/config");await overridesReady.catch(()=>{});return resolveBackupDestination(config?.database,process.env)}async function uploadBackup(destination,file,fileName){const{Storage}=await import("@stacksjs/storage"),key=backupObjectKey(destination,fileName),diskName=destination.kind==="disk"?destination.target:"s3",disk=Storage.disk(diskName);if(typeof disk.putStream!=="function")throw TypeError(`The '${diskName}' disk cannot stream uploads, so a database dump cannot be copied to it. Use an S3-backed disk for \`backups.destination\`.`);await disk.putStream(key,Bun.file(file).stream());return destination.kind==="disk"?`disk://${destination.target}/${key}`:`s3://${destination.target}/${key}`}function backupDir(out){const dir=out?.trim()||DEFAULT_BACKUP_DIR;return isAbsolute(dir)?dir:resolve(process.cwd(),dir)}function existingBackups(dir){if(!existsSync(dir))return[];return readdirSync(dir).filter(isBackupFileName).sort()}function sqliteDatabaseExists(target){const path=sqliteFile(target);return existsSync(path)&&statSync(path).size>0}function sqliteFile(target){return isAbsolute(target.database)?target.database:resolve(process.cwd(),target.database)}async function runTool(command,password){const proc=Bun.spawn([command.bin,...command.args],{env:{...process.env,...command.env},stdout:"pipe",stderr:"pipe"}),[code,stderr]=await Promise.all([proc.exited,new Response(proc.stderr).text()]);if(code===0)return;const detail=toolFailureDetail(stderr,command.bin)||`exit code ${code}`;throw Error(withoutPassword(`${command.bin}: ${detail}`,password))}function prune(dir,retain){const removed=prunableBackups(existingBackups(dir),retain);for(const name of removed)rmSync(join(dir,name),{force:!0});return removed}export function db(buddy){buddy.command("db:backup","Dump the application database to a file").option("--out [dir]",`Where to write the dump (default: ${DEFAULT_BACKUP_DIR})`).option("--retain [count]","How many dumps to keep",{default:String(DEFAULT_RETAIN)}).option("--before-migrations","Deploy mode: succeed quietly when there is no database yet",{default:!1}).option("--destination [uri]","Copy the dump offsite: s3://bucket/prefix or disk://name/prefix").option("--verbose","Enable verbose output",{default:!1}).example("buddy db:backup").example("buddy db:backup --out /var/backups/app --retain 30").example("buddy db:backup --destination disk://backups/daily").action(async(options)=>{const perf=await intro("buddy db:backup");try{const target=await backupTarget();if(!target){await outro("No dumpable database is configured; nothing to back up.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(target.engine==="sqlite"&&!sqliteDatabaseExists(target)){const message=`No database at ${target.database} yet; nothing to back up.`;if(options.beforeMigrations){await outro(message,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}log.warn(message);await outro("Nothing backed up.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const dir=backupDir(options.out);mkdirSync(dir,{recursive:!0});const name=backupFileName(target,new Date),destination=join(dir,name),command=dumpCommand(target,destination);if(command){if(options.verbose)log.info(describeCommand(command));await runTool(command,target.password)}else await dumpSqlite(sqliteFile(target),destination);const size=existsSync(destination)?statSync(destination).size:0;log.success(`Wrote ${destination} (${(size/1024).toFixed(1)} KB)`);const retain=Number.parseInt(String(options.retain??DEFAULT_RETAIN),10),removed=prune(dir,retain);if(removed.length)log.info(`Pruned ${removed.length} older dump(s), keeping ${retain}.`);const offsite=await backupDestination(options.destination);if(offsite){const uploaded=await uploadBackup(offsite,destination,name);log.success(`Copied to ${uploaded}`)}else log.info("This dump is on the same disk as the database. Set `backups.destination` in config/database.ts (or DB_BACKUP_DESTINATION) to copy it off the box.");await outro("Database backed up",{startTime:perf,useSeconds:!0})}catch(error){await log.error("Database backup failed:",error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("db:backups","List the database dumps that have been taken").option("--out [dir]",`Where dumps are kept (default: ${DEFAULT_BACKUP_DIR})`).example("buddy db:backups").action(async(options)=>{const dir=backupDir(options.out),backups=existingBackups(dir);if(!backups.length){log.info(`No dumps in ${dir}.`);await log.flush();process.exit(ExitCode.Success)}log.info(`${backups.length} dump(s) in ${dir}, oldest first:`);for(const name of backups){const size=statSync(join(dir,name)).size;log.info(` ${name} ${(size/1024).toFixed(1)} KB`)}process.exit(ExitCode.Success)});buddy.command("db:restore [file]","Restore the application database from a dump").option("--out [dir]",`Where dumps are kept (default: ${DEFAULT_BACKUP_DIR})`).option("--force","Skip the confirmation prompt",{default:!1}).option("--verbose","Enable verbose output",{default:!1}).example("buddy db:restore").example("buddy db:restore 2026-08-13T09-00-00-000.sqlite.sqlite --force").action(async(file,options)=>{const perf=await intro("buddy db:restore");try{const target=await backupTarget();if(!target){await log.error("No restorable database is configured.");process.exit(ExitCode.FatalError)}const dir=backupDir(options.out),backups=existingBackups(dir),name=file??backups[backups.length-1];if(!name){await log.error(`No dumps found in ${dir}.`);process.exit(ExitCode.FatalError)}const source=isAbsolute(name)?name:join(dir,name);if(!existsSync(source)){await log.error(`No such dump: ${source}`);process.exit(ExitCode.FatalError)}if(!options.force){if(await confirmOrNull({message:`Replace ${target.engine} database "${target.database}" with ${name}? The current contents are lost.`,initial:!1})!==!0){await outro("Restore cancelled.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}const command=restoreCommand(target,source);if(command){if(options.verbose)log.info(describeCommand(command));await runTool(command,target.password)}else{const displaced=await restoreSqlite(source,sqliteFile(target),Date.now());if(displaced)log.info(`The database that was there is kept at ${displaced}`)}log.success(`Restored ${target.database} from ${name}`);await outro("Database restored",{startTime:perf,useSeconds:!0})}catch(error){await log.error("Database restore failed:",error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)})}
|