@stacksjs/buddy 0.73.2 → 0.74.0

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,2 +1,45 @@
1
+ import { fetchPublishedVersions } from '../registry';
1
2
  import type { CLI } from '@stacksjs/types';
2
3
  export declare function create(buddy: CLI): void;
4
+ /**
5
+ * Uses `@stacksjs/gitit`'s library API directly rather than shelling out to
6
+ * `bunx --bun @stacksjs/gitit`. `bunx` always resolves the published npm
7
+ * package into an ephemeral install, bypassing whatever gitit version is
8
+ * actually installed in this project, and adds a registry round-trip that
9
+ * has no benefit here since gitit is already a direct dependency.
10
+ *
11
+ * The source is pinned to `gh:stacksjs/stacks` on purpose: gitit's default
12
+ * template registry still points the bare `stacks` name at the old org and
13
+ * only reaches us via GitHub's repo-transfer redirect. Resolving the GitHub
14
+ * provider directly removes that third-party lookup (and its supply-chain
15
+ * risk) entirely.
16
+ *
17
+ * `force` lets the template extract into an already-existing directory, which
18
+ * is what makes scaffolding into a freshly cloned repository work: gitit
19
+ * otherwise refuses any non-empty destination, and a `.git` directory counts as
20
+ * non-empty. It unpacks alongside whatever is there rather than clearing it, so
21
+ * `.git` survives. Never use `forceClean` here — that deletes the destination
22
+ * first, which would take the repository's history with it. `isFolderCheck()`
23
+ * has already established the target holds nothing but `.git`.
24
+ */
25
+ /**
26
+ * The template ref to scaffold from: the tag matching the framework version the
27
+ * app is about to pin, or `null` for the default branch.
28
+ *
29
+ * This used to be the default branch unconditionally, while `unpublish:core`
30
+ * pinned the framework to the newest PUBLISHED version. Those are two different
31
+ * points in history, and the gap between them is exactly where a release has
32
+ * not happened yet - so a freshly scaffolded app carried userland (`config/`,
33
+ * `app/`, `routes/`) written against framework changes it could not install,
34
+ * and failed its own `./buddy typecheck` before the user had touched anything.
35
+ * `MobileConfig` did it in stacksjs/stacks#2322, and `security.api` was doing
36
+ * it again while this was being written.
37
+ *
38
+ * Scaffolding from the tag removes the disagreement by construction rather than
39
+ * reporting it afterwards, and makes `buddy new` reproducible: two people
40
+ * scaffolding a week apart get the same app rather than whatever main happened
41
+ * to be.
42
+ */
43
+ export declare function templateRef(fetchVersions?: typeof fetchPublishedVersions): Promise<string | null>;
44
+ /** The gitit spec for a template ref, or the default branch when there is none. */
45
+ export declare function templateSpec(ref: string | null): string;
@@ -1 +1 @@
1
- import{chmodSync,cpSync,existsSync,readFileSync,readdirSync,rmSync,writeFileSync}from"node:fs";import process from"node:process";import{runAction}from"@stacksjs/actions";import{bold,cyan,dim,intro,log,onUnknownSubcommand,runCommand}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{resolve}from"@stacksjs/path";import{isFolder}from"@stacksjs/storage";import{ExitCode}from"@stacksjs/types";import{uninstallAllFeatures}from"./features";import{ensurePantryDependencies,ensurePantryInstalled}from"./setup";import{resultFailed}from"../result";export function create(buddy){const descriptions={name:"The name of the project",command:"Create a new Stacks project",ui:"Are you building a UI?",components:"Are you building UI components?",webComponents:"Automagically built optimized custom elements/web components?",views:"How about views?",functions:"Are you developing functions/composables?",api:"Are you building an API?",database:"Do you need a database?",notifications:"Do you need notifications? e.g. email, SMS, push or chat notifications",cache:"Do you need caching?",email:"Do you need email?",project:"Target a specific project",minimal:"Skip optional feature bundles (cms, commerce, dashboard, marketing, monitoring, realtime, queue) - bare-bones API/SPA starter that can re-add them later via `./buddy <feature>:install`.",withCore:"Keep the framework vendored in `storage/framework/core` as a Bun workspace, for working ON Stacks. Apps that only work WITH Stacks want the default, which resolves every @stacksjs/* package from npm.",verbose:"Enable verbose output"};buddy.command("new [name]",descriptions.command).alias("create [name]").option("-n, --name [name]",descriptions.name,{default:!1}).option("-u, --ui",descriptions.ui,{default:!0}).option("-c, --components",descriptions.components,{default:!0}).option("-w, --web-components",descriptions.webComponents,{default:!0}).option("-p, --views",descriptions.views,{default:!0}).option("-f, --functions",descriptions.functions,{default:!0}).option("-a, --api",descriptions.api,{default:!0}).option("-d, --database",descriptions.database,{default:!0}).option("-ca, --cache",descriptions.cache,{default:!1}).option("-e, --email",descriptions.email,{default:!1}).option("-P, --project [project]",descriptions.project,{default:!1}).option("-m, --minimal",descriptions.minimal,{default:!1}).option("--with-core",descriptions.withCore,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy new <name>` ...",options);const startTime=await intro("buddy new");name=name??options.name;const path=resolve(process.cwd(),name);isFolderCheck(path);await onlineCheck();const result=await download(name,path,options);if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}ensureExecutableScripts(path);applyAppVcsTemplate(path);applyAppConfigTemplate(path);removeFrameworkTests(path);await ensureEnv(path,options);if(options.minimal)await stripFeatures(path);await install(path,options);if(!options.withCore)await unvendorCore(path,options);if(startTime){const time=performance.now()-startTime;log.success(dim(`[${time.toFixed(2)}ms] Completed`))}log.info(bold("Welcome to the Stacks Framework! \u269B\uFE0F"));log.info(`Get started: ${cyan(`cd ${name}`)} and then ${cyan("./buddy dev")}`);log.info(`Run ${cyan("./buddy doctor")} anytime to check your setup`);log.info("To learn more, visit https://stacksjs.com");process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"new")}function isFolderCheck(path){if(!isFolder(path))return;if(readdirSync(path).filter((entry)=>entry!==".git").length===0)return;log.error(`Path ${path} already exists`);process.exit(ExitCode.FatalError)}async function onlineCheck(){if(await isOnline())return;log.info("It appears you are disconnected from the internet.");log.info("Creating a new project requires a brief internet connection to download the template and install dependencies.");process.exit(ExitCode.FatalError)}async function isOnline(){try{return(await fetch("https://github.com",{method:"HEAD",signal:AbortSignal.timeout(3000)})).ok}catch{return!1}}async function download(name,path,_options){log.info("Setting up your stack.");try{const{downloadTemplate}=await import("@stacksjs/gitit");await downloadTemplate("gh:stacksjs/stacks",{dir:name,force:!0});log.success(`Successfully scaffolded your project at ${cyan(path)}`);return{isErr:!1}}catch(error){return{isErr:!0,error:error instanceof Error?error.message:String(error)}}}function applyAppVcsTemplate(path){const source=resolve(path,"storage/framework/defaults/vcs/github"),destination=resolve(path,".github");if(!existsSync(source)){log.warn("No app CI template found at storage/framework/defaults/vcs/github - leaving .github as downloaded.");return}log.info("Installing app-shaped GitHub workflows...");try{rmSync(destination,{recursive:!0,force:!0});cpSync(source,destination,{recursive:!0});log.success("App CI installed")}catch(error){log.warn(`Could not install the app CI template: ${error instanceof Error?error.message:String(error)}`)}}function applyAppConfigTemplate(path){const source=resolve(path,"storage/framework/defaults/scaffold/config"),destination=resolve(path,"config");if(!existsSync(source)){log.warn("No app config template found at storage/framework/defaults/scaffold/config - leaving config as downloaded.");return}const slug=path.replace(/\/+$/,"").split("/").pop()||"stacks-app",displayName=slug.split(/[-_\s]+/).filter(Boolean).map((part)=>`${part.charAt(0).toUpperCase()}${part.slice(1)}`).join(" ");log.info("Installing app-safe infrastructure configuration...");try{for(const file of readdirSync(source)){if(!file.endsWith(".ts"))continue;const rendered=readFileSync(resolve(source,file),"utf8").replaceAll("__APP_NAME__",displayName).replaceAll("__APP_SLUG__",slug);writeFileSync(resolve(destination,file),rendered)}log.success("App-safe infrastructure configuration installed")}catch(error){log.warn(`Could not install the app config template: ${error instanceof Error?error.message:String(error)}`)}}function removeFrameworkTests(path){const tests=resolve(path,"tests");if(!existsSync(tests))return;log.info("Removing the framework's own test suite...");for(const entry of readdirSync(tests)){if(entry==="setup.ts")continue;rmSync(resolve(tests,entry),{recursive:!0,force:!0})}log.success("App starts with a clean test suite")}function ensureExecutableScripts(path){for(const script of["buddy","bootstrap"])try{chmodSync(resolve(path,script),493)}catch{}}async function ensureEnv(path,_options){log.info("Ensuring your environment is ready...");await ensurePantryInstalled();await ensurePantryDependencies(path);log.success("Environment is ready")}async function install(path,options){log.info("Installing & setting up Stacks");log.info("Copying .env.example \u2192 .env");let result=await runCommand("cp .env.example .env",{...options,cwd:path});if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}log.info("Removing template-encrypted env files...");const{rm}=await import("node:fs/promises");for(const stale of[".env.development",".env.staging",".env.production",".env.keys"])await rm(`${path}/${stale}`,{force:!0});log.info("Generating application key...");const keyResult=await runAction(Action.KeyGenerate,{...options,cwd:path});if(resultFailed(keyResult)){log.error(keyResult.error);process.exit(ExitCode.FatalError)}if(existsSync(resolve(path,".git")))log.info("Existing git repository detected, skipping git init");else{log.info("Initializing git repository...");result=await runCommand("git init",{...options,cwd:path});if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}}log.success("Installed & set-up \uD83D\uDE80")}async function unvendorCore(path,options){log.info("Resolving the framework from npm (pass --with-core to keep it vendored)...");const result=await runCommand("./buddy unpublish:core --all --force",{...options,cwd:path});if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}log.success("Framework resolved from npm")}async function stripFeatures(path){log.info("Stripping optional feature bundles (--minimal)...");const results=await uninstallAllFeatures({root:path});let strippedAny=!1;for(const{feature,configOutcome,filesRemoved}of results)if(configOutcome==="flipped"||filesRemoved.length>0){strippedAny=!0;const fileSummary=filesRemoved.length>0?` (${filesRemoved.length} path${filesRemoved.length===1?"":"s"})`:"";log.info(` - ${feature}${fileSummary}`)}if(!strippedAny)log.info(" \u2192 no feature scaffolding present; nothing to strip.");else log.success("Minimal skeleton ready - run `./buddy <feature>:install` to add features back.")}
1
+ import{chmodSync,cpSync,existsSync,readFileSync,readdirSync,rmSync,writeFileSync}from"node:fs";import process from"node:process";import{runAction}from"@stacksjs/actions";import{bold,cyan,dim,intro,log,onUnknownSubcommand,runCommand}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{resolve}from"@stacksjs/path";import{isFolder}from"@stacksjs/storage";import{ExitCode}from"@stacksjs/types";import{uninstallAllFeatures}from"./features";import{ensurePantryDependencies,ensurePantryInstalled}from"./setup";import{resultFailed}from"../result";import{fetchPublishedVersions}from"../registry";export function create(buddy){const descriptions={name:"The name of the project",command:"Create a new Stacks project",ui:"Are you building a UI?",components:"Are you building UI components?",webComponents:"Automagically built optimized custom elements/web components?",views:"How about views?",functions:"Are you developing functions/composables?",api:"Are you building an API?",database:"Do you need a database?",notifications:"Do you need notifications? e.g. email, SMS, push or chat notifications",cache:"Do you need caching?",email:"Do you need email?",project:"Target a specific project",minimal:"Skip optional feature bundles (cms, commerce, dashboard, marketing, monitoring, realtime, queue) - bare-bones API/SPA starter that can re-add them later via `./buddy <feature>:install`.",withCore:"Keep the framework vendored in `storage/framework/core` as a Bun workspace, for working ON Stacks. Apps that only work WITH Stacks want the default, which resolves every @stacksjs/* package from npm.",verbose:"Enable verbose output"};buddy.command("new [name]",descriptions.command).alias("create [name]").option("-n, --name [name]",descriptions.name,{default:!1}).option("-u, --ui",descriptions.ui,{default:!0}).option("-c, --components",descriptions.components,{default:!0}).option("-w, --web-components",descriptions.webComponents,{default:!0}).option("-p, --views",descriptions.views,{default:!0}).option("-f, --functions",descriptions.functions,{default:!0}).option("-a, --api",descriptions.api,{default:!0}).option("-d, --database",descriptions.database,{default:!0}).option("-ca, --cache",descriptions.cache,{default:!1}).option("-e, --email",descriptions.email,{default:!1}).option("-P, --project [project]",descriptions.project,{default:!1}).option("-m, --minimal",descriptions.minimal,{default:!1}).option("--with-core",descriptions.withCore,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy new <name>` ...",options);const startTime=await intro("buddy new");name=name??options.name;const path=resolve(process.cwd(),name);isFolderCheck(path);await onlineCheck();const result=await download(name,path,options);if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}ensureExecutableScripts(path);applyAppVcsTemplate(path);applyAppConfigTemplate(path);removeFrameworkTests(path);await ensureEnv(path,options);if(options.minimal)await stripFeatures(path);await install(path,options);if(!options.withCore)await unvendorCore(path,options);if(startTime){const time=performance.now()-startTime;log.success(dim(`[${time.toFixed(2)}ms] Completed`))}log.info(bold("Welcome to the Stacks Framework! \u269B\uFE0F"));log.info(`Get started: ${cyan(`cd ${name}`)} and then ${cyan("./buddy dev")}`);log.info(`Run ${cyan("./buddy doctor")} anytime to check your setup`);log.info("To learn more, visit https://stacksjs.com");process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"new")}function isFolderCheck(path){if(!isFolder(path))return;if(readdirSync(path).filter((entry)=>entry!==".git").length===0)return;log.error(`Path ${path} already exists`);process.exit(ExitCode.FatalError)}async function onlineCheck(){if(await isOnline())return;log.info("It appears you are disconnected from the internet.");log.info("Creating a new project requires a brief internet connection to download the template and install dependencies.");process.exit(ExitCode.FatalError)}async function isOnline(){try{return(await fetch("https://github.com",{method:"HEAD",signal:AbortSignal.timeout(3000)})).ok}catch{return!1}}export async function templateRef(fetchVersions=fetchPublishedVersions){try{const{latest}=await fetchVersions("stacks");return latest?`v${latest}`:null}catch{return null}}export function templateSpec(ref){return ref?`gh:stacksjs/stacks#${ref}`:"gh:stacksjs/stacks"}async function download(name,path,_options){log.info("Setting up your stack.");const ref=await templateRef();try{const{downloadTemplate}=await import("@stacksjs/gitit");try{await downloadTemplate(templateSpec(ref),{dir:name,force:!0})}catch(error){if(!ref)throw error;log.warn(`No ${ref} tag to scaffold from (${error instanceof Error?error.message:String(error)}).`);log.info("Falling back to the default branch. If this app does not typecheck, that gap is why.");await downloadTemplate(templateSpec(null),{dir:name,force:!0})}log.success(`Successfully scaffolded your project at ${cyan(path)}`);return{isErr:!1}}catch(error){return{isErr:!0,error:error instanceof Error?error.message:String(error)}}}function applyAppVcsTemplate(path){const source=resolve(path,"storage/framework/defaults/vcs/github"),destination=resolve(path,".github");if(!existsSync(source)){log.warn("No app CI template found at storage/framework/defaults/vcs/github - leaving .github as downloaded.");return}log.info("Installing app-shaped GitHub workflows...");try{rmSync(destination,{recursive:!0,force:!0});cpSync(source,destination,{recursive:!0});log.success("App CI installed")}catch(error){log.warn(`Could not install the app CI template: ${error instanceof Error?error.message:String(error)}`)}}function applyAppConfigTemplate(path){const source=resolve(path,"storage/framework/defaults/scaffold/config"),destination=resolve(path,"config");if(!existsSync(source)){log.warn("No app config template found at storage/framework/defaults/scaffold/config - leaving config as downloaded.");return}const slug=path.replace(/\/+$/,"").split("/").pop()||"stacks-app",displayName=slug.split(/[-_\s]+/).filter(Boolean).map((part)=>`${part.charAt(0).toUpperCase()}${part.slice(1)}`).join(" ");log.info("Installing app-safe infrastructure configuration...");try{for(const file of readdirSync(source)){if(!file.endsWith(".ts"))continue;const rendered=readFileSync(resolve(source,file),"utf8").replaceAll("__APP_NAME__",displayName).replaceAll("__APP_SLUG__",slug);writeFileSync(resolve(destination,file),rendered)}log.success("App-safe infrastructure configuration installed")}catch(error){log.warn(`Could not install the app config template: ${error instanceof Error?error.message:String(error)}`)}}function removeFrameworkTests(path){const tests=resolve(path,"tests");if(!existsSync(tests))return;log.info("Removing the framework's own test suite...");for(const entry of readdirSync(tests)){if(entry==="setup.ts")continue;rmSync(resolve(tests,entry),{recursive:!0,force:!0})}log.success("App starts with a clean test suite")}function ensureExecutableScripts(path){for(const script of["buddy","bootstrap"])try{chmodSync(resolve(path,script),493)}catch{}}async function ensureEnv(path,_options){log.info("Ensuring your environment is ready...");await ensurePantryInstalled();await ensurePantryDependencies(path);log.success("Environment is ready")}async function install(path,options){log.info("Installing & setting up Stacks");log.info("Copying .env.example \u2192 .env");let result=await runCommand("cp .env.example .env",{...options,cwd:path});if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}log.info("Removing template-encrypted env files...");const{rm}=await import("node:fs/promises");for(const stale of[".env.development",".env.staging",".env.production",".env.keys"])await rm(`${path}/${stale}`,{force:!0});log.info("Generating application key...");const keyResult=await runAction(Action.KeyGenerate,{...options,cwd:path});if(resultFailed(keyResult)){log.error(keyResult.error);process.exit(ExitCode.FatalError)}if(existsSync(resolve(path,".git")))log.info("Existing git repository detected, skipping git init");else{log.info("Initializing git repository...");result=await runCommand("git init",{...options,cwd:path});if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}}log.success("Installed & set-up \uD83D\uDE80")}async function unvendorCore(path,options){log.info("Resolving the framework from npm (pass --with-core to keep it vendored)...");const result=await runCommand("./buddy unpublish:core --all --force",{...options,cwd:path});if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}log.success("Framework resolved from npm")}async function stripFeatures(path){log.info("Stripping optional feature bundles (--minimal)...");const results=await uninstallAllFeatures({root:path});let strippedAny=!1;for(const{feature,configOutcome,filesRemoved}of results)if(configOutcome==="flipped"||filesRemoved.length>0){strippedAny=!0;const fileSummary=filesRemoved.length>0?` (${filesRemoved.length} path${filesRemoved.length===1?"":"s"})`:"";log.info(` - ${feature}${fileSummary}`)}if(!strippedAny)log.info(" \u2192 no feature scaffolding present; nothing to strip.");else log.success("Minimal skeleton ready - run `./buddy <feature>:install` to add features back.")}
@@ -20,13 +20,29 @@ import type { CLI } from '@stacksjs/types';
20
20
  * backup and therefore the deploy, which is a worse failure than the one it
21
21
  * would be reporting. The validator has already printed the issues itself.
22
22
  *
23
- * Deliberately untested, which is worth stating rather than hiding. In this
24
- * repo `database.default` comes from `DB_CONNECTION` in the environment,
25
- * available synchronously, so early and late reads agree and no assertion can
26
- * tell them apart - measured over three runs, and an ordering assertion also
27
- * passed with the barrier removed. Reproducing the divergence needs a
28
- * `config/database.ts` that is not env-derived or that carries a top-level
29
- * await. A test that passes either way would only look like coverage.
23
+ * Deliberately untested, which is worth stating rather than hiding - but not
24
+ * for the reason recorded here before. That reason was that early and late
25
+ * reads agree in this repo, because `database.default` comes from
26
+ * `DB_CONNECTION` and the environment is available synchronously. They do not
27
+ * agree: the framework default is a hardcoded `'sqlite'` that never consults
28
+ * the environment, and only `config/database.ts` reads `DB_CONNECTION`, so the
29
+ * two differ whenever that variable says anything else. Measured directly against the config layer:
30
+ *
31
+ * early: {"dbDefault":"sqlite"}
32
+ * late: {"dbDefault":"postgres"}
33
+ *
34
+ * What is true is the observation that an ordering assertion passes with this
35
+ * barrier removed, and the reason is the boot sequence rather than the value.
36
+ * `bunfig.toml` preloads the framework's own preloader, so the config load is
37
+ * already in flight before `main()` runs and has settled long before commands
38
+ * are registered, let alone run. Instrumented on both edges:
39
+ * `atRegistration=settled`, `atHandlerEntry=already-settled` - with the await
40
+ * here removed.
41
+ *
42
+ * So this await is defence against a boot sequence that stops winning that
43
+ * race, not against `DB_CONNECTION` being synchronous. Cheap enough to keep on
44
+ * a path where being wrong means restoring from a backup of the wrong database,
45
+ * and a test could only assert an ordering the preload already guarantees.
30
46
  */
31
47
  export declare function backupTarget(): Promise<BackupTarget | null>;
32
48
  export declare function db(buddy: CLI): void;
@@ -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}
@@ -1,9 +1,9 @@
1
- import{existsSync,mkdirSync,realpathSync}from"node:fs";import{cp,readdir,stat}from"node:fs/promises";import{homedir}from"node:os";import{dirname,join,resolve}from"node:path";import process from"node:process";import{italic,log,onUnknownSubcommand}from"@stacksjs/cli";import{path}from"@stacksjs/path";import{fs,globSync}from"@stacksjs/storage";import{pruneVendoredCoreFromWorkflows,splitFrameworkTypecheckScript}from"../workflow-prune";import{detectInstaller,findCoreReferences,isDanglingLink,rewriteCoreCommandPaths,rewriteCoreSourceImports,rewriteSurvivingFrameworkManifests}from"../unvendor-rewrite";import{ExitCode}from"@stacksjs/types";export function publish(buddy){const descriptions={command:"Publish a Stacks default into your userland (app/) directory so you can customize it",model:"Publish a default model from storage/framework/defaults/app/Models/ to app/Models/",controller:"Publish a default controller from storage/framework/defaults/app/Controllers/ to app/Controllers/",middleware:"Publish a default middleware from storage/framework/defaults/app/Middleware/ to app/Middleware/",action:"Publish a default action from storage/framework/defaults/app/Actions/ to app/Actions/",core:"Publish a framework package source from node_modules/@stacksjs/<pkg>/ into storage/framework/core/<pkg>/ for editing",unpublishCore:"Drop a vendored storage/framework/core/<pkg>/ and go back to the installed @stacksjs/<pkg>",all:"Unvendor the whole framework: remove storage/framework/core and resolve every @stacksjs package from the `stacks` dependency in package.json",publishAll:"Vendor the whole framework: copy storage/framework/core out of a local Stacks checkout and wire it up as a Bun workspace",frameworkPath:"The Stacks checkout to vendor from (defaults to $STACKS_FRAMEWORK_PATH, ../stacks, then ~/Code/stacks)",coreStatus:"Report whether this project runs on a vendored storage/framework/core or on the published packages",name:"The name of the resource to publish (e.g. Cart, User)",pkg:"The name of the framework package (e.g. router, orm, faker - without @stacksjs/ prefix)",force:"Overwrite an existing userland file",forceUnpublish:"Delete the vendored source even when it has uncommitted changes",verbose:"Enable verbose output"};buddy.command("publish:model <name>",descriptions.model).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"model",name,defaultsDir:path.frameworkPath("defaults/app/Models"),userDir:path.userModelsPath(),force:!!options.force})});buddy.command("publish:controller <name>",descriptions.controller).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"controller",name,defaultsDir:path.frameworkPath("defaults/app/Controllers"),userDir:path.userControllersPath(),force:!!options.force})});buddy.command("publish:middleware <name>",descriptions.middleware).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"middleware",name,defaultsDir:path.frameworkPath("defaults/app/Middleware"),userDir:path.userMiddlewarePath(),force:!!options.force})});buddy.command("publish:action <name>",descriptions.action).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"action",name,defaultsDir:path.frameworkPath("defaults/app/Actions"),userDir:path.userActionsPath(),force:!!options.force})});buddy.command("publish:core [pkg]",descriptions.core).option("--all",descriptions.publishAll,{default:!1}).option("--path <path>",descriptions.frameworkPath).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy publish:core router").example("buddy publish:core --all").example("buddy publish:core --all --path ../stacks").action(async(pkg,options)=>{if(options.all){await vendorFramework(options.path,!!options.force);return}if(!pkg){log.error("Usage: buddy publish:core <pkg> (or --all to vendor the whole framework as a workspace)");process.exit(ExitCode.FatalError)}await publishCorePackage(pkg,!!options.force)});buddy.command("core:status",descriptions.coreStatus).alias("publish:core:status").option("--verbose",descriptions.verbose,{default:!1}).example("buddy core:status").action(async()=>{await reportCoreStatus()});buddy.command("unpublish:core [pkg]",descriptions.unpublishCore).option("--all",descriptions.all,{default:!1}).option("--force",descriptions.forceUnpublish,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(pkg,options)=>{if(options.all){await unvendorFramework(!!options.force);return}if(!pkg){log.error("Usage: buddy unpublish:core <pkg> (or --all to move the whole framework to node_modules)");process.exit(ExitCode.FatalError)}await unpublishCorePackage(pkg,!!options.force)});buddy.command("publish [resource] [name]",descriptions.command).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(resource,name,options)=>{if(!resource||!name){log.error("Usage: buddy publish:<resource> <Name> (e.g. buddy publish:model Cart)");process.exit(ExitCode.FatalError)}const handler={model:()=>publishResource({kind:"model",name,defaultsDir:path.frameworkPath("defaults/app/Models"),userDir:path.userModelsPath(),force:!!options.force}),controller:()=>publishResource({kind:"controller",name,defaultsDir:path.frameworkPath("defaults/app/Controllers"),userDir:path.userControllersPath(),force:!!options.force}),middleware:()=>publishResource({kind:"middleware",name,defaultsDir:path.frameworkPath("defaults/app/Middleware"),userDir:path.userMiddlewarePath(),force:!!options.force}),action:()=>publishResource({kind:"action",name,defaultsDir:path.frameworkPath("defaults/app/Actions"),userDir:path.userActionsPath(),force:!!options.force}),core:()=>publishCorePackage(name,!!options.force)}[resource.toLowerCase()];if(!handler){log.error(`Unknown publishable resource: ${italic(resource)}`);log.info("Available: model, controller, middleware, action, core");process.exit(ExitCode.FatalError)}await handler()});onUnknownSubcommand(buddy,"publish")}async function publishCorePackage(pkg,force){const shortName=pkg.replace(/^@stacksjs\//,"").replace(/^core\//,""),fail=(msg,hint)=>{process.stderr.write(`${msg}
1
+ import{existsSync,mkdirSync,realpathSync}from"node:fs";import{cp,readdir,stat}from"node:fs/promises";import{homedir}from"node:os";import{dirname,join,resolve}from"node:path";import process from"node:process";import{italic,log,onUnknownSubcommand}from"@stacksjs/cli";import{path}from"@stacksjs/path";import{fs,globSync}from"@stacksjs/storage";import{pruneVendoredCoreFromWorkflows,splitFrameworkTypecheckScript}from"../workflow-prune";import{detectInstaller,findCoreReferences,isDanglingLink,rewriteCoreCommandPaths,rewriteCoreSourceImports,rewriteSurvivingFrameworkManifests}from"../unvendor-rewrite";import{fetchPublishedVersions}from"../registry";import{ExitCode}from"@stacksjs/types";export function publish(buddy){const descriptions={command:"Publish a Stacks default into your userland (app/) directory so you can customize it",model:"Publish a default model from storage/framework/defaults/app/Models/ to app/Models/",controller:"Publish a default controller from storage/framework/defaults/app/Controllers/ to app/Controllers/",middleware:"Publish a default middleware from storage/framework/defaults/app/Middleware/ to app/Middleware/",action:"Publish a default action from storage/framework/defaults/app/Actions/ to app/Actions/",core:"Publish a framework package source from node_modules/@stacksjs/<pkg>/ into storage/framework/core/<pkg>/ for editing",unpublishCore:"Drop a vendored storage/framework/core/<pkg>/ and go back to the installed @stacksjs/<pkg>",all:"Unvendor the whole framework: remove storage/framework/core and resolve every @stacksjs package from the `stacks` dependency in package.json",publishAll:"Vendor the whole framework: copy storage/framework/core out of a local Stacks checkout and wire it up as a Bun workspace",frameworkPath:"The Stacks checkout to vendor from (defaults to $STACKS_FRAMEWORK_PATH, ../stacks, then ~/Code/stacks)",coreStatus:"Report whether this project runs on a vendored storage/framework/core or on the published packages",name:"The name of the resource to publish (e.g. Cart, User)",pkg:"The name of the framework package (e.g. router, orm, faker - without @stacksjs/ prefix)",force:"Overwrite an existing userland file",forceUnpublish:"Delete the vendored source even when it has uncommitted changes",verbose:"Enable verbose output"};buddy.command("publish:model <name>",descriptions.model).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"model",name,defaultsDir:path.frameworkPath("defaults/app/Models"),userDir:path.userModelsPath(),force:!!options.force})});buddy.command("publish:controller <name>",descriptions.controller).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"controller",name,defaultsDir:path.frameworkPath("defaults/app/Controllers"),userDir:path.userControllersPath(),force:!!options.force})});buddy.command("publish:middleware <name>",descriptions.middleware).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"middleware",name,defaultsDir:path.frameworkPath("defaults/app/Middleware"),userDir:path.userMiddlewarePath(),force:!!options.force})});buddy.command("publish:action <name>",descriptions.action).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"action",name,defaultsDir:path.frameworkPath("defaults/app/Actions"),userDir:path.userActionsPath(),force:!!options.force})});buddy.command("publish:core [pkg]",descriptions.core).option("--all",descriptions.publishAll,{default:!1}).option("--path <path>",descriptions.frameworkPath).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy publish:core router").example("buddy publish:core --all").example("buddy publish:core --all --path ../stacks").action(async(pkg,options)=>{if(options.all){await vendorFramework(options.path,!!options.force);return}if(!pkg){log.error("Usage: buddy publish:core <pkg> (or --all to vendor the whole framework as a workspace)");process.exit(ExitCode.FatalError)}await publishCorePackage(pkg,!!options.force)});buddy.command("core:status",descriptions.coreStatus).alias("publish:core:status").option("--verbose",descriptions.verbose,{default:!1}).example("buddy core:status").action(async()=>{await reportCoreStatus()});buddy.command("unpublish:core [pkg]",descriptions.unpublishCore).option("--all",descriptions.all,{default:!1}).option("--force",descriptions.forceUnpublish,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(pkg,options)=>{if(options.all){await unvendorFramework(!!options.force);return}if(!pkg){log.error("Usage: buddy unpublish:core <pkg> (or --all to move the whole framework to node_modules)");process.exit(ExitCode.FatalError)}await unpublishCorePackage(pkg,!!options.force)});buddy.command("publish [resource] [name]",descriptions.command).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(resource,name,options)=>{if(!resource||!name){log.error("Usage: buddy publish:<resource> <Name> (e.g. buddy publish:model Cart)");process.exit(ExitCode.FatalError)}const handler={model:()=>publishResource({kind:"model",name,defaultsDir:path.frameworkPath("defaults/app/Models"),userDir:path.userModelsPath(),force:!!options.force}),controller:()=>publishResource({kind:"controller",name,defaultsDir:path.frameworkPath("defaults/app/Controllers"),userDir:path.userControllersPath(),force:!!options.force}),middleware:()=>publishResource({kind:"middleware",name,defaultsDir:path.frameworkPath("defaults/app/Middleware"),userDir:path.userMiddlewarePath(),force:!!options.force}),action:()=>publishResource({kind:"action",name,defaultsDir:path.frameworkPath("defaults/app/Actions"),userDir:path.userActionsPath(),force:!!options.force}),core:()=>publishCorePackage(name,!!options.force)}[resource.toLowerCase()];if(!handler){log.error(`Unknown publishable resource: ${italic(resource)}`);log.info("Available: model, controller, middleware, action, core");process.exit(ExitCode.FatalError)}await handler()});onUnknownSubcommand(buddy,"publish")}async function publishCorePackage(pkg,force){const shortName=pkg.replace(/^@stacksjs\//,"").replace(/^core\//,""),fail=(msg,hint)=>{process.stderr.write(`${msg}
2
2
  `);if(hint)process.stderr.write(` ${hint}
