@stacksjs/buddy 0.74.27 → 0.74.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/commands/add.js +1 -1
- package/dist/commands/build.js +1 -1
- package/dist/commands/cloud.d.ts +91 -0
- package/dist/commands/cloud.js +7 -3
- package/dist/commands/deploy.d.ts +10 -0
- package/dist/commands/deploy.js +3 -3
- package/dist/commands/email.js +1 -1
- package/dist/commands/generate.d.ts +13 -1
- package/dist/commands/generate.js +1 -1
- package/dist/commands/link.js +1 -1
- package/dist/commands/lint.js +1 -1
- package/dist/commands/make.js +1 -1
- package/dist/commands/migrate.js +3 -3
- package/dist/commands/seed.js +1 -1
- package/dist/commands/share.js +1 -1
- package/dist/commands/stacks.js +1 -1
- package/dist/commands/tinker.js +1 -1
- package/dist/commands/types.js +1 -1
- package/dist/lazy-commands.js +1 -1
- package/dist/production-server.js +1 -1
- package/package.json +55 -55
package/README.md
CHANGED
|
@@ -157,7 +157,7 @@ buddy make:lang de # bootstraps a lang/de.yml language file
|
|
|
157
157
|
buddy make:stack my-project # scaffolds a project-shaped registry stack
|
|
158
158
|
|
|
159
159
|
buddy migrate # runs database migrations
|
|
160
|
-
buddy
|
|
160
|
+
buddy dns:pull # prints the live zone as a ./config/dns.ts block
|
|
161
161
|
|
|
162
162
|
buddy dns example.com # list all DNS records for example.com
|
|
163
163
|
buddy dns example.com --type MX # list MX records for example.com
|
package/dist/commands/add.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import process from"node:process";import{installStack}from"@stacksjs/actions";import{intro,italic,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";export function add(buddy){buddy.command("add <stack>","Pull a registered stack and merge its project files into this Stacks application").option("--force","Overwrite existing files",{default:!1}).option("--dry-run","Show which files would be installed without changing the project",{default:!1}).option("--conflict <strategy>","Resolve existing files with skip, overwrite, or backup",{default:"skip"}).option("-p, --project <path>","Target a specific Stacks project").option("--verbose","Show every file copied or skipped",{default:!1}).example("buddy add calendar").example("buddy add table --dry-run").example("buddy add calendar --conflict backup").action(async(stack,options)=>{const perf=await intro("buddy add");if(!await installStack({name:stack,force:options.force,dryRun:options.dryRun,conflict:options.conflict,project:options.project,verbose:options.verbose})){await outro(`Could not add stack ${italic(stack)}.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}if(options.dryRun)log.info("Dry run complete. No project files were changed.");await outro(`Stack ${italic(stack)} added.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"add")}
|
|
1
|
+
import process from"node:process";import{installStack}from"@stacksjs/actions";import{intro,italic,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";export function add(buddy){buddy.command("add <stack>","Pull a registered stack and merge its project files into this Stacks application").option("--force","Overwrite existing files",{default:!1}).option("--dry-run","Show which files would be installed without changing the project",{default:!1}).option("--conflict <strategy>","Resolve existing files with skip, overwrite, or backup",{default:"skip"}).option("-p, --project <path>","Target a specific Stacks project").option("--verbose","Show every file copied or skipped",{default:!1}).example("buddy add calendar").example("buddy add table --dry-run").example("buddy add calendar --conflict backup").action(async(stack,options)=>{const perf=await intro("buddy add");if(!await installStack({name:stack,force:options.force,dryRun:options.dryRun,conflict:options.conflict,project:options.project,verbose:options.verbose})){await outro(`Could not add stack ${italic(stack)}.`,{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}if(options.dryRun)log.info("Dry run complete. No project files were changed.");await outro(`Stack ${italic(stack)} added.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"add")}
|
package/dist/commands/build.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import process from"node:process";import{intro,log,multiselect,onUnknownSubcommand,outro}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{hasTTY,isCI}from"@stacksjs/env";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";let _runAction;async function runAction(...args){if(!_runAction)_runAction=(await import("@stacksjs/actions")).runAction;return _runAction(...args)}export function build(buddy){const descriptions={build:"Build any of your libraries (packages) for production use",components:"Build your component library",webComponents:"Build your framework agnostic web component library",elements:"An alias to the -w flag",buddy:"Build the Buddy binary",functions:"Build your function library",libs:"Build every package configured in config/library.ts",desktop:"Build the Desktop Application",mobile:"Build the native iOS and Android applications",android:"Build the native Android application",ios:"Build the native iOS application",dmg:"Package the desktop build as a macOS .app inside a .dmg",pages:"Build your frontend",docs:"Build your documentation",framework:"Build Stacks framework",cli:"Automagically build the CLI",server:"Build the Stacks cloud server (Docker image)",frontendStatic:"Build the prerendered marketing/public static site (storage/framework/frontend-dist)",select:"What are you trying to build?",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("build [type]",descriptions.build).option("-c, --components",descriptions.components).option("-w, --web-components",descriptions.webComponents).option("-e, --elements",descriptions.elements).option("-f, --functions",descriptions.functions).option("-l, --libs",descriptions.libs).option("-k, --desktop",descriptions.desktop).option("-m, --mobile",descriptions.mobile).option("--android",descriptions.android).option("--ios",descriptions.ios).option("-p, --views",descriptions.pages).option("--pages",descriptions.pages).option("-d, --docs",descriptions.docs).option("-b, --buddy",descriptions.buddy,{default:!1}).option("-s, --stacks",descriptions.framework,{default:!1}).option("--project [project]",descriptions.project,{default:!1}).option("--server",descriptions.server,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(server,options)=>{log.debug("Running `buddy build` ...",options);applyBuildTarget(server,options);if(options.mobile){options.android=!0;options.ios=!0}if(hasNoOptions(options)){if(!isCI&&hasTTY&&process.stdin.isTTY){const answers=await multiselect({message:descriptions.select,choices:[{label:"Frontend (views)",value:"views"},{label:"Components",value:"components"},{label:"Web Components",value:"webComponents"},{label:"Functions",value:"functions"},{label:"All library packages",value:"libs"},{label:"Desktop application",value:"desktop"},{label:"Mobile applications (iOS + Android)",value:"mobile"},{label:"Android application",value:"android"},{label:"iOS application",value:"ios"},{label:"Documentation",value:"docs"},{label:"Stacks framework",value:"stacks"},{label:"Buddy CLI",value:"buddy"},{label:"Server (Docker image)",value:"server"}]}),selected=new Set(answers);if(selected.has("views"))options.views=!0;if(selected.has("components"))options.components=!0;if(selected.has("webComponents"))options.webComponents=!0;if(selected.has("functions"))options.functions=!0;if(selected.has("libs"))options.libs=!0;if(selected.has("desktop"))options.desktop=!0;if(selected.has("mobile")){options.mobile=!0;options.android=!0;options.ios=!0}if(selected.has("android"))options.android=!0;if(selected.has("ios"))options.ios=!0;if(selected.has("docs"))options.docs=!0;if(selected.has("stacks"))options.stacks=!0;if(selected.has("buddy"))options.buddy=!0;if(selected.has("server"))options.server=!0}if(hasNoOptions(options)){options.views=!0;log.info("No build target specified, defaulting to the frontend (views). See `buddy build --help` for all targets.")}}let succeeded=!0;if(options.docs)succeeded=await runBuildAction(Action.BuildDocs,"documentation",options)&&succeeded;if(options.components)succeeded=await runBuildAction(Action.BuildComponentLibs,"component libraries",options)&&succeeded;if(options.webComponents)succeeded=await runBuildAction(Action.BuildWebComponentLib,"web component library",options)&&succeeded;if(options.functions)succeeded=await runBuildAction(Action.BuildFunctionLib,"function library",options)&&succeeded;if(options.libs)succeeded=await runBuildAction(Action.BuildLibs,"library packages",options)&&succeeded;if(options.desktop)succeeded=await runBuildAction(Action.BuildDesktop,"desktop application",options)&&succeeded;if(options.android)succeeded=await runBuildAction(Action.BuildAndroid,"Android application",options)&&succeeded;if(options.ios)succeeded=await runBuildAction(Action.BuildIos,"iOS application",options)&&succeeded;if(options.views)succeeded=await runBuildAction(Action.BuildViews,"frontend",options)&&succeeded;if(options.stacks)succeeded=await runBuildAction(Action.BuildStacks,"Stacks framework",options)&&succeeded;if(options.buddy)succeeded=await runBuildAction(Action.BuildCli,"Buddy CLI",options)&&succeeded;if(options.server)succeeded=await runBuildAction(Action.BuildServer,"server",options)&&succeeded;if(!succeeded)process.exit(ExitCode.FatalError);process.exit(ExitCode.Success)});buddy.command("build:components","Automagically build component libraries for production use & npm/CDN distribution").alias("prod:components").option("-c, --components",descriptions.components,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:components` ...",options);if(!await runBuildAction(Action.BuildComponentLibs,"component libraries"))process.exit(ExitCode.FatalError)});buddy.command("build:cli",descriptions.cli).alias("prod:cli").option("-b, --buddy",descriptions.buddy,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:cli` ...",options);await runAction(Action.BuildCli,options)});buddy.command("build:server",descriptions.server).alias("prod:server").alias("build:docker").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:server` ...",options);await runAction(Action.BuildServer,options)});buddy.command("build:functions","Automagically build function library for npm/CDN distribution").option("-f, --functions",descriptions.functions,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:functions` ...",options);if(!await runBuildAction(Action.BuildFunctionLib,"function library",options))process.exit(ExitCode.FatalError)});buddy.command("build:libs",descriptions.libs).alias("build:libraries").alias("prod:libs").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:libs` ...",options);if(!await runBuildAction(Action.BuildLibs,"library packages",options))process.exit(ExitCode.FatalError)});buddy.command("build:web-components","Automagically build Web Component library for npm/CDN distribution").alias("build:wc").alias("prod:web-components").alias("prod:wc").option("-w, --web-components",descriptions.webComponents,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:web-components` ...",options);if(!await runBuildAction(Action.BuildWebComponentLib,"web component library"))process.exit(ExitCode.FatalError)});buddy.command("build:frontend",descriptions.pages).alias("build:pages").alias("build:views").alias("prod:frontend").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:frontend` ...",options);await runAction(Action.BuildViews,options)});buddy.command("build:docs","Automagically build your documentation site.").alias("prod:docs").alias("build:documentation").alias("prod:documentation").option("-d, --docs",descriptions.docs,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:docs` ...",options);await runAction(Action.BuildDocs,options)});buddy.command("build:frontend-static",descriptions.frontendStatic).alias("build:public").alias("prod:frontend-static").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:frontend-static` ...",options);await runAction(Action.BuildFrontendStatic,options)});buddy.command("build:core","Automagically build the Stacks core.").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:core` ...",options);const startTime=await intro("buddy build:core"),result=await runAction(Action.BuildCore,options);if(resultFailed(result)){await log.error("Failed to build the Stacks core.",result.error);process.exit(ExitCode.FatalError)}await outro("Core packages built successfully",{startTime,useSeconds:!0})});buddy.command("build:desktop",descriptions.desktop).alias("prod:desktop").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:desktop` ...",options);const perf=await intro("buddy build:desktop"),result=await runAction(Action.BuildDesktop,options);if(resultFailed(result)){await outro("While running the build:desktop command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}console.log("");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("build:mobile",descriptions.mobile).alias("prod:mobile").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:mobile` ...",options);const perf=await intro("buddy build:mobile"),androidSucceeded=await runBuildAction(Action.BuildAndroid,"Android application",options),iosSucceeded=await runBuildAction(Action.BuildIos,"iOS application",options);if(!androidSucceeded||!iosSucceeded){await outro("One or more mobile application builds failed",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro("iOS and Android applications built",{startTime:perf,useSeconds:!0})});buddy.command("build:android",descriptions.android).alias("prod:android").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:android` ...",options);const perf=await intro("buddy build:android"),result=await runAction(Action.BuildAndroid,options);if(resultFailed(result)){await outro("While building the Android application, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Android application built",{startTime:perf,useSeconds:!0})});buddy.command("build:ios",descriptions.ios).alias("prod:ios").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:ios` ...",options);const perf=await intro("buddy build:ios"),result=await runAction(Action.BuildIos,options);if(resultFailed(result)){await outro("While building the iOS application, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("iOS application built",{startTime:perf,useSeconds:!0})});buddy.command("build:dmg",descriptions.dmg).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:dmg` ...",options);const perf=await intro("buddy build:dmg"),result=await runAction(Action.BuildDmg,options);if(resultFailed(result)){await outro("While running the build:dmg command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}console.log("");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("build:stacks","Build the Stacks framework.").option("-s, --stacks",descriptions.framework,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:stacks` ...",options);const startTime=await intro("buddy build:stacks"),result=await runAction(Action.BuildStacks,options);if(resultFailed(result)){await log.error("Failed to build Stacks.",result.error);process.exit(ExitCode.FatalError)}await outro("Stacks built successfully",{startTime,useSeconds:!0})});onUnknownSubcommand(buddy,"build")}function hasNoOptions(options){return!options.components&&!options.webComponents&&!options.elements&&!options.functions&&!options.libs&&!options.desktop&&!options.mobile&&!options.android&&!options.ios&&!options.views&&!options.docs&&!options.stacks&&!options.buddy&&!options.server}export function applyBuildTarget(target,options){switch(target){case"components":options.components=!0;break;case"web-components":options.webComponents=!0;break;case"functions":options.functions=!0;break;case"libs":case"libraries":options.libs=!0;break;case"desktop":options.desktop=!0;break;case"mobile":options.mobile=!0;options.android=!0;options.ios=!0;break;case"android":options.android=!0;break;case"ios":options.ios=!0;break;case"views":options.views=!0;break;case"docs":options.docs=!0;break;case"buddy":case"cli":options.buddy=!0;break;case"stacks":options.stacks=!0;break;case"server":options.server=!0;break}}async function runBuildAction(action,target,options){const result=await runAction(action,options);if(resultFailed(result)){log.error(`Failed to build ${target}.`,result.error);return!1}return!0}
|
|
1
|
+
import process from"node:process";import{intro,log,multiselect,onUnknownSubcommand,outro}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{hasTTY,isCI}from"@stacksjs/env";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";let _runAction;async function runAction(...args){if(!_runAction)_runAction=(await import("@stacksjs/actions")).runAction;return _runAction(...args)}export function build(buddy){const descriptions={build:"Build any of your libraries (packages) for production use",components:"Build your component library",webComponents:"Build your framework agnostic web component library",elements:"An alias to the -w flag",buddy:"Build the Buddy binary",functions:"Build your function library",libs:"Build every package configured in config/library.ts",desktop:"Build the Desktop Application",mobile:"Build the native iOS and Android applications",android:"Build the native Android application",ios:"Build the native iOS application",dmg:"Package the desktop build as a macOS .app inside a .dmg",pages:"Build your frontend",docs:"Build your documentation",framework:"Build Stacks framework",cli:"Automagically build the CLI",server:"Build the Stacks cloud server (Docker image)",frontendStatic:"Build the prerendered marketing/public static site (storage/framework/frontend-dist)",select:"What are you trying to build?",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("build [type]",descriptions.build).option("-c, --components",descriptions.components).option("-w, --web-components",descriptions.webComponents).option("-e, --elements",descriptions.elements).option("-f, --functions",descriptions.functions).option("-l, --libs",descriptions.libs).option("-k, --desktop",descriptions.desktop).option("-m, --mobile",descriptions.mobile).option("--android",descriptions.android).option("--ios",descriptions.ios).option("-p, --views",descriptions.pages).option("--pages",descriptions.pages).option("-d, --docs",descriptions.docs).option("-b, --buddy",descriptions.buddy,{default:!1}).option("-s, --stacks",descriptions.framework,{default:!1}).option("--project [project]",descriptions.project,{default:!1}).option("--server",descriptions.server,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(server,options)=>{log.debug("Running `buddy build` ...",options);applyBuildTarget(server,options);if(options.mobile){options.android=!0;options.ios=!0}if(hasNoOptions(options)){if(!isCI&&hasTTY&&process.stdin.isTTY){const answers=await multiselect({message:descriptions.select,choices:[{label:"Frontend (views)",value:"views"},{label:"Components",value:"components"},{label:"Web Components",value:"webComponents"},{label:"Functions",value:"functions"},{label:"All library packages",value:"libs"},{label:"Desktop application",value:"desktop"},{label:"Mobile applications (iOS + Android)",value:"mobile"},{label:"Android application",value:"android"},{label:"iOS application",value:"ios"},{label:"Documentation",value:"docs"},{label:"Stacks framework",value:"stacks"},{label:"Buddy CLI",value:"buddy"},{label:"Server (Docker image)",value:"server"}]}),selected=new Set(answers);if(selected.has("views"))options.views=!0;if(selected.has("components"))options.components=!0;if(selected.has("webComponents"))options.webComponents=!0;if(selected.has("functions"))options.functions=!0;if(selected.has("libs"))options.libs=!0;if(selected.has("desktop"))options.desktop=!0;if(selected.has("mobile")){options.mobile=!0;options.android=!0;options.ios=!0}if(selected.has("android"))options.android=!0;if(selected.has("ios"))options.ios=!0;if(selected.has("docs"))options.docs=!0;if(selected.has("stacks"))options.stacks=!0;if(selected.has("buddy"))options.buddy=!0;if(selected.has("server"))options.server=!0}if(hasNoOptions(options)){options.views=!0;log.info("No build target specified, defaulting to the frontend (views). See `buddy build --help` for all targets.")}}let succeeded=!0;if(options.docs)succeeded=await runBuildAction(Action.BuildDocs,"documentation",options)&&succeeded;if(options.components)succeeded=await runBuildAction(Action.BuildComponentLibs,"component libraries",options)&&succeeded;if(options.webComponents)succeeded=await runBuildAction(Action.BuildWebComponentLib,"web component library",options)&&succeeded;if(options.functions)succeeded=await runBuildAction(Action.BuildFunctionLib,"function library",options)&&succeeded;if(options.libs)succeeded=await runBuildAction(Action.BuildLibs,"library packages",options)&&succeeded;if(options.desktop)succeeded=await runBuildAction(Action.BuildDesktop,"desktop application",options)&&succeeded;if(options.android)succeeded=await runBuildAction(Action.BuildAndroid,"Android application",options)&&succeeded;if(options.ios)succeeded=await runBuildAction(Action.BuildIos,"iOS application",options)&&succeeded;if(options.views)succeeded=await runBuildAction(Action.BuildViews,"frontend",options)&&succeeded;if(options.stacks)succeeded=await runBuildAction(Action.BuildStacks,"Stacks framework",options)&&succeeded;if(options.buddy)succeeded=await runBuildAction(Action.BuildCli,"Buddy CLI",options)&&succeeded;if(options.server)succeeded=await runBuildAction(Action.BuildServer,"server",options)&&succeeded;if(!succeeded)process.exit(ExitCode.FatalError);process.exit(ExitCode.Success)});buddy.command("build:components","Automagically build component libraries for production use & npm/CDN distribution").alias("prod:components").option("-c, --components",descriptions.components,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:components` ...",options);if(!await runBuildAction(Action.BuildComponentLibs,"component libraries",options))process.exit(ExitCode.FatalError)});buddy.command("build:cli",descriptions.cli).alias("prod:cli").option("-b, --buddy",descriptions.buddy,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:cli` ...",options);await runAction(Action.BuildCli,options)});buddy.command("build:server",descriptions.server).alias("prod:server").alias("build:docker").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:server` ...",options);await runAction(Action.BuildServer,options)});buddy.command("build:functions","Automagically build function library for npm/CDN distribution").option("-f, --functions",descriptions.functions,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:functions` ...",options);if(!await runBuildAction(Action.BuildFunctionLib,"function library",options))process.exit(ExitCode.FatalError)});buddy.command("build:libs",descriptions.libs).alias("build:libraries").alias("prod:libs").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:libs` ...",options);if(!await runBuildAction(Action.BuildLibs,"library packages",options))process.exit(ExitCode.FatalError)});buddy.command("build:web-components","Automagically build Web Component library for npm/CDN distribution").alias("build:wc").alias("prod:web-components").alias("prod:wc").option("-w, --web-components",descriptions.webComponents,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:web-components` ...",options);if(!await runBuildAction(Action.BuildWebComponentLib,"web component library",options))process.exit(ExitCode.FatalError)});buddy.command("build:frontend",descriptions.pages).alias("build:pages").alias("build:views").alias("prod:frontend").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:frontend` ...",options);await runAction(Action.BuildViews,options)});buddy.command("build:docs","Automagically build your documentation site.").alias("prod:docs").alias("build:documentation").alias("prod:documentation").option("-d, --docs",descriptions.docs,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:docs` ...",options);await runAction(Action.BuildDocs,options)});buddy.command("build:frontend-static",descriptions.frontendStatic).alias("build:public").alias("prod:frontend-static").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:frontend-static` ...",options);await runAction(Action.BuildFrontendStatic,options)});buddy.command("build:core","Automagically build the Stacks core.").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:core` ...",options);const startTime=await intro("buddy build:core"),result=await runAction(Action.BuildCore,options);if(resultFailed(result)){await log.error("Failed to build the Stacks core.",result.error);process.exit(ExitCode.FatalError)}await outro("Core packages built successfully",{startTime,useSeconds:!0})});buddy.command("build:desktop",descriptions.desktop).alias("prod:desktop").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:desktop` ...",options);const perf=await intro("buddy build:desktop"),result=await runAction(Action.BuildDesktop,options);if(resultFailed(result)){await outro("While running the build:desktop command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}console.log("");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("build:mobile",descriptions.mobile).alias("prod:mobile").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:mobile` ...",options);const perf=await intro("buddy build:mobile"),androidSucceeded=await runBuildAction(Action.BuildAndroid,"Android application",options),iosSucceeded=await runBuildAction(Action.BuildIos,"iOS application",options);if(!androidSucceeded||!iosSucceeded){await outro("One or more mobile application builds failed",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}await outro("iOS and Android applications built",{startTime:perf,useSeconds:!0})});buddy.command("build:android",descriptions.android).alias("prod:android").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:android` ...",options);const perf=await intro("buddy build:android"),result=await runAction(Action.BuildAndroid,options);if(resultFailed(result)){await outro("While building the Android application, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Android application built",{startTime:perf,useSeconds:!0})});buddy.command("build:ios",descriptions.ios).alias("prod:ios").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:ios` ...",options);const perf=await intro("buddy build:ios"),result=await runAction(Action.BuildIos,options);if(resultFailed(result)){await outro("While building the iOS application, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("iOS application built",{startTime:perf,useSeconds:!0})});buddy.command("build:dmg",descriptions.dmg).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:dmg` ...",options);const perf=await intro("buddy build:dmg"),result=await runAction(Action.BuildDmg,options);if(resultFailed(result)){await outro("While running the build:dmg command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}console.log("");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("build:stacks","Build the Stacks framework.").option("-s, --stacks",descriptions.framework,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:stacks` ...",options);const startTime=await intro("buddy build:stacks"),result=await runAction(Action.BuildStacks,options);if(resultFailed(result)){await log.error("Failed to build Stacks.",result.error);process.exit(ExitCode.FatalError)}await outro("Stacks built successfully",{startTime,useSeconds:!0})});onUnknownSubcommand(buddy,"build")}function hasNoOptions(options){return!options.components&&!options.webComponents&&!options.elements&&!options.functions&&!options.libs&&!options.desktop&&!options.mobile&&!options.android&&!options.ios&&!options.views&&!options.docs&&!options.stacks&&!options.buddy&&!options.server}export function applyBuildTarget(target,options){switch(target){case"components":options.components=!0;break;case"web-components":options.webComponents=!0;break;case"functions":options.functions=!0;break;case"libs":case"libraries":options.libs=!0;break;case"desktop":options.desktop=!0;break;case"mobile":options.mobile=!0;options.android=!0;options.ios=!0;break;case"android":options.android=!0;break;case"ios":options.ios=!0;break;case"views":options.views=!0;break;case"docs":options.docs=!0;break;case"buddy":case"cli":options.buddy=!0;break;case"stacks":options.stacks=!0;break;case"server":options.server=!0;break}}async function runBuildAction(action,target,options){const result=await runAction(action,options);if(resultFailed(result)){log.error(`Failed to build ${target}.`,result.error);return!1}return!0}
|
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -1,5 +1,96 @@
|
|
|
1
1
|
import type { CLI } from '@stacksjs/types';
|
|
2
|
+
import type { OperationPlan } from '@stacksjs/ts-cloud';
|
|
3
|
+
/**
|
|
4
|
+
* The side effects a rename needs, wired to whichever fleet this project has.
|
|
5
|
+
*
|
|
6
|
+
* `ServerRenameEffects` keeps every capability optional but the taken-name check
|
|
7
|
+
* and the inventory, and DROPS the step for anything a fleet cannot do - so this
|
|
8
|
+
* supplies what it can and lets the plan come out shorter rather than pretending
|
|
9
|
+
* a step ran.
|
|
10
|
+
*
|
|
11
|
+
* The four records and where each one lives here:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Provider.** `HetznerClient.renameServer`. Only Hetzner has one; an ssh
|
|
14
|
+
* fleet is named in `config/cloud.ts`, which a command must not rewrite
|
|
15
|
+
* behind the author's back, so that fleet gets no provider step.
|
|
16
|
+
* 2. **State pin** (`storage/cloud/state/<stack>.json`). Offered only when a pin
|
|
17
|
+
* exists AND names this server: `findComputeTargets` rejects a pin whose
|
|
18
|
+
* recorded name no longer matches the live one, so a provider rename that
|
|
19
|
+
* skips this quietly invalidates the pin. A pin naming some other server is
|
|
20
|
+
* not this rename's business.
|
|
21
|
+
* 3. **Hostname.** Set over SSH, and only when the box has an address to reach.
|
|
22
|
+
* 4. **Inventory.** Derived, not stored (`toInventoryServer`) - so for a
|
|
23
|
+
* provider fleet the provider record IS the inventory, and the write here is
|
|
24
|
+
* against the snapshot this run is holding.
|
|
25
|
+
*/
|
|
26
|
+
export declare function renameEffects(tsCloudConfig: any, servers: any[], current: any): Promise<any>;
|
|
27
|
+
/**
|
|
28
|
+
* The plan that destroys `name`.
|
|
29
|
+
*
|
|
30
|
+
* Built here rather than upstream because ts-cloud has no destroy planner: it
|
|
31
|
+
* supplies the drained-site scan that decides whether a teardown may run at all,
|
|
32
|
+
* and leaves the teardown itself to the driver that provisioned the box.
|
|
33
|
+
*
|
|
34
|
+
* Deleting the server comes FIRST, and the pin is cleared after. The other order
|
|
35
|
+
* reads as safer and is not: a crash between the two would leave a live server
|
|
36
|
+
* that nothing points at, which is the state that gets forgotten and billed.
|
|
37
|
+
*/
|
|
38
|
+
export declare function planServerDestroy(name: string, effects: ServerDestroyEffects): OperationPlan;
|
|
39
|
+
/** {@link ServerDestroyEffects} wired to this project's fleet. */
|
|
40
|
+
export declare function destroyEffects(tsCloudConfig: any, current: any): Promise<ServerDestroyEffects>;
|
|
41
|
+
/**
|
|
42
|
+
* The other declared sites that answer on the same hostnames as this one.
|
|
43
|
+
*
|
|
44
|
+
* A move repoints DNS, and DNS is per HOSTNAME, not per site. This project
|
|
45
|
+
* mounts four sites on `stacksjs.com` - `main` at `/`, `docs` at `/docs`, `blog`
|
|
46
|
+
* at `/blog`, `discord` at `/discord` - so moving `docs` alone carries its tree
|
|
47
|
+
* to the target and points the whole apex there, leaving the other three
|
|
48
|
+
* serving from a box nothing resolves to any more.
|
|
49
|
+
*
|
|
50
|
+
* ts-cloud cannot see this: it is handed one site and one hostname, and both are
|
|
51
|
+
* correct in isolation. The sharing lives in the project's own site model, so
|
|
52
|
+
* the check does too.
|
|
53
|
+
*/
|
|
54
|
+
export declare function sitesSharingHostnames(sites: Record<string, any>, siteName: string, hostnames: string[], hostsOf: (name: string, site: any) => string[]): string[];
|
|
55
|
+
/**
|
|
56
|
+
* The box whose gateway currently answers for one of these hostnames.
|
|
57
|
+
*
|
|
58
|
+
* Asked of the boxes rather than read from config, because config says where a
|
|
59
|
+
* site is DECLARED to live and a move is about where it actually is - which are
|
|
60
|
+
* the same thing right up until the moment somebody needs to move it.
|
|
61
|
+
*/
|
|
62
|
+
export declare function serverServing(servers: any[], hosts: string[], probe: (server: any) => Promise<{ routes?: any[], unavailable?: string }>): Promise<any | undefined>;
|
|
63
|
+
/**
|
|
64
|
+
* The on-box engine database this project owns, when it has one.
|
|
65
|
+
*
|
|
66
|
+
* Named in `SiteMoveOptions` and NOT given effects, which is what makes
|
|
67
|
+
* `planSiteMove` refuse: a Postgres or MySQL database lives in the engine's own
|
|
68
|
+
* data directory rather than in the site tree, so moving the tree alone would
|
|
69
|
+
* pass every check in the plan - the app starts, answers its health gate, takes
|
|
70
|
+
* the DNS cutover - and then serve production an empty database. Carrying one
|
|
71
|
+
* needs a dump, a role, and a restore on the target, and until that exists the
|
|
72
|
+
* honest answer is to refuse the move rather than to make it silently.
|
|
73
|
+
*
|
|
74
|
+
* SQLite is not this case: it lives under `shared/`, which the tree carries
|
|
75
|
+
* already. An external database is not either - the target reaches the same
|
|
76
|
+
* endpoint the source did.
|
|
77
|
+
*/
|
|
78
|
+
export declare function onBoxDatabase(tsCloudConfig: any): Promise<{ name: string } | undefined>;
|
|
2
79
|
export declare function cloud(buddy: CLI): void;
|
|
80
|
+
/**
|
|
81
|
+
* What tearing a server down needs, so the plan can be built without a provider.
|
|
82
|
+
*
|
|
83
|
+
* Two records, not four: a destroy is the reverse of a provision, and the box's
|
|
84
|
+
* own hostname and inventory row go away with it. What does NOT go away on its
|
|
85
|
+
* own is the state pin, which would otherwise keep naming a server that no
|
|
86
|
+
* longer exists and send the next deploy looking for it.
|
|
87
|
+
*/
|
|
88
|
+
export declare interface ServerDestroyEffects {
|
|
89
|
+
providerExists: () => Promise<boolean>
|
|
90
|
+
deleteProvider: () => Promise<void>
|
|
91
|
+
pinnedName?: () => Promise<string | undefined>
|
|
92
|
+
clearPin?: () => Promise<void>
|
|
93
|
+
}
|
|
3
94
|
/**
|
|
4
95
|
* What happened to `config/cloud.ts` when an attach was applied.
|
|
5
96
|
*
|
package/dist/commands/cloud.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
-
import{existsSync,readdirSync,readFileSync}from"node:fs";import{join}from"node:path";import process from"node:process";import{intro,italic,log,onUnknownSubcommand,outro,prompts,runCommand,text,underline}from"@stacksjs/cli";import{addJumpBox,deleteCdkRemnants,deleteIamUsers,deleteJumpBox,deleteLogGroups,deleteParameterStore,deleteStacksBuckets,deleteStacksFunctions,deleteSubnets,deleteVpcs,getCloudFrontDistributionId,getJumpBoxInstanceId}from"@stacksjs/cloud";import{hasTTY,isCI}from"@stacksjs/env";import{path as p}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";import{isSshPipelineProvider,sshFleetFromConfigAndState}from"./deploy-ssh-target";async function createTemporaryCdkRole(roleName){const{AWSClient}=await import("@stacksjs/ts-cloud"),client=new AWSClient,trustPolicy={Version:"2012-10-17",Statement:[{Effect:"Allow",Principal:{Service:"cloudformation.amazonaws.com"},Action:"sts:AssumeRole"}]};try{try{const getRoleParams=new URLSearchParams({Action:"GetRole",RoleName:roleName,Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:getRoleParams.toString()});log.debug(`Role ${roleName} already exists`);return}catch(e){if(!e.message?.includes("NoSuchEntity")&&!e.message?.includes("cannot be found"))throw e}log.info("Creating temporary IAM role to enable stack deletion...");const createRoleParams=new URLSearchParams({Action:"CreateRole",RoleName:roleName,AssumeRolePolicyDocument:JSON.stringify(trustPolicy),Description:"Temporary role to allow CloudFormation to delete stuck stack",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:createRoleParams.toString()});log.success("Created IAM role");log.info("Attaching permissions...");const attachPolicyParams=new URLSearchParams({Action:"AttachRolePolicy",RoleName:roleName,PolicyArn:"arn:aws:iam::aws:policy/AdministratorAccess",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:attachPolicyParams.toString()});log.success("IAM role ready for stack deletion");log.info("Waiting for IAM role to propagate...");await new Promise((resolve)=>setTimeout(resolve,1e4))}catch(error){if(error.message?.includes("EntityAlreadyExists"))log.debug("Role already exists");else throw error}}async function deleteTemporaryCdkRole(roleName){const{AWSClient}=await import("@stacksjs/ts-cloud"),client=new AWSClient;try{const detachPolicyParams=new URLSearchParams({Action:"DetachRolePolicy",RoleName:roleName,PolicyArn:"arn:aws:iam::aws:policy/AdministratorAccess",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:detachPolicyParams.toString()});const deleteRoleParams=new URLSearchParams({Action:"DeleteRole",RoleName:roleName,Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:deleteRoleParams.toString()});log.success("Cleaned up temporary IAM role")}catch(error){log.debug(`Could not clean up temporary role: ${error.message}`)}}function isResultError(result){return resultFailed(result)}function getResultError(result){if(!result||typeof result!=="object")return"Unknown error";return String(result.error||"Unknown error")}function getResultValue(result){if(!result||typeof result!=="object")return;return result.value}async function refuse(...messages){for(const message of messages.slice(0,-1))await log.error(message);return log.exit(messages[messages.length-1],ExitCode.FatalError)}async function assertFleetProvider(tsCloudConfig,command){const provider=tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws";if(isSshPipelineProvider(provider))return;await log.info("The on-box half (the rpx gateway registry) is provider-independent; the server listing is not yet.");await log.exit(`\`buddy ${command}\` can list Hetzner and ssh servers, and this project's provider is '${provider}'.`,ExitCode.FatalError)}function readSshStatePins(cwd=process.cwd()){const dir=join(cwd,"storage","cloud","state");if(!existsSync(dir))return[];const pins=[];for(const file of readdirSync(dir)){if(!file.endsWith(".json"))continue;try{pins.push(JSON.parse(readFileSync(join(dir,file),"utf8")))}catch{}}return pins}async function listFleet(tsCloudConfig){if((tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER)==="ssh"){const servers=sshFleetFromConfigAndState(tsCloudConfig,readSshStatePins());return{servers,problem:servers.length===0?"No ssh hosts are configured. Add one under `ssh.hosts` in config/cloud.ts, or set TS_CLOUD_SSH_HOST.":void 0}}const{HetznerClient,resolveHetznerApiToken,toInventoryServer}=await import("@stacksjs/ts-cloud"),apiToken=resolveHetznerApiToken(tsCloudConfig);if(!apiToken)return{servers:[],problem:"No Hetzner API token, so no servers were looked up. Set HCLOUD_TOKEN (or HETZNER_API_TOKEN, or hetzner.apiToken in config/cloud.ts)."};try{return{servers:(await new HetznerClient({apiToken}).listServers()).map((server)=>toInventoryServer(server)).sort((a,b)=>a.name.localeCompare(b.name))}}catch(error){const status=Number(error?.status),where=Number.isFinite(status)&&status>0?`returned HTTP ${status}`:"could not be reached";return{servers:[],problem:`The Hetzner API ${where}, so the server list is incomplete. ${error?.message??String(error)}`+(status===401||status===403?" That is an auth failure, not an empty project: check the token is valid for this Hetzner project.":"")}}}async function declaredSitesFor(tsCloudConfig,slug){const{resolveSiteKind,siteInstallBase}=await import("@stacksjs/ts-cloud");return Object.entries(tsCloudConfig?.sites??{}).map(([name,site])=>{const domain=typeof site?.domain==="string"&&site.domain.trim()?site.domain.trim():void 0,port=Number(site?.port);return{name,kind:resolveSiteKind(site),domain,path:typeof site?.path==="string"&&site.path.trim()?site.path.trim():"/",port:Number.isFinite(port)&&port>0?port:void 0,installBase:siteInstallBase(slug,name),loopbackOnly:!domain}})}function describeAttachEdits(slug,owner,edit,dryRun){const lines=[" Two edits make the attach real, in two different repositories:",""];if(edit.state==="refused"){lines.push(` 1. config/cloud.ts here: could not edit it (${edit.reason})`);lines.push(` Add \`attachTo: '${owner}'\` to the \`cloud\` block by hand.`)}else if(edit.state==="already-set")lines.push(` 1. config/cloud.ts here: already sets attachTo: '${owner}'. Nothing to do.`);else lines.push(` 1. config/cloud.ts here: ${dryRun?`would set attachTo: '${owner}' (--dry-run, not written)`:`set attachTo: '${owner}'`}`);lines.push("");lines.push(` 2. In the '${owner}' project's own repository, which this command cannot edit:`);lines.push(` add '${slug}' to the \`tenants\` array in its config/cloud.ts.`);lines.push(` Without it, that project's deploy ships ${slug.toUpperCase()}_* keys from its`);lines.push(" env files into this project's .env instead of dropping them.");lines.push("");lines.push(" Then `buddy deploy` from here puts these sites on that box.");return lines}export function cloud(buddy){const descriptions={cloud:"Interact with the Stacks Cloud",ssh:"SSH into the Stacks Cloud",add:"Add a resource to the Stacks Cloud",remove:"Remove the Stacks Cloud. In case it fails, try again",optimizeCost:"Remove certain resources that may be re-applied at a later time",cleanUp:"Remove all resources that were retained during the cloud deletion",invalidateCache:"Invalidate the CloudFront cache",diff:"Show the diff of the current, undeployed cloud changes ",dashboard:"Run the local Stacks Cloud management cockpit (servers, sites, deploys)",sites:"List every server and what each one is hosting, across projects",attach:"Attach this project to a server another project owns, after checking it is safe",attachServer:"Server to attach to, by provider name or by owning project slug",dryRun:"Print the plan and change nothing",sitesEnv:"Environment to take the inventory for",remote:"Skip the SSH read of each box's gateway registry (co-tenants are then not listed)",json:"Emit the inventory as JSON",host:"Host to bind the dashboard to",port:"Port to bind the dashboard to",env:"Environment to manage",paths:"The paths to invalidate",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("cloud",descriptions.cloud).option("--ssh",descriptions.ssh,{default:!1}).option("--connect",descriptions.ssh,{default:!1}).option("--invalidate-cache",descriptions.invalidateCache,{default:!1}).option("--paths [paths]",descriptions.paths).option("--diff",descriptions.diff,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud` ...",options);const startTime=performance.now();if(options.ssh||options.connect){const jumpBoxId=await getJumpBoxInstanceId(),result=await runCommand(`aws ssm start-session --target ${jumpBoxId}`,{...options,cwd:p.projectPath(),stdin:"pipe"});if(isResultError(result)){await outro("While running the cloud command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(options.invalidateCache){if(!await prompts.confirm("Would you like to invalidate the CDN (CloudFront) cache?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Invalidating the CloudFront cache...");const{AWSCloudFrontClient}=await import("@stacksjs/ts-cloud"),cloudfront=new AWSCloudFrontClient,distributionId=await getCloudFrontDistributionId();try{const invalidationId=await cloudfront.invalidateAll(distributionId);log.success(`Invalidation created: ${invalidationId}`);log.info("Status: pending")}catch(err){log.error(`Failed to invalidate CloudFront cache: ${err.message}`)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(options.diff){try{const{InfrastructureGenerator}=await import("@stacksjs/ts-cloud"),{CloudFormationClient}=await import("@stacksjs/ts-cloud/aws"),{tsCloud:cloudConfig}=await import("~/config/cloud"),environment=process.env.APP_ENV||process.env.NODE_ENV||"production",newTemplate=new InfrastructureGenerator({config:cloudConfig,environment}).generate().toJSON(),stackName=`${cloudConfig.project?.slug||"stacks"}-${environment}`,cfn=new CloudFormationClient(process.env.AWS_REGION||"us-east-1");let currentTemplate="{}";try{currentTemplate=(await cfn.getTemplate(stackName)).TemplateBody}catch{log.info("No deployed stack found. Showing full template as diff.")}if(currentTemplate===newTemplate)log.info("No changes detected.");else{log.info("Changes detected between deployed and local template:");log.info(`Current template: ${currentTemplate.length} bytes`);log.info(`New template: ${newTemplate.length} bytes`)}}catch(error){log.error(`Failed to compute diff: ${error.message}`)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Not implemented yet. Read more about `buddy cloud` here: https://stacksjs.com/docs/cloud");await log.flush();process.exit(ExitCode.Success)});buddy.command("cloud:add",descriptions.add).option("--jump-box","Remove the jump-box",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:add` ...",options);const startTime=await intro("buddy cloud:add");if(options.jumpBox){if(!await prompts.confirm("Would you like to add a jump-box to your cloud?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("The jump-box is getting added to your cloud resources...");log.info("This takes a few moments, please be patient.");await new Promise((resolve)=>setTimeout(resolve,2000));const result=await addJumpBox();if(isResultError(result)){await outro("While running the cloud:add command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}log.info(italic("View the jump-box in the AWS console:"));log.info(underline("https://us-east-1.console.aws.amazon.com/ec2/home?region=us-east-1#Instances:instanceState=running"));log.info(italic("Once it finished initializing, you may SSH into it:"));log.info(underline("buddy cloud --ssh"));await outro("Your jump-box was added.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("This functionality is not yet implemented.");await log.flush();process.exit(ExitCode.Success)});buddy.command("cloud:remove",descriptions.remove).alias("cloud:destroy").alias("cloud:rm").alias("undeploy").option("--jump-box","Remove the jump-box",{default:!1}).option("--force","Force deletion of stack in bad state",{default:!1}).option("--yes","Skip confirmation prompts",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:remove` ...",options);const startTime=await intro("buddy cloud:remove"),environment=process.env.APP_ENV||process.env.NODE_ENV||"production";if(!options.yes&&(isCI||!hasTTY||!process.stdin.isTTY)){log.syncError(`Refusing to remove the "${environment}" cloud infrastructure from a non-interactive shell without confirmation.`);log.syncError(" \u27A1\uFE0F Re-run with `--yes` to confirm (e.g. in CI): `buddy cloud:remove --yes`");await outro("cloud:remove cancelled.",{startTime,useSeconds:!0});await log.flush();process.exit(ExitCode.FatalError)}if(options.jumpBox){if(!options.yes){if(!await prompts.confirm("Would you like to remove your jump-box for now?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}}const result=await deleteJumpBox();if(isResultError(result)){await outro("While removing your jump-box, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Your jump-box was removed.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(!options.yes){log.warning(`This will permanently delete the "${environment}" cloud infrastructure (compute, storage, CDN, and DNS managed by Stacks). This cannot be undone.`);if((await text({message:`Type the environment name "${environment}" to confirm (blank to cancel):`})).trim()!==environment){await outro("cloud:remove cancelled - confirmation did not match.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}}console.log("");console.log("Removing cloud infrastructure...");console.log(` ${italic("This typically takes 2-5 minutes.")}`);console.log("");if(!process.env.AWS_ACCESS_KEY_ID||!process.env.AWS_SECRET_ACCESS_KEY){const{existsSync,readFileSync}=await import("node:fs"),{projectPath}=await import("@stacksjs/path"),envFiles=[projectPath(`.env.${environment}`),projectPath(".env")];for(const envPath of envFiles)if(existsSync(envPath)){const lines=readFileSync(envPath,"utf-8").split(`
|
|
2
|
-
`);for(const line of lines){const trimmed=line.trim();if(trimmed.startsWith("#")||!trimmed.includes("="))continue;const[key,...valueParts]=trimmed.split("="),value=valueParts.join("=").replace(/^["']|["']$/g,"");if(key==="AWS_ACCESS_KEY_ID"||key==="AWS_SECRET_ACCESS_KEY"||key==="AWS_REGION"||key==="AWS_ACCOUNT_ID")process.env[key]=value}break}}delete process.env.AWS_PROFILE;try{const{undeployStack}=await import("../../../actions/deploy"),region=process.env.AWS_REGION||"us-east-1";await undeployStack({environment,region,verbose:options.verbose});await outro("Cloud infrastructure removed",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}catch(error){console.log("");console.error("\u2717 Failed to remove cloud infrastructure");const errorStr=String(error.message||error);if(errorStr.includes("security token")||errorStr.includes("credentials")){console.log("");console.error(" AWS credentials are invalid or expired");console.log(" Check your AWS credentials in .env.production:");console.log(" - AWS_ACCESS_KEY_ID");console.log(" - AWS_SECRET_ACCESS_KEY")}else if(errorStr.includes("region")||errorStr.includes("AWS_REGION")){console.log("");console.error(" AWS Region not configured");console.log(" Add AWS_REGION to your .env.production file")}else if(errorStr.includes("AccessDenied")){console.log("");console.error(" Access denied");console.log(" Your AWS credentials may not have permission to delete stacks")}else console.error(` ${errorStr}`);console.log("");console.log("Troubleshooting:");console.log(" ./buddy cloud:cleanup - Clean up resources manually");console.log(" --verbose - Show detailed error information");console.log("");if(options.verbose)console.error("Error details:",error);await outro("Failed to remove infrastructure",{startTime,useSeconds:!0});process.exit(ExitCode.FatalError)}});buddy.command("cloud:optimize-cost",descriptions.optimizeCost).option("--jump-box","Remove the jump-box",{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:optimize-cost` ...",options);const startTime=await intro("buddy cloud:optimize-cost");if(options.jumpBox){if(!await prompts.confirm("Would you like to remove your jump-box to optimize your costs?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}await deleteJumpBox();await outro("Your jump-box was removed & cost optimizations are applied.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}await outro("No cost optimization was applied",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:cleanup",descriptions.cleanUp).alias("cloud:clean-up").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:cleanup` ...",options);const startTime=await intro("buddy cloud:cleanup");delete process.env.AWS_PROFILE;log.info("Cleaning up your cloud resources will take a while to complete. Please be patient.");await new Promise((resolve)=>setTimeout(resolve,2000));const cleanupSteps=[{label:"jump-boxes",fn:deleteJumpBox,ignoreErrors:["Jump-box not found"]},{label:"retained S3 buckets",fn:deleteStacksBuckets},{label:"retained Lambda functions",fn:deleteStacksFunctions,ignoreErrors:["No stacks functions found"]},{label:"remaining Stacks logs",fn:deleteLogGroups},{label:"stored parameters",fn:deleteParameterStore},{label:"VPCs",fn:deleteVpcs},{label:"Subnets",fn:deleteSubnets},{label:"CDK remnants",fn:deleteCdkRemnants},{label:"IAM users",fn:deleteIamUsers}],errors=[];for(const step of cleanupSteps){log.info(`Removing any ${step.label}...`);try{const result=await step.fn();if(isResultError(result)){const errMsg=getResultError(result);if(!step.ignoreErrors?.includes(errMsg)){log.warn(`${step.label} cleanup issue: ${errMsg}`);errors.push({label:step.label,error:errMsg})}}else{const value=getResultValue(result);if(value)log.info(String(value))}}catch(e){const errMsg=e.message||"AWS SDK error";log.warn(`${step.label} cleanup skipped: ${errMsg}`);errors.push({label:step.label,error:errMsg})}}if(errors.length>0){log.warn(`Cleanup completed with ${errors.length} issue(s):`);for(const{label,error}of errors)log.warn(` - ${label}: ${error}`)}await outro("AWS resources have been removed",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:invalidate-cache",descriptions.invalidateCache).option("--paths [paths]",descriptions.paths,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:invalidate-cache` ...",options);const startTime=await intro("buddy cloud:invalidate-cache");if(!await prompts.confirm("Would you like to invalidate the CloudFront cache?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Invalidating the CloudFront cache...");const distributionId=await getCloudFrontDistributionId();if(!distributionId){await outro("Could not resolve CloudFront distribution ID",{startTime,useSeconds:!0},"Ensure your cloud stack is deployed before invalidating cache.");process.exit(ExitCode.FatalError)}const paths=options.paths?String(options.paths):"/*",result=await runCommand(`aws cloudfront create-invalidation --distribution-id ${distributionId} --paths ${paths}`,{...options,cwd:p.projectPath(),stdin:"pipe"});if(isResultError(result)){await outro("While running the cloud command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:diff",descriptions.diff).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:diff` ...",options);const startTime=await intro("buddy cloud:diff");try{const{InfrastructureGenerator}=await import("@stacksjs/ts-cloud"),{CloudFormationClient}=await import("@stacksjs/ts-cloud/aws"),{tsCloud:cloudConfig}=await import("~/config/cloud"),environment=process.env.APP_ENV||process.env.NODE_ENV||"production",newTemplate=new InfrastructureGenerator({config:cloudConfig,environment}).generate().toJSON(),stackName=`${cloudConfig.project?.slug||"stacks"}-${environment}`,cfn=new CloudFormationClient(process.env.AWS_REGION||"us-east-1");let currentTemplate="{}";try{currentTemplate=(await cfn.getTemplate(stackName)).TemplateBody}catch{log.info("No deployed stack found. Showing full template as diff.")}if(currentTemplate===newTemplate)log.info("No changes detected.");else{log.info("Changes detected between deployed and local template:");log.info(`Current template: ${currentTemplate.length} bytes`);log.info(`New template: ${newTemplate.length} bytes`)}}catch(error){await outro("While running the cloud diff command, there was an issue",{startTime,useSeconds:!0},error.message);process.exit(ExitCode.FatalError)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:dashboard",descriptions.dashboard).alias("cloud:cockpit").option("--host [host]",descriptions.host,{default:"127.0.0.1"}).option("--port [port]",descriptions.port,{default:"7676"}).option("--env [env]",descriptions.env).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:dashboard` ...",options);const startTime=await intro("buddy cloud:dashboard"),tsCloud=await import("@stacksjs/ts-cloud");if(typeof tsCloud.startLocalDashboardServer!=="function"){await outro("The installed @stacksjs/ts-cloud does not provide the local cockpit yet",{startTime,useSeconds:!0},"Update your dependencies (requires @stacksjs/ts-cloud >= 0.5.27).");process.exit(ExitCode.FatalError)}try{const server=await tsCloud.startLocalDashboardServer({host:options.host?String(options.host):void 0,port:options.port?Number(options.port):void 0,environment:options.env,verbose:!!options.verbose});log.success(`Stacks Cloud cockpit running at ${underline(server.url)}`);log.info(italic("Manage servers, sites, SSH keys and deploys. Press Ctrl+C to stop."));await new Promise(()=>{})}catch(error){await outro("While starting the cloud dashboard, there was an issue",{startTime,useSeconds:!0},error?.message??String(error));process.exit(ExitCode.FatalError)}});buddy.command("cloud:sites",descriptions.sites).option("--env [env]",descriptions.sitesEnv).option("--no-remote",descriptions.remote).option("-J, --json",descriptions.json,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:sites` ...",options);const{formatInventory,probeHostRoutes}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy"),environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0),slug=tsCloudConfig?.project?.slug||"app";await assertFleetProvider(tsCloudConfig,"cloud:sites");const listing=await listFleet(tsCloudConfig);if(listing.problem)await log.error(listing.problem);const declared=await declaredSitesFor(tsCloudConfig,slug),probes=options.remote===!1?[]:await probeFleet(listing.servers),inventory={slug,servers:listing.servers,probes,declared};if(options.json)console.log(JSON.stringify({environment,...inventory},null,2));else for(const line of formatInventory(inventory))console.log(line);process.exit(listing.problem&&listing.servers.length===0?ExitCode.FatalError:ExitCode.Success);async function probeFleet(servers){const{sshExec}=await import("@stacksjs/ts-cloud"),probes=[];for(let index=0;index<servers.length;index+=6)probes.push(...await Promise.all(servers.slice(index,index+6).map((server)=>probeHostRoutes(server,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})))));return probes}});buddy.command("cloud:attach",descriptions.attach).option("--server <server>",descriptions.attachServer).option("--env [env]",descriptions.sitesEnv).option("--dry-run",descriptions.dryRun,{default:!1}).option("-J, --json",descriptions.json,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:attach` ...",options);const{attachConflicts,attachIsViable,attachPreconditions,formatAttachPlan,probeHostRoutes,resolveAttachTarget,setAttachToInCloudConfig,sshExec}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy");if(!options.server)return await refuse("Which server? Pass --server <name|owner-slug>. `buddy cloud:sites` lists them.");const environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0),slug=tsCloudConfig?.project?.slug||"app";await assertFleetProvider(tsCloudConfig,"cloud:attach");const listing=await listFleet(tsCloudConfig);if(listing.problem)return await refuse(listing.problem);const target=resolveAttachTarget(listing.servers,String(options.server),environment);if("problem"in target)return await refuse(`${target.problem} \`buddy cloud:sites\` lists what is there.`);const server=target.server,preconditions=attachPreconditions(slug,server);if(preconditions.length>0)return await refuse(...preconditions);const declared=await declaredSitesFor(tsCloudConfig,slug),probe=await probeHostRoutes(server,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})),plan={slug,owner:server.project,server,declared,conflicts:probe.unavailable?[]:attachConflicts(slug,declared,probe.routes),registryRead:!probe.unavailable,registryProblem:probe.unavailable},viable=attachIsViable(plan),edit=viable?await applyAttachToConfig(plan.owner,Boolean(options.dryRun)):void 0;if(options.json)console.log(JSON.stringify({environment,...plan,edit},null,2));else{for(const line of formatAttachPlan(plan))console.log(line);if(edit)console.log(["",...describeAttachEdits(plan.slug,plan.owner,edit,Boolean(options.dryRun))].join(`
|
|
3
|
-
`)
|
|
1
|
+
import{existsSync,readdirSync,readFileSync,writeFileSync}from"node:fs";import{join}from"node:path";import process from"node:process";import{intro,italic,log,onUnknownSubcommand,outro,prompts,runCommand,text,underline}from"@stacksjs/cli";import{addJumpBox,deleteCdkRemnants,deleteIamUsers,deleteJumpBox,deleteLogGroups,deleteParameterStore,deleteStacksBuckets,deleteStacksFunctions,deleteSubnets,deleteVpcs,getCloudFrontDistributionId,getJumpBoxInstanceId}from"@stacksjs/cloud";import{hasTTY,isCI}from"@stacksjs/env";import{path as p}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";import{isSshPipelineProvider,sshFleetFromConfigAndState}from"./deploy-ssh-target";async function createTemporaryCdkRole(roleName){const{AWSClient}=await import("@stacksjs/ts-cloud"),client=new AWSClient,trustPolicy={Version:"2012-10-17",Statement:[{Effect:"Allow",Principal:{Service:"cloudformation.amazonaws.com"},Action:"sts:AssumeRole"}]};try{try{const getRoleParams=new URLSearchParams({Action:"GetRole",RoleName:roleName,Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:getRoleParams.toString()});log.debug(`Role ${roleName} already exists`);return}catch(e){if(!e.message?.includes("NoSuchEntity")&&!e.message?.includes("cannot be found"))throw e}log.info("Creating temporary IAM role to enable stack deletion...");const createRoleParams=new URLSearchParams({Action:"CreateRole",RoleName:roleName,AssumeRolePolicyDocument:JSON.stringify(trustPolicy),Description:"Temporary role to allow CloudFormation to delete stuck stack",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:createRoleParams.toString()});log.success("Created IAM role");log.info("Attaching permissions...");const attachPolicyParams=new URLSearchParams({Action:"AttachRolePolicy",RoleName:roleName,PolicyArn:"arn:aws:iam::aws:policy/AdministratorAccess",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:attachPolicyParams.toString()});log.success("IAM role ready for stack deletion");log.info("Waiting for IAM role to propagate...");await new Promise((resolve)=>setTimeout(resolve,1e4))}catch(error){if(error.message?.includes("EntityAlreadyExists"))log.debug("Role already exists");else throw error}}async function deleteTemporaryCdkRole(roleName){const{AWSClient}=await import("@stacksjs/ts-cloud"),client=new AWSClient;try{const detachPolicyParams=new URLSearchParams({Action:"DetachRolePolicy",RoleName:roleName,PolicyArn:"arn:aws:iam::aws:policy/AdministratorAccess",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:detachPolicyParams.toString()});const deleteRoleParams=new URLSearchParams({Action:"DeleteRole",RoleName:roleName,Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:deleteRoleParams.toString()});log.success("Cleaned up temporary IAM role")}catch(error){log.debug(`Could not clean up temporary role: ${error.message}`)}}function isResultError(result){return resultFailed(result)}function getResultError(result){if(!result||typeof result!=="object")return"Unknown error";return String(result.error||"Unknown error")}function getResultValue(result){if(!result||typeof result!=="object")return;return result.value}async function refuse(...messages){for(const message of messages.slice(0,-1))await log.error(message);return log.exit(messages[messages.length-1],ExitCode.FatalError)}async function driverStatePins(){const{driverStatePath}=await import("@stacksjs/ts-cloud"),dir=join(driverStatePath("probe"),"..");let files;try{files=readdirSync(dir).filter((name)=>name.endsWith(".json"))}catch{return[]}const pins=[];for(const name of files){const file=join(dir,name);try{const state=JSON.parse(readFileSync(file,"utf8"));if(typeof state?.serverName==="string")pins.push({file,state})}catch{}}return pins}function writePinName(file,next){const state=JSON.parse(readFileSync(file,"utf8"));writeFileSync(file,`${JSON.stringify({...state,serverName:next},null,2)}
|
|
2
|
+
`)}export async function renameEffects(tsCloudConfig,servers,current){const taken=servers.map((s)=>String(s?.name)).filter(Boolean),effects={takenNames:async()=>taken,inventoryName:()=>String(current?.name??""),renameInventory:(next)=>{current.name=next}},pinned=(await driverStatePins()).filter((pin)=>pin.state.serverName===current?.name);if(pinned.length>0){effects.stateName=async()=>{const stale=(await driverStatePins()).find((pin)=>pin.state.serverName===current?.name);return stale?String(stale.state.serverName):void 0};effects.writeStateName=async(next)=>{for(const pin of pinned)writePinName(pin.file,next)}}const host=current?.ipv4;if(host){const{sshExec,buildSetHostnameScript}=await import("@stacksjs/ts-cloud"),ssh={user:"root",connectTimeoutSec:10};effects.remoteHostname=async()=>{const result=await sshExec(host,"hostname",ssh);if(result.code!==0)throw Error(result.stderr.trim()||`Could not reach ${host} over SSH.`);return result.stdout.trim()};effects.setRemoteHostname=async(next)=>{const result=await sshExec(host,buildSetHostnameScript(next),ssh);if(result.code!==0)throw Error(result.stderr.trim()||`Setting the hostname on ${host} failed.`)}}if((tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws")!=="hetzner")return effects;const{HetznerClient}=await import("@stacksjs/ts-cloud"),{resolveHetznerApiToken}=await import("./deploy"),apiToken=resolveHetznerApiToken(tsCloudConfig),serverId=Number(current?.id);if(!apiToken||!Number.isFinite(serverId))return effects;const client=new HetznerClient({apiToken});effects.providerName=async()=>(await client.getServer(serverId))?.name;effects.renameProvider=async(next)=>{await client.renameServer(serverId,next)};return effects}export function planServerDestroy(name,effects){const steps=[{id:"provider:delete",title:`Delete the server ${name}`,change:{from:name,to:"gone"},destructive:!0,satisfied:async()=>!await effects.providerExists(),apply:async()=>await effects.deleteProvider()}];if(effects.pinnedName&&effects.clearPin)steps.push({id:"state:clear",title:"Clear the state pin that names it",change:{from:name,to:"none"},satisfied:async()=>await effects.pinnedName()!==name,apply:async()=>await effects.clearPin()});return{operation:"server:destroy",target:name,steps}}export async function destroyEffects(tsCloudConfig,current){const pinned=(await driverStatePins()).filter((pin)=>pin.state.serverName===current?.name),effects={providerExists:async()=>!0,deleteProvider:async()=>{throw Error(`No provider API for this fleet, so ${current?.name} cannot be deleted from here.`)}};if(pinned.length>0){effects.pinnedName=async()=>{const stale=(await driverStatePins()).find((pin)=>pin.state.serverName===current?.name);return stale?String(stale.state.serverName):void 0};effects.clearPin=async()=>{for(const pin of pinned){const{serverId:_id,serverName:_name,...rest}=JSON.parse(readFileSync(pin.file,"utf8"));writeFileSync(pin.file,`${JSON.stringify(rest,null,2)}
|
|
3
|
+
`)}}}if((tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws")!=="hetzner")return effects;const{HetznerClient}=await import("@stacksjs/ts-cloud"),{resolveHetznerApiToken}=await import("./deploy"),apiToken=resolveHetznerApiToken(tsCloudConfig),serverId=Number(current?.id);if(!apiToken||!Number.isFinite(serverId))return effects;const client=new HetznerClient({apiToken});effects.providerExists=async()=>{try{return Boolean(await client.getServer(serverId))}catch{return!1}};effects.deleteProvider=async()=>{await client.deleteServer(serverId)};return effects}export function sitesSharingHostnames(sites,siteName,hostnames,hostsOf){return Object.entries(sites??{}).filter(([name])=>name!==siteName).filter(([name,site])=>hostsOf(name,site).some((host)=>hostnames.includes(host))).map(([name,site])=>`${name} (${String(site?.path??"/")})`).sort()}async function moveEffects(context){const{slug,siteName,rawSite,source,target,tsCloudConfig}=context,{buildCertificatePackScript,buildCertificateStateScript,buildCertificateUnpackScript,buildRpxConfig,buildRpxFragmentRefreshScript,buildSshArgs,certificatesMatch,DEFAULT_RPX_CERTS_DIR,parseCertificateState,probeHostRoutes,sshExec,sshExecOrThrow}=await import("@stacksjs/ts-cloud"),{reconcileHetznerDns}=await import("./deploy"),ssh={user:"root",connectTimeoutSec:10},runOnSource=(script)=>sshExecOrThrow(source.ipv4,script,ssh),runOnTarget=(script)=>sshExecOrThrow(target.ipv4,script,ssh);async function carry(path){const args=buildSshArgs(ssh,"ssh"),reader=Bun.spawn(["ssh",...args,`root@${source.ipv4}`,`cat ${path}`],{stdout:"pipe",stderr:"pipe"}),writer=Bun.spawn(["ssh",...args,`root@${target.ipv4}`,`cat > ${path}`],{stdin:reader.stdout,stdout:"pipe",stderr:"pipe"}),[readCode,writeCode]=await Promise.all([reader.exited,writer.exited]);if(readCode!==0||writeCode!==0){const why=(await new Response(readCode===0?writer.stderr:reader.stderr).text()).trim();throw Error(why||`Carrying ${path} from ${source.name} to ${target.name} failed.`)}}const staged=async(path)=>(await sshExec(target.ipv4,`test -s ${path} && echo staged`,ssh)).stdout.includes("staged"),{siteMoveArchivePath,siteMoveCertArchivePath}=await import("@stacksjs/ts-cloud"),archive=siteMoveArchivePath(slug,siteName),effects={runOnSource,runOnTarget,archiveStaged:()=>staged(archive),transferArchive:()=>carry(archive),publishedAddress:async()=>{const host=context.hostnames[0];if(!host)return;try{const{resolve4}=await import("node:dns/promises");return(await resolve4(host))[0]}catch{return}},cutoverDns:async()=>{const warnings=[],collect=new Proxy(log,{get:(target_,key)=>key==="warn"?(message)=>{warnings.push(String(message))}:target_[key]});await reconcileHetznerDns({[siteName]:rawSite},target.ipv4,collect,target.ipv6,tsCloudConfig?.infrastructure?.compute?.proxy?.autoWww);return warnings},targetRoutesSite:async()=>{const probe=await probeHostRoutes(target,(host,command)=>sshExec(host,command,ssh));if(probe.unavailable)return!1;return probe.routes.some((route)=>context.hostnames.includes(String(route.host)))},refreshTargetGateway:async()=>{const rpx=buildRpxConfig(tsCloudConfig?.sites??{},{proxy:tsCloudConfig?.infrastructure?.compute?.proxy??{},slug});await runOnTarget(buildRpxFragmentRefreshScript({config:rpx,slug}).join(`
|
|
4
|
+
`))}},certArchive=siteMoveCertArchivePath(slug,siteName);effects.certificates={inPlace:async()=>{const read=async(run)=>parseCertificateState(await run(buildCertificateStateScript(DEFAULT_RPX_CERTS_DIR,context.hostnames)));return certificatesMatch(await read(runOnSource),await read(runOnTarget))},carry:async()=>{await runOnSource(buildCertificatePackScript(DEFAULT_RPX_CERTS_DIR,context.hostnames,certArchive));await carry(certArchive);await runOnTarget(buildCertificateUnpackScript(DEFAULT_RPX_CERTS_DIR,certArchive))}};return effects}export async function serverServing(servers,hosts,probe){for(const candidate of servers){if(!candidate?.ipv4)continue;const result=await probe(candidate);if(!result.unavailable&&(result.routes??[]).some((route)=>hosts.includes(String(route.host))))return candidate}return}export async function onBoxDatabase(tsCloudConfig){const{isLocalDatabase,resolveAppDatabase}=await import("@stacksjs/ts-cloud"),database=resolveAppDatabase(tsCloudConfig);if(!database||!isLocalDatabase(database))return;const engine=String(database.engine||database.driver||"");if(engine==="sqlite"||engine==="better-sqlite3")return;const name=String(database.name||"");return name?{name}:void 0}async function assertFleetProvider(tsCloudConfig,command){const provider=tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws";if(isSshPipelineProvider(provider))return;await log.info("The on-box half (the rpx gateway registry) is provider-independent; the server listing is not yet.");await log.exit(`\`buddy ${command}\` can list Hetzner and ssh servers, and this project's provider is '${provider}'.`,ExitCode.FatalError)}function readSshStatePins(cwd=process.cwd()){const dir=join(cwd,"storage","cloud","state");if(!existsSync(dir))return[];const pins=[];for(const file of readdirSync(dir)){if(!file.endsWith(".json"))continue;try{pins.push(JSON.parse(readFileSync(join(dir,file),"utf8")))}catch{}}return pins}async function listFleet(tsCloudConfig){if((tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER)==="ssh"){const servers=sshFleetFromConfigAndState(tsCloudConfig,readSshStatePins());return{servers,problem:servers.length===0?"No ssh hosts are configured. Add one under `ssh.hosts` in config/cloud.ts, or set TS_CLOUD_SSH_HOST.":void 0}}const{HetznerClient,toInventoryServer}=await import("@stacksjs/ts-cloud"),{resolveHetznerApiToken}=await import("./deploy"),apiToken=resolveHetznerApiToken(tsCloudConfig);if(!apiToken)return{servers:[],problem:"No Hetzner API token, so no servers were looked up. Set HCLOUD_TOKEN (or HETZNER_API_TOKEN, or hetzner.apiToken in config/cloud.ts)."};try{return{servers:(await new HetznerClient({apiToken}).listServers()).map((server)=>toInventoryServer(server)).sort((a,b)=>a.name.localeCompare(b.name))}}catch(error){const status=Number(error?.status),where=Number.isFinite(status)&&status>0?`returned HTTP ${status}`:"could not be reached";return{servers:[],problem:`The Hetzner API ${where}, so the server list is incomplete. ${error?.message??String(error)}`+(status===401||status===403?" That is an auth failure, not an empty project: check the token is valid for this Hetzner project.":"")}}}async function declaredSitesFor(tsCloudConfig,slug){const{resolveSiteKind,siteInstallBase}=await import("@stacksjs/ts-cloud");return Object.entries(tsCloudConfig?.sites??{}).map(([name,site])=>{const domain=typeof site?.domain==="string"&&site.domain.trim()?site.domain.trim():void 0,port=Number(site?.port);return{name,kind:resolveSiteKind(site),domain,path:typeof site?.path==="string"&&site.path.trim()?site.path.trim():"/",port:Number.isFinite(port)&&port>0?port:void 0,installBase:siteInstallBase(slug,name),loopbackOnly:!domain}})}function describeAttachEdits(slug,owner,edit,dryRun){const lines=[" Two edits make the attach real, in two different repositories:",""];if(edit.state==="refused"){lines.push(` 1. config/cloud.ts here: could not edit it (${edit.reason})`);lines.push(` Add \`attachTo: '${owner}'\` to the \`cloud\` block by hand.`)}else if(edit.state==="already-set")lines.push(` 1. config/cloud.ts here: already sets attachTo: '${owner}'. Nothing to do.`);else lines.push(` 1. config/cloud.ts here: ${dryRun?`would set attachTo: '${owner}' (--dry-run, not written)`:`set attachTo: '${owner}'`}`);lines.push("");lines.push(` 2. In the '${owner}' project's own repository, which this command cannot edit:`);lines.push(` add '${slug}' to the \`tenants\` array in its config/cloud.ts.`);lines.push(` Without it, that project's deploy ships ${slug.toUpperCase()}_* keys from its`);lines.push(" env files into this project's .env instead of dropping them.");lines.push("");lines.push(" Then `buddy deploy` from here puts these sites on that box.");return lines}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",rename:"Rename a server in place, keeping the provider, state pin, hostname and inventory in step",renameTo:"The new server name",renameEnv:"Environment whose fleet and state pin to rename in",renameJson:"Emit the rename plan as JSON",destroy:"Destroy a drained server, once nothing on it is anyone's rollback",destroyEnv:"Environment whose fleet and state pin the server belongs to",destroyJson:"Emit the teardown plan as JSON",destroyConfirm:"The exact server name, which an irreversible step requires before it runs",destroyDiscardDrained:"Destroy it even though it still holds site trees that are somebody's rollback",move:"Move a deployed site to another server, cutting DNS over once the target serves it",moveTo:"Server to move the site to, by provider name or by owning project slug",moveFrom:"Server the site is on now, when more than one could be serving it",moveEnv:"Environment whose fleet and sites to move within",moveJson:"Emit the move plan as JSON",moveConfirm:"The exact site name, which the irreversible steps require before they run",attachServer:"Server to attach to, by provider name or by owning project slug",dryRun:"Print the plan and change nothing",sitesEnv:"Environment to take the inventory for",remote:"Skip the SSH read of each box's gateway registry (co-tenants are then not listed)",json:"Emit the inventory as JSON",host:"Host to bind the dashboard to",port:"Port to bind the dashboard to",env:"Environment to manage",paths:"The paths to invalidate",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("cloud",descriptions.cloud).option("--ssh",descriptions.ssh,{default:!1}).option("--connect",descriptions.ssh,{default:!1}).option("--invalidate-cache",descriptions.invalidateCache,{default:!1}).option("--paths [paths]",descriptions.paths).option("--diff",descriptions.diff,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud` ...",options);const startTime=performance.now();if(options.ssh||options.connect){const jumpBoxId=await getJumpBoxInstanceId(),result=await runCommand(`aws ssm start-session --target ${jumpBoxId}`,{...options,cwd:p.projectPath(),stdin:"pipe"});if(isResultError(result)){await outro("While running the cloud command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(options.invalidateCache){if(!await prompts.confirm("Would you like to invalidate the CDN (CloudFront) cache?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Invalidating the CloudFront cache...");const{AWSCloudFrontClient}=await import("@stacksjs/ts-cloud"),cloudfront=new AWSCloudFrontClient,distributionId=await getCloudFrontDistributionId();try{const invalidationId=await cloudfront.invalidateAll(distributionId);log.success(`Invalidation created: ${invalidationId}`);log.info("Status: pending")}catch(err){log.error(`Failed to invalidate CloudFront cache: ${err.message}`)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(options.diff){try{const{InfrastructureGenerator}=await import("@stacksjs/ts-cloud"),{CloudFormationClient}=await import("@stacksjs/ts-cloud/aws"),{tsCloud:cloudConfig}=await import("~/config/cloud"),environment=process.env.APP_ENV||process.env.NODE_ENV||"production",newTemplate=new InfrastructureGenerator({config:cloudConfig,environment}).generate().toJSON(),stackName=`${cloudConfig.project?.slug||"stacks"}-${environment}`,cfn=new CloudFormationClient(process.env.AWS_REGION||"us-east-1");let currentTemplate="{}";try{currentTemplate=(await cfn.getTemplate(stackName)).TemplateBody}catch{log.info("No deployed stack found. Showing full template as diff.")}if(currentTemplate===newTemplate)log.info("No changes detected.");else{log.info("Changes detected between deployed and local template:");log.info(`Current template: ${currentTemplate.length} bytes`);log.info(`New template: ${newTemplate.length} bytes`)}}catch(error){log.error(`Failed to compute diff: ${error.message}`)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Not implemented yet. Read more about `buddy cloud` here: https://stacksjs.com/docs/cloud");await log.flush();process.exit(ExitCode.Success)});buddy.command("cloud:add",descriptions.add).option("--jump-box","Remove the jump-box",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:add` ...",options);const startTime=await intro("buddy cloud:add");if(options.jumpBox){if(!await prompts.confirm("Would you like to add a jump-box to your cloud?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("The jump-box is getting added to your cloud resources...");log.info("This takes a few moments, please be patient.");await new Promise((resolve)=>setTimeout(resolve,2000));const result=await addJumpBox();if(isResultError(result)){await outro("While running the cloud:add command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}log.info(italic("View the jump-box in the AWS console:"));log.info(underline("https://us-east-1.console.aws.amazon.com/ec2/home?region=us-east-1#Instances:instanceState=running"));log.info(italic("Once it finished initializing, you may SSH into it:"));log.info(underline("buddy cloud --ssh"));await outro("Your jump-box was added.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("This functionality is not yet implemented.");await log.flush();process.exit(ExitCode.Success)});buddy.command("cloud:remove",descriptions.remove).alias("cloud:rm").alias("undeploy").option("--jump-box","Remove the jump-box",{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,type:"warning"});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(`
|
|
5
|
+
`);for(const line of lines){const trimmed=line.trim();if(trimmed.startsWith("#")||!trimmed.includes("="))continue;const[key,...valueParts]=trimmed.split("="),value=valueParts.join("=").replace(/^["']|["']$/g,"");if(key==="AWS_ACCESS_KEY_ID"||key==="AWS_SECRET_ACCESS_KEY"||key==="AWS_REGION"||key==="AWS_ACCOUNT_ID")process.env[key]=value}break}}delete process.env.AWS_PROFILE;try{const{undeployStack}=await import("../../../actions/deploy"),region=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,type:"error"});process.exit(ExitCode.FatalError)}});buddy.command("cloud:optimize-cost",descriptions.optimizeCost).option("--jump-box","Remove the jump-box",{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:optimize-cost` ...",options);const startTime=await intro("buddy cloud:optimize-cost");if(options.jumpBox){if(!await prompts.confirm("Would you like to remove your jump-box to optimize your costs?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}await deleteJumpBox();await outro("Your jump-box was removed & cost optimizations are applied.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}await outro("No cost optimization was applied",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:cleanup",descriptions.cleanUp).alias("cloud:clean-up").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:cleanup` ...",options);const startTime=await intro("buddy cloud:cleanup");delete process.env.AWS_PROFILE;log.info("Cleaning up your cloud resources will take a while to complete. Please be patient.");await new Promise((resolve)=>setTimeout(resolve,2000));const cleanupSteps=[{label:"jump-boxes",fn:deleteJumpBox,ignoreErrors:["Jump-box not found"]},{label:"retained S3 buckets",fn:deleteStacksBuckets},{label:"retained Lambda functions",fn:deleteStacksFunctions,ignoreErrors:["No stacks functions found"]},{label:"remaining Stacks logs",fn:deleteLogGroups},{label:"stored parameters",fn:deleteParameterStore},{label:"VPCs",fn:deleteVpcs},{label:"Subnets",fn:deleteSubnets},{label:"CDK remnants",fn:deleteCdkRemnants},{label:"IAM users",fn:deleteIamUsers}],errors=[];for(const step of cleanupSteps){log.info(`Removing any ${step.label}...`);try{const result=await step.fn();if(isResultError(result)){const errMsg=getResultError(result);if(!step.ignoreErrors?.includes(errMsg)){log.warn(`${step.label} cleanup issue: ${errMsg}`);errors.push({label:step.label,error:errMsg})}}else{const value=getResultValue(result);if(value)log.info(String(value))}}catch(e){const errMsg=e.message||"AWS SDK error";log.warn(`${step.label} cleanup skipped: ${errMsg}`);errors.push({label:step.label,error:errMsg})}}if(errors.length>0){log.warn(`Cleanup completed with ${errors.length} issue(s):`);for(const{label,error}of errors)log.warn(` - ${label}: ${error}`)}await outro("AWS resources have been removed",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:invalidate-cache",descriptions.invalidateCache).option("--paths [paths]",descriptions.paths,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:invalidate-cache` ...",options);const startTime=await intro("buddy cloud:invalidate-cache");if(!await prompts.confirm("Would you like to invalidate the CloudFront cache?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Invalidating the CloudFront cache...");const distributionId=await getCloudFrontDistributionId();if(!distributionId){await outro("Could not resolve CloudFront distribution ID",{startTime,useSeconds:!0},"Ensure your cloud stack is deployed before invalidating cache.");process.exit(ExitCode.FatalError)}const paths=options.paths?String(options.paths):"/*",result=await runCommand(`aws cloudfront create-invalidation --distribution-id ${distributionId} --paths ${paths}`,{...options,cwd:p.projectPath(),stdin:"pipe"});if(isResultError(result)){await outro("While running the cloud command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:diff",descriptions.diff).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:diff` ...",options);const startTime=await intro("buddy cloud:diff");try{const{InfrastructureGenerator}=await import("@stacksjs/ts-cloud"),{CloudFormationClient}=await import("@stacksjs/ts-cloud/aws"),{tsCloud:cloudConfig}=await import("~/config/cloud"),environment=process.env.APP_ENV||process.env.NODE_ENV||"production",newTemplate=new InfrastructureGenerator({config:cloudConfig,environment}).generate().toJSON(),stackName=`${cloudConfig.project?.slug||"stacks"}-${environment}`,cfn=new CloudFormationClient(process.env.AWS_REGION||"us-east-1");let currentTemplate="{}";try{currentTemplate=(await cfn.getTemplate(stackName)).TemplateBody}catch{log.info("No deployed stack found. Showing full template as diff.")}if(currentTemplate===newTemplate)log.info("No changes detected.");else{log.info("Changes detected between deployed and local template:");log.info(`Current template: ${currentTemplate.length} bytes`);log.info(`New template: ${newTemplate.length} bytes`)}}catch(error){await outro("While running the cloud diff command, there was an issue",{startTime,useSeconds:!0},error.message);process.exit(ExitCode.FatalError)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:dashboard",descriptions.dashboard).alias("cloud:cockpit").option("--host [host]",descriptions.host,{default:"127.0.0.1"}).option("--port [port]",descriptions.port,{default:"7676"}).option("--env [env]",descriptions.env).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:dashboard` ...",options);const startTime=await intro("buddy cloud:dashboard"),tsCloud=await import("@stacksjs/ts-cloud");if(typeof tsCloud.startLocalDashboardServer!=="function"){await outro("The installed @stacksjs/ts-cloud does not provide the local cockpit yet",{startTime,useSeconds:!0},"Update your dependencies (requires @stacksjs/ts-cloud >= 0.5.27).");process.exit(ExitCode.FatalError)}try{const server=await tsCloud.startLocalDashboardServer({host:options.host?String(options.host):void 0,port:options.port?Number(options.port):void 0,environment:options.env,verbose:!!options.verbose});log.success(`Stacks Cloud cockpit running at ${underline(server.url)}`);log.info(italic("Manage servers, sites, SSH keys and deploys. Press Ctrl+C to stop."));await new Promise(()=>{})}catch(error){await outro("While starting the cloud dashboard, there was an issue",{startTime,useSeconds:!0},error?.message??String(error));process.exit(ExitCode.FatalError)}});buddy.command("cloud:sites",descriptions.sites).option("--env [env]",descriptions.sitesEnv).option("--no-remote",descriptions.remote).option("-J, --json",descriptions.json,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:sites` ...",options);const{formatInventory,probeHostRoutes}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy"),environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0),slug=tsCloudConfig?.project?.slug||"app";await assertFleetProvider(tsCloudConfig,"cloud:sites");const listing=await listFleet(tsCloudConfig);if(listing.problem)await log.error(listing.problem);const declared=await declaredSitesFor(tsCloudConfig,slug),probes=options.remote===!1?[]:await probeFleet(listing.servers),inventory={slug,servers:listing.servers,probes,declared};if(options.json)console.log(JSON.stringify({environment,...inventory},null,2));else for(const line of formatInventory(inventory))console.log(line);process.exit(listing.problem&&listing.servers.length===0?ExitCode.FatalError:ExitCode.Success);async function probeFleet(servers){const{sshExec}=await import("@stacksjs/ts-cloud"),probes=[];for(let index=0;index<servers.length;index+=6)probes.push(...await Promise.all(servers.slice(index,index+6).map((server)=>probeHostRoutes(server,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})))));return probes}});buddy.command("cloud:attach",descriptions.attach).option("--server <server>",descriptions.attachServer).option("--env [env]",descriptions.sitesEnv).option("--dry-run",descriptions.dryRun,{default:!1}).option("-J, --json",descriptions.json,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:attach` ...",options);const{attachConflicts,attachIsViable,attachPreconditions,formatAttachPlan,probeHostRoutes,resolveAttachTarget,setAttachToInCloudConfig,sshExec}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy");if(!options.server)return await refuse("Which server? Pass --server <name|owner-slug>. `buddy cloud:sites` lists them.");const environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0),slug=tsCloudConfig?.project?.slug||"app";await assertFleetProvider(tsCloudConfig,"cloud:attach");const listing=await listFleet(tsCloudConfig);if(listing.problem)return await refuse(listing.problem);const target=resolveAttachTarget(listing.servers,String(options.server),environment);if("problem"in target)return await refuse(`${target.problem} \`buddy cloud:sites\` lists what is there.`);const server=target.server,preconditions=attachPreconditions(slug,server);if(preconditions.length>0)return await refuse(...preconditions);const declared=await declaredSitesFor(tsCloudConfig,slug),probe=await probeHostRoutes(server,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})),plan={slug,owner:server.project,server,declared,conflicts:probe.unavailable?[]:attachConflicts(slug,declared,probe.routes),registryRead:!probe.unavailable,registryProblem:probe.unavailable},viable=attachIsViable(plan),edit=viable?await applyAttachToConfig(plan.owner,Boolean(options.dryRun)):void 0;if(options.json)console.log(JSON.stringify({environment,...plan,edit},null,2));else{for(const line of formatAttachPlan(plan))console.log(line);if(edit)console.log(["",...describeAttachEdits(plan.slug,plan.owner,edit,Boolean(options.dryRun))].join(`
|
|
6
|
+
`))}process.exit(viable?ExitCode.Success:ExitCode.FatalError);async function applyAttachToConfig(owner,dryRun){const configPath=p.projectPath("config/cloud.ts"),{readFileSync,writeFileSync}=await import("node:fs"),before=readFileSync(configPath,"utf8");try{const after=setAttachToInCloudConfig({configText:before,owner});if(after===before)return{state:"already-set"};if(dryRun)return{state:"would-write"};writeFileSync(configPath,after);return{state:"written"}}catch(error){return{state:"refused",reason:error instanceof Error?error.message:String(error)}}}});buddy.command("cloud:rename <server>",descriptions.rename).option("--to <name>",descriptions.renameTo).option("--env [env]",descriptions.renameEnv).option("--dry-run",descriptions.dryRun,{default:!1}).option("-J, --json",descriptions.renameJson,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(server,options)=>{log.debug("Running `buddy cloud:rename` ...",options);const{applyPlan,formatPlan,pendingSteps,planServerRename,resolvePlan,validateServerName}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy");if(!options.to)return await refuse("Rename it to what? Pass --to <name>.");const next=String(options.to);try{validateServerName(next)}catch(error){return await refuse(error instanceof Error?error.message:String(error))}const environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0);await assertFleetProvider(tsCloudConfig,"cloud:rename");const listing=await listFleet(tsCloudConfig);if(listing.problem)return await refuse(listing.problem);const current=listing.servers.find((s)=>s?.name===server);if(!current)return await refuse(`No server named ${server}. \`buddy cloud:sites\` lists what is there.`);if(server===next)return await refuse(`${server} is already called that.`);const plan=await planServerRename(server,next,await renameEffects(tsCloudConfig,listing.servers,current)),resolved=await resolvePlan(plan),pending=pendingSteps(resolved);if(options.json){console.log(JSON.stringify({operation:plan.operation,target:plan.target,to:next,steps:resolved.map((entry)=>({id:entry.step.id,title:entry.step.title,state:entry.state,reason:entry.reason,change:entry.step.change}))},null,2));return}console.log("");for(const line of formatPlan(plan,resolved))console.log(line);console.log("");if(options.dryRun){await log.info(`Dry run: ${pending.length} step(s) would run. Nothing changed.`);return}if(pending.length===0){await log.success(`${server} is already named ${next} everywhere. Nothing to do.`);return}const outcome=await applyPlan(plan,resolved,{log:(message)=>console.log(` ${message}`)});if(!outcome.success){const failed=outcome.steps.find((step)=>step.state==="failed");return await refuse(`Stopped at: ${failed?.title??"an unnamed step"}`,failed?.error??"The step gave no reason.",`Re-run \`buddy cloud:rename ${server} --to ${next}\` to continue: completed steps skip themselves.`)}await log.success(`Renamed ${server} to ${next}`)});buddy.command("cloud:destroy <server>",descriptions.destroy).option("--env [env]",descriptions.destroyEnv).option("--confirm <name>",descriptions.destroyConfirm).option("--discard-drained",descriptions.destroyDiscardDrained,{default:!1}).option("--dry-run",descriptions.dryRun,{default:!1}).option("-J, --json",descriptions.destroyJson,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(server,options)=>{log.debug("Running `buddy cloud:destroy` ...",options);const{applyPlan,buildDrainedSiteScanScript,formatDrainedSiteRefusal,formatPlan,parseDrainedSites,pendingSteps,resolvePlan,sshExec}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy"),environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0);await assertFleetProvider(tsCloudConfig,"cloud:destroy");const listing=await listFleet(tsCloudConfig);if(listing.problem)return await refuse(listing.problem);const current=listing.servers.find((s)=>s?.name===server);if(!current)return await refuse(`No server named ${server}. \`buddy cloud:sites\` lists what is there.`);const slug=String(tsCloudConfig?.project?.slug||"app");if(current.ipv4&&!options.discardDrained){const scan=await sshExec(current.ipv4,buildDrainedSiteScanScript(slug).join(`
|
|
7
|
+
`),{user:"root",connectTimeoutSec:10});if(scan.code===0){const drained=parseDrainedSites(scan.stdout);if(drained.length>0)return await refuse(formatDrainedSiteRefusal(drained,slug,"--discard-drained"))}else if(!options.dryRun)return await refuse(`Could not scan ${server} for drained sites: ${scan.stderr.trim()||"the box did not answer over SSH."}`,"A box that cannot be scanned may still be holding a rollback. Re-run with --discard-drained if it is not.")}const plan=planServerDestroy(server,await destroyEffects(tsCloudConfig,current)),resolved=await resolvePlan(plan),pending=pendingSteps(resolved);if(options.json){console.log(JSON.stringify({operation:plan.operation,target:plan.target,steps:resolved.map((entry)=>({id:entry.step.id,title:entry.step.title,state:entry.state,reason:entry.reason,destructive:entry.step.destructive,change:entry.step.change}))},null,2));return}console.log("");for(const line of formatPlan(plan,resolved))console.log(line);console.log("");if(options.dryRun){await log.info(`Dry run: ${pending.length} step(s) would run. Nothing changed.`);return}if(pending.length===0){await log.success(`${server} is already gone, and nothing still points at it.`);return}if(options.confirm!==server)return await refuse(`Destroying ${server} cannot be undone.`,`Re-run with \`--confirm ${server}\` to go ahead.`);const outcome=await applyPlan(plan,resolved,{log:(message)=>console.log(` ${message}`),confirm:options.confirm});if(!outcome.success){const failed=outcome.steps.find((step)=>step.state==="failed");return await refuse(`Stopped at: ${failed?.title??"an unnamed step"}`,failed?.error??"The step gave no reason.",`Re-run \`buddy cloud:destroy ${server} --confirm ${server}\` to continue: completed steps skip themselves.`)}await log.success(`Destroyed ${server}`)});buddy.command("cloud:move <site>",descriptions.move).option("--to <server>",descriptions.moveTo).option("--from <server>",descriptions.moveFrom).option("--env [env]",descriptions.moveEnv).option("--confirm <site>",descriptions.moveConfirm).option("--dry-run",descriptions.dryRun,{default:!1}).option("-J, --json",descriptions.moveJson,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(site,options)=>{log.debug("Running `buddy cloud:move` ...",options);const{applyPlan,formatPlan,gatewayHostnames,pendingSteps,planSiteMove,probeHostRoutes,resolveAttachTarget,resolvePlan,siteInstallBase,sshExec}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy");if(!options.to)return await refuse("Move it where? Pass --to <name|owner-slug>. `buddy cloud:sites` lists them.");const environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0),slug=String(tsCloudConfig?.project?.slug||"app");await assertFleetProvider(tsCloudConfig,"cloud:move");const rawSite=(tsCloudConfig?.sites??{})[site];if(!rawSite)return await refuse(`config/cloud.ts declares no site named '${site}'.`);const listing=await listFleet(tsCloudConfig);if(listing.problem)return await refuse(listing.problem);const resolvedTarget=resolveAttachTarget(listing.servers,String(options.to),environment);if("problem"in resolvedTarget)return await refuse(`${resolvedTarget.problem} \`buddy cloud:sites\` lists what is there.`);const target=resolvedTarget.server,hostnames=gatewayHostnames({[site]:rawSite},{autoWww:Boolean(tsCloudConfig?.infrastructure?.compute?.proxy?.autoWww)}),shared=sitesSharingHostnames(tsCloudConfig?.sites??{},site,hostnames,(name,declared)=>gatewayHostnames({[name]:declared},{autoWww:Boolean(tsCloudConfig?.infrastructure?.compute?.proxy?.autoWww)}));if(shared.length>0)return await refuse(`'${site}' answers on ${hostnames.join(", ")}, and so do ${shared.length} other site(s): ${shared.join(", ")}.`,"A move repoints the hostname, not the path - so this would carry one tree to the target and send every request for the others to a box that does not have them.","Give the site its own hostname first, or move the whole set to the target and deploy once.");const source=options.from?listing.servers.find((s)=>s?.name===options.from):await serverServing(listing.servers,hostnames,(candidate)=>probeHostRoutes(candidate,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})));if(!source)return await refuse(options.from?`No server named ${options.from}.`:`Could not tell which server serves '${site}'. Pass --from <name> to say.`);if(source.name===target.name)return await refuse(`'${site}' is already on ${target.name}.`);if(!source.ipv4||!target.ipv4)return await refuse(`A move needs an address for both boxes, and ${(source.ipv4?target:source).name} has none.`);let plan;try{plan=await planSiteMove({slug,siteName:site,appBase:siteInstallBase(slug,site),from:source.name,to:target.name,targetAddress:target.ipv4,port:Number(rawSite?.port)||void 0,database:await onBoxDatabase(tsCloudConfig)},await moveEffects({tsCloudConfig,slug,siteName:site,rawSite,source,target,hostnames}))}catch(error){return await refuse(error instanceof Error?error.message:String(error))}const resolved=await resolvePlan(plan),pending=pendingSteps(resolved);if(options.json){console.log(JSON.stringify({operation:plan.operation,target:plan.target,from:source.name,to:target.name,hostnames,steps:resolved.map((entry)=>({id:entry.step.id,title:entry.step.title,state:entry.state,reason:entry.reason,destructive:entry.step.destructive,change:entry.step.change}))},null,2));return}console.log("");for(const line of formatPlan(plan,resolved))console.log(line);console.log("");if(options.dryRun){await log.info(`Dry run: ${pending.length} step(s) would run. Nothing changed.`);return}if(pending.length===0){await log.success(`'${site}' is already on ${target.name}, serving and cut over.`);return}const outcome=await applyPlan(plan,resolved,{log:(message)=>console.log(` ${message}`),confirm:options.confirm});if(!outcome.success){const failed=outcome.steps.find((step)=>step.state==="failed");return await refuse(`Stopped at: ${failed?.title??"an unnamed step"}`,failed?.error??"The step gave no reason.",`The source still holds the site's files - that is the rollback. Re-run \`buddy cloud:move ${site} --to ${target.name}\` to continue: completed steps skip themselves.`)}await log.success(`Moved '${site}' from ${source.name} to ${target.name}`)});onUnknownSubcommand(buddy,"cloud")}
|
|
@@ -624,6 +624,16 @@ export declare function configDnsDomains(sites: Record<string, any>): string[];
|
|
|
624
624
|
* the zone externally managed.
|
|
625
625
|
*/
|
|
626
626
|
export declare function dnsProviderNameFromNameservers(nameservers: string[]): 'porkbun' | 'cloudflare' | 'route53' | 'godaddy' | null;
|
|
627
|
+
/**
|
|
628
|
+
* Publish A (and AAAA) records for every hostname the gateway will answer for
|
|
629
|
+
* `sites`, at `ip`, and report the FQDNs this run actually created.
|
|
630
|
+
*
|
|
631
|
+
* Exported because a DNS cutover is not only part of a deploy: `cloud:move`
|
|
632
|
+
* repoints a site at another box with exactly this reconciliation, and a second
|
|
633
|
+
* implementation of "which names does this site publish, and at which
|
|
634
|
+
* registrar" is the drift this function exists to prevent.
|
|
635
|
+
*/
|
|
636
|
+
export declare function reconcileHetznerDns(sites: Record<string, any>, ip: string, logger: typeof log, ipv6?: string, autoWww?: boolean): Promise<string[]>;
|
|
627
637
|
export declare function deploy(buddy: CLI): void;
|
|
628
638
|
/**
|
|
629
639
|
* Use console.log for clean output without timestamps
|