@stacksjs/buddy 0.74.2 → 0.74.4
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/cloud.js +2 -2
- package/dist/commands/create.js +1 -1
- package/dist/commands/deploy-preview.js +1 -1
- package/dist/commands/deploy-ssh-target.d.ts +155 -0
- package/dist/commands/deploy-ssh-target.js +1 -0
- package/dist/commands/deploy.d.ts +18 -5
- package/dist/commands/deploy.js +10 -9
- package/dist/commands/docs/agent-counts.d.ts +1 -0
- package/dist/commands/docs/agent-counts.js +2 -0
- package/dist/commands/docs.js +1 -1
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/features.d.ts +41 -122
- package/dist/commands/features.js +1 -1
- package/dist/commands/generate.js +2 -2
- package/dist/commands/index.d.ts +1 -0
- package/dist/commands/index.js +1 -1
- package/dist/commands/phone.js +1 -1
- package/dist/commands/server-image.d.ts +86 -0
- package/dist/commands/server-image.js +2 -0
- package/dist/commands/server-trust.d.ts +71 -0
- package/dist/commands/server-trust.js +2 -0
- package/dist/commands/server.d.ts +4 -0
- package/dist/commands/server.js +10 -0
- package/dist/lazy-commands.d.ts +1 -1
- package/dist/lazy-commands.js +1 -1
- package/package.json +54 -52
package/dist/commands/cloud.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
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";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 assertHetznerProvider(tsCloudConfig,command){const provider=tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws";if(provider==="hetzner")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 only list Hetzner servers, and this project's provider is '${provider}'.`,ExitCode.FatalError)}async function listFleet(tsCloudConfig){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(`
|
|
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 assertHetznerProvider(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 assertHetznerProvider(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(`
|
|
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(`
|
|
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")}
|
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)){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;log.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)){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)){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)){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)){log.error(result.error);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)){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;log.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)){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)){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)){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("");log.error(` cd ${path} && rm -f node_modules/stacks && bun install`);log.error("");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,4 +1,4 @@
|
|
|
1
|
-
export function resolveDeploymentEnvironment(options){const requested=options.positional||options.option||(options.staging?"staging":options.development?"development":"production");return requested==="prod"?"production":requested==="dev"?"development":requested}export function applyDeploymentDomainOverride(config,domain){if(domain!==void 0&&typeof domain!=="string")throw Error("Domain must be a valid DNS name.");const override=domain?.trim().toLowerCase();if(!override)return config;if(!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(override))throw Error("Domain must be a valid DNS name.");const entries=Object.entries(config.sites||{}),primary=entries.map(([,site])=>site).find((site)=>site?.path==="/"&&typeof site.start==="string"&&strings(site.domain).length>0)||entries.map(([,site])=>site).find((site)=>strings(site?.domain).length>0),current=strings(primary?.domain)[0]?.toLowerCase();if(!current||current===override)return config;const replaceHost=(value)=>{const normalized=value.toLowerCase();if(normalized===current)return override;if(normalized.endsWith(`.${current}`))return`${value.slice(0,-current.length)}${override}`;return value.replace(new RegExp(`(https?://)((?:[a-z0-9-]+\\.)*)${current.replace(/[.]/g,"\\.")}(?=[:/?#]|$)`,"gi"),(_match,scheme,prefix)=>`${scheme}${prefix}${override}`)};return{...config,sites:Object.fromEntries(entries.map(([name,site])=>{if(!site)return[name,site];const next={...site};if(typeof next.domain==="string")next.domain=replaceHost(next.domain);else if(Array.isArray(next.domain))next.domain=next.domain.map((value)=>typeof value==="string"?replaceHost(value):value);if(typeof next.redirect==="string")next.redirect=replaceHost(next.redirect);else if(next.redirect&&typeof next.redirect==="object"&&!Array.isArray(next.redirect)){const redirect={...next.redirect};if(typeof redirect.to==="string")redirect.to=replaceHost(redirect.to);next.redirect=redirect}return[name,next]}))}}function strings(value){if(typeof value==="string"&&value.trim())return[value.trim()];if(!Array.isArray(value))return[];return value.filter((entry)=>typeof entry==="string"&&entry.trim().length>0)}function previewSite(name,site,resolveSiteKind){const port=typeof site.port==="number"&&Number.isInteger(site.port)?site.port:null;return{name,kind:resolveSiteKind(site),domains:strings(site.domain),path:typeof site.path==="string"&&site.path?site.path:"/",root:typeof site.root==="string"&&site.root?site.root:null,port,build:typeof site.build==="string"&&site.build?site.build:null,preStart:strings(site.preStart)}}function operation(phase,label,detail,sites=[]){return{phase,label,detail,sites}}export function createDeploymentPreview(options){const config=applyDeploymentDomainOverride(options.config||{},options.domain),configuredSites=options.applyEnvironmentToSites(config.sites||{},options.environment,config),availableSites=Object.entries(configuredSites).filter((entry)=>Boolean(entry[1]));if(options.site&&!availableSites.some(([name])=>name===options.site)){const available=availableSites.map(([name])=>name).join(", ")||"none";throw Error(`Site '${options.site}' is not configured. Available sites: ${available}.`)}const selectedSites=availableSites.filter(([name])=>!options.site||name===options.site).map(([name,site])=>previewSite(name,site,options.resolveSiteKind)),provider=config.cloud?.provider||options.fallbackProvider||"aws",mode=config.mode||options.fallbackMode||"server",attachTo=config.cloud?.attachTo||null,projectName=config.project?.name||options.fallbackProjectName||"Stacks application",projectSlug=config.project?.slug||options.fallbackProjectSlug||"app",region=config.environments?.[options.environment]?.region||config.project?.region||options.fallbackRegion||"us-east-1",siteNames=selectedSites.map((site)=>site.name),shippable=selectedSites.filter((site)=>site.kind!=="bucket"&&site.kind!=="redirect"),staticSites=shippable.filter((site)=>site.kind==="server-static"&&site.build),runtimeSites=shippable.filter((site)=>site.kind==="server-app"||site.kind==="server-php"),publicSites=selectedSites.filter((site)=>site.domains.length>0),operations=[operation("validate","Validate deployment inputs",`Resolve the ${options.environment} configuration and list the prerequisites checked before a real deployment.`,siteNames)];if(provider==="hetzner")operations.push(attachTo?operation("infrastructure","Use attached server",`Resolve the existing '${attachTo}' server and verify that this project owns its gateway fragment and ports.`,siteNames):operation("infrastructure","Reconcile compute infrastructure",`Create or reuse the ${config.infrastructure?.compute?.size||"configured"} Hetzner server, firewall, SSH key, and managed services.`,siteNames));else operations.push(operation("infrastructure","Reconcile cloud infrastructure",`Generate and apply the ${provider} infrastructure for ${region}.`,siteNames));if(staticSites.length>0)operations.push(operation("build","Build static sites",`Run each configured static build: ${staticSites.map((site)=>`${site.name}: ${site.build}`).join("; ")}.`,staticSites.map((site)=>site.name)));if(shippable.length>0){operations.push(operation("package","Package releases","Create source or static release archives while excluding local dependencies, secrets, databases, caches, logs, and server-owned paths.",shippable.map((site)=>site.name)));operations.push(operation("release",options.site?`Ship site '${options.site}'`:"Ship release",options.site?"Upload and activate only the selected site while preserving every other configured route and service.":"Upload and atomically activate the selected releases on the target infrastructure.",shippable.map((site)=>site.name)))}if(runtimeSites.length>0){const hookCount=runtimeSites.reduce((total,site)=>total+site.preStart.length,0);operations.push(operation("runtime","Prepare and restart services",`Run ${hookCount} configured pre-start command${hookCount===1?"":"s"}, update service definitions, and restart application runtimes.`,runtimeSites.map((site)=>site.name)))}if(publicSites.length>0){operations.push(operation("gateway","Reconcile public routes","Regenerate the reverse-proxy routes from the complete environment-aware site model.",publicSites.map((site)=>site.name)));operations.push(operation("dns","Reconcile DNS records","Publish the configured public domains through their resolved DNS providers.",publicSites.map((site)=>site.name)));operations.push(operation("tls","Reconcile TLS certificates","Issue or renew certificates for public domains and reload the gateway when records change.",publicSites.map((site)=>site.name)))}if(options.docker)operations.push(operation("container","Build OCI images","Build the requested OCI images with Pantry and push them when registry credentials are configured.",shippable.map((site)=>site.name)));return{version:1,dryRun:!0,project:{name:projectName,slug:projectSlug},provider,mode,environment:options.environment,region,target:{site:options.site||null,domain:options.domain||null,attachTo},sites:selectedSites,operations,warnings:[...options.warnings||[]]}}export function formatDeploymentPreview(plan){const lines=["","Deployment preview","No changes will be made.","",`Project: ${plan.project.name} (${plan.project.slug})`,`Environment: ${plan.environment}`,`Provider: ${plan.provider}`,`Mode: ${plan.mode}`,`Region: ${plan.region}`,`Target: ${plan.target.site||"all configured sites"}`,"","Planned operations:",...plan.operations.map((item,index)=>`${index+1}. ${item.label}
|
|
1
|
+
import{dnsPublishingAllowed,resolveSshTarget}from"./deploy-ssh-target";export function resolveDeploymentEnvironment(options){const requested=options.positional||options.option||(options.staging?"staging":options.development?"development":"production");return requested==="prod"?"production":requested==="dev"?"development":requested}export function applyDeploymentDomainOverride(config,domain){if(domain!==void 0&&typeof domain!=="string")throw Error("Domain must be a valid DNS name.");const override=domain?.trim().toLowerCase();if(!override)return config;if(!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(override))throw Error("Domain must be a valid DNS name.");const entries=Object.entries(config.sites||{}),primary=entries.map(([,site])=>site).find((site)=>site?.path==="/"&&typeof site.start==="string"&&strings(site.domain).length>0)||entries.map(([,site])=>site).find((site)=>strings(site?.domain).length>0),current=strings(primary?.domain)[0]?.toLowerCase();if(!current||current===override)return config;const replaceHost=(value)=>{const normalized=value.toLowerCase();if(normalized===current)return override;if(normalized.endsWith(`.${current}`))return`${value.slice(0,-current.length)}${override}`;return value.replace(new RegExp(`(https?://)((?:[a-z0-9-]+\\.)*)${current.replace(/[.]/g,"\\.")}(?=[:/?#]|$)`,"gi"),(_match,scheme,prefix)=>`${scheme}${prefix}${override}`)};return{...config,sites:Object.fromEntries(entries.map(([name,site])=>{if(!site)return[name,site];const next={...site};if(typeof next.domain==="string")next.domain=replaceHost(next.domain);else if(Array.isArray(next.domain))next.domain=next.domain.map((value)=>typeof value==="string"?replaceHost(value):value);if(typeof next.redirect==="string")next.redirect=replaceHost(next.redirect);else if(next.redirect&&typeof next.redirect==="object"&&!Array.isArray(next.redirect)){const redirect={...next.redirect};if(typeof redirect.to==="string")redirect.to=replaceHost(redirect.to);next.redirect=redirect}return[name,next]}))}}function strings(value){if(typeof value==="string"&&value.trim())return[value.trim()];if(!Array.isArray(value))return[];return value.filter((entry)=>typeof entry==="string"&&entry.trim().length>0)}function previewSite(name,site,resolveSiteKind){const port=typeof site.port==="number"&&Number.isInteger(site.port)?site.port:null;return{name,kind:resolveSiteKind(site),domains:strings(site.domain),path:typeof site.path==="string"&&site.path?site.path:"/",root:typeof site.root==="string"&&site.root?site.root:null,port,build:typeof site.build==="string"&&site.build?site.build:null,preStart:strings(site.preStart)}}function operation(phase,label,detail,sites=[]){return{phase,label,detail,sites}}export function createDeploymentPreview(options){const config=applyDeploymentDomainOverride(options.config||{},options.domain),configuredSites=options.applyEnvironmentToSites(config.sites||{},options.environment,config),availableSites=Object.entries(configuredSites).filter((entry)=>Boolean(entry[1]));if(options.site&&!availableSites.some(([name])=>name===options.site)){const available=availableSites.map(([name])=>name).join(", ")||"none";throw Error(`Site '${options.site}' is not configured. Available sites: ${available}.`)}const selectedSites=availableSites.filter(([name])=>!options.site||name===options.site).map(([name,site])=>previewSite(name,site,options.resolveSiteKind)),provider=config.cloud?.provider||options.fallbackProvider||"aws",mode=config.mode||options.fallbackMode||"server",attachTo=config.cloud?.attachTo||null,projectName=config.project?.name||options.fallbackProjectName||"Stacks application",projectSlug=config.project?.slug||options.fallbackProjectSlug||"app",region=config.environments?.[options.environment]?.region||config.project?.region||options.fallbackRegion||"us-east-1",siteNames=selectedSites.map((site)=>site.name),shippable=selectedSites.filter((site)=>site.kind!=="bucket"&&site.kind!=="redirect"),staticSites=shippable.filter((site)=>site.kind==="server-static"&&site.build),runtimeSites=shippable.filter((site)=>site.kind==="server-app"||site.kind==="server-php"),publicSites=selectedSites.filter((site)=>site.domains.length>0),operations=[operation("validate","Validate deployment inputs",`Resolve the ${options.environment} configuration and list the prerequisites checked before a real deployment.`,siteNames)],sshTarget=provider==="ssh"?resolveSshTarget(config):null,publishesDns=dnsPublishingAllowed({provider,publicIp:sshTarget?.host,sites:configuredSites});if(provider==="ssh"){const where=sshTarget?`${sshTarget.user}@${sshTarget.host}${sshTarget.port===22?"":`:${sshTarget.port}`}`:"the configured host";operations.push(attachTo?operation("infrastructure","Use attached server",`Resolve the existing '${attachTo}' server at ${where} and verify that this project owns its gateway fragment and ports.`,siteNames):operation("infrastructure","Adopt SSH host",`Check ${where} over SSH (architecture, OS, memory, disk, sudo, clock, outbound HTTPS), then install bun, the rpx gateway and the systemd units if they are missing.`,siteNames))}else if(provider==="hetzner")operations.push(attachTo?operation("infrastructure","Use attached server",`Resolve the existing '${attachTo}' server and verify that this project owns its gateway fragment and ports.`,siteNames):operation("infrastructure","Reconcile compute infrastructure",`Create or reuse the ${config.infrastructure?.compute?.size||"configured"} Hetzner server, firewall, SSH key, and managed services.`,siteNames));else operations.push(operation("infrastructure","Reconcile cloud infrastructure",`Generate and apply the ${provider} infrastructure for ${region}.`,siteNames));if(staticSites.length>0)operations.push(operation("build","Build static sites",`Run each configured static build: ${staticSites.map((site)=>`${site.name}: ${site.build}`).join("; ")}.`,staticSites.map((site)=>site.name)));if(shippable.length>0){operations.push(operation("package","Package releases","Create source or static release archives while excluding local dependencies, secrets, databases, caches, logs, and server-owned paths.",shippable.map((site)=>site.name)));operations.push(operation("release",options.site?`Ship site '${options.site}'`:"Ship release",options.site?"Upload and activate only the selected site while preserving every other configured route and service.":"Upload and atomically activate the selected releases on the target infrastructure.",shippable.map((site)=>site.name)))}if(runtimeSites.length>0){const hookCount=runtimeSites.reduce((total,site)=>total+site.preStart.length,0);operations.push(operation("runtime","Prepare and restart services",`Run ${hookCount} configured pre-start command${hookCount===1?"":"s"}, update service definitions, and restart application runtimes.`,runtimeSites.map((site)=>site.name)))}if(publicSites.length>0){operations.push(operation("gateway","Reconcile public routes",publishesDns?"Regenerate the reverse-proxy routes from the complete environment-aware site model.":"Regenerate the reverse-proxy routes from the complete environment-aware site model, served over the local network only.",publicSites.map((site)=>site.name)));if(publishesDns){operations.push(operation("dns","Reconcile DNS records","Publish the configured public domains through their resolved DNS providers.",publicSites.map((site)=>site.name)));operations.push(operation("tls","Reconcile TLS certificates","Issue or renew certificates for public domains and reload the gateway when records change.",publicSites.map((site)=>site.name)))}}if(options.docker)operations.push(operation("container","Build OCI images","Build the requested OCI images with Pantry and push them when registry credentials are configured.",shippable.map((site)=>site.name)));return{version:1,dryRun:!0,project:{name:projectName,slug:projectSlug},provider,mode,environment:options.environment,region,target:{site:options.site||null,domain:options.domain||null,attachTo},sites:selectedSites,operations,warnings:[...options.warnings||[]]}}export function formatDeploymentPreview(plan){const lines=["","Deployment preview","No changes will be made.","",`Project: ${plan.project.name} (${plan.project.slug})`,`Environment: ${plan.environment}`,`Provider: ${plan.provider}`,`Mode: ${plan.mode}`,`Region: ${plan.region}`,`Target: ${plan.target.site||"all configured sites"}`,"","Planned operations:",...plan.operations.map((item,index)=>`${index+1}. ${item.label}
|
|
2
2
|
${item.detail}`)];if(plan.warnings.length>0)lines.push("","Warnings:",...plan.warnings.map((warning)=>`- ${warning}`));return`${lines.join(`
|
|
3
3
|
`)}
|
|
4
4
|
`}export const deploymentPreviewJsonPrefix="STACKS_DEPLOY_PREVIEW_JSON=";
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** True for the providers that deploy by copying a tarball over SSH. */
|
|
2
|
+
export declare function isSshPipelineProvider(provider: string | undefined): provider is SshPipelineProvider;
|
|
3
|
+
/**
|
|
4
|
+
* The target a Hetzner deploy has always used: root, port 22, key left to ssh.
|
|
5
|
+
*
|
|
6
|
+
* Passing an already-built target through unchanged lets a call site accept
|
|
7
|
+
* either an IP string (every existing caller) or a full target.
|
|
8
|
+
*/
|
|
9
|
+
export declare function hetznerTarget(ip: string): SshTarget;
|
|
10
|
+
/** Normalise a call-site argument that may still be a bare IP string. */
|
|
11
|
+
export declare function toSshTarget(value: string | SshTarget): SshTarget;
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the SSH target for a `provider: 'ssh'` deploy.
|
|
14
|
+
*
|
|
15
|
+
* Precedence is env over config, so a CI run or a one-off can point at another
|
|
16
|
+
* box without editing `config/cloud.ts`. Returns null when no host is known at
|
|
17
|
+
* all; the caller turns that into a message about what to configure, because
|
|
18
|
+
* "no host" is a setup mistake rather than a failure worth a stack trace.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveSshTarget(tsCloudConfig: { ssh?: SshConfigBlock } | undefined, env?: NodeJS.ProcessEnv): SshTarget | null;
|
|
21
|
+
/**
|
|
22
|
+
* The argv for an `ssh` invocation against a target.
|
|
23
|
+
*
|
|
24
|
+
* For a Hetzner target this is byte-identical to the array the call sites built
|
|
25
|
+
* inline, so nothing about that path changes. A pinned host key is expressed as
|
|
26
|
+
* `StrictHostKeyChecking=yes` against a known_hosts the caller supplies; ts-cloud
|
|
27
|
+
* writes the pin, so buddy only has to honour it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function sshCliArgs(target: SshTarget, options?: { connectTimeoutSec?: number, knownHostsFile?: string }): string[];
|
|
30
|
+
/** The options object ts-cloud's own SSH helpers take for this target. */
|
|
31
|
+
export declare function remoteExecOptions(target: SshTarget, connectTimeoutSec?: number): { user: string, port?: number, identityFile?: string, connectTimeoutSec: number };
|
|
32
|
+
/**
|
|
33
|
+
* True for an address the public internet cannot route to.
|
|
34
|
+
*
|
|
35
|
+
* A LAN deploy must never publish its own address: an A record pointing at
|
|
36
|
+
* 192.168.x.y is not merely useless, it hands every visitor's browser a name
|
|
37
|
+
* that resolves to whatever sits at that address on THEIR network. Bare
|
|
38
|
+
* hostnames and mDNS names count as private for the same reason.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isPrivateHost(host: string): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Whether this deploy may publish DNS, request certificates and touch the CDN.
|
|
43
|
+
*
|
|
44
|
+
* Hetzner always may. An SSH host may only when it has an address the world can
|
|
45
|
+
* reach and at least one site that claims a domain, because ACME validation and
|
|
46
|
+
* an A record are both meaningless otherwise. `TS_CLOUD_SSH_PUBLISH_DNS` forces
|
|
47
|
+
* the answer either way, for a Pi behind a port forward whose public address
|
|
48
|
+
* this process cannot see.
|
|
49
|
+
*/
|
|
50
|
+
export declare function dnsPublishingAllowed(input: {
|
|
51
|
+
provider: string
|
|
52
|
+
publicIp?: string
|
|
53
|
+
/** A site may declare one domain or several; either counts as claiming one. */
|
|
54
|
+
sites?: Record<string, { domain?: string | string[] } | null | undefined>
|
|
55
|
+
env?: NodeJS.ProcessEnv
|
|
56
|
+
}): boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Merge a pin over whatever is already recorded for this stack.
|
|
59
|
+
*
|
|
60
|
+
* The driver writes its own pin while provisioning, carrying things only it
|
|
61
|
+
* learns: the host key fingerprint it pinned, the address the box reports on
|
|
62
|
+
* the local network, which bootstrap version ran. A caller that then writes its
|
|
63
|
+
* own pin over the top silently discards them. Only keys this caller actually
|
|
64
|
+
* has a value for are allowed to win.
|
|
65
|
+
*/
|
|
66
|
+
export declare function mergeSshStatePin(existing: Record<string, unknown> | null | undefined, next: Record<string, unknown>): Record<string, unknown>;
|
|
67
|
+
/** The persisted pin an SSH deploy writes so the next one skips discovery. */
|
|
68
|
+
export declare function sshStatePin(input: {
|
|
69
|
+
stackName: string
|
|
70
|
+
target: SshTarget
|
|
71
|
+
deployStoragePath?: string
|
|
72
|
+
hostKeyFingerprint?: string
|
|
73
|
+
lanIp?: string
|
|
74
|
+
bootstrapVersion?: number
|
|
75
|
+
}): Record<string, unknown>;
|
|
76
|
+
/**
|
|
77
|
+
* Where to reach the app on the local network after a LAN deploy.
|
|
78
|
+
*
|
|
79
|
+
* The gateway answers on 443 for the hostname it holds a local certificate for,
|
|
80
|
+
* so that is the address to lead with. Each site's own port is listed too: only
|
|
81
|
+
* one name resolves over mDNS, so a second site is reachable by port until the
|
|
82
|
+
* user gives it a name their router or hosts file can resolve.
|
|
83
|
+
*/
|
|
84
|
+
export declare function lanUrls(sites: Record<string, { domain?: string | string[], port?: number } | null | undefined> | undefined, target: SshTarget, lanHostname?: string): string[];
|
|
85
|
+
/** What to call this deploy target in log lines the user reads. */
|
|
86
|
+
export declare function deployTargetLabel(provider: string, profile?: SshProfile): string;
|
|
87
|
+
/**
|
|
88
|
+
* The fleet of an `ssh` project, assembled without a provider API.
|
|
89
|
+
*
|
|
90
|
+
* There is nothing to enumerate: a host is in the fleet because the config or a
|
|
91
|
+
* previous deploy's state pin names it. Hosts are keyed by address so a pin and
|
|
92
|
+
* the config entry it came from do not list the same box twice, and the config
|
|
93
|
+
* wins, because it is what the next deploy will actually use.
|
|
94
|
+
*/
|
|
95
|
+
export declare function sshFleetFromConfigAndState(tsCloudConfig: { ssh?: SshConfigBlock, project?: { slug?: string } } | undefined, pins?: Array<Record<string, unknown>>): SshInventoryServer[];
|
|
96
|
+
/** Everything needed to open an SSH connection to the deploy target. */
|
|
97
|
+
export declare interface SshTarget {
|
|
98
|
+
host: string
|
|
99
|
+
user: string
|
|
100
|
+
port: number
|
|
101
|
+
identityFile?: string
|
|
102
|
+
profile: SshProfile
|
|
103
|
+
hostKey: SshHostKeyPolicy
|
|
104
|
+
}
|
|
105
|
+
/** One host as declared in `ssh.hosts`. */
|
|
106
|
+
export declare interface SshHostConfig {
|
|
107
|
+
host?: string
|
|
108
|
+
user?: string
|
|
109
|
+
port?: number
|
|
110
|
+
privateKeyPath?: string
|
|
111
|
+
role?: string
|
|
112
|
+
}
|
|
113
|
+
/** The `ssh` block of a ts-cloud config. */
|
|
114
|
+
export declare interface SshConfigBlock {
|
|
115
|
+
hosts?: SshHostConfig[]
|
|
116
|
+
hostKey?: SshHostKeyPolicy
|
|
117
|
+
sudo?: boolean
|
|
118
|
+
profile?: SshProfile
|
|
119
|
+
publicIp?: 'auto' | string
|
|
120
|
+
lan?: { hostname?: string, tls?: 'local-ca' | 'off' }
|
|
121
|
+
}
|
|
122
|
+
/** One server as the fleet listing reports it. */
|
|
123
|
+
export declare interface SshInventoryServer {
|
|
124
|
+
id: string
|
|
125
|
+
name: string
|
|
126
|
+
status: string
|
|
127
|
+
ipv4?: string
|
|
128
|
+
ipv6?: string
|
|
129
|
+
type?: string
|
|
130
|
+
location?: string
|
|
131
|
+
labels: Record<string, string>
|
|
132
|
+
project?: string
|
|
133
|
+
environment?: string
|
|
134
|
+
role?: string
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Where an SSH deploy is going, and what it is allowed to do when it gets there.
|
|
138
|
+
*
|
|
139
|
+
* `buddy deploy` has two SSH-shaped targets: a Hetzner box ts-cloud provisioned
|
|
140
|
+
* and a plain Linux host somebody already owns (`cloud.provider: 'ssh'`, a
|
|
141
|
+
* Raspberry Pi being the case this was built for). Everything they differ on is
|
|
142
|
+
* decided here, in pure functions, so the decisions can be tested without a box:
|
|
143
|
+
* which host and user to talk to, whether the address is one the public DNS may
|
|
144
|
+
* ever hear about, and what to tell the user at the end.
|
|
145
|
+
*
|
|
146
|
+
* The Hetzner path keeps its exact behaviour. `hetznerTarget` reproduces the
|
|
147
|
+
* `root@<ip>` on port 22 the raw `execSync('ssh ...')` call sites built inline,
|
|
148
|
+
* argument for argument, so routing them through here changes nothing for it.
|
|
149
|
+
*/
|
|
150
|
+
/** A provider whose deploy runs over SSH rather than CloudFormation. */
|
|
151
|
+
export type SshPipelineProvider = 'hetzner' | 'ssh';
|
|
152
|
+
/** Tuning that only applies to a small single-board computer. */
|
|
153
|
+
export type SshProfile = 'raspberry-pi' | 'generic';
|
|
154
|
+
/** How much the deploy trusts a host key it has not seen before. */
|
|
155
|
+
export type SshHostKeyPolicy = 'pin' | 'accept-new' | 'insecure';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const DEFAULT_SSH_PORT=22,DEFAULT_CONNECT_TIMEOUT_SECS=15;export function isSshPipelineProvider(provider){return provider==="hetzner"||provider==="ssh"}function expandHome(value,env){if(!value.startsWith("~/"))return value;const home=env.HOME||env.USERPROFILE;return home?`${home}/${value.slice(2)}`:value}export function hetznerTarget(ip){return{host:ip,user:"root",port:DEFAULT_SSH_PORT,profile:"generic",hostKey:"accept-new"}}export function toSshTarget(value){return typeof value==="string"?hetznerTarget(value):value}export function resolveSshTarget(tsCloudConfig,env=process.env){const ssh=tsCloudConfig?.ssh,declared=(Array.isArray(ssh?.hosts)?ssh.hosts:[]).find((entry)=>!entry?.role||entry.role==="app"),host=env.TS_CLOUD_SSH_HOST||declared?.host;if(!host)return null;const user=env.TS_CLOUD_SSH_USER||declared?.user||"root",envPort=env.TS_CLOUD_SSH_PORT?Number.parseInt(env.TS_CLOUD_SSH_PORT,10):Number.NaN,configPort=Number(declared?.port),port=Number.isFinite(envPort)&&envPort>0?envPort:Number.isFinite(configPort)&&configPort>0?configPort:DEFAULT_SSH_PORT,key=env.TS_CLOUD_SSH_KEY||declared?.privateKeyPath,hostKeyRaw=env.TS_CLOUD_SSH_HOST_KEY||ssh?.hostKey,hostKey=hostKeyRaw==="accept-new"||hostKeyRaw==="insecure"?hostKeyRaw:"pin",profile=(env.TS_CLOUD_SSH_PROFILE||ssh?.profile)==="raspberry-pi"?"raspberry-pi":"generic";return{host,user,port,identityFile:key?expandHome(key,env):void 0,profile,hostKey}}export function sshCliArgs(target,options={}){const timeout=options.connectTimeoutSec??DEFAULT_CONNECT_TIMEOUT_SECS,args=[];if(target.hostKey==="insecure")args.push("-o","StrictHostKeyChecking=no","-o","UserKnownHostsFile=/dev/null");else if(target.hostKey==="pin"&&options.knownHostsFile)args.push("-o","StrictHostKeyChecking=yes","-o",`UserKnownHostsFile=${options.knownHostsFile}`);else{args.push("-o","StrictHostKeyChecking=accept-new");if(options.knownHostsFile)args.push("-o",`UserKnownHostsFile=${options.knownHostsFile}`)}args.push("-o","BatchMode=yes","-o",`ConnectTimeout=${timeout}`);if(target.port!==DEFAULT_SSH_PORT)args.push("-p",String(target.port));if(target.identityFile)args.push("-i",target.identityFile);args.push(`${target.user}@${target.host}`);return args}export function remoteExecOptions(target,connectTimeoutSec=DEFAULT_CONNECT_TIMEOUT_SECS){return{user:target.user,...target.port!==DEFAULT_SSH_PORT?{port:target.port}:{},...target.identityFile?{identityFile:target.identityFile}:{},connectTimeoutSec}}export function isPrivateHost(host){const value=String(host||"").trim().toLowerCase().replace(/^\[|\]$/g,"");if(!value)return!0;const v4=/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(value);if(v4){const[a,b]=[Number(v4[1]),Number(v4[2])];if(a===10||a===127||a===0)return!0;if(a===172&&b>=16&&b<=31)return!0;if(a===192&&b===168)return!0;if(a===100&&b>=64&&b<=127)return!0;if(a===169&&b===254)return!0;return!1}if(value.includes(":")){if(value==="::1"||value==="::")return!0;if(/^f[cd]/.test(value)||/^fe[89ab]/.test(value))return!0;return!1}if(value==="localhost")return!0;if(/\.(local|localhost|internal|lan|intranet|home\.arpa)$/.test(value))return!0;return!value.includes(".")}export function dnsPublishingAllowed(input){if(input.provider!=="ssh")return!0;const override=(input.env??process.env).TS_CLOUD_SSH_PUBLISH_DNS;if(override==="0"||override==="false")return!1;if(!Object.values(input.sites??{}).some((site)=>{const domain=site?.domain;return Array.isArray(domain)?domain.length>0:Boolean(domain)}))return!1;if(override==="1"||override==="true")return!0;return Boolean(input.publicIp)&&!isPrivateHost(input.publicIp)}export function mergeSshStatePin(existing,next){if(!existing||existing.provider!=="ssh")return next;const merged={...existing};for(const[key,value]of Object.entries(next))if(value!==void 0&&value!==null&&value!=="")merged[key]=value;return merged}export function sshStatePin(input){return{provider:"ssh",stackName:input.stackName,host:input.target.host,publicIp:input.target.host,sshUser:input.target.user,sshPort:input.target.port,...input.target.identityFile?{sshPrivateKeyPath:input.target.identityFile}:{},...input.hostKeyFingerprint?{hostKeyFingerprint:input.hostKeyFingerprint}:{},...input.lanIp?{lanIp:input.lanIp}:{},profile:input.target.profile,deployStoragePath:input.deployStoragePath??"/var/ts-cloud/staging",...input.bootstrapVersion?{bootstrapVersion:input.bootstrapVersion}:{}}}export function lanUrls(sites,target,lanHostname){const gatewayHost=lanHostname||target.host,urls=[`https://${gatewayHost}`];for(const site of Object.values(sites??{})){const port=Number(site?.port);if(Number.isFinite(port)&&port>0)urls.push(`http://${gatewayHost}:${port}`)}return urls}export function deployTargetLabel(provider,profile){if(provider==="hetzner")return"Hetzner Cloud";if(provider==="ssh")return profile==="raspberry-pi"?"Raspberry Pi over SSH":"SSH host";return provider}export function sshFleetFromConfigAndState(tsCloudConfig,pins=[]){const project=tsCloudConfig?.project?.slug,byHost=new Map;for(const pin of pins){if(pin?.provider!=="ssh")continue;const host=typeof pin.host==="string"?pin.host:typeof pin.publicIp==="string"?pin.publicIp:"";if(!host)continue;const stackName=typeof pin.stackName==="string"?pin.stackName:"";byHost.set(host,{id:host,name:stackName||host,status:"unknown",ipv4:isPrivateHost(host)?void 0:host,labels:{},project,environment:stackName.includes("-")?stackName.slice(stackName.lastIndexOf("-")+1):void 0,role:"app"})}for(const entry of tsCloudConfig?.ssh?.hosts??[]){if(!entry?.host)continue;const existing=byHost.get(entry.host);byHost.set(entry.host,{...existing,id:entry.host,name:existing?.name||entry.host,status:existing?.status||"unknown",ipv4:isPrivateHost(entry.host)?void 0:entry.host,labels:existing?.labels??{},project,role:entry.role||"app"})}return[...byHost.values()].sort((a,b)=>a.name.localeCompare(b.name))}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { env } from '@stacksjs/env';
|
|
2
2
|
import type { CLI } from '@stacksjs/types';
|
|
3
|
+
import type { SshTarget } from './deploy-ssh-target';
|
|
3
4
|
export declare function resolveTsCloudCliPath(tsCloudEntry?: unknown): string;
|
|
4
5
|
export declare function runDeployRollback(site: string | undefined, options: DeployRollbackOptions, execute?: (command: string[]) => Promise<number>): Promise<number>;
|
|
5
6
|
/**
|
|
@@ -8,6 +9,10 @@ export declare function runDeployRollback(site: string | undefined, options: Dep
|
|
|
8
9
|
* pure AWS setups that only export the legacy `CloudConfig`).
|
|
9
10
|
*/
|
|
10
11
|
export declare function loadTsCloudConfig(envName?: string): Promise<TsCloudConfig | undefined>;
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the cloud provider from a ts-cloud config (defaults to aws).
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveProvider(tsCloudConfig: any): string;
|
|
11
16
|
/**
|
|
12
17
|
* Why the last attempt failed, as one line fit for an error message.
|
|
13
18
|
*
|
|
@@ -130,7 +135,7 @@ export declare function orphanedFragmentDomains(fragment: string, ours: Iterable
|
|
|
130
135
|
* fragment still serving a dead release. It is config rather than a flag so
|
|
131
136
|
* the decision stays in git, next to the sites it used to sit among.
|
|
132
137
|
*/
|
|
133
|
-
export declare function assertFragmentIsOurs(
|
|
138
|
+
export declare function assertFragmentIsOurs(where: string | SshTarget, tsCloudConfig: any, log: { error: (m: string) => void, info: (m: string) => void }): Promise<void>;
|
|
134
139
|
/**
|
|
135
140
|
* Refuse to start a site on a port another tenant is already serving.
|
|
136
141
|
*
|
|
@@ -146,7 +151,7 @@ export declare function assertFragmentIsOurs(ip: string, tsCloudConfig: any, log
|
|
|
146
151
|
* Ports already held by THIS project's own units are fine — that is a redeploy
|
|
147
152
|
* replacing itself.
|
|
148
153
|
*/
|
|
149
|
-
export declare function assertPortsAreFree(
|
|
154
|
+
export declare function assertPortsAreFree(where: string | SshTarget, tsCloudConfig: any, log: { error: (m: string) => void, info: (m: string) => void }): Promise<void>;
|
|
150
155
|
export declare function resolveDeployEnvValues(environment: 'production' | 'staging' | 'development', tsCloudConfig?: { project?: { slug?: string } }): Promise<Record<string, string>>;
|
|
151
156
|
/**
|
|
152
157
|
* Merge the deploy-target's resolved env values underneath each site's own
|
|
@@ -433,7 +438,7 @@ export declare function mailServerOwnerFromConfig(config: { server?: { attachTo?
|
|
|
433
438
|
/**
|
|
434
439
|
* The Hetzner token, resolved the same way everywhere.
|
|
435
440
|
*
|
|
436
|
-
* `
|
|
441
|
+
* `deployOverSsh` accepted `hetzner.apiToken` from the config or either env
|
|
437
442
|
* var, while `resolveAttachTargetBox` read only `process.env.HCLOUD_TOKEN`. A
|
|
438
443
|
* project that configured the token in `config/cloud.ts`, or set only
|
|
439
444
|
* `HETZNER_API_TOKEN`, therefore passed the token check at the top of the deploy
|
|
@@ -679,6 +684,14 @@ export declare interface TsCloudConfig {
|
|
|
679
684
|
project?: { name?: string, slug?: string, region?: string }
|
|
680
685
|
cloud?: { attachTo?: string, retiredDomains?: unknown, provider?: string }
|
|
681
686
|
hetzner?: { apiToken?: string, location?: string }
|
|
687
|
+
ssh?: {
|
|
688
|
+
hosts?: Array<{ host?: string, user?: string, port?: number, privateKeyPath?: string, role?: string }>
|
|
689
|
+
hostKey?: 'pin' | 'accept-new' | 'insecure'
|
|
690
|
+
sudo?: boolean
|
|
691
|
+
profile?: 'raspberry-pi' | 'generic'
|
|
692
|
+
publicIp?: 'auto' | string
|
|
693
|
+
lan?: { hostname?: string, tls?: 'local-ca' | 'off' }
|
|
694
|
+
}
|
|
682
695
|
infrastructure?: TsCloudInfrastructure
|
|
683
696
|
sites?: Record<string, TsCloudSite | null | undefined>
|
|
684
697
|
environments?: Record<string, { domainPrefix?: string, region?: string } | undefined>
|
|
@@ -687,7 +700,7 @@ export declare interface TsCloudConfig {
|
|
|
687
700
|
[key: string]: unknown
|
|
688
701
|
}
|
|
689
702
|
declare interface AttachedComputeBox {
|
|
690
|
-
serverId
|
|
703
|
+
serverId?: number
|
|
691
704
|
serverName: string
|
|
692
705
|
publicIp: string
|
|
693
706
|
publicIpv6?: string
|
|
@@ -709,7 +722,7 @@ export declare interface MailTenantResult {
|
|
|
709
722
|
* mean either lying about the pin or re-checking it at every use.
|
|
710
723
|
*/
|
|
711
724
|
export declare interface AttachTargetBox {
|
|
712
|
-
serverId
|
|
725
|
+
serverId?: number
|
|
713
726
|
serverName: string
|
|
714
727
|
publicIp?: string
|
|
715
728
|
publicIpv6?: string
|