3
3
  `);process.exit(ExitCode.FatalError)};if(!shortName||shortName.includes("/")||shortName.includes(".."))fail(`Invalid package name: ${pkg}`,"Use a short name like `router` or the fully qualified `@stacksjs/router`.");const sourceDir=resolve(process.cwd(),"node_modules","@stacksjs",shortName),targetDir=path.frameworkPath(`core/${shortName}`);try{if(!(await stat(sourceDir)).isDirectory())fail(`${sourceDir} is not a directory.`)}catch{fail(`Could not find @stacksjs/${shortName} in node_modules.`,"Run `bun install` first, or check the package name.")}if(existsSync(targetDir)&&!force)fail(`Already published: ${targetDir.replace(`${process.cwd()}/`,"")}`,"Pass --force to overwrite.");mkdirSync(dirname(targetDir),{recursive:!0});const SKIP=new Set(["node_modules","dist",".bun",".cache","tsconfig.tsbuildinfo"]);let copied=0;await copyTreeFiltered(sourceDir,targetDir,SKIP,()=>copied++);log.success(`Published @stacksjs/${shortName} \u2192 ${italic(targetDir.replace(`${process.cwd()}/`,""))} (${copied} files)`);log.info("Edit freely - local changes win over the installed package.")}async function copyTreeFiltered(sourceDir,targetDir,skip,onFile){mkdirSync(targetDir,{recursive:!0});const entries=await readdir(sourceDir,{withFileTypes:!0});for(const entry of entries){if(skip.has(entry.name))continue;const src=`${sourceDir}/${entry.name}`,dst=`${targetDir}/${entry.name}`;if(entry.isDirectory()){await copyTreeFiltered(src,dst,skip,onFile);continue}await cp(src,dst,{force:!0,dereference:!0});onFile()}}async function publishResource(ctx){const{kind,name,defaultsDir,userDir,force}=ctx,fileName=name.endsWith(".ts")?name:`${name}.ts`,matches=globSync([`${defaultsDir}/**/${fileName}`],{absolute:!0});if(!matches.length){log.error(`Could not find default ${kind}: ${italic(fileName)}`);log.info(`Looked under: ${italic(defaultsDir)}`);process.exit(ExitCode.FatalError)}if(matches.length>1){log.warn(`Multiple defaults match ${italic(fileName)}; using the first:`);for(const m of matches)log.info(` ${m}`)}const sourcePath=matches[0];if(!sourcePath)throw Error(`Could not resolve default ${kind}: ${fileName}`);const targetPath=`${userDir.replace(/\/$/,"")}/${fileName}`;if(existsSync(targetPath)&&!force){log.error(`Already exists: ${italic(targetPath)}`);log.info("Pass --force to overwrite.");process.exit(ExitCode.FatalError)}mkdirSync(dirname(targetPath),{recursive:!0});await fs.promises.copyFile(sourcePath,targetPath);const carried=await carryRelativeImports(sourcePath,targetPath);log.success(`Published ${kind} ${italic(name)} \u2192 ${italic(targetPath.replace(`${process.cwd()}/`,""))}`);for(const file of carried)log.info(` + ${italic(file.replace(`${process.cwd()}/`,""))} (imported by it)`)}export async function carryRelativeImports(sourcePath,targetPath,seen=new Set){if(seen.has(sourcePath))return[];seen.add(sourcePath);const source=await fs.promises.readFile(sourcePath,"utf-8"),written=[],root=realpathSync(process.cwd()),targetDir=realpathSync(dirname(targetPath)),specifiers=[...source.matchAll(/from\s+['"](\.[^'"]+)['"]/g)].map((match)=>match[1]);for(const specifier of new Set(specifiers)){if(!specifier)continue;const candidates=specifier.endsWith(".ts")?[specifier]:[`${specifier}.ts`,`${specifier}/index.ts`];for(const candidate of candidates){const from=resolve(dirname(sourcePath),candidate),to=resolve(targetDir,candidate);if(!existsSync(from))continue;if(!to.startsWith(`${root}/`))break;if(!existsSync(to)){mkdirSync(dirname(to),{recursive:!0});await fs.promises.copyFile(from,to);written.push(to)}written.push(...await carryRelativeImports(from,to,seen));break}}return written}async function vendorFramework(explicitPath,force){const coreDir=path.frameworkPath("core"),rel=(p)=>p.replace(`${process.cwd()}/`,"");if(existsSync(coreDir)&&!force){log.info(`Already vendored: ${italic(rel(coreDir))}`);log.info("Pass --force to replace it with a fresh copy of the checkout.");return}const framework=resolveFrameworkCheckout(explicitPath);if(!framework){log.error("No Stacks checkout found to vendor from.");log.info("Pass one with `--path <dir>`, or set STACKS_FRAMEWORK_PATH.");log.info("A checkout is required: the published packages ship `dist` only, so a copy of them would not be editable.");process.exit(ExitCode.FatalError)}const sourceCore=join(framework,"storage/framework/core"),corePkgPath=resolve(sourceCore,"package.json");if(!existsSync(corePkgPath)){log.error(`${sourceCore} has no package.json - that does not look like a Stacks checkout.`);process.exit(ExitCode.FatalError)}const depName=JSON.parse(await fs.promises.readFile(corePkgPath,"utf-8")).name??"stacks";log.info(`Vendoring the framework from ${italic(framework)}...`);if(existsSync(coreDir))await fs.promises.rm(coreDir,{recursive:!0,force:!0});const SKIP=new Set(["node_modules",".bun",".cache","tsconfig.tsbuildinfo"]);let copied=0;await copyTreeFiltered(sourceCore,coreDir,SKIP,()=>copied++);const rootPkgPath=resolve(process.cwd(),"package.json"),rootPkg=JSON.parse(await fs.promises.readFile(rootPkgPath,"utf-8")),provided=new Set([depName]);for(const entry of await readdir(coreDir,{withFileTypes:!0})){if(!entry.isDirectory())continue;if(existsSync(resolve(coreDir,entry.name,"package.json")))provided.add(`@stacksjs/${entry.name}`)}let repointed=0;for(const field of["dependencies","devDependencies"]){const deps=rootPkg[field];if(!deps)continue;for(const name of Object.keys(deps)){if(!provided.has(name)||deps[name].startsWith("workspace:"))continue;deps[name]="workspace:*";repointed++}}if(!rootPkg.dependencies?.[depName]&&!rootPkg.devDependencies?.[depName])rootPkg.dependencies={...rootPkg.dependencies,[depName]:"workspace:*"};const workspaces=rootPkg.workspaces??[];let addedGlobs=0;for(const glob of["storage/framework/core","storage/framework/core/*"]){if(workspaces.some((existing)=>existing.replace(/^\.\//,"").replace(/\/$/,"")===glob))continue;workspaces.push(glob);addedGlobs++}rootPkg.workspaces=workspaces;await fs.promises.writeFile(rootPkgPath,`${JSON.stringify(rootPkg,null,2)}
