@stacksjs/buddy 0.75.439 → 0.75.443
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/cloud.js +2 -2
- package/package.json +57 -57
package/dist/commands/cloud.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import{existsSync,readdirSync,readFileSync,writeFileSync}from"node:fs";import{join}from"node:path";import process from"node:process";import{intro,italic,log,onUnknownSubcommand,outro,prompts,runCommand,text,underline}from"@stacksjs/cli";import{addJumpBox,deleteCdkRemnants,deleteIamUsers,deleteJumpBox,deleteLogGroups,deleteParameterStore,deleteStacksBuckets,deleteStacksFunctions,deleteSubnets,deleteVpcs,cloudEnvironment,diffStackTemplate,getCloudFrontDistributionId,stacksCloudRegion}from"@stacksjs/cloud";import{hasTTY,isCI}from"@stacksjs/env";import{path as p}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";import{isSshPipelineProvider,sshFleetFromConfigAndState}from"./deploy-ssh-target";async function createTemporaryCdkRole(roleName){const{AWSClient}=await import("@stacksjs/ts-cloud"),client=new AWSClient,trustPolicy={Version:"2012-10-17",Statement:[{Effect:"Allow",Principal:{Service:"cloudformation.amazonaws.com"},Action:"sts:AssumeRole"}]};try{try{const getRoleParams=new URLSearchParams({Action:"GetRole",RoleName:roleName,Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:getRoleParams.toString()});log.debug(`Role ${roleName} already exists`);return}catch(e){if(!e.message?.includes("NoSuchEntity")&&!e.message?.includes("cannot be found"))throw e}log.info("Creating temporary IAM role to enable stack deletion...");const createRoleParams=new URLSearchParams({Action:"CreateRole",RoleName:roleName,AssumeRolePolicyDocument:JSON.stringify(trustPolicy),Description:"Temporary role to allow CloudFormation to delete stuck stack",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:createRoleParams.toString()});log.success("Created IAM role");log.info("Attaching permissions...");const attachPolicyParams=new URLSearchParams({Action:"AttachRolePolicy",RoleName:roleName,PolicyArn:"arn:aws:iam::aws:policy/AdministratorAccess",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:attachPolicyParams.toString()});log.success("IAM role ready for stack deletion");log.info("Waiting for IAM role to propagate...");await new Promise((resolve)=>setTimeout(resolve,1e4))}catch(error){if(error.message?.includes("EntityAlreadyExists"))log.debug("Role already exists");else throw error}}async function deleteTemporaryCdkRole(roleName){const{AWSClient}=await import("@stacksjs/ts-cloud"),client=new AWSClient;try{const detachPolicyParams=new URLSearchParams({Action:"DetachRolePolicy",RoleName:roleName,PolicyArn:"arn:aws:iam::aws:policy/AdministratorAccess",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:detachPolicyParams.toString()});const deleteRoleParams=new URLSearchParams({Action:"DeleteRole",RoleName:roleName,Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:deleteRoleParams.toString()});log.success("Cleaned up temporary IAM role")}catch(error){log.debug(`Could not clean up temporary role: ${error.message}`)}}function isResultError(result){return resultFailed(result)}function getResultError(result){if(!result||typeof result!=="object")return"Unknown error";return String(result.error||"Unknown error")}function getResultValue(result){if(!result||typeof result!=="object")return;return result.value}async function refuse(...messages){for(const message of messages.slice(0,-1))await log.error(message);return log.exit(messages[messages.length-1],ExitCode.FatalError)}async function driverStatePins(){const{driverStatePath}=await import("@stacksjs/ts-cloud"),dir=join(driverStatePath("probe"),"..");let files;try{files=readdirSync(dir).filter((name)=>name.endsWith(".json"))}catch{return[]}const pins=[];for(const name of files){const file=join(dir,name);try{const state=JSON.parse(readFileSync(file,"utf8"));if(typeof state?.serverName==="string")pins.push({file,state})}catch{}}return pins}function writePinName(file,next){const state=JSON.parse(readFileSync(file,"utf8"));writeFileSync(file,`${JSON.stringify({...state,serverName:next},null,2)}
|
|
2
2
|
`)}export async function renameEffects(tsCloudConfig,servers,current){const taken=servers.map((s)=>String(s?.name)).filter(Boolean),effects={takenNames:async()=>taken,inventoryName:()=>String(current?.name??""),renameInventory:(next)=>{current.name=next}},pinned=(await driverStatePins()).filter((pin)=>pin.state.serverName===current?.name);if(pinned.length>0){effects.stateName=async()=>{const stale=(await driverStatePins()).find((pin)=>pin.state.serverName===current?.name);return stale?String(stale.state.serverName):void 0};effects.writeStateName=async(next)=>{for(const pin of pinned)writePinName(pin.file,next)}}const host=current?.ipv4;if(host){const{sshExec,buildSetHostnameScript}=await import("@stacksjs/ts-cloud"),ssh={user:"root",connectTimeoutSec:10};effects.remoteHostname=async()=>{const result=await sshExec(host,"hostname",ssh);if(result.code!==0)throw Error(result.stderr.trim()||`Could not reach ${host} over SSH.`);return result.stdout.trim()};effects.setRemoteHostname=async(next)=>{const result=await sshExec(host,buildSetHostnameScript(next),ssh);if(result.code!==0)throw Error(result.stderr.trim()||`Setting the hostname on ${host} failed.`)}}if((tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws")!=="hetzner")return effects;const{HetznerClient}=await import("@stacksjs/ts-cloud"),{resolveHetznerApiToken}=await import("./deploy"),apiToken=resolveHetznerApiToken(tsCloudConfig),serverId=Number(current?.id);if(!apiToken||!Number.isFinite(serverId))return effects;const client=new HetznerClient({apiToken});effects.providerName=async()=>(await client.getServer(serverId))?.name;effects.renameProvider=async(next)=>{await client.renameServer(serverId,next)};return effects}export function planServerDestroy(name,effects){const steps=[{id:"provider:delete",title:`Delete the server ${name}`,change:{from:name,to:"gone"},destructive:!0,satisfied:async()=>!await effects.providerExists(),apply:async()=>await effects.deleteProvider()}];if(effects.pinnedName&&effects.clearPin)steps.push({id:"state:clear",title:"Clear the state pin that names it",change:{from:name,to:"none"},satisfied:async()=>await effects.pinnedName()!==name,apply:async()=>await effects.clearPin()});return{operation:"server:destroy",target:name,steps}}export async function destroyEffects(tsCloudConfig,current){const pinned=(await driverStatePins()).filter((pin)=>pin.state.serverName===current?.name),effects={providerExists:async()=>!0,deleteProvider:async()=>{throw Error(`No provider API for this fleet, so ${current?.name} cannot be deleted from here.`)}};if(pinned.length>0){effects.pinnedName=async()=>{const stale=(await driverStatePins()).find((pin)=>pin.state.serverName===current?.name);return stale?String(stale.state.serverName):void 0};effects.clearPin=async()=>{for(const pin of pinned){const{serverId:_id,serverName:_name,...rest}=JSON.parse(readFileSync(pin.file,"utf8"));writeFileSync(pin.file,`${JSON.stringify(rest,null,2)}
|
|
3
3
|
`)}}}if((tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws")!=="hetzner")return effects;const{HetznerClient}=await import("@stacksjs/ts-cloud"),{resolveHetznerApiToken}=await import("./deploy"),apiToken=resolveHetznerApiToken(tsCloudConfig),serverId=Number(current?.id);if(!apiToken||!Number.isFinite(serverId))return effects;const client=new HetznerClient({apiToken});effects.providerExists=async()=>{try{return Boolean(await client.getServer(serverId))}catch{return!1}};effects.deleteProvider=async()=>{await client.deleteServer(serverId)};return effects}export function sitesSharingHostnames(sites,siteName,hostnames,hostsOf){return Object.entries(sites??{}).filter(([name])=>name!==siteName).filter(([name,site])=>hostsOf(name,site).some((host)=>hostnames.includes(host))).map(([name,site])=>`${name} (${String(site?.path??"/")})`).sort()}async function moveEffects(context){const{slug,siteName,rawSite,source,target,tsCloudConfig}=context,{buildCertificatePackScript,buildCertificateStateScript,buildCertificateUnpackScript,buildRpxConfig,buildRpxFragmentRefreshScript,buildSshArgs,certificatesMatch,DEFAULT_RPX_CERTS_DIR,parseCertificateState,probeHostRoutes,sshExec,sshExecOrThrow}=await import("@stacksjs/ts-cloud"),{reconcileHetznerDns}=await import("./deploy"),ssh={user:"root",connectTimeoutSec:10},runOnSource=(script)=>sshExecOrThrow(source.ipv4,script,ssh),runOnTarget=(script)=>sshExecOrThrow(target.ipv4,script,ssh);async function carry(path){const args=buildSshArgs(ssh,"ssh"),reader=Bun.spawn(["ssh",...args,`root@${source.ipv4}`,`cat ${path}`],{stdout:"pipe",stderr:"pipe"}),writer=Bun.spawn(["ssh",...args,`root@${target.ipv4}`,`cat > ${path}`],{stdin:reader.stdout,stdout:"pipe",stderr:"pipe"}),[readCode,writeCode]=await Promise.all([reader.exited,writer.exited]);if(readCode!==0||writeCode!==0){const why=(await new Response(readCode===0?writer.stderr:reader.stderr).text()).trim();throw Error(why||`Carrying ${path} from ${source.name} to ${target.name} failed.`)}}const staged=async(path)=>(await sshExec(target.ipv4,`test -s ${path} && echo staged`,ssh)).stdout.includes("staged"),{siteMoveArchivePath,siteMoveCertArchivePath}=await import("@stacksjs/ts-cloud"),archive=siteMoveArchivePath(slug,siteName),effects={runOnSource,runOnTarget,archiveStaged:()=>staged(archive),transferArchive:()=>carry(archive),publishedAddress:async()=>{const host=context.hostnames[0];if(!host)return;try{const{resolve4}=await import("node:dns/promises");return(await resolve4(host))[0]}catch{return}},cutoverDns:async()=>{const warnings=[],collect=new Proxy(log,{get:(target_,key)=>key==="warn"?(message)=>{warnings.push(String(message))}:target_[key]});await reconcileHetznerDns({[siteName]:rawSite},target.ipv4,collect,target.ipv6,tsCloudConfig?.infrastructure?.compute?.proxy?.autoWww);return warnings},targetRoutesSite:async()=>{const probe=await probeHostRoutes(target,(host,command)=>sshExec(host,command,ssh));if(probe.unavailable)return!1;return probe.routes.some((route)=>context.hostnames.includes(String(route.host)))},refreshTargetGateway:async()=>{const rpx=buildRpxConfig(tsCloudConfig?.sites??{},{proxy:tsCloudConfig?.infrastructure?.compute?.proxy??{},slug});await runOnTarget(buildRpxFragmentRefreshScript({config:rpx,slug}).join(`
|
|
4
|
-
`))}},certArchive=siteMoveCertArchivePath(slug,siteName);effects.certificates={inPlace:async()=>{const read=async(run)=>parseCertificateState(await run(buildCertificateStateScript(DEFAULT_RPX_CERTS_DIR,context.hostnames)));return certificatesMatch(await read(runOnSource),await read(runOnTarget))},carry:async()=>{await runOnSource(buildCertificatePackScript(DEFAULT_RPX_CERTS_DIR,context.hostnames,certArchive));await carry(certArchive);await runOnTarget(buildCertificateUnpackScript(DEFAULT_RPX_CERTS_DIR,certArchive))}};return effects}export async function serverServing(servers,hosts,probe){for(const candidate of servers){if(!candidate?.ipv4)continue;const result=await probe(candidate);if(!result.unavailable&&(result.routes??[]).some((route)=>hosts.includes(String(route.host))))return candidate}return}export async function onBoxDatabase(tsCloudConfig){const{isLocalDatabase,resolveAppDatabase}=await import("@stacksjs/ts-cloud"),database=resolveAppDatabase(tsCloudConfig);if(!database||!isLocalDatabase(database))return;const engine=String(database.engine||database.driver||"");if(engine==="sqlite"||engine==="better-sqlite3")return;const name=String(database.name||"");return name?{name}:void 0}async function assertFleetProvider(tsCloudConfig,command){const provider=tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws";if(isSshPipelineProvider(provider))return;await log.info("The on-box half (the rpx gateway registry) is provider-independent; the server listing is not yet.");await log.exit(`\`buddy ${command}\` can list Hetzner and ssh servers, and this project's provider is '${provider}'.`,ExitCode.FatalError)}function readSshStatePins(cwd=process.cwd()){const dir=join(cwd,"storage","cloud","state");if(!existsSync(dir))return[];const pins=[];for(const file of readdirSync(dir)){if(!file.endsWith(".json"))continue;try{pins.push(JSON.parse(readFileSync(join(dir,file),"utf8")))}catch{}}return pins}async function listFleet(tsCloudConfig){if((tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER)==="ssh"){const servers=sshFleetFromConfigAndState(tsCloudConfig,readSshStatePins());return{servers,problem:servers.length===0?"No ssh hosts are configured. Add one under `ssh.hosts` in config/cloud.ts, or set TS_CLOUD_SSH_HOST.":void 0}}const{HetznerClient,toInventoryServer}=await import("@stacksjs/ts-cloud"),{resolveHetznerApiToken}=await import("./deploy"),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}async function runCloudDiff(){try{const{InfrastructureGenerator}=await import("@stacksjs/ts-cloud"),{tsCloud:cloudConfig}=await import("~/config/cloud"),environment=cloudEnvironment(),template=new InfrastructureGenerator({config:cloudConfig,environment}).generate().toJSON(),result=await diffStackTemplate(template,{config:cloudConfig});if(result.isErr)return result.error.message;const diff=result.value;if(!diff.deployed)log.info(`${diff.stack} is not deployed in ${diff.region}. The whole template (${diff.localBytes} bytes) is new.`);else if(!diff.changed)log.info(`No changes detected against ${diff.stack} (${diff.region}).`);else{log.info(`Changes detected between ${diff.stack} (${diff.region}) and the local template:`);log.info(`Deployed template: ${diff.deployedBytes} bytes`);log.info(`Local template: ${diff.localBytes} bytes`)}return}catch(error){return error instanceof Error?error.message:String(error)}}export function cloud(buddy){buddy.command("cloud:apply","Apply configured server size, persistent volumes and deployment storage").option("--env [env]","Environment to reconcile",{default:"production"}).option("--dry-run","Show the compute plan without changing anything",{default:!1}).option("--yes","Apply the displayed plan without prompting",{default:!1}).option("-J, --json","Print the plan as JSON",{default:!1}).action(async(options)=>{try{const{createCloudDriver}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy"),config=await loadTsCloudConfig(options.env);if(!config?.project?.slug)throw Error("config/cloud.ts must declare tsCloud.project.slug");const environment=options.env||"production",nativeConfig=config,driver=createCloudDriver({config:nativeConfig});if(!driver.applyComputeChanges)throw Error("This cloud provider does not support configured compute changes");const plan=await driver.applyComputeChanges({config:nativeConfig,environment,dryRun:!0});if(options.json)console.log(JSON.stringify(plan,null,2));else{for(const change of plan.changes)await log.info(`${change.resource}: ${change.action} (${change.detail})`);if(plan.waitingForCapacity)await log.warn(plan.waitingForCapacity)}if(options.dryRun)return;if(!await confirmCloudChange(options,"buddy cloud:apply --yes","Apply these compute changes?","apply compute changes"))return;const applied=await driver.applyComputeChanges({config:nativeConfig,environment});if(applied.waitingForCapacity)await log.warn(applied.waitingForCapacity);await log.success("Configured compute changes applied")}catch(error){await log.error(error instanceof Error?error.message:String(error));process.exitCode=ExitCode.FatalError}});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",rename:"Rename a server in place, keeping the provider, state pin, hostname and inventory in step",renameTo:"The new server name",renameEnv:"Environment whose fleet and state pin to rename in",renameJson:"Emit the rename plan as JSON",destroy:"Destroy a drained server, once nothing on it is anyone's rollback",destroyEnv:"Environment whose fleet and state pin the server belongs to",destroyJson:"Emit the teardown plan as JSON",destroyConfirm:"The exact server name, which an irreversible step requires before it runs",destroyDiscardDrained:"Destroy it even though it still holds site trees that are somebody's rollback",move:"Move a deployed site to another server, cutting DNS over once the target serves it",moveTo:"Server to move the site to, by provider name or by owning project slug",moveFrom:"Server the site is on now, when more than one could be serving it",moveEnv:"Environment whose fleet and sites to move within",moveJson:"Emit the move plan as JSON",moveConfirm:"The exact site name, which the irreversible steps require before they run",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("-y, --yes","Invalidate the CDN cache without asking first",{default:!1}).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 jumpBox=await addJumpBox();if(isResultError(jumpBox)){await outro("Could not find the jump box",{startTime,useSeconds:!0},getResultError(jumpBox));process.exit(ExitCode.FatalError)}const{message,state}=getResultValue(jumpBox);if(!state.instanceId){log.info(message);await outro("There is no jump box to connect to",{startTime,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}const result=await runCommand(["aws","ssm","start-session","--target",state.instanceId,"--region",state.region],{...options,cwd:p.projectPath(),stdin:"inherit",stdout:"inherit"});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 confirmInvalidation(options,"buddy cloud --invalidate-cache --yes","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){const failure=await runCloudDiff();if(failure){await outro("While running the cloud diff command, there was an issue",{startTime,useSeconds:!0},failure);process.exit(ExitCode.FatalError)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Not implemented yet. Read more about `buddy cloud` here: https://stacksjs.com/docs/cloud");await log.flush();process.exit(ExitCode.Success)});buddy.command("cloud:add",descriptions.add).option("--jump-box","Check for the jump box, and say how to declare one if the stack has none",{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){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)}const change=getResultValue(result);log.info(change.message);if(!change.done){await outro("No jump box was added.",{startTime,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}await outro("Your cloud has a jump box.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("This functionality is not yet implemented.");await log.flush();process.exit(ExitCode.Success)});buddy.command("cloud:remove",descriptions.remove).alias("cloud:rm").alias("undeploy").option("--jump-box","Check for the jump box, and say how to remove it from the stack",{default:!1}).option("-y, --yes","Skip the confirmation prompt",{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.jumpBox){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)}const change=getResultValue(result);log.info(change.message);if(!change.done){await outro("The jump box was not removed.",{startTime,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}await outro("Your cloud has no jump box.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}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,type:"warning"});await log.flush();process.exit(ExitCode.FatalError)}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(`
|
|
5
|
-
`);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=await stacksCloudRegion(void 0,environment);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,type:"error"});process.exit(ExitCode.FatalError)}});buddy.command("cloud:optimize-cost",descriptions.optimizeCost).option("--jump-box","Check for the jump box, and say how to remove it from the stack",{default:!0}).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){const result=await deleteJumpBox();if(isResultError(result)){await outro("While checking your jump-box, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}const change=getResultValue(result);log.info(change.message);if(!change.done){await outro("No cost optimization was applied",{startTime,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}}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("--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:"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("-y, --yes","Invalidate without asking first",{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 confirmInvalidation(options,"buddy cloud:invalidate-cache --yes","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",stdout:"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("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:diff` ...",options);const startTime=await intro("buddy cloud:diff"),failure=await runCloudDiff();if(failure){await outro("While running the cloud diff command, there was an issue",{startTime,useSeconds:!0},failure);process.exit(ExitCode.FatalError)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:dashboard",descriptions.dashboard).alias("cloud:cockpit").option("--host [host]",descriptions.host,{default:"127.0.0.1"}).option("--port [port]",descriptions.port,{default:"7676"}).option("--env [env]",descriptions.env).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:dashboard` ...",options);const startTime=await intro("buddy cloud:dashboard"),tsCloud=await import("@stacksjs/ts-cloud");if(typeof tsCloud.startLocalDashboardServer!=="function"){await outro("The installed @stacksjs/ts-cloud does not provide the local cockpit yet",{startTime,useSeconds:!0},"Update your dependencies (requires @stacksjs/ts-cloud >= 0.5.27).");process.exit(ExitCode.FatalError)}try{const server=await tsCloud.startLocalDashboardServer({host:options.host?String(options.host):void 0,port:options.port?Number(options.port):void 0,environment:options.env,verbose:!!options.verbose});log.success(`Stacks Cloud cockpit running at ${underline(server.url)}`);log.info(italic("Manage servers, sites, SSH keys and deploys. Press Ctrl+C to stop."));await new Promise(()=>{})}catch(error){await outro("While starting the cloud dashboard, there was an issue",{startTime,useSeconds:!0},error?.message??String(error));process.exit(ExitCode.FatalError)}});buddy.command("cloud:sites",descriptions.sites).option("--env [env]",descriptions.sitesEnv).option("--no-remote",descriptions.remote).option("-J, --json",descriptions.json,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:sites` ...",options);const{formatInventory,probeHostRoutes}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy"),environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0),slug=tsCloudConfig?.project?.slug||"app";await assertFleetProvider(tsCloudConfig,"cloud:sites");const listing=await listFleet(tsCloudConfig);if(listing.problem)await log.error(listing.problem);const declared=await declaredSitesFor(tsCloudConfig,slug),probes=options.remote===!1?[]:await probeFleet(listing.servers),inventory={slug,servers:listing.servers,probes,declared};if(options.json)console.log(JSON.stringify({environment,...inventory},null,2));else for(const line of formatInventory(inventory))console.log(line);process.exit(listing.problem&&listing.servers.length===0?ExitCode.FatalError:ExitCode.Success);async function probeFleet(servers){const{sshExec}=await import("@stacksjs/ts-cloud"),probes=[];for(let index=0;index<servers.length;index+=6)probes.push(...await Promise.all(servers.slice(index,index+6).map((server)=>probeHostRoutes(server,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})))));return probes}});buddy.command("cloud:attach",descriptions.attach).option("--server <server>",descriptions.attachServer).option("--env [env]",descriptions.sitesEnv).option("--dry-run",descriptions.dryRun,{default:!1}).option("-J, --json",descriptions.json,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:attach` ...",options);const{attachConflicts,attachIsViable,attachPreconditions,formatAttachPlan,probeHostRoutes,resolveAttachTarget,setAttachToInCloudConfig,sshExec}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy");if(!options.server)return await refuse("Which server? Pass --server <name|owner-slug>. `buddy cloud:sites` lists them.");const environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0),slug=tsCloudConfig?.project?.slug||"app";await assertFleetProvider(tsCloudConfig,"cloud:attach");const listing=await listFleet(tsCloudConfig);if(listing.problem)return await refuse(listing.problem);const target=resolveAttachTarget(listing.servers,String(options.server),environment);if("problem"in target)return await refuse(`${target.problem} \`buddy cloud:sites\` lists what is there.`);const server=target.server,preconditions=attachPreconditions(slug,server);if(preconditions.length>0)return await refuse(...preconditions);const declared=await declaredSitesFor(tsCloudConfig,slug),probe=await probeHostRoutes(server,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})),plan={slug,owner:server.project,server,declared,conflicts:probe.unavailable?[]:attachConflicts(slug,declared,probe.routes),registryRead:!probe.unavailable,registryProblem:probe.unavailable},viable=attachIsViable(plan),edit=viable?await applyAttachToConfig(plan.owner,Boolean(options.dryRun)):void 0;if(options.json)console.log(JSON.stringify({environment,...plan,edit},null,2));else{for(const line of formatAttachPlan(plan))console.log(line);if(edit)console.log(["",...describeAttachEdits(plan.slug,plan.owner,edit,Boolean(options.dryRun))].join(`
|
|
4
|
+
`))}},certArchive=siteMoveCertArchivePath(slug,siteName);effects.certificates={inPlace:async()=>{const read=async(run)=>parseCertificateState(await run(buildCertificateStateScript(DEFAULT_RPX_CERTS_DIR,context.hostnames)));return certificatesMatch(await read(runOnSource),await read(runOnTarget))},carry:async()=>{await runOnSource(buildCertificatePackScript(DEFAULT_RPX_CERTS_DIR,context.hostnames,certArchive));await carry(certArchive);await runOnTarget(buildCertificateUnpackScript(DEFAULT_RPX_CERTS_DIR,certArchive))}};return effects}export async function serverServing(servers,hosts,probe){for(const candidate of servers){if(!candidate?.ipv4)continue;const result=await probe(candidate);if(!result.unavailable&&(result.routes??[]).some((route)=>hosts.includes(String(route.host))))return candidate}return}export async function onBoxDatabase(tsCloudConfig){const{isLocalDatabase,resolveAppDatabase}=await import("@stacksjs/ts-cloud"),database=resolveAppDatabase(tsCloudConfig);if(!database||!isLocalDatabase(database))return;const engine=String(database.engine||database.driver||"");if(engine==="sqlite"||engine==="better-sqlite3")return;const name=String(database.name||"");return name?{name}:void 0}async function assertFleetProvider(tsCloudConfig,command){const provider=tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws";if(isSshPipelineProvider(provider))return;await log.info("The on-box half (the rpx gateway registry) is provider-independent; the server listing is not yet.");await log.exit(`\`buddy ${command}\` can list Hetzner and ssh servers, and this project's provider is '${provider}'.`,ExitCode.FatalError)}function readSshStatePins(cwd=process.cwd()){const dir=join(cwd,"storage","cloud","state");if(!existsSync(dir))return[];const pins=[];for(const file of readdirSync(dir)){if(!file.endsWith(".json"))continue;try{pins.push(JSON.parse(readFileSync(join(dir,file),"utf8")))}catch{}}return pins}async function listFleet(tsCloudConfig){if((tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER)==="ssh"){const servers=sshFleetFromConfigAndState(tsCloudConfig,readSshStatePins());return{servers,problem:servers.length===0?"No ssh hosts are configured. Add one under `ssh.hosts` in config/cloud.ts, or set TS_CLOUD_SSH_HOST.":void 0}}const{HetznerClient,toInventoryServer}=await import("@stacksjs/ts-cloud"),{resolveHetznerApiToken}=await import("./deploy"),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}async function runCloudDiff(){try{const{InfrastructureGenerator}=await import("@stacksjs/ts-cloud"),{tsCloud:cloudConfig}=await import(p.projectPath("config/cloud")),environment=cloudEnvironment(),template=new InfrastructureGenerator({config:cloudConfig,environment}).generate().toJSON(),result=await diffStackTemplate(template,{config:cloudConfig});if(result.isErr)return result.error.message;const diff=result.value;if(!diff.deployed)log.info(`${diff.stack} is not deployed in ${diff.region}. The whole template (${diff.localBytes} bytes) is new.`);else if(!diff.changed)log.info(`No changes detected against ${diff.stack} (${diff.region}).`);else{log.info(`Changes detected between ${diff.stack} (${diff.region}) and the local template:`);log.info(`Deployed template: ${diff.deployedBytes} bytes`);log.info(`Local template: ${diff.localBytes} bytes`)}return}catch(error){return error instanceof Error?error.message:String(error)}}export function cloud(buddy){buddy.command("cloud:apply","Apply configured server size, persistent volumes and deployment storage").option("--env [env]","Environment to reconcile",{default:"production"}).option("--dry-run","Show the compute plan without changing anything",{default:!1}).option("--yes","Apply the displayed plan without prompting",{default:!1}).option("-J, --json","Print the plan as JSON",{default:!1}).action(async(options)=>{try{const{createCloudDriver}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy"),config=await loadTsCloudConfig(options.env);if(!config?.project?.slug)throw Error("config/cloud.ts must declare tsCloud.project.slug");const environment=options.env||"production",nativeConfig=config,driver=createCloudDriver({config:nativeConfig});if(!driver.applyComputeChanges)throw Error("This cloud provider does not support configured compute changes");const plan=await driver.applyComputeChanges({config:nativeConfig,environment,dryRun:!0});if(options.json)console.log(JSON.stringify(plan,null,2));else{for(const change of plan.changes)await log.info(`${change.resource}: ${change.action} (${change.detail})`);if(plan.waitingForCapacity)await log.warn(plan.waitingForCapacity)}if(options.dryRun)return;if(!await confirmCloudChange(options,"buddy cloud:apply --yes","Apply these compute changes?","apply compute changes"))return;const applied=await driver.applyComputeChanges({config:nativeConfig,environment});if(applied.waitingForCapacity)await log.warn(applied.waitingForCapacity);await log.success("Configured compute changes applied")}catch(error){await log.error(error instanceof Error?error.message:String(error));process.exitCode=ExitCode.FatalError}});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",rename:"Rename a server in place, keeping the provider, state pin, hostname and inventory in step",renameTo:"The new server name",renameEnv:"Environment whose fleet and state pin to rename in",renameJson:"Emit the rename plan as JSON",destroy:"Destroy a drained server, once nothing on it is anyone's rollback",destroyEnv:"Environment whose fleet and state pin the server belongs to",destroyJson:"Emit the teardown plan as JSON",destroyConfirm:"The exact server name, which an irreversible step requires before it runs",destroyDiscardDrained:"Destroy it even though it still holds site trees that are somebody's rollback",move:"Move a deployed site to another server, cutting DNS over once the target serves it",moveTo:"Server to move the site to, by provider name or by owning project slug",moveFrom:"Server the site is on now, when more than one could be serving it",moveEnv:"Environment whose fleet and sites to move within",moveJson:"Emit the move plan as JSON",moveConfirm:"The exact site name, which the irreversible steps require before they run",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("-y, --yes","Invalidate the CDN cache without asking first",{default:!1}).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 jumpBox=await addJumpBox();if(isResultError(jumpBox)){await outro("Could not find the jump box",{startTime,useSeconds:!0},getResultError(jumpBox));process.exit(ExitCode.FatalError)}const{message,state}=getResultValue(jumpBox);if(!state.instanceId){log.info(message);await outro("There is no jump box to connect to",{startTime,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}const result=await runCommand(["aws","ssm","start-session","--target",state.instanceId,"--region",state.region],{...options,cwd:p.projectPath(),stdin:"inherit",stdout:"inherit"});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 confirmInvalidation(options,"buddy cloud --invalidate-cache --yes","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){const failure=await runCloudDiff();if(failure){await outro("While running the cloud diff command, there was an issue",{startTime,useSeconds:!0},failure);process.exit(ExitCode.FatalError)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Not implemented yet. Read more about `buddy cloud` here: https://stacksjs.com/docs/cloud");await log.flush();process.exit(ExitCode.Success)});buddy.command("cloud:add",descriptions.add).option("--jump-box","Check for the jump box, and say how to declare one if the stack has none",{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){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)}const change=getResultValue(result);log.info(change.message);if(!change.done){await outro("No jump box was added.",{startTime,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}await outro("Your cloud has a jump box.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("This functionality is not yet implemented.");await log.flush();process.exit(ExitCode.Success)});buddy.command("cloud:remove",descriptions.remove).alias("cloud:rm").alias("undeploy").option("--jump-box","Check for the jump box, and say how to remove it from the stack",{default:!1}).option("-y, --yes","Skip the confirmation prompt",{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.jumpBox){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)}const change=getResultValue(result);log.info(change.message);if(!change.done){await outro("The jump box was not removed.",{startTime,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}await outro("Your cloud has no jump box.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}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,type:"warning"});await log.flush();process.exit(ExitCode.FatalError)}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(`
|
|
5
|
+
`);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("@stacksjs/actions/deploy/stack"),region=await stacksCloudRegion(void 0,environment);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,type:"error"});process.exit(ExitCode.FatalError)}});buddy.command("cloud:optimize-cost",descriptions.optimizeCost).option("--jump-box","Check for the jump box, and say how to remove it from the stack",{default:!0}).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){const result=await deleteJumpBox();if(isResultError(result)){await outro("While checking your jump-box, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}const change=getResultValue(result);log.info(change.message);if(!change.done){await outro("No cost optimization was applied",{startTime,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}}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("--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:"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("-y, --yes","Invalidate without asking first",{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 confirmInvalidation(options,"buddy cloud:invalidate-cache --yes","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",stdout:"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("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:diff` ...",options);const startTime=await intro("buddy cloud:diff"),failure=await runCloudDiff();if(failure){await outro("While running the cloud diff command, there was an issue",{startTime,useSeconds:!0},failure);process.exit(ExitCode.FatalError)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:dashboard",descriptions.dashboard).alias("cloud:cockpit").option("--host [host]",descriptions.host,{default:"127.0.0.1"}).option("--port [port]",descriptions.port,{default:"7676"}).option("--env [env]",descriptions.env).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:dashboard` ...",options);const startTime=await intro("buddy cloud:dashboard"),tsCloud=await import("@stacksjs/ts-cloud");if(typeof tsCloud.startLocalDashboardServer!=="function"){await outro("The installed @stacksjs/ts-cloud does not provide the local cockpit yet",{startTime,useSeconds:!0},"Update your dependencies (requires @stacksjs/ts-cloud >= 0.5.27).");process.exit(ExitCode.FatalError)}try{const server=await tsCloud.startLocalDashboardServer({host:options.host?String(options.host):void 0,port:options.port?Number(options.port):void 0,environment:options.env,verbose:!!options.verbose});log.success(`Stacks Cloud cockpit running at ${underline(server.url)}`);log.info(italic("Manage servers, sites, SSH keys and deploys. Press Ctrl+C to stop."));await new Promise(()=>{})}catch(error){await outro("While starting the cloud dashboard, there was an issue",{startTime,useSeconds:!0},error?.message??String(error));process.exit(ExitCode.FatalError)}});buddy.command("cloud:sites",descriptions.sites).option("--env [env]",descriptions.sitesEnv).option("--no-remote",descriptions.remote).option("-J, --json",descriptions.json,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:sites` ...",options);const{formatInventory,probeHostRoutes}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy"),environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0),slug=tsCloudConfig?.project?.slug||"app";await assertFleetProvider(tsCloudConfig,"cloud:sites");const listing=await listFleet(tsCloudConfig);if(listing.problem)await log.error(listing.problem);const declared=await declaredSitesFor(tsCloudConfig,slug),probes=options.remote===!1?[]:await probeFleet(listing.servers),inventory={slug,servers:listing.servers,probes,declared};if(options.json)console.log(JSON.stringify({environment,...inventory},null,2));else for(const line of formatInventory(inventory))console.log(line);process.exit(listing.problem&&listing.servers.length===0?ExitCode.FatalError:ExitCode.Success);async function probeFleet(servers){const{sshExec}=await import("@stacksjs/ts-cloud"),probes=[];for(let index=0;index<servers.length;index+=6)probes.push(...await Promise.all(servers.slice(index,index+6).map((server)=>probeHostRoutes(server,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})))));return probes}});buddy.command("cloud:attach",descriptions.attach).option("--server <server>",descriptions.attachServer).option("--env [env]",descriptions.sitesEnv).option("--dry-run",descriptions.dryRun,{default:!1}).option("-J, --json",descriptions.json,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:attach` ...",options);const{attachConflicts,attachIsViable,attachPreconditions,formatAttachPlan,probeHostRoutes,resolveAttachTarget,setAttachToInCloudConfig,sshExec}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy");if(!options.server)return await refuse("Which server? Pass --server <name|owner-slug>. `buddy cloud:sites` lists them.");const environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0),slug=tsCloudConfig?.project?.slug||"app";await assertFleetProvider(tsCloudConfig,"cloud:attach");const listing=await listFleet(tsCloudConfig);if(listing.problem)return await refuse(listing.problem);const target=resolveAttachTarget(listing.servers,String(options.server),environment);if("problem"in target)return await refuse(`${target.problem} \`buddy cloud:sites\` lists what is there.`);const server=target.server,preconditions=attachPreconditions(slug,server);if(preconditions.length>0)return await refuse(...preconditions);const declared=await declaredSitesFor(tsCloudConfig,slug),probe=await probeHostRoutes(server,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})),plan={slug,owner:server.project,server,declared,conflicts:probe.unavailable?[]:attachConflicts(slug,declared,probe.routes),registryRead:!probe.unavailable,registryProblem:probe.unavailable},viable=attachIsViable(plan),edit=viable?await applyAttachToConfig(plan.owner,Boolean(options.dryRun)):void 0;if(options.json)console.log(JSON.stringify({environment,...plan,edit},null,2));else{for(const line of formatAttachPlan(plan))console.log(line);if(edit)console.log(["",...describeAttachEdits(plan.slug,plan.owner,edit,Boolean(options.dryRun))].join(`
|
|
6
6
|
`))}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)}}}});buddy.command("cloud:rename <server>",descriptions.rename).option("--to <name>",descriptions.renameTo).option("--env [env]",descriptions.renameEnv).option("--dry-run",descriptions.dryRun,{default:!1}).option("-J, --json",descriptions.renameJson,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(server,options)=>{log.debug("Running `buddy cloud:rename` ...",options);const{applyPlan,formatPlan,pendingSteps,planServerRename,resolvePlan,validateServerName}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy");if(!options.to)return await refuse("Rename it to what? Pass --to <name>.");const next=String(options.to);try{validateServerName(next)}catch(error){return await refuse(error instanceof Error?error.message:String(error))}const environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0);await assertFleetProvider(tsCloudConfig,"cloud:rename");const listing=await listFleet(tsCloudConfig);if(listing.problem)return await refuse(listing.problem);const current=listing.servers.find((s)=>s?.name===server);if(!current)return await refuse(`No server named ${server}. \`buddy cloud:sites\` lists what is there.`);if(server===next)return await refuse(`${server} is already called that.`);const plan=await planServerRename(server,next,await renameEffects(tsCloudConfig,listing.servers,current)),resolved=await resolvePlan(plan),pending=pendingSteps(resolved);if(options.json){console.log(JSON.stringify({operation:plan.operation,target:plan.target,to:next,steps:resolved.map((entry)=>({id:entry.step.id,title:entry.step.title,state:entry.state,reason:entry.reason,change:entry.step.change}))},null,2));return}console.log("");for(const line of formatPlan(plan,resolved))console.log(line);console.log("");if(options.dryRun){await log.info(`Dry run: ${pending.length} step(s) would run. Nothing changed.`);return}if(pending.length===0){await log.success(`${server} is already named ${next} everywhere. Nothing to do.`);return}const outcome=await applyPlan(plan,resolved,{log:(message)=>console.log(` ${message}`)});if(!outcome.success){const failed=outcome.steps.find((step)=>step.state==="failed");return await refuse(`Stopped at: ${failed?.title??"an unnamed step"}`,failed?.error??"The step gave no reason.",`Re-run \`buddy cloud:rename ${server} --to ${next}\` to continue: completed steps skip themselves.`)}await log.success(`Renamed ${server} to ${next}`)});buddy.command("cloud:destroy <server>",descriptions.destroy).option("--env [env]",descriptions.destroyEnv).option("--confirm <name>",descriptions.destroyConfirm).option("--discard-drained",descriptions.destroyDiscardDrained,{default:!1}).option("--dry-run",descriptions.dryRun,{default:!1}).option("-J, --json",descriptions.destroyJson,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(server,options)=>{log.debug("Running `buddy cloud:destroy` ...",options);const{applyPlan,buildDrainedSiteScanScript,formatDrainedSiteRefusal,formatPlan,parseDrainedSites,pendingSteps,resolvePlan,sshExec}=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);await assertFleetProvider(tsCloudConfig,"cloud:destroy");const listing=await listFleet(tsCloudConfig);if(listing.problem)return await refuse(listing.problem);const current=listing.servers.find((s)=>s?.name===server);if(!current)return await refuse(`No server named ${server}. \`buddy cloud:sites\` lists what is there.`);const slug=String(tsCloudConfig?.project?.slug||"app");if(current.ipv4&&!options.discardDrained){const scan=await sshExec(current.ipv4,buildDrainedSiteScanScript(slug).join(`
|
|
7
7
|
`),{user:"root",connectTimeoutSec:10});if(scan.code===0){const drained=parseDrainedSites(scan.stdout);if(drained.length>0)return await refuse(formatDrainedSiteRefusal(drained,slug,"--discard-drained"))}else if(!options.dryRun)return await refuse(`Could not scan ${server} for drained sites: ${scan.stderr.trim()||"the box did not answer over SSH."}`,"A box that cannot be scanned may still be holding a rollback. Re-run with --discard-drained if it is not.")}const plan=planServerDestroy(server,await destroyEffects(tsCloudConfig,current)),resolved=await resolvePlan(plan),pending=pendingSteps(resolved);if(options.json){console.log(JSON.stringify({operation:plan.operation,target:plan.target,steps:resolved.map((entry)=>({id:entry.step.id,title:entry.step.title,state:entry.state,reason:entry.reason,destructive:entry.step.destructive,change:entry.step.change}))},null,2));return}console.log("");for(const line of formatPlan(plan,resolved))console.log(line);console.log("");if(options.dryRun){await log.info(`Dry run: ${pending.length} step(s) would run. Nothing changed.`);return}if(pending.length===0){await log.success(`${server} is already gone, and nothing still points at it.`);return}if(options.confirm!==server)return await refuse(`Destroying ${server} cannot be undone.`,`Re-run with \`--confirm ${server}\` to go ahead.`);const outcome=await applyPlan(plan,resolved,{log:(message)=>console.log(` ${message}`),confirm:options.confirm});if(!outcome.success){const failed=outcome.steps.find((step)=>step.state==="failed");return await refuse(`Stopped at: ${failed?.title??"an unnamed step"}`,failed?.error??"The step gave no reason.",`Re-run \`buddy cloud:destroy ${server} --confirm ${server}\` to continue: completed steps skip themselves.`)}await log.success(`Destroyed ${server}`)});buddy.command("cloud:move <site>",descriptions.move).option("--to <server>",descriptions.moveTo).option("--from <server>",descriptions.moveFrom).option("--env [env]",descriptions.moveEnv).option("--confirm <site>",descriptions.moveConfirm).option("--dry-run",descriptions.dryRun,{default:!1}).option("-J, --json",descriptions.moveJson,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(site,options)=>{log.debug("Running `buddy cloud:move` ...",options);const{applyPlan,formatPlan,gatewayHostnames,pendingSteps,planSiteMove,probeHostRoutes,resolveAttachTarget,resolvePlan,siteInstallBase,sshExec}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy");if(!options.to)return await refuse("Move it where? Pass --to <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=String(tsCloudConfig?.project?.slug||"app");await assertFleetProvider(tsCloudConfig,"cloud:move");const rawSite=(tsCloudConfig?.sites??{})[site];if(!rawSite)return await refuse(`config/cloud.ts declares no site named '${site}'.`);const listing=await listFleet(tsCloudConfig);if(listing.problem)return await refuse(listing.problem);const resolvedTarget=resolveAttachTarget(listing.servers,String(options.to),environment);if("problem"in resolvedTarget)return await refuse(`${resolvedTarget.problem} \`buddy cloud:sites\` lists what is there.`);const target=resolvedTarget.server,hostnames=gatewayHostnames({[site]:rawSite},{autoWww:Boolean(tsCloudConfig?.infrastructure?.compute?.proxy?.autoWww)}),shared=sitesSharingHostnames(tsCloudConfig?.sites??{},site,hostnames,(name,declared)=>gatewayHostnames({[name]:declared},{autoWww:Boolean(tsCloudConfig?.infrastructure?.compute?.proxy?.autoWww)}));if(shared.length>0)return await refuse(`'${site}' answers on ${hostnames.join(", ")}, and so do ${shared.length} other site(s): ${shared.join(", ")}.`,"A move repoints the hostname, not the path - so this would carry one tree to the target and send every request for the others to a box that does not have them.","Give the site its own hostname first, or move the whole set to the target and deploy once.");const source=options.from?listing.servers.find((s)=>s?.name===options.from):await serverServing(listing.servers,hostnames,(candidate)=>probeHostRoutes(candidate,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})));if(!source)return await refuse(options.from?`No server named ${options.from}.`:`Could not tell which server serves '${site}'. Pass --from <name> to say.`);if(source.name===target.name)return await refuse(`'${site}' is already on ${target.name}.`);if(!source.ipv4||!target.ipv4)return await refuse(`A move needs an address for both boxes, and ${(source.ipv4?target:source).name} has none.`);let plan;try{plan=await planSiteMove({slug,siteName:site,appBase:siteInstallBase(slug,site),from:source.name,to:target.name,targetAddress:target.ipv4,port:Number(rawSite?.port)||void 0,database:await onBoxDatabase(tsCloudConfig)},await moveEffects({tsCloudConfig,slug,siteName:site,rawSite,source,target,hostnames}))}catch(error){return await refuse(error instanceof Error?error.message:String(error))}const resolved=await resolvePlan(plan),pending=pendingSteps(resolved);if(options.json){console.log(JSON.stringify({operation:plan.operation,target:plan.target,from:source.name,to:target.name,hostnames,steps:resolved.map((entry)=>({id:entry.step.id,title:entry.step.title,state:entry.state,reason:entry.reason,destructive:entry.step.destructive,change:entry.step.change}))},null,2));return}console.log("");for(const line of formatPlan(plan,resolved))console.log(line);console.log("");if(options.dryRun){await log.info(`Dry run: ${pending.length} step(s) would run. Nothing changed.`);return}if(pending.length===0){await log.success(`'${site}' is already on ${target.name}, serving and cut over.`);return}const outcome=await applyPlan(plan,resolved,{log:(message)=>console.log(` ${message}`),confirm:options.confirm});if(!outcome.success){const failed=outcome.steps.find((step)=>step.state==="failed");return await refuse(`Stopped at: ${failed?.title??"an unnamed step"}`,failed?.error??"The step gave no reason.",`The source still holds the site's files - that is the rollback. Re-run \`buddy cloud:move ${site} --to ${target.name}\` to continue: completed steps skip themselves.`)}await log.success(`Moved '${site}' from ${source.name} to ${target.name}`)});onUnknownSubcommand(buddy,"cloud")}export async function confirmInvalidation(options,retry,question){return confirmCloudChange(options,retry,question,"invalidate the CDN cache")}export async function confirmCloudChange(options,retry,question,action){if(options.yes)return!0;if(isCI||!hasTTY||!process.stdin.isTTY){log.syncError(`Refusing to ${action} from a non-interactive shell without confirmation.`);log.syncError(` \u27A1\uFE0F Re-run with \`--yes\`: \`${retry}\``);await log.flush();process.exit(ExitCode.FatalError)}return await prompts.confirm(question)}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/buddy",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.443",
|
|
6
6
|
"description": "Meet Buddy. The Stacks runtime.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -95,72 +95,72 @@
|
|
|
95
95
|
"prepublishOnly": "bun run build"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
|
98
|
-
"@stacksjs/actions": "0.75.
|
|
99
|
-
"@stacksjs/ai": "0.75.
|
|
100
|
-
"@stacksjs/alias": "0.75.
|
|
101
|
-
"@stacksjs/analytics": "0.75.
|
|
102
|
-
"@stacksjs/api": "0.75.
|
|
103
|
-
"@stacksjs/arrays": "0.75.
|
|
104
|
-
"@stacksjs/auth": "0.75.
|
|
105
|
-
"@stacksjs/browser-extension": "0.75.
|
|
106
|
-
"@stacksjs/build": "0.75.
|
|
98
|
+
"@stacksjs/actions": "0.75.443",
|
|
99
|
+
"@stacksjs/ai": "0.75.443",
|
|
100
|
+
"@stacksjs/alias": "0.75.443",
|
|
101
|
+
"@stacksjs/analytics": "0.75.443",
|
|
102
|
+
"@stacksjs/api": "0.75.443",
|
|
103
|
+
"@stacksjs/arrays": "0.75.443",
|
|
104
|
+
"@stacksjs/auth": "0.75.443",
|
|
105
|
+
"@stacksjs/browser-extension": "0.75.443",
|
|
106
|
+
"@stacksjs/build": "0.75.443",
|
|
107
107
|
"@stacksjs/bumpx": "^0.2.26",
|
|
108
|
-
"@stacksjs/cache": "0.75.
|
|
109
|
-
"@stacksjs/chat": "0.75.
|
|
108
|
+
"@stacksjs/cache": "0.75.443",
|
|
109
|
+
"@stacksjs/chat": "0.75.443",
|
|
110
110
|
"@stacksjs/clapp": "^0.2.16",
|
|
111
|
-
"@stacksjs/cli": "0.75.
|
|
112
|
-
"@stacksjs/cloud": "0.75.
|
|
113
|
-
"@stacksjs/cms": "0.75.
|
|
114
|
-
"@stacksjs/collections": "0.75.
|
|
115
|
-
"@stacksjs/commerce": "0.75.
|
|
116
|
-
"@stacksjs/config": "0.75.
|
|
117
|
-
"@stacksjs/database": "0.75.
|
|
118
|
-
"@stacksjs/desktop-build": "0.75.
|
|
119
|
-
"@stacksjs/dns": "0.75.
|
|
111
|
+
"@stacksjs/cli": "0.75.443",
|
|
112
|
+
"@stacksjs/cloud": "0.75.443",
|
|
113
|
+
"@stacksjs/cms": "0.75.443",
|
|
114
|
+
"@stacksjs/collections": "0.75.443",
|
|
115
|
+
"@stacksjs/commerce": "0.75.443",
|
|
116
|
+
"@stacksjs/config": "0.75.443",
|
|
117
|
+
"@stacksjs/database": "0.75.443",
|
|
118
|
+
"@stacksjs/desktop-build": "0.75.443",
|
|
119
|
+
"@stacksjs/dns": "0.75.443",
|
|
120
120
|
"@stacksjs/dnsx": "^0.2.3",
|
|
121
|
-
"@stacksjs/email": "0.75.
|
|
122
|
-
"@stacksjs/enums": "0.75.
|
|
123
|
-
"@stacksjs/env": "0.75.
|
|
124
|
-
"@stacksjs/error-handling": "0.75.
|
|
125
|
-
"@stacksjs/events": "0.75.
|
|
126
|
-
"@stacksjs/features": "0.75.
|
|
127
|
-
"@stacksjs/git": "0.75.
|
|
121
|
+
"@stacksjs/email": "0.75.443",
|
|
122
|
+
"@stacksjs/enums": "0.75.443",
|
|
123
|
+
"@stacksjs/env": "0.75.443",
|
|
124
|
+
"@stacksjs/error-handling": "0.75.443",
|
|
125
|
+
"@stacksjs/events": "0.75.443",
|
|
126
|
+
"@stacksjs/features": "0.75.443",
|
|
127
|
+
"@stacksjs/git": "0.75.443",
|
|
128
128
|
"@stacksjs/gitit": "^0.2.5",
|
|
129
|
-
"@stacksjs/health": "0.75.
|
|
129
|
+
"@stacksjs/health": "0.75.443",
|
|
130
130
|
"@stacksjs/httx": "^0.1.10",
|
|
131
|
-
"@stacksjs/image": "0.75.
|
|
132
|
-
"@stacksjs/lint": "0.75.
|
|
133
|
-
"@stacksjs/logging": "0.75.
|
|
134
|
-
"@stacksjs/notifications": "0.75.
|
|
135
|
-
"@stacksjs/objects": "0.75.
|
|
136
|
-
"@stacksjs/orm": "0.75.
|
|
137
|
-
"@stacksjs/path": "0.75.
|
|
138
|
-
"@stacksjs/payments": "0.75.
|
|
139
|
-
"@stacksjs/realtime": "0.75.
|
|
140
|
-
"@stacksjs/router": "0.75.
|
|
131
|
+
"@stacksjs/image": "0.75.443",
|
|
132
|
+
"@stacksjs/lint": "0.75.443",
|
|
133
|
+
"@stacksjs/logging": "0.75.443",
|
|
134
|
+
"@stacksjs/notifications": "0.75.443",
|
|
135
|
+
"@stacksjs/objects": "0.75.443",
|
|
136
|
+
"@stacksjs/orm": "0.75.443",
|
|
137
|
+
"@stacksjs/path": "0.75.443",
|
|
138
|
+
"@stacksjs/payments": "0.75.443",
|
|
139
|
+
"@stacksjs/realtime": "0.75.443",
|
|
140
|
+
"@stacksjs/router": "0.75.443",
|
|
141
141
|
"@stacksjs/rpx": "^0.11.54",
|
|
142
|
-
"@stacksjs/scheduler": "0.75.
|
|
143
|
-
"@stacksjs/search-engine": "0.75.
|
|
144
|
-
"@stacksjs/security": "0.75.
|
|
145
|
-
"@stacksjs/server": "0.75.
|
|
146
|
-
"@stacksjs/sites": "0.75.
|
|
147
|
-
"@stacksjs/skills": "0.75.
|
|
148
|
-
"@stacksjs/socials": "0.75.
|
|
149
|
-
"@stacksjs/storage": "0.75.
|
|
150
|
-
"@stacksjs/strings": "0.75.
|
|
151
|
-
"@stacksjs/stx": "0.2.
|
|
152
|
-
"@stacksjs/testing": "0.75.
|
|
153
|
-
"@stacksjs/tinker": "0.75.
|
|
142
|
+
"@stacksjs/scheduler": "0.75.443",
|
|
143
|
+
"@stacksjs/search-engine": "0.75.443",
|
|
144
|
+
"@stacksjs/security": "0.75.443",
|
|
145
|
+
"@stacksjs/server": "0.75.443",
|
|
146
|
+
"@stacksjs/sites": "0.75.443",
|
|
147
|
+
"@stacksjs/skills": "0.75.443",
|
|
148
|
+
"@stacksjs/socials": "0.75.443",
|
|
149
|
+
"@stacksjs/storage": "0.75.443",
|
|
150
|
+
"@stacksjs/strings": "0.75.443",
|
|
151
|
+
"@stacksjs/stx": "0.2.787",
|
|
152
|
+
"@stacksjs/testing": "0.75.443",
|
|
153
|
+
"@stacksjs/tinker": "0.75.443",
|
|
154
154
|
"@stacksjs/tlsx": "^0.13.19",
|
|
155
155
|
"@stacksjs/ts-cloud": "^0.16.91",
|
|
156
|
-
"@stacksjs/tunnel": "0.75.
|
|
157
|
-
"@stacksjs/types": "0.75.
|
|
158
|
-
"@stacksjs/ui": "0.75.
|
|
159
|
-
"@stacksjs/utils": "0.75.
|
|
160
|
-
"@stacksjs/validation": "0.75.
|
|
156
|
+
"@stacksjs/tunnel": "0.75.443",
|
|
157
|
+
"@stacksjs/types": "0.75.443",
|
|
158
|
+
"@stacksjs/ui": "0.75.443",
|
|
159
|
+
"@stacksjs/utils": "0.75.443",
|
|
160
|
+
"@stacksjs/validation": "0.75.443",
|
|
161
161
|
"ajv": "^8.20.0",
|
|
162
162
|
"ajv-formats": "^3.0.1",
|
|
163
|
-
"bun-plugin-stx": "0.2.
|
|
163
|
+
"bun-plugin-stx": "0.2.787",
|
|
164
164
|
"ts-pantry": "^0.11.161"
|
|
165
165
|
},
|
|
166
166
|
"devDependencies": {
|