@stacksjs/buddy 0.73.2 → 0.73.3

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.
@@ -1,2 +1,11 @@
1
1
  import type { CLI } from '@stacksjs/types';
2
2
  export declare function cloud(buddy: CLI): void;
3
+ /**
4
+ * What happened to `config/cloud.ts` when an attach was applied.
5
+ *
6
+ * `refused` is not a failure of the attach: ts-cloud's editor only rewrites the
7
+ * shape the templates generate and reports anything else, so the operator makes
8
+ * a one-line edit by hand instead of the tool guessing at their file.
9
+ */
10
+ declare type AttachEditOutcome = | { state: 'written' | 'would-write' | 'already-set' }
11
+ | { state: 'refused', reason: string }
@@ -1,2 +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}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{declaredSites,describeInventory,listProviderServers,probeHostRoutes}=await import("../cloud-inventory"),{loadTsCloudConfig,resolveHetznerApiToken}=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",provider=tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws";if(provider!=="hetzner"){await log.info("The on-box half (the rpx gateway registry) is provider-independent; the server listing is not yet.");await log.exit(`\`buddy cloud:sites\` can only list Hetzner servers, and this project's provider is '${provider}'.`,ExitCode.FatalError)}let helpers={};try{const deployApi=await import("@stacksjs/ts-cloud/deploy");helpers={resolveSiteKind:deployApi.resolveSiteKind,siteInstallBase:deployApi.siteInstallBase}}catch(error){log.debug("Could not load @stacksjs/ts-cloud/deploy for site classification:",error)}const declared=declaredSites(tsCloudConfig?.sites,helpers,slug),listing=await listProviderServers(resolveHetznerApiToken(tsCloudConfig)),probes=[];if(options.remote!==!1){const{sshExec}=await import("@stacksjs/ts-cloud"),batchSize=6;for(let index=0;index<listing.servers.length;index+=batchSize)probes.push(...await Promise.all(listing.servers.slice(index,index+batchSize).map((server)=>probeHostRoutes(server,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})))))}const inventory={slug,environment,servers:listing.servers,probes,declared,providerFailure:listing.failure};if(options.json)console.log(JSON.stringify(inventory,null,2));else for(const line of describeInventory(inventory))console.log(line);process.exit(listing.failure&&listing.servers.length===0?ExitCode.FatalError:ExitCode.Success)});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{declaredSites,listProviderServers,probeHostRoutes}=await import("../cloud-inventory"),{attachConflicts,attachPreconditions,describeAttachPlan,resolveAttachTarget,setAttachTo}=await import("../cloud-attach"),{loadTsCloudConfig,resolveHetznerApiToken}=await import("./deploy"),refuse=async(...messages)=>{for(const message of messages.slice(0,-1))await log.error(message);return log.exit(messages[messages.length-1],ExitCode.FatalError)};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",listing=await listProviderServers(resolveHetznerApiToken(tsCloudConfig));if(listing.failure){const{describeProviderFailure}=await import("../cloud-inventory");return await refuse(describeProviderFailure(listing.failure))}const target=resolveAttachTarget(listing.servers,String(options.server),environment);if("problem"in target)return await refuse(target.problem);const server=target.server,preconditions=attachPreconditions(slug,server);if(preconditions.length>0)return await refuse(...preconditions);let helpers={};try{const deployApi=await import("@stacksjs/ts-cloud/deploy");helpers={resolveSiteKind:deployApi.resolveSiteKind,siteInstallBase:deployApi.siteInstallBase}}catch(error){log.debug("Could not load @stacksjs/ts-cloud/deploy for site classification:",error)}const declared=declaredSites(tsCloudConfig?.sites,helpers,slug),{sshExec}=await import("@stacksjs/ts-cloud"),probe=await probeHostRoutes(server,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})),conflicts=probe.unavailable?[]:attachConflicts(slug,declared,probe.routes),blocked=Boolean(probe.unavailable)||conflicts.length>0;let edit;if(!blocked){const configPath=p.projectPath("config/cloud.ts"),{readFileSync,writeFileSync}=await import("node:fs");edit=setAttachTo(readFileSync(configPath,"utf8"),server.project);if(!options.dryRun&&"text"in edit&&edit.changed)writeFileSync(configPath,edit.text)}const plan={slug,owner:server.project,server,declared,conflicts,registryRead:!probe.unavailable,registryProblem:probe.unavailable,edit,dryRun:Boolean(options.dryRun)};if(options.json){const edited=!edit?void 0:("problem"in edit)?edit:{changed:edit.changed};console.log(JSON.stringify({...plan,edit:edited},null,2))}else for(const line of describeAttachPlan(plan))console.log(line);process.exit(blocked?ExitCode.FatalError:ExitCode.Success)});onUnknownSubcommand(buddy,"cloud")}
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(`
3
+ `))}process.exit(viable?ExitCode.Success:ExitCode.FatalError);async function applyAttachToConfig(owner,dryRun){const configPath=p.projectPath("config/cloud.ts"),{readFileSync,writeFileSync}=await import("node:fs"),before=readFileSync(configPath,"utf8");try{const after=setAttachToInCloudConfig({configText:before,owner});if(after===before)return{state:"already-set"};if(dryRun)return{state:"would-write"};writeFileSync(configPath,after);return{state:"written"}}catch(error){return{state:"refused",reason:error instanceof Error?error.message:String(error)}}}});onUnknownSubcommand(buddy,"cloud")}
@@ -1 +1 @@
1
- import process from"node:process";import{log,onUnknownSubcommand}from"@stacksjs/cli";import{decryptEnv,encryptEnv,getEnv,getKeypair,resolveEnvFile,rotateKeypair,setEnv}from"@stacksjs/env";import{ExitCode}from"@stacksjs/types";export function env(buddy){const descriptions={env:"Interact with your environment variables",get:"Get an environment variable",set:"Set an environment variable",encrypt:"Encrypt a value",decrypt:"Decrypt a value",check:"Check environment configuration and validate setup",pretty:"Pretty print the result",stdout:"Output the result to stdout",keypair:"Generate a keypair",fileKeys:"The path to the file containing the keys",excludeKey:"The key to exclude from encryption",rotate:"Rotate a keypair",format:"The format to output the result (json, shell, eval)",all:"Get all environment variables",file:"The environment file to use",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("env:get [key]",descriptions.get).option("-f, --file [file]",descriptions.file,{default:""}).option("--format [format]",descriptions.format,{default:"json"}).option("-a, --all",descriptions.all,{default:!1}).option("-p, --pretty",descriptions.pretty,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:get SECRET").example("buddy env:get SECRET --file .env.production").example("buddy env:get --all --pretty").example("buddy env:get --format shell").example("buddy env:get --format eval").example("buddy env:get --format json").action(async(key,options)=>{log.debug("Running `buddy env:get` ...",options);const result=getEnv(key,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,all:options.all,format:options.format,prettyPrint:options.pretty});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:set [key] [value]",descriptions.set).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("--plain","Don't encrypt the value",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:set SECRET=value").example("buddy env:set SECRET value").example("buddy env:set SECRET value --file .env.production").example("buddy env:set SECRET value --plain").action(async(key,value,options)=>{log.debug("Running `buddy env:set` ...",options);if(!key){console.error("A key is required. Pass an empty value to clear it: `buddy env:set KEY ''`");process.exit(ExitCode.FatalError)}if(value===void 0){console.error(`No value given for ${key}. Pass one, or an empty string to clear it: \`buddy env:set ${key} ''\``);process.exit(ExitCode.FatalError)}const result=setEnv(key,value,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,plain:options.plain});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:encrypt [key]",descriptions.encrypt).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:encrypt").example("buddy env:encrypt --file .env.production").example('buddy env:encrypt -k "SECRET_*"').action(async(key,options)=>{log.debug("Running `buddy env:encrypt` ...",options);const result=encryptEnv({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,excludeKey:options.excludeKey,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:decrypt [key]",descriptions.decrypt).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).example("buddy env:decrypt").example("buddy env:decrypt --file .env.production").example('buddy env:decrypt -k "SECRET_*"').action(async(key,options)=>{log.debug("Running `buddy env:decrypt` ...",options);const result=decryptEnv({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:keypair [key]",descriptions.keypair).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("--format [format]",descriptions.format,{default:"json"}).example("buddy env:keypair").example("buddy env:keypair --file .env.production").example("buddy env:keypair DOTENV_PRIVATE_KEY").action(async(key,options)=>{log.debug("Running `buddy env:keypair` ...",options);const result=getKeypair(key,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,format:options.format});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:rotate [key]",descriptions.rotate).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:rotate").example("buddy env:rotate --file .env.production").action(async(key,options)=>{log.debug("Running `buddy env:rotate` ...",options);const result=rotateKeypair({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,excludeKey:options.excludeKey,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:check",descriptions.check).option("-f, --file [file]",descriptions.file,{default:""}).option("--strict","Require every value in a committed env file to be encrypted, not just secret-shaped ones",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:check").example("buddy env:check --file .env.production").example("buddy env:check --file .env.production --strict").action(async(options)=>{log.debug("Running `buddy env:check` ...",options);const{bold,dim,green,red,yellow,intro}=await import("@stacksjs/cli"),{storage}=await import("@stacksjs/storage"),{existsSync}=await import("node:fs"),{resolve}=await import("node:path");await intro("buddy env:check");const checks=[],envFile=options.file||".env",envPath=resolve(process.cwd(),envFile);if(existsSync(envPath)){checks.push({name:`${envFile} file`,status:"pass",message:"Found"});try{const envContent=await storage.readTextFile(envPath),contentStr=typeof envContent==="string"?envContent:envContent.data,values=parseEnvAssignments(contentStr),keys=Object.keys(values),varCount=keys.length;checks.push({name:"Environment variables",status:"pass",message:`${varCount} variables defined`});if("APP_KEY"in values)if((values.APP_KEY??"").length>0)checks.push({name:"APP_KEY",status:"pass",message:"Set"});else checks.push({name:"APP_KEY",status:"warn",message:"Empty (run: buddy key:generate)"});else checks.push({name:"APP_KEY",status:"warn",message:"Not found (run: buddy key:generate)"});const hasPublicKey=keys.some((key)=>key.startsWith("DOTENV_PUBLIC_KEY")),hasPrivateKey=keys.some((key)=>key.startsWith("DOTENV_PRIVATE_KEY"))||existsSync(resolve(process.cwd(),".env.keys"));if(hasPublicKey&&hasPrivateKey)checks.push({name:"Encryption keys",status:"pass",message:"Public and private keys configured"});else if(hasPublicKey||hasPrivateKey)checks.push({name:"Encryption keys",status:"warn",message:"Incomplete keypair (run: buddy env:keypair)"});else checks.push({name:"Encryption keys",status:"warn",message:"Not configured (optional)"});const declaredTenants=await resolveDeclaredTenants();if(declaredTenants.tenants.length===0)checks.push({name:"Tenant isolation",status:"pass",message:"No tenants declared (cloud.tenants)"});else{const{foreignTenantKeys,partitionTenantEnv}=await import("@stacksjs/env"),foreign=foreignTenantKeys(partitionTenantEnv(values,declaredTenants)),isShipped=/^\.env\.[a-z]+$/.test(envFile),total=foreign.reduce((sum,entry)=>sum+entry.keys.length,0);if(foreign.length===0)checks.push({name:"Tenant isolation",status:"pass",message:`No foreign keys (checked ${declaredTenants.tenants.join(", ")})`});else if(!isShipped)checks.push({name:"Tenant isolation",status:"pass",message:`${total} archived key(s) - ${envFile} is local-only and never deployed`});else{checks.push({name:"Tenant isolation",status:"warn",message:`${total} key(s) belong to another tenant - move them to .env`});for(const{tenant,keys}of foreign)checks.push({name:` ${tenant}`,status:"warn",message:keys.join(", ")})}}const{plaintextSecrets,trackedEnvFiles}=await import("@stacksjs/env");if(!trackedEnvFiles(gitLsFiles()).includes(envFile))checks.push({name:"Committed secrets",status:"pass",message:`${envFile} is not committed; plaintext here stays local`});else{let placeholders={};try{const examplePath=resolve(process.cwd(),".env.example");if(existsSync(examplePath))placeholders=parseEnvAssignments(await storage.readTextFile(examplePath).then((f)=>f.data))}catch{}const leaked=plaintextSecrets(values,{placeholders,strict:options.strict});if(leaked.length===0)checks.push({name:"Committed secrets",status:"pass",message:`No unencrypted secrets in ${envFile}`});else checks.push({name:"Committed secrets",status:"fail",message:`${leaked.length} unencrypted secret${leaked.length===1?"":"s"} in committed ${envFile}: ${leaked.map((f)=>f.key).join(", ")}. Run \`buddy env:encrypt\`, then rotate them - they are in git history.`})}}catch(error){checks.push({name:`${envFile} content`,status:"fail",message:`Cannot read file: ${error}`})}}else checks.push({name:`${envFile} file`,status:"fail",message:"Not found"});const keysPath=resolve(process.cwd(),".env.keys");if(existsSync(keysPath))checks.push({name:".env.keys file",status:"pass",message:"Found"});else checks.push({name:".env.keys file",status:"warn",message:"Not found (optional for encryption)"});console.log("");console.log(bold("Environment Configuration Check:"));console.log(dim("\u2500".repeat(60)));console.log("");let hasFailures=!1,hasWarnings=!1;for(const check of checks){let statusIcon="",statusColor=(text)=>text;if(check.status==="pass"){statusIcon="\u2713";statusColor=green}else if(check.status==="warn"){statusIcon="\u26A0";statusColor=yellow;hasWarnings=!0}else{statusIcon="\u2717";statusColor=red;hasFailures=!0}console.log(`${statusColor(statusIcon)} ${bold(check.name.padEnd(25))} ${dim(check.message)}`)}console.log("");console.log(dim("\u2500".repeat(60)));console.log("");if(hasFailures){console.log(red("\u2717 Some critical checks failed. Please address the issues above."));process.exit(ExitCode.FatalError)}else if(hasWarnings){console.log(yellow("\u26A0 Some checks have warnings. Your environment should work but may have issues."));process.exit(ExitCode.Success)}else{console.log(green("\u2713 All checks passed! Your environment configuration looks healthy."));process.exit(ExitCode.Success)}});onUnknownSubcommand(buddy,"env")}async function resolveDeclaredTenants(){try{const{config}=await import("@stacksjs/config"),cloud=config.cloud,app=config.app,tenants=Array.isArray(cloud?.tenants)?cloud.tenants.filter((slug)=>typeof slug==="string"):[];return{self:app?.name,tenants}}catch{return{tenants:[]}}}function gitLsFiles(){try{const result=Bun.spawnSync(["git","ls-files"],{cwd:process.cwd(),stdout:"pipe",stderr:"ignore"});return result.success?result.stdout.toString():""}catch{return""}}export function parseEnvAssignments(content){const values={},assignment=/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/i,lines=content.split(/\r?\n/);for(let i=0;i<lines.length;i++){const line=lines[i];if(!line.trim()||line.trimStart().startsWith("#"))continue;const match=line.match(assignment);if(!match)continue;const[,key,first]=match;let raw=first.trim();const quote=raw[0]==='"'||raw[0]==="'"?raw[0]:void 0;if(quote){raw=raw.slice(1);while(!raw.endsWith(quote)&&i+1<lines.length){i++;raw+=lines[i]}raw=raw.endsWith(quote)?raw.slice(0,-1):raw}else raw=raw.replace(/\s+#.*$/,"").trim();values[key]=raw}return values}
1
+ import process from"node:process";import{log,onUnknownSubcommand}from"@stacksjs/cli";import{decryptEnv,encryptEnv,getEnv,getKeypair,resolveEnvFile,rotateKeypair,setEnv}from"@stacksjs/env";import{ExitCode}from"@stacksjs/types";export function env(buddy){const descriptions={env:"Interact with your environment variables",get:"Get an environment variable",set:"Set an environment variable",encrypt:"Encrypt a value",decrypt:"Decrypt a value",check:"Check environment configuration and validate setup",pretty:"Pretty print the result",stdout:"Output the result to stdout",keypair:"Generate a keypair",fileKeys:"The path to the file containing the keys",excludeKey:"The key to exclude from encryption",rotate:"Rotate a keypair",rotateStdout:"Print the rotated file and its new keypair without writing either",rotateDryRun:"Report what would change without writing anything",format:"The format to output the result (json, shell, eval)",all:"Get all environment variables",file:"The environment file to use",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("env:get [key]",descriptions.get).option("-f, --file [file]",descriptions.file,{default:""}).option("--format [format]",descriptions.format,{default:"json"}).option("-a, --all",descriptions.all,{default:!1}).option("-p, --pretty",descriptions.pretty,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:get SECRET").example("buddy env:get SECRET --file .env.production").example("buddy env:get --all --pretty").example("buddy env:get --format shell").example("buddy env:get --format eval").example("buddy env:get --format json").action(async(key,options)=>{log.debug("Running `buddy env:get` ...",options);const result=getEnv(key,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,all:options.all,format:options.format,prettyPrint:options.pretty});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:set [key] [value]",descriptions.set).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("--plain","Don't encrypt the value",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:set SECRET=value").example("buddy env:set SECRET value").example("buddy env:set SECRET value --file .env.production").example("buddy env:set SECRET value --plain").action(async(key,value,options)=>{log.debug("Running `buddy env:set` ...",options);if(!key){console.error("A key is required. Pass an empty value to clear it: `buddy env:set KEY ''`");process.exit(ExitCode.FatalError)}if(value===void 0){console.error(`No value given for ${key}. Pass one, or an empty string to clear it: \`buddy env:set ${key} ''\``);process.exit(ExitCode.FatalError)}const result=setEnv(key,value,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,plain:options.plain});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:encrypt [key]",descriptions.encrypt).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:encrypt").example("buddy env:encrypt --file .env.production").example('buddy env:encrypt -k "SECRET_*"').action(async(key,options)=>{log.debug("Running `buddy env:encrypt` ...",options);const result=encryptEnv({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,excludeKey:options.excludeKey,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:decrypt [key]",descriptions.decrypt).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).example("buddy env:decrypt").example("buddy env:decrypt --file .env.production").example('buddy env:decrypt -k "SECRET_*"').action(async(key,options)=>{log.debug("Running `buddy env:decrypt` ...",options);const result=decryptEnv({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:keypair [key]",descriptions.keypair).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("--format [format]",descriptions.format,{default:"json"}).example("buddy env:keypair").example("buddy env:keypair --file .env.production").example("buddy env:keypair DOTENV_PRIVATE_KEY").action(async(key,options)=>{log.debug("Running `buddy env:keypair` ...",options);const result=getKeypair(key,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,format:options.format});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:rotate [key]",descriptions.rotate).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.rotateStdout,{default:!1}).option("--dry-run",descriptions.rotateDryRun,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:rotate").example("buddy env:rotate --file .env.production").example("buddy env:rotate --file .env.production --dry-run").action(async(key,options)=>{log.debug("Running `buddy env:rotate` ...",options);const result=rotateKeypair({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,excludeKey:options.excludeKey,stdout:options.stdout,dryRun:options.dryRun});if(result.success){console.log(result.output);if(result.notice)console.error(result.notice);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:check",descriptions.check).option("-f, --file [file]",descriptions.file,{default:""}).option("--strict","Require every value in a committed env file to be encrypted, not just secret-shaped ones",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:check").example("buddy env:check --file .env.production").example("buddy env:check --file .env.production --strict").action(async(options)=>{log.debug("Running `buddy env:check` ...",options);const{bold,dim,green,red,yellow,intro}=await import("@stacksjs/cli"),{storage}=await import("@stacksjs/storage"),{existsSync}=await import("node:fs"),{resolve}=await import("node:path");await intro("buddy env:check");const checks=[],envFile=options.file||".env",envPath=resolve(process.cwd(),envFile);if(existsSync(envPath)){checks.push({name:`${envFile} file`,status:"pass",message:"Found"});try{const envContent=await storage.readTextFile(envPath),contentStr=typeof envContent==="string"?envContent:envContent.data,values=parseEnvAssignments(contentStr),keys=Object.keys(values),varCount=keys.length;checks.push({name:"Environment variables",status:"pass",message:`${varCount} variables defined`});if("APP_KEY"in values)if((values.APP_KEY??"").length>0)checks.push({name:"APP_KEY",status:"pass",message:"Set"});else checks.push({name:"APP_KEY",status:"warn",message:"Empty (run: buddy key:generate)"});else checks.push({name:"APP_KEY",status:"warn",message:"Not found (run: buddy key:generate)"});const hasPublicKey=keys.some((key)=>key.startsWith("DOTENV_PUBLIC_KEY")),hasPrivateKey=keys.some((key)=>key.startsWith("DOTENV_PRIVATE_KEY"))||existsSync(resolve(process.cwd(),".env.keys"));if(hasPublicKey&&hasPrivateKey)checks.push({name:"Encryption keys",status:"pass",message:"Public and private keys configured"});else if(hasPublicKey||hasPrivateKey)checks.push({name:"Encryption keys",status:"warn",message:"Incomplete keypair (run: buddy env:keypair)"});else checks.push({name:"Encryption keys",status:"warn",message:"Not configured (optional)"});const declaredTenants=await resolveDeclaredTenants();if(declaredTenants.tenants.length===0)checks.push({name:"Tenant isolation",status:"pass",message:"No tenants declared (cloud.tenants)"});else{const{foreignTenantKeys,partitionTenantEnv}=await import("@stacksjs/env"),foreign=foreignTenantKeys(partitionTenantEnv(values,declaredTenants)),isShipped=/^\.env\.[a-z]+$/.test(envFile),total=foreign.reduce((sum,entry)=>sum+entry.keys.length,0);if(foreign.length===0)checks.push({name:"Tenant isolation",status:"pass",message:`No foreign keys (checked ${declaredTenants.tenants.join(", ")})`});else if(!isShipped)checks.push({name:"Tenant isolation",status:"pass",message:`${total} archived key(s) - ${envFile} is local-only and never deployed`});else{checks.push({name:"Tenant isolation",status:"warn",message:`${total} key(s) belong to another tenant - move them to .env`});for(const{tenant,keys}of foreign)checks.push({name:` ${tenant}`,status:"warn",message:keys.join(", ")})}}const{plaintextSecrets,trackedEnvFiles}=await import("@stacksjs/env");if(!trackedEnvFiles(gitLsFiles()).includes(envFile))checks.push({name:"Committed secrets",status:"pass",message:`${envFile} is not committed; plaintext here stays local`});else{let placeholders={};try{const examplePath=resolve(process.cwd(),".env.example");if(existsSync(examplePath))placeholders=parseEnvAssignments(await storage.readTextFile(examplePath).then((f)=>f.data))}catch{}const leaked=plaintextSecrets(values,{placeholders,strict:options.strict});if(leaked.length===0)checks.push({name:"Committed secrets",status:"pass",message:`No unencrypted secrets in ${envFile}`});else checks.push({name:"Committed secrets",status:"fail",message:`${leaked.length} unencrypted secret${leaked.length===1?"":"s"} in committed ${envFile}: ${leaked.map((f)=>f.key).join(", ")}. Run \`buddy env:encrypt\`, then rotate them - they are in git history.`})}}catch(error){checks.push({name:`${envFile} content`,status:"fail",message:`Cannot read file: ${error}`})}}else checks.push({name:`${envFile} file`,status:"fail",message:"Not found"});const keysPath=resolve(process.cwd(),".env.keys");if(existsSync(keysPath))checks.push({name:".env.keys file",status:"pass",message:"Found"});else checks.push({name:".env.keys file",status:"warn",message:"Not found (optional for encryption)"});console.log("");console.log(bold("Environment Configuration Check:"));console.log(dim("\u2500".repeat(60)));console.log("");let hasFailures=!1,hasWarnings=!1;for(const check of checks){let statusIcon="",statusColor=(text)=>text;if(check.status==="pass"){statusIcon="\u2713";statusColor=green}else if(check.status==="warn"){statusIcon="\u26A0";statusColor=yellow;hasWarnings=!0}else{statusIcon="\u2717";statusColor=red;hasFailures=!0}console.log(`${statusColor(statusIcon)} ${bold(check.name.padEnd(25))} ${dim(check.message)}`)}console.log("");console.log(dim("\u2500".repeat(60)));console.log("");if(hasFailures){console.log(red("\u2717 Some critical checks failed. Please address the issues above."));process.exit(ExitCode.FatalError)}else if(hasWarnings){console.log(yellow("\u26A0 Some checks have warnings. Your environment should work but may have issues."));process.exit(ExitCode.Success)}else{console.log(green("\u2713 All checks passed! Your environment configuration looks healthy."));process.exit(ExitCode.Success)}});onUnknownSubcommand(buddy,"env")}async function resolveDeclaredTenants(){try{const{config}=await import("@stacksjs/config"),cloud=config.cloud,app=config.app,tenants=Array.isArray(cloud?.tenants)?cloud.tenants.filter((slug)=>typeof slug==="string"):[];return{self:app?.name,tenants}}catch{return{tenants:[]}}}function gitLsFiles(){try{const result=Bun.spawnSync(["git","ls-files"],{cwd:process.cwd(),stdout:"pipe",stderr:"ignore"});return result.success?result.stdout.toString():""}catch{return""}}export function parseEnvAssignments(content){const values={},assignment=/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/i,lines=content.split(/\r?\n/);for(let i=0;i<lines.length;i++){const line=lines[i];if(!line.trim()||line.trimStart().startsWith("#"))continue;const match=line.match(assignment);if(!match)continue;const[,key,first]=match;let raw=first.trim();const quote=raw[0]==='"'||raw[0]==="'"?raw[0]:void 0;if(quote){raw=raw.slice(1);while(!raw.endsWith(quote)&&i+1<lines.length){i++;raw+=lines[i]}raw=raw.endsWith(quote)?raw.slice(0,-1):raw}else raw=raw.replace(/\s+#.*$/,"").trim();values[key]=raw}return values}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/buddy",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.73.2",
5
+ "version": "0.73.3",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -95,63 +95,63 @@
95
95
  "prepublishOnly": "bun run build"
96
96
  },
97
97
  "dependencies": {
98
- "@stacksjs/actions": "^0.73.2",
99
- "@stacksjs/ai": "^0.73.2",
100
- "@stacksjs/alias": "^0.73.2",
101
- "@stacksjs/analytics": "^0.73.2",
102
- "@stacksjs/api": "^0.73.2",
103
- "@stacksjs/arrays": "^0.73.2",
104
- "@stacksjs/auth": "^0.73.2",
105
- "@stacksjs/browser-extension": "^0.73.2",
106
- "@stacksjs/build": "^0.73.2",
107
- "@stacksjs/cache": "^0.73.2",
108
- "@stacksjs/chat": "^0.73.2",
98
+ "@stacksjs/actions": "^0.73.3",
99
+ "@stacksjs/ai": "^0.73.3",
100
+ "@stacksjs/alias": "^0.73.3",
101
+ "@stacksjs/analytics": "^0.73.3",
102
+ "@stacksjs/api": "^0.73.3",
103
+ "@stacksjs/arrays": "^0.73.3",
104
+ "@stacksjs/auth": "^0.73.3",
105
+ "@stacksjs/browser-extension": "^0.73.3",
106
+ "@stacksjs/build": "^0.73.3",
107
+ "@stacksjs/cache": "^0.73.3",
108
+ "@stacksjs/chat": "^0.73.3",
109
109
  "@stacksjs/clapp": "^0.2.12",
110
- "@stacksjs/cli": "^0.73.2",
111
- "@stacksjs/cloud": "^0.73.2",
112
- "@stacksjs/cms": "^0.73.2",
113
- "@stacksjs/collections": "^0.73.2",
114
- "@stacksjs/config": "^0.73.2",
115
- "@stacksjs/database": "^0.73.2",
116
- "@stacksjs/desktop-build": "^0.73.2",
117
- "@stacksjs/dns": "^0.73.2",
110
+ "@stacksjs/cli": "^0.73.3",
111
+ "@stacksjs/cloud": "^0.73.3",
112
+ "@stacksjs/cms": "^0.73.3",
113
+ "@stacksjs/collections": "^0.73.3",
114
+ "@stacksjs/config": "^0.73.3",
115
+ "@stacksjs/database": "^0.73.3",
116
+ "@stacksjs/desktop-build": "^0.73.3",
117
+ "@stacksjs/dns": "^0.73.3",
118
118
  "@stacksjs/dnsx": "^0.2.3",
119
- "@stacksjs/email": "^0.73.2",
120
- "@stacksjs/enums": "^0.73.2",
121
- "@stacksjs/env": "^0.73.2",
122
- "@stacksjs/error-handling": "^0.73.2",
123
- "@stacksjs/events": "^0.73.2",
124
- "@stacksjs/git": "^0.73.2",
119
+ "@stacksjs/email": "^0.73.3",
120
+ "@stacksjs/enums": "^0.73.3",
121
+ "@stacksjs/env": "^0.73.3",
122
+ "@stacksjs/error-handling": "^0.73.3",
123
+ "@stacksjs/events": "^0.73.3",
124
+ "@stacksjs/git": "^0.73.3",
125
125
  "@stacksjs/gitit": "^0.2.5",
126
- "@stacksjs/health": "^0.73.2",
126
+ "@stacksjs/health": "^0.73.3",
127
127
  "@stacksjs/httx": "^0.1.10",
128
- "@stacksjs/image": "^0.73.2",
129
- "@stacksjs/lint": "^0.73.2",
130
- "@stacksjs/logging": "^0.73.2",
131
- "@stacksjs/notifications": "^0.73.2",
132
- "@stacksjs/objects": "^0.73.2",
133
- "@stacksjs/orm": "^0.73.2",
134
- "@stacksjs/path": "^0.73.2",
135
- "@stacksjs/payments": "^0.73.2",
136
- "@stacksjs/realtime": "^0.73.2",
137
- "@stacksjs/router": "^0.73.2",
128
+ "@stacksjs/image": "^0.73.3",
129
+ "@stacksjs/lint": "^0.73.3",
130
+ "@stacksjs/logging": "^0.73.3",
131
+ "@stacksjs/notifications": "^0.73.3",
132
+ "@stacksjs/objects": "^0.73.3",
133
+ "@stacksjs/orm": "^0.73.3",
134
+ "@stacksjs/path": "^0.73.3",
135
+ "@stacksjs/payments": "^0.73.3",
136
+ "@stacksjs/realtime": "^0.73.3",
137
+ "@stacksjs/router": "^0.73.3",
138
138
  "@stacksjs/rpx": "^0.11.42",
139
- "@stacksjs/scheduler": "^0.73.2",
140
- "@stacksjs/search-engine": "^0.73.2",
141
- "@stacksjs/security": "^0.73.2",
142
- "@stacksjs/server": "^0.73.2",
143
- "@stacksjs/sites": "^0.73.2",
144
- "@stacksjs/skills": "^0.73.2",
145
- "@stacksjs/storage": "^0.73.2",
146
- "@stacksjs/strings": "^0.73.2",
147
- "@stacksjs/testing": "^0.73.2",
148
- "@stacksjs/tinker": "^0.73.2",
139
+ "@stacksjs/scheduler": "^0.73.3",
140
+ "@stacksjs/search-engine": "^0.73.3",
141
+ "@stacksjs/security": "^0.73.3",
142
+ "@stacksjs/server": "^0.73.3",
143
+ "@stacksjs/sites": "^0.73.3",
144
+ "@stacksjs/skills": "^0.73.3",
145
+ "@stacksjs/storage": "^0.73.3",
146
+ "@stacksjs/strings": "^0.73.3",
147
+ "@stacksjs/testing": "^0.73.3",
148
+ "@stacksjs/tinker": "^0.73.3",
149
149
  "@stacksjs/ts-cloud": "^0.12.10",
150
- "@stacksjs/tunnel": "^0.73.2",
151
- "@stacksjs/types": "^0.73.2",
152
- "@stacksjs/ui": "^0.73.2",
153
- "@stacksjs/utils": "^0.73.2",
154
- "@stacksjs/validation": "^0.73.2",
150
+ "@stacksjs/tunnel": "^0.73.3",
151
+ "@stacksjs/types": "^0.73.3",
152
+ "@stacksjs/ui": "^0.73.3",
153
+ "@stacksjs/utils": "^0.73.3",
154
+ "@stacksjs/validation": "^0.73.3",
155
155
  "ajv": "^8.20.0",
156
156
  "ajv-formats": "^3.0.1",
157
157
  "ts-pantry": "^0.11.35"
@@ -1,93 +0,0 @@
1
- import type { DeclaredSite, HostedRoute, InventoryServer } from './cloud-inventory';
2
- /**
3
- * Pick the box named by `--server`, by provider name or by owning project.
4
- *
5
- * Both spellings are accepted because both are what an operator has: the
6
- * provider console shows `stacks-production-app`, while `cloud.attachTo` takes
7
- * the owner's slug (`stacks`). Matching either avoids making the operator
8
- * translate between them, and an ambiguous match refuses rather than guessing.
9
- */
10
- export declare function resolveAttachTarget(servers: readonly InventoryServer[], wanted: string, environment?: string): AttachTarget;
11
- /**
12
- * Reasons this attach must not proceed at all, independent of what is on the box.
13
- *
14
- * Separate from conflicts because these are about identity rather than
15
- * occupancy: no amount of moving ports would make them safe.
16
- */
17
- export declare function attachPreconditions(slug: string, server: InventoryServer): string[];
18
- /**
19
- * The port from an rpx upstream (`host:port`).
20
- *
21
- * Splits on the LAST colon so a bracketed IPv6 literal (`[::1]:3022`) parses as
22
- * port 3022 rather than as part of the address. Anything that is not a valid
23
- * TCP port yields nothing, so a malformed route narrows the map instead of
24
- * poisoning it.
25
- */
26
- export declare function parseUpstreamPort(upstream: string): number | undefined;
27
- /**
28
- * Every port the box already serves, mapped to the project that owns it.
29
- *
30
- * `ignoreSlug` is this project's own slug: a re-attach finds its own fragment
31
- * already on the box from the last deploy, and counting it would make every
32
- * repeat run conflict with itself. Only app routes are read - a static, redirect
33
- * or proxy route binds no port, and their targets are paths and URLs that
34
- * happen to contain colons.
35
- *
36
- * First writer wins, so two fragments disagreeing produce one stable owner
37
- * rather than an order-dependent one.
38
- */
39
- export declare function portOwners(routes: readonly HostedRoute[], ignoreSlug?: string): PortOwners;
40
- /**
41
- * Where this project's sites would land on top of another project's.
42
- *
43
- * Two independent collisions, and the port one is the dangerous half: a route
44
- * clash produces a visibly wrong page, while a port clash produces a working
45
- * box that serves the wrong site to about half its visitors with nothing logged.
46
- */
47
- export declare function attachConflicts(slug: string, declared: readonly DeclaredSite[], routes: readonly HostedRoute[]): AttachConflict[];
48
- /**
49
- * Set `cloud.attachTo` in a `config/cloud.ts`.
50
- *
51
- * Deliberately narrow. This edits TypeScript source with text, which is only
52
- * defensible while it refuses everything it does not certainly understand, so
53
- * it handles exactly the shape the scaffold generates:
54
- *
55
- * cloud: {
56
- * provider: 'hetzner',
57
- * },
58
- *
59
- * Anything else - two `cloud:` blocks, a nested object inside it, a one-line
60
- * form - is reported rather than rewritten, and the caller prints the edit for
61
- * a person to make. A config mangled by a clever regex is a far worse outcome
62
- * than a config the tool declined to touch. (ts-cloud has real editors for
63
- * this in `deploy/site-config-editor`, but they are not reachable from the
64
- * published package: stacksjs/ts-cloud#191.)
65
- */
66
- export declare function setAttachTo(configText: string, owner: string): AttachEdit;
67
- /** The plan an operator reads, as lines. */
68
- export declare function describeAttachPlan(plan: AttachPlan): string[];
69
- export declare interface AttachConflict {
70
- kind: 'port' | 'route'
71
- site: string
72
- detail: string
73
- heldBy: string
74
- }
75
- export declare interface AttachPlan {
76
- slug: string
77
- owner: string
78
- server: InventoryServer
79
- declared: readonly DeclaredSite[]
80
- conflicts: readonly AttachConflict[]
81
- registryRead: boolean
82
- registryProblem?: string
83
- edit?: AttachEdit
84
- dryRun: boolean
85
- }
86
- /** The server an attach would target, or why one could not be picked. */
87
- export type AttachTarget = | { server: InventoryServer }
88
- | { problem: string }
89
- /** A port on the box, and the project already serving it. */
90
- export type PortOwners = Map<number, string>;
91
- /** The result of editing `config/cloud.ts`, or why it was left alone. */
92
- export type AttachEdit = | { text: string, changed: boolean }
93
- | { problem: string }
@@ -1,7 +0,0 @@
1
- export function resolveAttachTarget(servers,wanted,environment){const target=wanted.trim();if(!target)return{problem:"No server named. Pass --server <name|owner-slug>."};const[named,...alsoNamed]=servers.filter((server)=>server.name===target);if(named&&alsoNamed.length===0)return{server:named};let byOwner=servers.filter((server)=>server.project===target);if(byOwner.length>1&&environment)byOwner=byOwner.filter((server)=>!server.environment||server.environment===environment);const[owned,...alsoOwned]=byOwner;if(owned&&alsoOwned.length===0)return{server:owned};if(byOwner.length>1)return{problem:`'${target}' owns ${byOwner.length} servers (${byOwner.map((server)=>server.name).join(", ")}). Name one of them with --server, or narrow it with --env.`};return{problem:`No server matched '${target}'. Nothing is named that, and no box carries the label ts-cloud/project=${target}. \`buddy cloud:sites\` lists what is there.`}}export function attachPreconditions(slug,server){const problems=[];if(!server.project)problems.push(`'${server.name}' carries no ts-cloud/project label, so it is not a box ts-cloud provisioned. Attaching to it would deploy into a host nothing here manages.`);else if(server.project===slug)problems.push(`This project's slug is '${slug}', which is also the slug that owns '${server.name}'. A tenant deploy owns /etc/rpx/sites.d/<slug>.json, so attaching would overwrite the owner's gateway fragment and take its sites down. Change this project's slug first.`);if(server.status!=="running")problems.push(`'${server.name}' is ${server.status}, so what it serves could not be read.`);if(!server.ipv4)problems.push(`'${server.name}' has no public IPv4 address, so it cannot be reached to check what it serves.`);return problems}export function parseUpstreamPort(upstream){const separator=upstream.lastIndexOf(":");if(separator<0)return;const port=Number(upstream.slice(separator+1));return Number.isInteger(port)&&port>0&&port<=65535?port:void 0}export function portOwners(routes,ignoreSlug){const owners=new Map;for(const route of routes){if(route.kind!=="app"||route.slug===ignoreSlug)continue;for(const upstream of route.target.split(",")){const port=parseUpstreamPort(upstream.trim());if(port!==void 0&&!owners.has(port))owners.set(port,route.slug)}}return owners}export function attachConflicts(slug,declared,routes){const conflicts=[],ports=portOwners(routes,slug),taken=new Map;for(const route of routes)if(route.slug!==slug)taken.set(routeKey(route.host,route.path),route.slug);for(const site of declared){if(site.port!==void 0){const holder=ports.get(site.port);if(holder)conflicts.push({kind:"port",site:site.name,detail:`port ${site.port}`,heldBy:holder})}if(site.domain){const holder=taken.get(routeKey(site.domain,site.path));if(holder)conflicts.push({kind:"route",site:site.name,detail:`${site.domain}${site.path==="/"?"/":site.path}`,heldBy:holder})}}return conflicts}function routeKey(host,path){const normalized=path==="/"?"/":path.replace(/\/+$/,"");return`${host.toLowerCase()}${normalized||"/"}`}export function setAttachTo(configText,owner){const blocks=[...configText.matchAll(/\n( {2})cloud: \{\n([\s\S]*?)\n\1\},\n/g)],[match]=blocks;if(!match)return{problem:"No `cloud: { ... }` block found in config/cloud.ts."};if(blocks.length>1)return{problem:`Found ${blocks.length} \`cloud: { ... }\` blocks in config/cloud.ts, so which one to edit is ambiguous.`};const[whole,indent,body]=match;if(indent===void 0||body===void 0)return{problem:"The `cloud: { ... }` block did not parse into an indent and a body."};if(body.includes("{"))return{problem:"The `cloud: { ... }` block holds a nested object, which this edit does not attempt to rewrite."};const existing=body.match(/^\s*attachTo:\s*(['"])([^'"]*)\1\s*,?\s*$/m);if(existing){const[line,quote="'",current=""]=existing;if(current===owner)return{text:configText,changed:!1};const repointed=line.replace(`${quote}${current}${quote}`,`'${owner}'`);return{text:configText.replace(whole,whole.replace(line,repointed)),changed:!0}}const inner=`${indent} `,replacement=whole.replace(`
2
- ${indent}},
3
- `,`
4
- ${inner}// Deploy onto the box '${owner}' owns rather than provisioning one.
5
- ${inner}attachTo: '${owner}',
6
- ${indent}},
7
- `);return{text:configText.replace(whole,replacement),changed:!0}}export function describeAttachPlan(plan){const{slug,owner,server,declared,conflicts}=plan,lines=[];lines.push(`Attach '${slug}' to '${server.name}' (${server.ipv4??"no IPv4"}), owned by '${owner}'.`,"");lines.push(` ${declared.length} site${declared.length===1?"":"s"} would deploy onto this box:`);for(const site of declared){const where=site.loopbackOnly?`loopback only${site.port?` on :${site.port}`:""}`:`${site.domain}${site.path==="/"?"/":site.path}${site.port?` on :${site.port}`:""}`;lines.push(` ${site.name} ${where} -> ${site.installBase??"(install path unresolved)"}`)}lines.push("");if(!plan.registryRead){lines.push(` Could not read what '${server.name}' already serves: ${plan.registryProblem??"unknown reason"}`);lines.push(" So this attach is UNCHECKED: a port or hostname already taken by another");lines.push(" project would not error, it would serve that project's site from your domain.");lines.push("")}else if(conflicts.length>0){lines.push(` ${conflicts.length} conflict${conflicts.length===1?"":"s"} with what the box already serves:`);for(const conflict of conflicts)lines.push(` site '${conflict.site}' wants ${conflict.detail}, held by '${conflict.heldBy}'`);lines.push("");lines.push(" Two services on one port do not error: the kernel load-balances, and each");lines.push(" domain serves the other's site about half the time. Pick free ports and");lines.push(" hostnames in config/cloud.ts, then re-run.");lines.push("")}else lines.push(` No conflicts with what '${server.name}' already serves.`,"");if(plan.edit)lines.push(...describeEdits(plan,plan.edit));return lines}function describeEdits(plan,edit){const lines=[" Two edits make the attach real, in two different repositories:",""];if("problem"in edit){lines.push(` 1. config/cloud.ts here: could not edit it (${edit.problem})`);lines.push(` Add \`attachTo: '${plan.owner}'\` to the \`cloud\` block by hand.`)}else if(!edit.changed)lines.push(` 1. config/cloud.ts here: already sets attachTo: '${plan.owner}'. Nothing to do.`);else if(plan.dryRun)lines.push(` 1. config/cloud.ts here: would set attachTo: '${plan.owner}' (--dry-run, not written)`);else lines.push(` 1. config/cloud.ts here: set attachTo: '${plan.owner}'`);lines.push("");lines.push(` 2. In the '${plan.owner}' project's own repository, which this command cannot edit:`);lines.push(` add '${plan.slug}' to the \`tenants\` array in its config/cloud.ts.`);lines.push(` Without it, that project's deploy ships ${plan.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}
@@ -1,241 +0,0 @@
1
- export declare function toInventoryServer(raw: ProviderServerPayload | null | undefined): InventoryServer;
2
- /**
3
- * The sites this project declares, in the same terms the box reports.
4
- *
5
- * `kind` and `installBase` come from ts-cloud when it is loadable, because
6
- * `siteInstallBase` is documented as the single source of truth for the
7
- * install path and a second copy of that rule here would be free to drift.
8
- * When ts-cloud cannot be loaded the site is still listed, without them: a
9
- * partial inventory beats no inventory, and every other field is local.
10
- */
11
- export declare function declaredSites(sites: Record<string, any> | undefined, helpers?: { resolveSiteKind?: (site: any) => string, siteInstallBase?: (slug: string, site: string) => string }, slug?: string): DeclaredSite[];
12
- export declare function routesFromFragments(fragments: readonly HostRouteFragment[]): HostedRoute[];
13
- /**
14
- * Line this project's declared sites up against what the box serves.
15
- *
16
- * Matching is on host + path rather than on the site key, because the site key
17
- * is local to a repository and the box has no idea what it is. Two projects
18
- * both calling a site `main` is normal; two projects serving the same host and
19
- * path is the collision worth seeing.
20
- */
21
- export declare function reconcile(declared: readonly DeclaredSite[], routes: readonly HostedRoute[], slug: string): Reconciliation;
22
- /** Group routes by the project that owns them, biggest tenant first. */
23
- export declare function tenantsOf(routes: readonly HostedRoute[]): Array<{ slug: string, routes: HostedRoute[] }>;
24
- /**
25
- * Which servers this project's own sites are not accounted for on.
26
- *
27
- * A project attaches to exactly one box per environment, so its sites should
28
- * all show up in one place. Sites missing everywhere is the signal that
29
- * matters for consolidation: either they were never deployed, or they are on a
30
- * box this listing did not reach.
31
- */
32
- export declare function unaccountedSites(declared: readonly DeclaredSite[], probes: readonly HostProbe[], slug: string): DeclaredSite[];
33
- /**
34
- * Every server in the Hetzner project, not just this one's.
35
- *
36
- * Deliberately unfiltered. `resolveAttachTargetBox` in the deploy command asks
37
- * the same API for ONE box by label; consolidation needs the opposite - the
38
- * full fleet, including boxes this project has no connection to, because
39
- * "which boxes could these sites move onto" is the question being answered.
40
- *
41
- * Failures are reported rather than swallowed, for the reason the deploy
42
- * command learned the hard way: a missing token, a 401 and an empty project
43
- * are three very different answers and they used to print as one.
44
- */
45
- export declare function listProviderServers(token: string | undefined, fetchImpl?: typeof fetch): Promise<ProviderListing>;
46
- /** How to phrase a provider failure for an operator. */
47
- export declare function describeProviderFailure(failure: ProviderFailure): string;
48
- /**
49
- * A shell snippet dumping every registry fragment, one base64 line per file.
50
- *
51
- * base64 rather than `cat`, because the fragments are pretty-printed JSON
52
- * spanning many lines and this keeps the output unambiguously one record per
53
- * line without needing a JSON tool on the box. A missing directory prints
54
- * nothing and reads back as "no co-tenants", which is the truth on a box that
55
- * has never been deployed to.
56
- */
57
- export declare function buildHostRoutesScript(sitesDir?: string): string;
58
- /**
59
- * Parse {@link buildHostRoutesScript} output into fragments.
60
- *
61
- * A line that will not decode or parse is skipped rather than thrown, matching
62
- * how the box's own assembler treats a corrupt fragment: one bad file must not
63
- * take the listing down. The cost is that its routes are invisible here, which
64
- * is still strictly more than the nothing this command could see before.
65
- */
66
- export declare function parseHostRoutesOutput(stdout: string): HostRouteFragment[];
67
- /**
68
- * Ask one box what it serves.
69
- *
70
- * Never throws. A box that is off, unreachable, or refuses the key returns an
71
- * `unavailable` reason instead, so one unreachable server does not cost the
72
- * listing of every other one - the same failure the captured-mail inbox had,
73
- * where a single bad record 503'd the whole endpoint.
74
- */
75
- export declare function probeHostRoutes(server: InventoryServer, exec: (host: string, command: string) => Promise<{ code: number, stdout: string, stderr: string }>): Promise<HostProbe>;
76
- /**
77
- * The listing an operator reads, as lines.
78
- *
79
- * Returned rather than logged so the format is testable and the command stays
80
- * a thin caller. Every count is stated so a partial answer reads as partial:
81
- * a box that could not be probed says so on its own line, and the summary
82
- * separates "not deployed" from "not visible from here".
83
- */
84
- export declare function describeInventory(inventory: Inventory): string[];
85
- /**
86
- * Where the rpx gateway keeps one registry fragment per project.
87
- *
88
- * Duplicated from ts-cloud's `HOST_SITES_DIR` rather than imported, because
89
- * that module (`deploy/site-ports`) publishes a `.d.ts` with no JavaScript
90
- * behind it - the whole file is types-only in 0.12.4 and 0.12.7, so
91
- * `buildHostSitePortsScript` and `parseHostSiteFragments` type-check on import
92
- * and then throw at runtime. Reported upstream; when they become loadable,
93
- * delete both helpers below and call ts-cloud's.
94
- */
95
- export declare const HOST_SITES_DIR: '/etc/rpx/sites.d';
96
- /**
97
- * Read-only inventory of what is hosted on which server.
98
- *
99
- * Consolidating boxes (stacksjs/stacks#2342) starts with a question nothing
100
- * could answer: what is actually running on each server? `config/cloud.ts`
101
- * cannot answer it. It describes ONE project's sites, and the boxes are
102
- * multi-tenant - other projects deploy onto them from their own repositories
103
- * with `cloud.attachTo`, and their sites appear nowhere in this file. Reading
104
- * config here and calling it an inventory would report a shared box as if this
105
- * project were alone on it, which is exactly the wrong answer to act on.
106
- *
107
- * So the inventory is assembled from three sources, weakest to strongest:
108
- *
109
- * 1. `config/cloud.ts` - what THIS project intends to deploy, and where.
110
- * 2. The provider API - which servers exist, and which project owns each
111
- * (ts-cloud stamps `ts-cloud/project`, `/environment` and `/role` labels
112
- * on every box it provisions).
113
- * 3. The box's own rpx registry (`/etc/rpx/sites.d`) - one JSON fragment per
114
- * project, holding every route that project serves here. This is the only
115
- * source that sees co-tenants, so it is the one that decides what is
116
- * hosted where. `@stacksjs/ts-cloud` already builds and parses it for the
117
- * port allocator; this module reuses those primitives rather than
118
- * inventing a second reading of the same files.
119
- *
120
- * The pure half (shaping, reconciling, rendering) is separated from the two IO
121
- * calls so the reconciliation can be tested without a server or a token.
122
- */
123
- /** A server as reported by the provider, with its ts-cloud identity resolved. */
124
- export declare interface InventoryServer {
125
- id: string
126
- name: string
127
- status: string
128
- ipv4?: string
129
- ipv6?: string
130
- type?: string
131
- location?: string
132
- labels: Record<string, string>
133
- project?: string
134
- environment?: string
135
- role?: string
136
- }
137
- /** One site this project declares in `config/cloud.ts`. */
138
- export declare interface DeclaredSite {
139
- name: string
140
- kind: string
141
- domain?: string
142
- path: string
143
- port?: number
144
- installBase?: string
145
- loopbackOnly: boolean
146
- }
147
- /** One route the box serves, as recorded by the project that deployed it. */
148
- export declare interface HostedRoute {
149
- slug: string
150
- host: string
151
- path: string
152
- target: string
153
- kind: 'app' | 'static' | 'redirect' | 'unknown'
154
- }
155
- /** What a box answered when asked for its registry. */
156
- export declare interface HostProbe {
157
- server: string
158
- ip?: string
159
- routes: HostedRoute[]
160
- unavailable?: string
161
- }
162
- /** Declared sites lined up against what a box actually serves. */
163
- export declare interface Reconciliation {
164
- present: DeclaredSite[]
165
- absent: DeclaredSite[]
166
- loopback: DeclaredSite[]
167
- foreign: HostedRoute[]
168
- }
169
- /**
170
- * Shape one provider server record into the inventory's own type.
171
- *
172
- * Written against the Hetzner server payload (the shape `resolveAttachTargetBox`
173
- * in the deploy command already reads), but only touches fields any provider
174
- * listing carries, so an AWS/local-box listing can be mapped onto it too.
175
- */
176
- /**
177
- * One provider server record, as the listing returns it.
178
- *
179
- * Written against the Hetzner payload and naming only the fields this function
180
- * reads, so another provider's listing satisfies it too. Every field is
181
- * optional and `unknown` because it is JSON from an external API: `text()` is
182
- * what turns each one into a string or nothing.
183
- */
184
- export declare interface ProviderServerPayload {
185
- id?: unknown
186
- name?: unknown
187
- status?: unknown
188
- labels?: Record<string, unknown>
189
- public_net?: { ipv4?: { ip?: unknown }, ipv6?: { ip?: unknown } }
190
- server_type?: { name?: unknown }
191
- datacenter?: { name?: unknown, location?: { name?: unknown } }
192
- }
193
- /**
194
- * Flatten the box's registry fragments into one route list.
195
- *
196
- * A fragment is `{ slug, ...RpxGatewayConfig }`, so `proxies` carries the
197
- * routes: `to` is the public host, `path` the prefix it owns, and exactly one
198
- * of `from` / `static` / `redirect` says where it goes. A fragment written by
199
- * an older ts-cloud may have no `slug`, which the writer defaults to `app`.
200
- */
201
- /**
202
- * One `/etc/rpx/sites.d` fragment, as parsed off the box.
203
- *
204
- * Every field is optional and `unknown`, because this is JSON written by
205
- * another machine and read here defensively - `text()` and the `typeof` tests
206
- * below are what turn it into something usable. As `any` those checks were
207
- * indistinguishable from probing for fields that never existed.
208
- */
209
- export declare interface HostRouteFragment {
210
- slug?: unknown
211
- proxies?: unknown
212
- }
213
- /** One proxy entry inside a fragment, in the same spirit. */
214
- export declare interface HostRouteProxy {
215
- to?: unknown
216
- path?: unknown
217
- from?: unknown
218
- static?: unknown
219
- redirect?: unknown
220
- }
221
- export declare interface ProviderListing {
222
- servers: InventoryServer[]
223
- failure?: ProviderFailure
224
- }
225
- /* ------------------------------------------------------------------------ *
226
- * Rendering.
227
- * ------------------------------------------------------------------------ */
228
- export declare interface Inventory {
229
- slug: string
230
- environment: string
231
- servers: InventoryServer[]
232
- probes: HostProbe[]
233
- declared: DeclaredSite[]
234
- providerFailure?: ProviderFailure
235
- }
236
- /* ------------------------------------------------------------------------ *
237
- * IO: the two calls that leave this machine.
238
- * ------------------------------------------------------------------------ */
239
- /** Why a provider listing came back with nothing. */
240
- export type ProviderFailure = | { kind: 'no-token' }
241
- | { kind: 'request-failed', status: number, detail?: string }
@@ -1,9 +0,0 @@
1
- const LABEL_PROJECT="ts-cloud/project",LABEL_ENVIRONMENT="ts-cloud/environment",LABEL_ROLE="ts-cloud/role";function text(value){return typeof value==="string"&&value.trim()?value.trim():void 0}export function toInventoryServer(raw){const labels={};for(const[key,value]of Object.entries(raw?.labels??{}))if(typeof value==="string")labels[key]=value;return{id:String(raw?.id??""),name:text(raw?.name)??"(unnamed)",status:text(raw?.status)??"unknown",ipv4:text(raw?.public_net?.ipv4?.ip),ipv6:text(raw?.public_net?.ipv6?.ip),type:text(raw?.server_type?.name),location:text(raw?.datacenter?.location?.name)??text(raw?.datacenter?.name),labels,project:text(labels[LABEL_PROJECT]),environment:text(labels[LABEL_ENVIRONMENT]),role:text(labels[LABEL_ROLE])}}export function declaredSites(sites,helpers,slug){return Object.entries(sites??{}).map(([name,site])=>{const domain=text(site?.domain),port=Number(site?.port);return{name,kind:helpers?.resolveSiteKind?.(site)??"unknown",domain,path:text(site?.path)??"/",port:Number.isFinite(port)&&port>0?port:void 0,installBase:slug&&helpers?.siteInstallBase?helpers.siteInstallBase(slug,name):void 0,loopbackOnly:!domain}})}export function routesFromFragments(fragments){const routes=[];for(const fragment of fragments){const slug=text(fragment?.slug)??"app",proxies=Array.isArray(fragment?.proxies)?fragment.proxies:[];for(const proxy of proxies){const host=text(proxy?.to);if(!host)continue;routes.push({slug,host,path:text(proxy?.path)??"/",...describeRouteTarget(proxy)})}}return routes.sort((a,b)=>a.slug.localeCompare(b.slug)||a.host.localeCompare(b.host)||a.path.localeCompare(b.path))}function describeRouteTarget(proxy){const from=proxy?.from;if(typeof from==="string"&&from.trim())return{target:from.trim(),kind:"app"};if(Array.isArray(from)&&from.length)return{target:from.filter((u)=>typeof u==="string").join(", "),kind:"app"};const staticRoute=proxy?.static;if(typeof staticRoute==="string"&&staticRoute.trim())return{target:staticRoute.trim(),kind:"static"};if(staticRoute&&typeof staticRoute==="object"){const dir=text(staticRoute.dir);if(dir)return{target:dir,kind:"static"}}const redirect=text(proxy?.redirect?.to)??text(proxy?.redirect);if(redirect)return{target:redirect,kind:"redirect"};return{target:"(no upstream)",kind:"unknown"}}export function reconcile(declared,routes,slug){const ours=new Set(routes.filter((route)=>route.slug===slug).map((route)=>routeKey(route.host,route.path))),present=[],absent=[],loopback=[];for(const site of declared)if(site.loopbackOnly)loopback.push(site);else if(ours.has(routeKey(site.domain,site.path)))present.push(site);else absent.push(site);return{present,absent,loopback,foreign:routes.filter((route)=>route.slug!==slug)}}function routeKey(host,path){const normalized=path==="/"?"/":path.replace(/\/+$/,"");return`${host.toLowerCase()}${normalized||"/"}`}export function tenantsOf(routes){const bySlug=new Map;for(const route of routes){const bucket=bySlug.get(route.slug);if(bucket)bucket.push(route);else bySlug.set(route.slug,[route])}return[...bySlug.entries()].map(([slug,grouped])=>({slug,routes:grouped})).sort((a,b)=>b.routes.length-a.routes.length||a.slug.localeCompare(b.slug))}export function unaccountedSites(declared,probes,slug){const seen=new Set;for(const probe of probes)for(const route of probe.routes)if(route.slug===slug)seen.add(routeKey(route.host,route.path));return declared.filter((site)=>!site.loopbackOnly&&!seen.has(routeKey(site.domain,site.path)))}export async function listProviderServers(token,fetchImpl=fetch){if(!token)return{servers:[],failure:{kind:"no-token"}};const servers=[];let page=1;while(page>0&&page<=40){let response;try{response=await fetchImpl(`https://api.hetzner.cloud/v1/servers?page=${page}&per_page=50`,{headers:{Authorization:`Bearer ${token}`}})}catch(error){return{servers,failure:{kind:"request-failed",status:0,detail:error instanceof Error?error.message:String(error)}}}if(!response.ok){const body=await response.text().catch(()=>"");return{servers,failure:{kind:"request-failed",status:response.status,detail:body.slice(0,200)||void 0}}}const payload=await response.json().catch(()=>({}));for(const raw of Array.isArray(payload?.servers)?payload.servers:[])servers.push(toInventoryServer(raw));const next=Number(payload?.meta?.pagination?.next_page);page=Number.isFinite(next)&&next>page?next:0}return{servers:servers.sort((a,b)=>a.name.localeCompare(b.name))}}export function describeProviderFailure(failure){if(failure.kind==="no-token")return"No Hetzner API token, so no servers were looked up. Set HCLOUD_TOKEN (or HETZNER_API_TOKEN, or hetzner.apiToken in config/cloud.ts).";return`The Hetzner API ${failure.status>0?`returned HTTP ${failure.status}`:"could not be reached"}, so the server list is incomplete.${failure.detail?` ${failure.detail}`:""}`+(failure.status===401||failure.status===403?" That is an auth failure, not an empty project: check the token is valid for this Hetzner project.":"")}export const HOST_SITES_DIR="/etc/rpx/sites.d";export function buildHostRoutesScript(sitesDir=HOST_SITES_DIR){return`d='${sitesDir}'
2
- [ -d "$d" ] || exit 0
3
- find "$d" -maxdepth 1 -type f -name '*.json' | sort | while IFS= read -r f; do
4
- base64 < "$f" | tr -d '\\n'
5
- echo
6
- done`}export function parseHostRoutesOutput(stdout){const fragments=[];for(const line of stdout.split(`
7
- `)){const encoded=line.trim();if(!encoded)continue;try{fragments.push(JSON.parse(Buffer.from(encoded,"base64").toString("utf8")))}catch{}}return fragments}export async function probeHostRoutes(server,exec){if(!server.ipv4)return{server:server.name,routes:[],unavailable:"no public IPv4 address to reach it on"};if(server.status!=="running")return{server:server.name,ip:server.ipv4,routes:[],unavailable:`server is ${server.status}`};try{const result=await exec(server.ipv4,buildHostRoutesScript());if(result.code!==0){const reason=result.stderr.trim().split(`
8
- `)[0]||`ssh exited ${result.code}`;return{server:server.name,ip:server.ipv4,routes:[],unavailable:reason}}return{server:server.name,ip:server.ipv4,routes:routesFromFragments(parseHostRoutesOutput(result.stdout))}}catch(error){return{server:server.name,ip:server.ipv4,routes:[],unavailable:error instanceof Error?error.message.split(`
9
- `)[0]:String(error)}}}export function describeInventory(inventory){const lines=[],{slug,servers,probes,declared}=inventory;if(inventory.providerFailure)lines.push(describeProviderFailure(inventory.providerFailure),"");if(servers.length===0)lines.push("No servers found.");else lines.push(`${servers.length} server${servers.length===1?"":"s"}:`,"");const probesByServer=new Map(probes.map((probe)=>[probe.server,probe]));for(const server of servers){const facts=[server.ipv4,server.type,server.location,server.status].filter(Boolean);lines.push(` ${server.name} ${facts.join(" ")}`);lines.push(` ${describeOwnership(server)}`);const probe=probesByServer.get(server.name);if(!probe){lines.push(" not probed (--no-remote), so co-tenants on this box are not listed");lines.push("");continue}if(probe.unavailable){lines.push(` could not read ${HOST_SITES_DIR}: ${probe.unavailable}`);lines.push("");continue}const tenants=tenantsOf(probe.routes);if(tenants.length===0){lines.push(` serves nothing: ${HOST_SITES_DIR} is empty or absent`);lines.push("");continue}lines.push(` serves ${probe.routes.length} route${probe.routes.length===1?"":"s"} for ${tenants.length} project${tenants.length===1?"":"s"}:`);for(const tenant of tenants){lines.push(` ${tenant.slug}${tenant.slug===slug?" (this project)":""}`);for(const route of tenant.routes)lines.push(` ${route.host}${route.path==="/"?"/":route.path} -> ${describeTarget(route)}`)}lines.push("")}lines.push(...describeDeclared(inventory));return lines}function describeOwnership(server){if(!server.project)return"no ts-cloud labels: provisioned outside ts-cloud, or by a version that did not label boxes";const detail=[server.environment,server.role&&`role ${server.role}`].filter(Boolean).join(", ");return`owned by '${server.project}'${detail?` (${detail})`:""}`}function describeTarget(route){if(route.kind==="redirect")return`redirect to ${route.target}`;if(route.kind==="static")return`static ${route.target}`;return route.target}function describeDeclared(inventory){const{slug,declared,probes}=inventory;if(declared.length===0)return[`This project ('${slug}') declares no sites in config/cloud.ts.`];const lines=[`This project ('${slug}') declares ${declared.length} site${declared.length===1?"":"s"}: ${declared.map((site)=>site.name).join(", ")}`],loopback=declared.filter((site)=>site.loopbackOnly);if(loopback.length>0)lines.push(` ${loopback.length} with no domain, so the gateway never routes ${loopback.length===1?"it":"them"} (reached through another site's proxy): ${loopback.map((site)=>site.name).join(", ")}`);const answered=probes.filter((probe)=>!probe.unavailable);if(answered.length===0){lines.push(" Nothing to reconcile them against: no box reported what it serves.");return lines}const unaccounted=unaccountedSites(declared,answered,slug);lines.push(` ${declared.length-loopback.length-unaccounted.length} routed by a box above`);if(unaccounted.length>0){lines.push(` ${unaccounted.length} not routed by any box above: ${unaccounted.map((site)=>site.name).join(", ")}`);const unread=probes.length-answered.length,unprobed=inventory.servers.length-probes.length;if(unread>0)lines.push(` Either they were never deployed, or they are on one of the ${unread} server${unread===1?"":"s"} that could not be read.`);else if(unprobed>0)lines.push(` Either they were never deployed, or they are on one of the ${unprobed} server${unprobed===1?"":"s"} this run did not probe.`);else lines.push(" Either they were never deployed, or they are on a server outside this provider account.")}return lines}