4
4
  `);const bunfigPath=resolve(process.cwd(),"bunfig.toml");let rewrittenPreloads=0;if(existsSync(bunfigPath)){const bunfig=await fs.promises.readFile(bunfigPath,"utf-8"),next=bunfig.replace(/(["'])@stacksjs\/([\w-]+)\/([^"']+?)\.js\1/g,(match,quote,pkgName,subpath)=>{if(!provided.has(`@stacksjs/${pkgName}`))return match;rewrittenPreloads++;return`${quote}./storage/framework/core/${pkgName}/${subpath}.ts${quote}`});if(next!==bunfig)await fs.promises.writeFile(bunfigPath,next)}const tsconfigPath=resolve(process.cwd(),"tsconfig.json");let rewroteTsconfig=!1;if(existsSync(tsconfigPath)){const raw=await fs.promises.readFile(tsconfigPath,"utf-8"),next=raw.replace(/"extends"\s*:\s*"\.\/storage\/framework\/tsconfig\.app\.json"/,'"extends": "./storage/framework/core/tsconfig.json"');if(next!==raw){await fs.promises.writeFile(tsconfigPath,next);rewroteTsconfig=!0}}log.success(`Vendored ${copied} files into ${italic(rel(coreDir))}`);log.info(`package.json now depends on ${depName}@workspace:*`);if(repointed>0)log.info(`Repointed ${repointed} version range${repointed===1?"":"s"} to workspace:*`);if(addedGlobs>0)log.info(`Added ${addedGlobs} workspace glob${addedGlobs===1?"":"s"} for the vendored packages`);if(rewrittenPreloads>0)log.info(`Rewrote ${rewrittenPreloads} bunfig.toml preload path${rewrittenPreloads===1?"":"s"} to the vendored source`);if(rewroteTsconfig)log.info("tsconfig.json now extends storage/framework/core/tsconfig.json");log.info("Linking the workspace...");if(await Bun.spawn(["bun","install"],{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){log.error("`bun install` failed. The files are in place; re-run the install once the failure is resolved.");process.exit(ExitCode.FatalError)}log.success("This project now runs on the vendored framework - edits under storage/framework/core are live.");log.info("Go back to the published packages any time with `buddy unpublish:core --all`.")}function resolveFrameworkCheckout(explicit){const candidates=[explicit,process.env.STACKS_FRAMEWORK_PATH,resolve(process.cwd(),"../stacks"),join(homedir(),"Code/stacks")].filter(Boolean);for(const candidate of candidates){const full=resolve(candidate);if(full===resolve(process.cwd()))continue;if(existsSync(join(full,"storage/framework/core/package.json")))return full}return null}async function reportCoreStatus(){const coreDir=path.frameworkPath("core"),rel=(p)=>p.replace(`${process.cwd()}/`,""),vendored=existsSync(resolve(coreDir,"package.json")),rootPkgPath=resolve(process.cwd(),"package.json"),rootPkg=existsSync(rootPkgPath)?JSON.parse(await fs.promises.readFile(rootPkgPath,"utf-8")):{},declared=rootPkg.dependencies?.stacks??rootPkg.devDependencies?.stacks;if(vendored){const corePkg=JSON.parse(await fs.promises.readFile(resolve(coreDir,"package.json"),"utf-8")),packages=(await readdir(coreDir,{withFileTypes:!0})).filter((entry)=>entry.isDirectory()&&existsSync(resolve(coreDir,entry.name,"package.json")));log.info(`Layout: vendored - ${italic(rel(coreDir))} (${packages.length} packages, v${corePkg.version??"unknown"})`);log.info(`Declared: stacks@${declared??"(not declared)"}`);if(declared&&!declared.startsWith("workspace:"))log.warn(`The vendored copy is not linked: stacks is declared as ${declared}, not workspace:*. Run \`buddy publish:core --all --force\` to relink, or \`buddy unpublish:core --all\` to remove it.`);log.info("Move to the published packages with `buddy unpublish:core --all`.");return}const installed=resolve(process.cwd(),"node_modules/@stacksjs/buddy/package.json"),installedVersion=existsSync(installed)?JSON.parse(await fs.promises.readFile(installed,"utf-8")).version:void 0;log.info("Layout: published packages - no storage/framework/core in this project");log.info(`Declared: stacks@${declared??"(not declared)"}`);log.info(`Installed: ${installedVersion?`v${installedVersion}`:"(run bun install)"}`);log.info("Vendor the framework for local development with `buddy publish:core --all`.")}async function unpublishCorePackage(pkg,force){const shortName=normalizeCoreName(pkg),targetDir=path.frameworkPath(`core/${shortName}`),rel=(p)=>p.replace(`${process.cwd()}/`,"");if(!existsSync(targetDir)){log.info(`Not vendored: ${italic(rel(targetDir))} - nothing to do.`);return}const installed=resolve(process.cwd(),"node_modules","@stacksjs",shortName);if(!existsSync(installed)&&!force){log.error(`@stacksjs/${shortName} is not installed, so removing the vendored copy would leave nothing to resolve.`);log.info(`Run \`bun add @stacksjs/${shortName}\` first, or pass --force to remove it anyway.`);process.exit(ExitCode.FatalError)}await assertNoUncommittedChanges(targetDir,force);await fs.promises.rm(targetDir,{recursive:!0,force:!0});log.success(`Unpublished ${italic(rel(targetDir))} - @stacksjs/${shortName} now resolves from node_modules.`)}async function unvendorFramework(force){const coreDir=path.frameworkPath("core"),rel=(p)=>p.replace(`${process.cwd()}/`,"");if(!existsSync(coreDir)){log.info("No storage/framework/core in this project - already on the installed packages.");return}const corePkgPath=resolve(coreDir,"package.json");if(!existsSync(corePkgPath)){log.error(`${rel(coreDir)} has no package.json, so its version cannot be determined.`);log.info("Unvendor the packages individually with `buddy unpublish:core <pkg>` instead.");process.exit(ExitCode.FatalError)}const corePkg=JSON.parse(await fs.promises.readFile(corePkgPath,"utf-8")),version=corePkg.version;if(!version){log.error(`${rel(corePkgPath)} has no version field.`);process.exit(ExitCode.FatalError)}await assertNoUncommittedChanges(coreDir,force);const depName=corePkg.name??"stacks",range=`^${await resolvePublishedVersion(depName,version)}`,rootPkgPath=resolve(process.cwd(),"package.json"),rootPkgRaw=await fs.promises.readFile(rootPkgPath,"utf-8"),rootPkg=JSON.parse(rootPkgRaw),provided=new Set([depName,...Object.keys(corePkg.dependencies??{}).filter((name)=>name.startsWith("@stacksjs/"))]);for(const entry of await readdir(coreDir,{withFileTypes:!0}))if(entry.isDirectory())provided.add(`@stacksjs/${entry.name}`);let repointed=0;const repointWorkspaceRanges=(pkg)=>{let touched=!1;for(const field of["dependencies","devDependencies","peerDependencies"]){const deps=pkg[field];if(!deps)continue;for(const[name,spec]of Object.entries(deps)){if(!spec.startsWith("workspace:")||!provided.has(name))continue;deps[name]=range;touched=!0;repointed++}}return touched};repointWorkspaceRanges(rootPkg);if(!rootPkg.dependencies?.[depName]&&!rootPkg.devDependencies?.[depName])rootPkg.dependencies={...rootPkg.dependencies,[depName]:range};let rewrittenScripts=0;for(const[name,script]of Object.entries(rootPkg.scripts??{})){if(!/storage\/framework\/core\/buddy\/src\/cli\.ts/.test(script))continue;rootPkg.scripts[name]=script.replace(/\bbunx?\s+(?:--bun\s+)?\.?\/?storage\/framework\/core\/buddy\/src\/cli\.ts/g,"./buddy");rewrittenScripts++}if(Array.isArray(rootPkg.workspaces)){rootPkg.workspaces=rootPkg.workspaces.filter((glob)=>!isCoreWorkspaceGlob(glob));if(rootPkg.workspaces.length===0)delete rootPkg.workspaces}await fs.promises.writeFile(rootPkgPath,`${JSON.stringify(rootPkg,null,2)}
5
5
  `);for(const glob of rootPkg.workspaces??[])for(const memberPkgPath of globSync(`${glob.replace(/\/$/,"")}/package.json`,{cwd:process.cwd(),absolute:!0})){const raw=await fs.promises.readFile(memberPkgPath,"utf-8"),memberPkg=JSON.parse(raw);if(repointWorkspaceRanges(memberPkg))await fs.promises.writeFile(memberPkgPath,`${JSON.stringify(memberPkg,null,2)}
6
6
  `)}const survivingManifests=await rewriteSurvivingFrameworkManifests(process.cwd(),provided,range),bunfigPath=resolve(process.cwd(),"bunfig.toml");let rewrittenPreloads=0;if(existsSync(bunfigPath)){const bunfig=await fs.promises.readFile(bunfigPath,"utf-8"),next=bunfig.replace(/(["'])\.?\/?storage\/framework\/core\/([\w-]+)\/([^"']+?)\.ts\1/g,(_match,quote,pkgName,subpath)=>{rewrittenPreloads++;return`${quote}@stacksjs/${pkgName}/${subpath}.js${quote}`});if(next!==bunfig)await fs.promises.writeFile(bunfigPath,next)}const tsconfigPath=resolve(process.cwd(),"tsconfig.json");let rewroteTsconfig=!1,rewroteTypecheck=!1;if(existsSync(tsconfigPath)){const raw=await fs.promises.readFile(tsconfigPath,"utf-8"),next=raw.replace(/"extends"\s*:\s*"\.\/storage\/framework\/core\/tsconfig\.json"/,'"extends": "./storage/framework/tsconfig.app.json"');if(next!==raw){await fs.promises.writeFile(tsconfigPath,next);rewroteTsconfig=!0}}const splitTypecheck=splitFrameworkTypecheckScript(rootPkg.scripts??{});if(splitTypecheck){rootPkg.scripts=splitTypecheck;rewroteTypecheck=!0;await fs.promises.writeFile(rootPkgPath,`${JSON.stringify(rootPkg,null,2)}
7
- `)}const rewrittenCommands=await rewriteCoreCommandPaths(process.cwd()),prunedWorkflows=await pruneVendoredCoreFromWorkflows(process.cwd()),rewrittenImports=await rewriteCoreSourceImports(process.cwd());await fs.promises.rm(coreDir,{recursive:!0,force:!0});const pantryLock=resolve(process.cwd(),"pantry.lock"),removedPantryLock=existsSync(pantryLock);await fs.promises.rm(pantryLock,{force:!0});let danglingRemoved=0;for(const depsDir of["node_modules","pantry"]){const scopedDir=resolve(process.cwd(),depsDir,"@stacksjs");if(!existsSync(scopedDir))continue;for(const entry of await readdir(scopedDir,{withFileTypes:!0})){if(!entry.isSymbolicLink())continue;const link=resolve(scopedDir,entry.name);if(existsSync(link))continue;await fs.promises.rm(link,{force:!0});danglingRemoved++}const rootLink=resolve(process.cwd(),depsDir,depName);if(existsSync(dirname(rootLink))&&isDanglingLink(rootLink)){await fs.promises.rm(rootLink,{force:!0});danglingRemoved++}}log.success(`Removed ${italic(rel(coreDir))}`);log.info(`package.json now depends on ${depName}@${range}`);if(repointed>0)log.info(`Repointed ${repointed} workspace: range${repointed===1?"":"s"} to ${range}`);if(survivingManifests.ranges>0)log.info(`Repointed ${survivingManifests.ranges} workspace: range${survivingManifests.ranges===1?"":"s"} across ${survivingManifests.files.length} surviving framework manifest${survivingManifests.files.length===1?"":"s"}`);if(removedPantryLock)log.info("Removed the legacy Pantry workspace lock; the install will resolve a package-layout graph");if(danglingRemoved>0)log.info(`Removed ${danglingRemoved} node_modules symlink${danglingRemoved===1?"":"s"} left pointing into it`);if(rewrittenScripts>0)log.info(`Repointed ${rewrittenScripts} package.json script${rewrittenScripts===1?"":"s"} to ./buddy`);if(rewrittenPreloads>0)log.info(`Rewrote ${rewrittenPreloads} bunfig.toml preload path${rewrittenPreloads===1?"":"s"} to package specifiers`);if(rewroteTsconfig)log.info("tsconfig.json now extends storage/framework/tsconfig.app.json");if(rewroteTypecheck)log.info("`typecheck` now checks this app as well as the framework files it still ships");for(const pruned of prunedWorkflows){const parts=[pruned.removedJobs.length>0?`${pruned.removedJobs.length} job${pruned.removedJobs.length===1?"":"s"} (${pruned.removedJobs.join(", ")})`:"",pruned.removedSteps>0?`${pruned.removedSteps} step${pruned.removedSteps===1?"":"s"}`:""].filter(Boolean);log.info(`${pruned.file}: removed ${parts.join(" and ")} that ran against the vendored core`)}if(rewrittenCommands.length>0){log.info(`Repointed vendored-CLI commands to ./buddy in ${rewrittenCommands.length} file${rewrittenCommands.length===1?"":"s"}:`);for(const file of rewrittenCommands)log.info(` ${file}`)}if(rewrittenImports.length>0){log.info(`Repointed vendored-source imports to package specifiers in ${rewrittenImports.length} file${rewrittenImports.length===1?"":"s"}:`);for(const file of rewrittenImports)log.info(` ${file}`)}const stragglers=await findCoreReferences(process.cwd());if(stragglers.length>0){log.warn(`${stragglers.length} file${stragglers.length===1?"":"s"} still reference storage/framework/core, which no longer exists:`);for(const{file,line,text}of stragglers)log.warn(` ${file}:${line} ${text}`);log.info("Run those through `./buddy <command>` or a package specifier before deploying.")}const installer=detectInstaller(process.cwd());log.info(`Installing the published packages with \`${installer.join(" ")}\`...`);if(await Bun.spawn(installer,{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){log.error(`\`${installer.join(" ")}\` failed. package.json and bunfig.toml were updated; re-run the install once the failure is resolved.`);process.exit(ExitCode.FatalError)}log.success("This project now runs on the published Stacks packages.");log.info("Vendor an individual package again any time with `buddy publish:core <pkg>`.")}async function resolvePublishedVersion(depName,vendored){let published;try{published=await fetchPublishedVersions(depName)}catch(error){log.warn(`Could not reach the npm registry to check ${depName} versions (${error instanceof Error?error.message:String(error)}).`);log.info(`Pinning the vendored version, ${depName}@^${vendored}.`);return vendored}if(published.versions.has(vendored))return vendored;if(!published.latest){log.warn(`${depName}@${vendored} is not published and the registry reports no latest version.`);return vendored}log.warn(`${depName}@${vendored} is not published yet - the vendored copy is ahead of npm.`);log.info(`Pinning the newest published version instead, ${depName}@^${published.latest}.`);return published.latest}async function fetchPublishedVersions(depName){const response=await fetch(`https://registry.npmjs.org/${depName.replace("/","%2F")}`,{headers:{accept:"application/vnd.npm.install-v1+json"}});if(!response.ok)throw Error(`registry responded ${response.status}`);const packument=await response.json();return{latest:packument["dist-tags"]?.latest,versions:new Set(Object.keys(packument.versions??{}))}}function normalizeCoreName(pkg){const shortName=pkg.replace(/^@stacksjs\//,"").replace(/^core\//,"");if(!shortName||shortName.includes("/")||shortName.includes("..")){process.stderr.write(`Invalid package name: ${pkg}
7
+ `)}const rewrittenCommands=await rewriteCoreCommandPaths(process.cwd()),prunedWorkflows=await pruneVendoredCoreFromWorkflows(process.cwd()),rewrittenImports=await rewriteCoreSourceImports(process.cwd());await fs.promises.rm(coreDir,{recursive:!0,force:!0});const pantryLock=resolve(process.cwd(),"pantry.lock"),removedPantryLock=existsSync(pantryLock);await fs.promises.rm(pantryLock,{force:!0});let danglingRemoved=0;for(const depsDir of["node_modules","pantry"]){const scopedDir=resolve(process.cwd(),depsDir,"@stacksjs");if(!existsSync(scopedDir))continue;for(const entry of await readdir(scopedDir,{withFileTypes:!0})){if(!entry.isSymbolicLink())continue;const link=resolve(scopedDir,entry.name);if(existsSync(link))continue;await fs.promises.rm(link,{force:!0});danglingRemoved++}const rootLink=resolve(process.cwd(),depsDir,depName);if(existsSync(dirname(rootLink))&&isDanglingLink(rootLink)){await fs.promises.rm(rootLink,{force:!0});danglingRemoved++}}log.success(`Removed ${italic(rel(coreDir))}`);log.info(`package.json now depends on ${depName}@${range}`);if(repointed>0)log.info(`Repointed ${repointed} workspace: range${repointed===1?"":"s"} to ${range}`);if(survivingManifests.ranges>0)log.info(`Repointed ${survivingManifests.ranges} workspace: range${survivingManifests.ranges===1?"":"s"} across ${survivingManifests.files.length} surviving framework manifest${survivingManifests.files.length===1?"":"s"}`);if(removedPantryLock)log.info("Removed the legacy Pantry workspace lock; the install will resolve a package-layout graph");if(danglingRemoved>0)log.info(`Removed ${danglingRemoved} node_modules symlink${danglingRemoved===1?"":"s"} left pointing into it`);if(rewrittenScripts>0)log.info(`Repointed ${rewrittenScripts} package.json script${rewrittenScripts===1?"":"s"} to ./buddy`);if(rewrittenPreloads>0)log.info(`Rewrote ${rewrittenPreloads} bunfig.toml preload path${rewrittenPreloads===1?"":"s"} to package specifiers`);if(rewroteTsconfig)log.info("tsconfig.json now extends storage/framework/tsconfig.app.json");if(rewroteTypecheck)log.info("`typecheck` now checks this app as well as the framework files it still ships");for(const pruned of prunedWorkflows){const parts=[pruned.removedJobs.length>0?`${pruned.removedJobs.length} job${pruned.removedJobs.length===1?"":"s"} (${pruned.removedJobs.join(", ")})`:"",pruned.removedSteps>0?`${pruned.removedSteps} step${pruned.removedSteps===1?"":"s"}`:""].filter(Boolean);log.info(`${pruned.file}: removed ${parts.join(" and ")} that ran against the vendored core`)}if(rewrittenCommands.length>0){log.info(`Repointed vendored-CLI commands to ./buddy in ${rewrittenCommands.length} file${rewrittenCommands.length===1?"":"s"}:`);for(const file of rewrittenCommands)log.info(` ${file}`)}if(rewrittenImports.length>0){log.info(`Repointed vendored-source imports to package specifiers in ${rewrittenImports.length} file${rewrittenImports.length===1?"":"s"}:`);for(const file of rewrittenImports)log.info(` ${file}`)}const stragglers=await findCoreReferences(process.cwd());if(stragglers.length>0){log.warn(`${stragglers.length} file${stragglers.length===1?"":"s"} still reference storage/framework/core, which no longer exists:`);for(const{file,line,text}of stragglers)log.warn(` ${file}:${line} ${text}`);log.info("Run those through `./buddy <command>` or a package specifier before deploying.")}const installer=detectInstaller(process.cwd());log.info(`Installing the published packages with \`${installer.join(" ")}\`...`);if(await Bun.spawn(installer,{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){log.error(`\`${installer.join(" ")}\` failed. package.json and bunfig.toml were updated; re-run the install once the failure is resolved.`);process.exit(ExitCode.FatalError)}log.success("This project now runs on the published Stacks packages.");log.info("Vendor an individual package again any time with `buddy publish:core <pkg>`.")}async function resolvePublishedVersion(depName,vendored){let published;try{published=await fetchPublishedVersions(depName)}catch(error){log.warn(`Could not reach the npm registry to check ${depName} versions (${error instanceof Error?error.message:String(error)}).`);log.info(`Pinning the vendored version, ${depName}@^${vendored}.`);return vendored}if(published.versions.has(vendored))return vendored;if(!published.latest){log.warn(`${depName}@${vendored} is not published and the registry reports no latest version.`);return vendored}log.warn(`${depName}@${vendored} is not published yet - the vendored copy is ahead of npm.`);log.info(`Pinning the newest published version instead, ${depName}@^${published.latest}.`);return published.latest}function normalizeCoreName(pkg){const shortName=pkg.replace(/^@stacksjs\//,"").replace(/^core\//,"");if(!shortName||shortName.includes("/")||shortName.includes("..")){process.stderr.write(`Invalid package name: ${pkg}
8
8
  `);process.stderr.write(" Use a short name like `router` or the fully qualified `@stacksjs/router`.\n");process.exit(ExitCode.FatalError)}return shortName}function isCoreWorkspaceGlob(glob){const normalized=glob.replace(/^\.\//,"").replace(/\/$/,"");return normalized==="storage/framework/core"||normalized.startsWith("storage/framework/core/")}async function assertNoUncommittedChanges(dir,force){if(force)return;try{const proc=Bun.spawn(["git","status","--porcelain","--",dir],{cwd:process.cwd(),stdout:"pipe",stderr:"ignore"}),output=await new Response(proc.stdout).text();if(await proc.exited!==0)return;const changed=output.split(`
9
9
  `).filter(Boolean);if(changed.length===0)return;log.error(`${changed.length} uncommitted change${changed.length===1?"":"s"} under ${italic(dir.replace(`${process.cwd()}/`,""))}:`);for(const line of changed.slice(0,10))log.info(` ${line}`);if(changed.length>10)log.info(` ... and ${changed.length-10} more`);log.info("Commit or stash them first, or pass --force to delete them anyway.");process.exit(ExitCode.FatalError)}catch{}}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Ask the registry what exists for a package.
3
+ *
4
+ * Uses the abbreviated packument (`application/vnd.npm.install-v1+json`), which
5
+ * is a fraction of the full document and carries the two things wanted here.
6
+ * Throws on any non-OK response so callers can decide what an unreachable
7
+ * registry means for them; it is not always fatal.
8
+ */
9
+ export declare function fetchPublishedVersions(depName: string): Promise<PublishedVersions>;
10
+ /**
11
+ * What npm actually has.
12
+ *
13
+ * Both scaffolding and unvendoring have to reconcile the checkout in front of
14
+ * them with the versions a user can install, and they were reconciling it
15
+ * differently: `unpublish:core` asked the registry before writing a range,
16
+ * while `buddy new` did not ask at all. Split out so there is one answer to
17
+ * "what is published" rather than one per caller.
18
+ */
19
+ export declare interface PublishedVersions {
20
+ latest?: string
21
+ versions: Set<string>
22
+ }
@@ -0,0 +1 @@
1
+ export async function fetchPublishedVersions(depName){const response=await fetch(`https://registry.npmjs.org/${depName.replace("/","%2F")}`,{headers:{accept:"application/vnd.npm.install-v1+json"}});if(!response.ok)throw Error(`registry responded ${response.status}`);const packument=await response.json();return{latest:packument["dist-tags"]?.latest,versions:new Set(Object.keys(packument.versions??{}))}}
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.74.0",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -95,65 +95,67 @@
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.74.0",
99
+ "@stacksjs/ai": "^0.74.0",
100
+ "@stacksjs/alias": "^0.74.0",
101
+ "@stacksjs/analytics": "^0.74.0",
102
+ "@stacksjs/api": "^0.74.0",
103
+ "@stacksjs/arrays": "^0.74.0",
104
+ "@stacksjs/auth": "^0.74.0",
105
+ "@stacksjs/browser-extension": "^0.74.0",
106
+ "@stacksjs/build": "^0.74.0",
107
+ "@stacksjs/cache": "^0.74.0",
108
+ "@stacksjs/chat": "^0.74.0",
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.74.0",
111
+ "@stacksjs/cloud": "^0.74.0",
112
+ "@stacksjs/cms": "^0.74.0",
113
+ "@stacksjs/collections": "^0.74.0",
114
+ "@stacksjs/config": "^0.74.0",
115
+ "@stacksjs/database": "^0.74.0",
116
+ "@stacksjs/desktop-build": "^0.74.0",
117
+ "@stacksjs/dns": "^0.74.0",
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.74.0",
120
+ "@stacksjs/enums": "^0.74.0",
121
+ "@stacksjs/env": "^0.74.0",
122
+ "@stacksjs/error-handling": "^0.74.0",
123
+ "@stacksjs/events": "^0.74.0",
124
+ "@stacksjs/git": "^0.74.0",
125
125
  "@stacksjs/gitit": "^0.2.5",
126
- "@stacksjs/health": "^0.73.2",
126
+ "@stacksjs/health": "^0.74.0",
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.74.0",
129
+ "@stacksjs/lint": "^0.74.0",
130
+ "@stacksjs/logging": "^0.74.0",
131
+ "@stacksjs/notifications": "^0.74.0",
132
+ "@stacksjs/objects": "^0.74.0",
133
+ "@stacksjs/orm": "^0.74.0",
134
+ "@stacksjs/path": "^0.74.0",
135
+ "@stacksjs/payments": "^0.74.0",
136
+ "@stacksjs/realtime": "^0.74.0",
137
+ "@stacksjs/router": "^0.74.0",
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.74.0",
140
+ "@stacksjs/search-engine": "^0.74.0",
141
+ "@stacksjs/security": "^0.74.0",
142
+ "@stacksjs/server": "^0.74.0",
143
+ "@stacksjs/sites": "^0.74.0",
144
+ "@stacksjs/skills": "^0.74.0",
145
+ "@stacksjs/storage": "^0.74.0",
146
+ "@stacksjs/strings": "^0.74.0",
147
+ "@stacksjs/stx": "^0.2.246",
148
+ "@stacksjs/testing": "^0.74.0",
149
+ "@stacksjs/tinker": "^0.74.0",
149
150
  "@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",
151
+ "@stacksjs/tunnel": "^0.74.0",
152
+ "@stacksjs/types": "^0.74.0",
153
+ "@stacksjs/ui": "^0.74.0",
154
+ "@stacksjs/utils": "^0.74.0",
155
+ "@stacksjs/validation": "^0.74.0",
155
156
  "ajv": "^8.20.0",
156
157
  "ajv-formats": "^3.0.1",
158
+ "bun-plugin-stx": "^0.2.246",
157
159
  "ts-pantry": "^0.11.35"
158
160
  },
159
161
  "devDependencies": {
@@ -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}