├── .gitignore ├── .kamal ├── hooks │ ├── docker-setup.sample │ ├── post-deploy.sample │ ├── post-proxy-reboot.sample │ ├── pre-build.sample │ ├── pre-connect.sample │ ├── pre-deploy.sample │ └── pre-proxy-reboot.sample └── secrets ├── .scripts ├── setup-vagrant.sh └── vagrant-provision.sh ├── README.md ├── Vagrantfile ├── examples ├── .kamal │ ├── hooks │ │ ├── docker-setup.sample │ │ ├── post-deploy.sample │ │ ├── post-proxy-reboot.sample │ │ ├── pre-build.sample │ │ ├── pre-connect.sample │ │ ├── pre-deploy.sample │ │ └── pre-proxy-reboot.sample │ └── secrets ├── config │ ├── deploy.production.yml │ └── deploy.staging.yml ├── nextjs │ ├── .gitignore │ ├── Makefile │ ├── README.md │ ├── eslint.config.mjs │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ │ ├── file.svg │ │ ├── globe.svg │ │ ├── next.svg │ │ ├── vercel.svg │ │ └── window.svg │ ├── src │ │ └── app │ │ │ ├── favicon.ico │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ ├── tailwind.config.ts │ └── tsconfig.json └── shango.yml ├── package.json ├── packages └── cli │ ├── .gitignore │ ├── package.json │ ├── prettier.config.js │ ├── shango.yml │ ├── src │ ├── commands │ │ ├── deploy.ts │ │ ├── init.ts │ │ ├── kl.ts │ │ ├── provision.ts │ │ ├── sync-secrets.ts │ │ └── update.ts │ ├── index.ts │ ├── lib │ │ ├── config │ │ │ ├── loader.ts │ │ │ ├── merger.ts │ │ │ └── validator.ts │ │ ├── hook-executor.ts │ │ ├── hooks │ │ │ ├── hook-manager.ts │ │ │ ├── hook-registry.ts │ │ │ └── types.ts │ │ ├── kamal-config │ │ │ ├── __tests__ │ │ │ │ └── kamal-configuration-manager.test.ts │ │ │ ├── index.ts │ │ │ ├── merger.ts │ │ │ ├── types.ts │ │ │ └── validator.ts │ │ ├── progress-reporter │ │ │ └── index.ts │ │ ├── provisionner │ │ │ ├── index.ts │ │ │ ├── requirements-validator.ts │ │ │ └── server-setup.ts │ │ ├── secrets │ │ │ ├── providers │ │ │ │ ├── base-provider.ts │ │ │ │ └── github-provider.ts │ │ │ ├── secret-loader.ts │ │ │ ├── secret-manager.ts │ │ │ └── types.ts │ │ ├── ssh │ │ │ ├── __test__ │ │ │ │ └── ssh-manager.test.ts │ │ │ └── ssh-manager.ts │ │ └── template │ │ │ ├── __tests__ │ │ │ ├── dockerfile.test.ts │ │ │ └── template-manager.test.ts │ │ │ ├── base.ts │ │ │ ├── dockerfile.ts │ │ │ ├── error.ts │ │ │ ├── github-action.ts │ │ │ ├── index.ts │ │ │ └── validator.ts │ ├── types │ │ └── index.ts │ └── util │ │ ├── execute-kamal.ts │ │ ├── execute-ssh2-command.ts │ │ ├── generate-config.ts │ │ └── load-config-file.ts │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── turbo.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | 177 | 178 | commander 179 | 180 | .shango-templates-test 181 | 182 | 183 | mind-map.md 184 | 185 | .turbo 186 | 187 | .vagrant 188 | 189 | -------------------------------------------------------------------------------- /.kamal/hooks/docker-setup.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Docker set up on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /.kamal/hooks/post-deploy.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # A sample post-deploy hook 4 | # 5 | # These environment variables are available: 6 | # KAMAL_RECORDED_AT 7 | # KAMAL_PERFORMER 8 | # KAMAL_VERSION 9 | # KAMAL_HOSTS 10 | # KAMAL_ROLE (if set) 11 | # KAMAL_DESTINATION (if set) 12 | # KAMAL_RUNTIME 13 | 14 | echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" 15 | -------------------------------------------------------------------------------- /.kamal/hooks/post-proxy-reboot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Rebooted kamal-proxy on $KAMAL_HOSTS" 4 | -------------------------------------------------------------------------------- /.kamal/hooks/pre-build.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # A sample pre-build hook 4 | # 5 | # Checks: 6 | # 1. We have a clean checkout 7 | # 2. A remote is configured 8 | # 3. The branch has been pushed to the remote 9 | # 4. The version we are deploying matches the remote 10 | # 11 | # These environment variables are available: 12 | # KAMAL_RECORDED_AT 13 | # KAMAL_PERFORMER 14 | # KAMAL_VERSION 15 | # KAMAL_HOSTS 16 | # KAMAL_ROLE (if set) 17 | # KAMAL_DESTINATION (if set) 18 | 19 | if [ -n "$(git status --porcelain)" ]; then 20 | echo "Git checkout is not clean, aborting..." >&2 21 | git status --porcelain >&2 22 | exit 1 23 | fi 24 | 25 | first_remote=$(git remote) 26 | 27 | if [ -z "$first_remote" ]; then 28 | echo "No git remote set, aborting..." >&2 29 | exit 1 30 | fi 31 | 32 | current_branch=$(git branch --show-current) 33 | 34 | if [ -z "$current_branch" ]; then 35 | echo "Not on a git branch, aborting..." >&2 36 | exit 1 37 | fi 38 | 39 | remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) 40 | 41 | if [ -z "$remote_head" ]; then 42 | echo "Branch not pushed to remote, aborting..." >&2 43 | exit 1 44 | fi 45 | 46 | if [ "$KAMAL_VERSION" != "$remote_head" ]; then 47 | echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 48 | exit 1 49 | fi 50 | 51 | exit 0 52 | -------------------------------------------------------------------------------- /.kamal/hooks/pre-connect.sample: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # A sample pre-connect check 4 | # 5 | # Warms DNS before connecting to hosts in parallel 6 | # 7 | # These environment variables are available: 8 | # KAMAL_RECORDED_AT 9 | # KAMAL_PERFORMER 10 | # KAMAL_VERSION 11 | # KAMAL_HOSTS 12 | # KAMAL_ROLE (if set) 13 | # KAMAL_DESTINATION (if set) 14 | # KAMAL_RUNTIME 15 | 16 | hosts = ENV["KAMAL_HOSTS"].split(",") 17 | results = nil 18 | max = 3 19 | 20 | elapsed = Benchmark.realtime do 21 | results = hosts.map do |host| 22 | Thread.new do 23 | tries = 1 24 | 25 | begin 26 | Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) 27 | rescue SocketError 28 | if tries < max 29 | puts "Retrying DNS warmup: #{host}" 30 | tries += 1 31 | sleep rand 32 | retry 33 | else 34 | puts "DNS warmup failed: #{host}" 35 | host 36 | end 37 | end 38 | 39 | tries 40 | end 41 | end.map(&:value) 42 | end 43 | 44 | retries = results.sum - hosts.size 45 | nopes = results.count { |r| r == max } 46 | 47 | puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] 48 | -------------------------------------------------------------------------------- /.kamal/hooks/pre-deploy.sample: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # A sample pre-deploy hook 4 | # 5 | # Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. 6 | # 7 | # Fails unless the combined status is "success" 8 | # 9 | # These environment variables are available: 10 | # KAMAL_RECORDED_AT 11 | # KAMAL_PERFORMER 12 | # KAMAL_VERSION 13 | # KAMAL_HOSTS 14 | # KAMAL_COMMAND 15 | # KAMAL_SUBCOMMAND 16 | # KAMAL_ROLE (if set) 17 | # KAMAL_DESTINATION (if set) 18 | 19 | # Only check the build status for production deployments 20 | if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" 21 | exit 0 22 | end 23 | 24 | require "bundler/inline" 25 | 26 | # true = install gems so this is fast on repeat invocations 27 | gemfile(true, quiet: true) do 28 | source "https://rubygems.org" 29 | 30 | gem "octokit" 31 | gem "faraday-retry" 32 | end 33 | 34 | MAX_ATTEMPTS = 72 35 | ATTEMPTS_GAP = 10 36 | 37 | def exit_with_error(message) 38 | $stderr.puts message 39 | exit 1 40 | end 41 | 42 | class GithubStatusChecks 43 | attr_reader :remote_url, :git_sha, :github_client, :combined_status 44 | 45 | def initialize 46 | @remote_url = `git config --get remote.origin.url`.strip.delete_prefix("https://github.com/") 47 | @git_sha = `git rev-parse HEAD`.strip 48 | @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) 49 | refresh! 50 | end 51 | 52 | def refresh! 53 | @combined_status = github_client.combined_status(remote_url, git_sha) 54 | end 55 | 56 | def state 57 | combined_status[:state] 58 | end 59 | 60 | def first_status_url 61 | first_status = combined_status[:statuses].find { |status| status[:state] == state } 62 | first_status && first_status[:target_url] 63 | end 64 | 65 | def complete_count 66 | combined_status[:statuses].count { |status| status[:state] != "pending"} 67 | end 68 | 69 | def total_count 70 | combined_status[:statuses].count 71 | end 72 | 73 | def current_status 74 | if total_count > 0 75 | "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." 76 | else 77 | "Build not started..." 78 | end 79 | end 80 | end 81 | 82 | 83 | $stdout.sync = true 84 | 85 | puts "Checking build status..." 86 | attempts = 0 87 | checks = GithubStatusChecks.new 88 | 89 | begin 90 | loop do 91 | case checks.state 92 | when "success" 93 | puts "Checks passed, see #{checks.first_status_url}" 94 | exit 0 95 | when "failure" 96 | exit_with_error "Checks failed, see #{checks.first_status_url}" 97 | when "pending" 98 | attempts += 1 99 | end 100 | 101 | exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS 102 | 103 | puts checks.current_status 104 | sleep(ATTEMPTS_GAP) 105 | checks.refresh! 106 | end 107 | rescue Octokit::NotFound 108 | exit_with_error "Build status could not be found" 109 | end 110 | -------------------------------------------------------------------------------- /.kamal/hooks/pre-proxy-reboot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /.kamal/secrets: -------------------------------------------------------------------------------- 1 | # Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, 2 | # and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either 3 | # password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. 4 | 5 | # Option 1: Read secrets from the environment 6 | KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD 7 | 8 | # Option 2: Read secrets via a command 9 | # RAILS_MASTER_KEY=$(cat config/master.key) 10 | 11 | # Option 3: Read secrets via kamal secrets helpers 12 | # These will handle logging in and fetching the secrets in as few calls as possible 13 | # There are adapters for 1Password, LastPass + Bitwarden 14 | # 15 | # SECRETS=$(kamal secrets fetch --adapter 1password --account my-account --from MyVault/MyItem KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) 16 | # KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD $SECRETS) 17 | # RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) 18 | -------------------------------------------------------------------------------- /.scripts/setup-vagrant.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | KEY_NAME="vagrant_ed25519" 4 | KEY_PATH="$HOME/.ssh/$KEY_NAME" 5 | PROVISION_SCRIPT="scripts/vagrant-provision.sh" 6 | 7 | echo "Generating new ED25519 key pair..." 8 | ssh-keygen -t ed25519 -f "$KEY_PATH" -N "" -C "vagrant_root_access_$(date +%Y%m%d_%H%M%S)" 9 | 10 | PUBLIC_KEY=$(cat "${KEY_PATH}.pub") 11 | 12 | echo "Creating/Updating vagrant-provision.sh..." 13 | cat >"$PROVISION_SCRIPT" <> /root/.ssh/authorized_keys 25 | 26 | # Set proper permissions 27 | chmod 600 /root/.ssh/authorized_keys 28 | chown root:root /root/.ssh/authorized_keys 29 | 30 | # Configure SSH 31 | sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config 32 | sed -i 's/PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config 33 | sed -i 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/' /etc/ssh/sshd_config 34 | 35 | # Restart SSH service 36 | systemctl restart sshd 37 | 38 | echo "Root SSH key-based authentication has been configured" 39 | EOF 40 | 41 | chmod +x "$PROVISION_SCRIPT" 42 | 43 | echo "Setup completed successfully!" 44 | echo "New SSH key generated at: $KEY_PATH" 45 | echo "You can now run 'vagrant up' to start your VM" 46 | if vagrant status | grep -q "running"; then 47 | VM_IP=$(vagrant ssh -c "hostname -I | cut -d' ' -f1" 2>/dev/null) 48 | if [ -n "$VM_IP" ]; then 49 | echo "To connect as root: ssh -i $KEY_PATH root@$VM_IP" 50 | else 51 | echo "To connect as root (once VM is running): ssh -i $KEY_PATH root@" 52 | fi 53 | else 54 | echo "To connect as root (once VM is running): ssh -i $KEY_PATH root@" 55 | fi 56 | -------------------------------------------------------------------------------- /.scripts/vagrant-provision.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | mkdir -p /root/.ssh 4 | chmod 700 /root/.ssh 5 | 6 | cp /home/vagrant/.ssh/authorized_keys /root/.ssh/ 7 | 8 | echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINj0KKo4TrLUhhWYVtRhwP39jHhrIuo9O0Qi1vVHc5jy vagrant_root_access_20250216_004906' >>/root/.ssh/authorized_keys 9 | 10 | chmod 600 /root/.ssh/authorized_keys 11 | chown root:root /root/.ssh/authorized_keys 12 | 13 | sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config 14 | sed -i 's/PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config 15 | sed -i 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/' /etc/ssh/sshd_config 16 | 17 | systemctl restart sshd 18 | 19 | echo "Root SSH key-based authentication has been configured" 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shango Deploy 🚀 2 | 3 | Shango Deploy is a modern deployment tool that simplifies the process of deploying web applications. It provides a high-level configuration interface that generates deployment configurations for various frameworks and automatically provisions servers with best practices. 4 | 5 | ## Features ✨ 6 | 7 | - 🛠 **Framework Support**: Deploy applications built with: 8 | - Next.js 9 | - Remix 10 | - Nuxt.js 11 | - Svelte 12 | - AdonisJS 13 | - NestJS 14 | 15 | - 🗄 **Database Integration**: 16 | - PostgreSQL 17 | - MySQL 18 | - SQLite 19 | - Redis (for caching) 20 | 21 | - 🔧 **Server Provisioning**: 22 | - Automatic security hardening 23 | - Docker setup 24 | - Fail2Ban configuration 25 | - UFW firewall setup 26 | - SSL/TLS configuration 27 | - System monitoring 28 | 29 | - 📦 **Built-in Templates**: 30 | - Dockerfile generation 31 | - GitHub Actions workflows 32 | - deployment configurations 33 | 34 | ## Installation 📥 35 | 36 | ```bash 37 | npm install -g shango-deploy 38 | ``` 39 | 40 | ## Quick Start 🚀 41 | 42 | 1. Initialize a new Shango configuration: 43 | 44 | ```bash 45 | shango add 46 | ``` 47 | 48 | 2. Follow the interactive prompts to configure your deployment. 49 | 50 | 3. Provision your servers: 51 | 52 | ```bash 53 | shango provision 54 | ``` 55 | 56 | 4. Deploy your application: 57 | 58 | ```bash 59 | shango deploy 60 | ``` 61 | 62 | ## Configuration 📝 63 | 64 | Shango uses a YAML configuration file (`shango.yml`) to define your deployment setup: 65 | 66 | ```yaml 67 | app: 68 | framework: nextjs 69 | domain: myapp.com 70 | packageManager: npm 71 | database: postgres 72 | cacheDatabase: redis 73 | 74 | servers: 75 | - environment: staging 76 | ipv4: 77 | - 33.34.20.3 78 | - 33.34.20.4 79 | - environment: production 80 | ipv4: 44.34.21.23 81 | ``` 82 | 83 | ## Server Requirements 🖥 84 | 85 | - Ubuntu 20.04 or newer 86 | - SSH access with root privileges 87 | - Open ports: 22 (SSH), 80 (HTTP), 443 (HTTPS) 88 | 89 | ## Security Best Practices 🔒 90 | 91 | Shango automatically implements several security best practices: 92 | 93 | - SSH hardening 94 | - Automatic security updates 95 | - Fail2Ban for brute force protection 96 | - UFW firewall configuration 97 | - SSL/TLS setup with Let's Encrypt 98 | - System hardening 99 | 100 | ## Contributing 🤝 101 | 102 | We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. 103 | 104 | 1. Fork the repository 105 | 2. Create a feature branch 106 | 3. Commit your changes 107 | 4. Push to the branch 108 | 5. Open a Pull Request 109 | 110 | ## Architecture 🏗 111 | 112 | Shango Deploy is built with a modular architecture: 113 | 114 | - **High-Level Config Parser**: Converts user-friendly configuration to detailed deployment specs 115 | - **Server Provisioner**: Handles server setup and security hardening 116 | - **Template System**: Manages framework-specific configurations and files 117 | - **Deployment Engine**: Orchestrates the deployment process 118 | 119 | ## License 📄 120 | 121 | MIT License - see the [LICENSE](LICENSE) file for details 122 | 123 | ## Support 💬 124 | 125 | - Documentation: [shango.devalade.me/docs](https://shango.devalade.me/docs) 126 | - Issues: [GitHub Issues](https://github.com/your-username/shango-deploy/issues) 127 | - Discord: [Join our community](https://discord.gg/shango-deploy) 128 | 129 | ## Credits 👏 130 | 131 | Shango Deploy is inspired by various deployment tools and best practices from the community. Special thanks to: 132 | 133 | - [Kamal](https://github.com/basecamp/kamal) 134 | - [Spin](https://github.com/serversideup/spin) 135 | - [Deployer](https://github.com/deployerphp/deployer) 136 | - [Docker](https://www.docker.com/) 137 | 138 | ## Roadmap 🗺 139 | 140 | - [ ] Support for more frameworks 141 | - [ ] Zero-downtime deployments 142 | - [ ] Custom deployment hooks 143 | - [ ] Monitoring integration 144 | - [ ] Backup management 145 | 146 | --- 147 | 148 | Built with ❤️ by [devalade](https://devalade.me) 149 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | Vagrant.configure("2") do |config| 5 | config.vm.box = "generic/ubuntu2204" 6 | 7 | config.vm.provider :libvirt do |libvirt| 8 | libvirt.memory = 2048 9 | libvirt.cpus = 2 10 | 11 | libvirt.storage :file, 12 | size: '20G', 13 | type: 'qcow2', 14 | bus: 'virtio', 15 | cache: 'writeback' 16 | end 17 | 18 | config.vm.provision "shell", path: ".scripts/vagrant-provision.sh" 19 | 20 | # config.vm.synced_folder ".", "/vagrant", type: "nfs" 21 | 22 | config.vm.hostname = "ubuntu-dev" 23 | end 24 | -------------------------------------------------------------------------------- /examples/.kamal/hooks/docker-setup.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Docker set up on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /examples/.kamal/hooks/post-deploy.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # A sample post-deploy hook 4 | # 5 | # These environment variables are available: 6 | # KAMAL_RECORDED_AT 7 | # KAMAL_PERFORMER 8 | # KAMAL_VERSION 9 | # KAMAL_HOSTS 10 | # KAMAL_ROLE (if set) 11 | # KAMAL_DESTINATION (if set) 12 | # KAMAL_RUNTIME 13 | 14 | echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" 15 | -------------------------------------------------------------------------------- /examples/.kamal/hooks/post-proxy-reboot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Rebooted kamal-proxy on $KAMAL_HOSTS" 4 | -------------------------------------------------------------------------------- /examples/.kamal/hooks/pre-build.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # A sample pre-build hook 4 | # 5 | # Checks: 6 | # 1. We have a clean checkout 7 | # 2. A remote is configured 8 | # 3. The branch has been pushed to the remote 9 | # 4. The version we are deploying matches the remote 10 | # 11 | # These environment variables are available: 12 | # KAMAL_RECORDED_AT 13 | # KAMAL_PERFORMER 14 | # KAMAL_VERSION 15 | # KAMAL_HOSTS 16 | # KAMAL_ROLE (if set) 17 | # KAMAL_DESTINATION (if set) 18 | 19 | if [ -n "$(git status --porcelain)" ]; then 20 | echo "Git checkout is not clean, aborting..." >&2 21 | git status --porcelain >&2 22 | exit 1 23 | fi 24 | 25 | first_remote=$(git remote) 26 | 27 | if [ -z "$first_remote" ]; then 28 | echo "No git remote set, aborting..." >&2 29 | exit 1 30 | fi 31 | 32 | current_branch=$(git branch --show-current) 33 | 34 | if [ -z "$current_branch" ]; then 35 | echo "Not on a git branch, aborting..." >&2 36 | exit 1 37 | fi 38 | 39 | remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) 40 | 41 | if [ -z "$remote_head" ]; then 42 | echo "Branch not pushed to remote, aborting..." >&2 43 | exit 1 44 | fi 45 | 46 | if [ "$KAMAL_VERSION" != "$remote_head" ]; then 47 | echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 48 | exit 1 49 | fi 50 | 51 | exit 0 52 | -------------------------------------------------------------------------------- /examples/.kamal/hooks/pre-connect.sample: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # A sample pre-connect check 4 | # 5 | # Warms DNS before connecting to hosts in parallel 6 | # 7 | # These environment variables are available: 8 | # KAMAL_RECORDED_AT 9 | # KAMAL_PERFORMER 10 | # KAMAL_VERSION 11 | # KAMAL_HOSTS 12 | # KAMAL_ROLE (if set) 13 | # KAMAL_DESTINATION (if set) 14 | # KAMAL_RUNTIME 15 | 16 | hosts = ENV["KAMAL_HOSTS"].split(",") 17 | results = nil 18 | max = 3 19 | 20 | elapsed = Benchmark.realtime do 21 | results = hosts.map do |host| 22 | Thread.new do 23 | tries = 1 24 | 25 | begin 26 | Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) 27 | rescue SocketError 28 | if tries < max 29 | puts "Retrying DNS warmup: #{host}" 30 | tries += 1 31 | sleep rand 32 | retry 33 | else 34 | puts "DNS warmup failed: #{host}" 35 | host 36 | end 37 | end 38 | 39 | tries 40 | end 41 | end.map(&:value) 42 | end 43 | 44 | retries = results.sum - hosts.size 45 | nopes = results.count { |r| r == max } 46 | 47 | puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] 48 | -------------------------------------------------------------------------------- /examples/.kamal/hooks/pre-deploy.sample: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # A sample pre-deploy hook 4 | # 5 | # Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. 6 | # 7 | # Fails unless the combined status is "success" 8 | # 9 | # These environment variables are available: 10 | # KAMAL_RECORDED_AT 11 | # KAMAL_PERFORMER 12 | # KAMAL_VERSION 13 | # KAMAL_HOSTS 14 | # KAMAL_COMMAND 15 | # KAMAL_SUBCOMMAND 16 | # KAMAL_ROLE (if set) 17 | # KAMAL_DESTINATION (if set) 18 | 19 | # Only check the build status for production deployments 20 | if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" 21 | exit 0 22 | end 23 | 24 | require "bundler/inline" 25 | 26 | # true = install gems so this is fast on repeat invocations 27 | gemfile(true, quiet: true) do 28 | source "https://rubygems.org" 29 | 30 | gem "octokit" 31 | gem "faraday-retry" 32 | end 33 | 34 | MAX_ATTEMPTS = 72 35 | ATTEMPTS_GAP = 10 36 | 37 | def exit_with_error(message) 38 | $stderr.puts message 39 | exit 1 40 | end 41 | 42 | class GithubStatusChecks 43 | attr_reader :remote_url, :git_sha, :github_client, :combined_status 44 | 45 | def initialize 46 | @remote_url = `git config --get remote.origin.url`.strip.delete_prefix("https://github.com/") 47 | @git_sha = `git rev-parse HEAD`.strip 48 | @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) 49 | refresh! 50 | end 51 | 52 | def refresh! 53 | @combined_status = github_client.combined_status(remote_url, git_sha) 54 | end 55 | 56 | def state 57 | combined_status[:state] 58 | end 59 | 60 | def first_status_url 61 | first_status = combined_status[:statuses].find { |status| status[:state] == state } 62 | first_status && first_status[:target_url] 63 | end 64 | 65 | def complete_count 66 | combined_status[:statuses].count { |status| status[:state] != "pending"} 67 | end 68 | 69 | def total_count 70 | combined_status[:statuses].count 71 | end 72 | 73 | def current_status 74 | if total_count > 0 75 | "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." 76 | else 77 | "Build not started..." 78 | end 79 | end 80 | end 81 | 82 | 83 | $stdout.sync = true 84 | 85 | puts "Checking build status..." 86 | attempts = 0 87 | checks = GithubStatusChecks.new 88 | 89 | begin 90 | loop do 91 | case checks.state 92 | when "success" 93 | puts "Checks passed, see #{checks.first_status_url}" 94 | exit 0 95 | when "failure" 96 | exit_with_error "Checks failed, see #{checks.first_status_url}" 97 | when "pending" 98 | attempts += 1 99 | end 100 | 101 | exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS 102 | 103 | puts checks.current_status 104 | sleep(ATTEMPTS_GAP) 105 | checks.refresh! 106 | end 107 | rescue Octokit::NotFound 108 | exit_with_error "Build status could not be found" 109 | end 110 | -------------------------------------------------------------------------------- /examples/.kamal/hooks/pre-proxy-reboot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /examples/.kamal/secrets: -------------------------------------------------------------------------------- 1 | # Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, 2 | # and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either 3 | # password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. 4 | 5 | # Option 1: Read secrets from the environment 6 | KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD 7 | 8 | # Option 2: Read secrets via a command 9 | # RAILS_MASTER_KEY=$(cat config/master.key) 10 | 11 | # Option 3: Read secrets via kamal secrets helpers 12 | # These will handle logging in and fetching the secrets in as few calls as possible 13 | # There are adapters for 1Password, LastPass + Bitwarden 14 | # 15 | # SECRETS=$(kamal secrets fetch --adapter 1password --account my-account --from MyVault/MyItem KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) 16 | # KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD $SECRETS) 17 | # RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) 18 | -------------------------------------------------------------------------------- /examples/config/deploy.production.yml: -------------------------------------------------------------------------------- 1 | # Name of your application. Used to uniquely configure containers. 2 | service: my-app 3 | 4 | # Name of the container image. 5 | image: my-user/my-app 6 | 7 | # Deploy to these servers. 8 | servers: 9 | web: 10 | - 192.168.0.1 11 | # job: 12 | # hosts: 13 | # - 192.168.0.1 14 | # cmd: bin/jobs 15 | 16 | # Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. 17 | # Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer. 18 | # 19 | # Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. 20 | proxy: 21 | ssl: true 22 | host: app.example.com 23 | # Proxy connects to your container on port 80 by default. 24 | # app_port: 3000 25 | 26 | # Credentials for your image host. 27 | registry: 28 | # Specify the registry server, if you're not using Docker Hub 29 | # server: registry.digitalocean.com / ghcr.io / ... 30 | username: my-user 31 | 32 | # Always use an access token rather than real password (pulled from .kamal/secrets). 33 | password: 34 | - KAMAL_REGISTRY_PASSWORD 35 | 36 | # Configure builder setup. 37 | builder: 38 | arch: amd64 39 | # Pass in additional build args needed for your Dockerfile. 40 | # args: 41 | # RUBY_VERSION: <%= File.read('.ruby-version').strip %> 42 | # Inject ENV variables into containers (secrets come from .kamal/secrets). 43 | # 44 | # env: 45 | # clear: 46 | # DB_HOST: 192.168.0.2 47 | # secret: 48 | # - RAILS_MASTER_KEY 49 | 50 | # Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: 51 | # "bin/kamal logs -r job" will tail logs from the first server in the job section. 52 | # 53 | # aliases: 54 | # shell: app exec --interactive --reuse "bash" 55 | 56 | # Use a different ssh user than root 57 | # 58 | # ssh: 59 | # user: app 60 | 61 | # Use a persistent storage volume. 62 | # 63 | # volumes: 64 | # - "app_storage:/app/storage" 65 | 66 | # Bridge fingerprinted assets, like JS and CSS, between versions to avoid 67 | # hitting 404 on in-flight requests. Combines all files from new and old 68 | # version inside the asset_path. 69 | # 70 | # asset_path: /app/public/assets 71 | 72 | # Configure rolling deploys by setting a wait time between batches of restarts. 73 | # 74 | # boot: 75 | # limit: 10 # Can also specify as a percentage of total hosts, such as "25%" 76 | # wait: 2 77 | 78 | # Use accessory services (secrets come from .kamal/secrets). 79 | # 80 | # accessories: 81 | # db: 82 | # image: mysql:8.0 83 | # host: 192.168.0.2 84 | # port: 3306 85 | # env: 86 | # clear: 87 | # MYSQL_ROOT_HOST: '%' 88 | # secret: 89 | # - MYSQL_ROOT_PASSWORD 90 | # files: 91 | # - config/mysql/production.cnf:/etc/mysql/my.cnf 92 | # - db/production.sql:/docker-entrypoint-initdb.d/setup.sql 93 | # directories: 94 | # - data:/var/lib/mysql 95 | # redis: 96 | # image: valkey/valkey:8 97 | # host: 192.168.0.2 98 | # port: 6379 99 | # directories: 100 | # - data:/data 101 | -------------------------------------------------------------------------------- /examples/config/deploy.staging.yml: -------------------------------------------------------------------------------- 1 | # Name of your application. Used to uniquely configure containers. 2 | service: my-app 3 | 4 | # Name of the container image. 5 | image: my-user/my-app 6 | 7 | # Deploy to these servers. 8 | servers: 9 | web: 10 | - 192.168.0.1 11 | # job: 12 | # hosts: 13 | # - 192.168.0.1 14 | # cmd: bin/jobs 15 | 16 | # Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. 17 | # Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer. 18 | # 19 | # Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. 20 | proxy: 21 | ssl: true 22 | host: app.example.com 23 | # Proxy connects to your container on port 80 by default. 24 | # app_port: 3000 25 | 26 | # Credentials for your image host. 27 | registry: 28 | # Specify the registry server, if you're not using Docker Hub 29 | # server: registry.digitalocean.com / ghcr.io / ... 30 | username: my-user 31 | 32 | # Always use an access token rather than real password (pulled from .kamal/secrets). 33 | password: 34 | - KAMAL_REGISTRY_PASSWORD 35 | 36 | # Configure builder setup. 37 | builder: 38 | arch: amd64 39 | # Pass in additional build args needed for your Dockerfile. 40 | # args: 41 | # RUBY_VERSION: <%= File.read('.ruby-version').strip %> 42 | # Inject ENV variables into containers (secrets come from .kamal/secrets). 43 | # 44 | # env: 45 | # clear: 46 | # DB_HOST: 192.168.0.2 47 | # secret: 48 | # - RAILS_MASTER_KEY 49 | 50 | # Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: 51 | # "bin/kamal logs -r job" will tail logs from the first server in the job section. 52 | # 53 | # aliases: 54 | # shell: app exec --interactive --reuse "bash" 55 | 56 | # Use a different ssh user than root 57 | # 58 | # ssh: 59 | # user: app 60 | 61 | # Use a persistent storage volume. 62 | # 63 | # volumes: 64 | # - "app_storage:/app/storage" 65 | 66 | # Bridge fingerprinted assets, like JS and CSS, between versions to avoid 67 | # hitting 404 on in-flight requests. Combines all files from new and old 68 | # version inside the asset_path. 69 | # 70 | # asset_path: /app/public/assets 71 | 72 | # Configure rolling deploys by setting a wait time between batches of restarts. 73 | # 74 | # boot: 75 | # limit: 10 # Can also specify as a percentage of total hosts, such as "25%" 76 | # wait: 2 77 | 78 | # Use accessory services (secrets come from .kamal/secrets). 79 | # 80 | # accessories: 81 | # db: 82 | # image: mysql:8.0 83 | # host: 192.168.0.2 84 | # port: 3306 85 | # env: 86 | # clear: 87 | # MYSQL_ROOT_HOST: '%' 88 | # secret: 89 | # - MYSQL_ROOT_PASSWORD 90 | # files: 91 | # - config/mysql/production.cnf:/etc/mysql/my.cnf 92 | # - db/production.sql:/docker-entrypoint-initdb.d/setup.sql 93 | # directories: 94 | # - data:/var/lib/mysql 95 | # redis: 96 | # image: valkey/valkey:8 97 | # host: 192.168.0.2 98 | # port: 6379 99 | # directories: 100 | # - data:/data 101 | -------------------------------------------------------------------------------- /examples/nextjs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /examples/nextjs/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: 2 | provision: shango -u vagrant -i ./.vagrant/machines/default/libvirt/private_key 3 | -------------------------------------------------------------------------------- /examples/nextjs/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /examples/nextjs/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /examples/nextjs/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /examples/nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "react": "^19.0.0", 13 | "react-dom": "^19.0.0", 14 | "next": "15.1.6" 15 | }, 16 | "devDependencies": { 17 | "typescript": "^5", 18 | "@types/node": "^20", 19 | "@types/react": "^19", 20 | "@types/react-dom": "^19", 21 | "postcss": "^8", 22 | "tailwindcss": "^3.4.1", 23 | "eslint": "^9", 24 | "eslint-config-next": "15.1.6", 25 | "@eslint/eslintrc": "^3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/nextjs/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /examples/nextjs/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/nextjs/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/nextjs/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/nextjs/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/nextjs/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/nextjs/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devalade/shango-deploy/822918c20f3c3147b0dcf01e451d897f9be94a6e/examples/nextjs/src/app/favicon.ico -------------------------------------------------------------------------------- /examples/nextjs/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --background: #ffffff; 7 | --foreground: #171717; 8 | } 9 | 10 | @media (prefers-color-scheme: dark) { 11 | :root { 12 | --background: #0a0a0a; 13 | --foreground: #ededed; 14 | } 15 | } 16 | 17 | body { 18 | color: var(--foreground); 19 | background: var(--background); 20 | font-family: Arial, Helvetica, sans-serif; 21 | } 22 | -------------------------------------------------------------------------------- /examples/nextjs/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const geistSans = Geist({ 6 | variable: "--font-geist-sans", 7 | subsets: ["latin"], 8 | }); 9 | 10 | const geistMono = Geist_Mono({ 11 | variable: "--font-geist-mono", 12 | subsets: ["latin"], 13 | }); 14 | 15 | export const metadata: Metadata = { 16 | title: "Create Next App", 17 | description: "Generated by create next app", 18 | }; 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: Readonly<{ 23 | children: React.ReactNode; 24 | }>) { 25 | return ( 26 | 27 | 30 | {children} 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /examples/nextjs/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export default function Home() { 4 | return ( 5 |
6 |
7 | Next.js logo 15 |
    16 |
  1. 17 | Get started by editing{" "} 18 | 19 | src/app/page.tsx 20 | 21 | . 22 |
  2. 23 |
  3. Save and see your changes instantly.
  4. 24 |
25 | 26 | 51 |
52 | 99 |
100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /examples/nextjs/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | background: "var(--background)", 13 | foreground: "var(--foreground)", 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } satisfies Config; 19 | -------------------------------------------------------------------------------- /examples/nextjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /examples/shango.yml: -------------------------------------------------------------------------------- 1 | app: 2 | name: test 3 | github_username: devalade 4 | framework: nextjs 5 | domain: test.local 6 | port: 3000 7 | environment: 8 | - name: test 9 | config: ./config/deploy.test.yml 10 | hosts: 11 | - test.local 12 | servers: 13 | - 1.1.1.1 14 | users: 15 | - username: deploy 16 | password: "" 17 | groups: 18 | - docker 19 | - sudo 20 | authorized_keys: 21 | - public_key: "" 22 | hooks: 23 | pre_deploy: 24 | - command: npm run build 25 | local: true 26 | post_deploy: 27 | - command: npm run db:migrate 28 | remote: true 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shango-deploy-root", 3 | "version": "0.0.0", 4 | "description": "Shango deploy monorepo", 5 | "author": "devalade", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/devalade/shango-deploy.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/devalade/shango-deploy/issues" 13 | }, 14 | "keywords": [ 15 | "deploy", 16 | "deployment", 17 | "continous-deployment", 18 | "automation" 19 | ], 20 | "scripts": { 21 | "preinstall": "npx only-allow pnpm", 22 | "dev": "turbo run shango-cli#dev", 23 | "test": "turbo run shango-cli#test", 24 | "local": "turbo run shango-cli#local", 25 | "build:cli": "turbo run shango-cli#build" 26 | }, 27 | "workspaces": [ 28 | "packages/*", 29 | "docs", 30 | "!**/test/**", 31 | "examples/*" 32 | ], 33 | "devDependencies": { 34 | "turbo": "^2.3.4", 35 | "typescript": "^5.5.4", 36 | "vitest": "^2.1.8" 37 | }, 38 | "engines": { 39 | "node": ">=20" 40 | }, 41 | "packageManager": "pnpm@9.15.3" 42 | } 43 | -------------------------------------------------------------------------------- /packages/cli/.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | 177 | 178 | commander 179 | 180 | .shango-templates-test 181 | 182 | 183 | mind-map.md 184 | 185 | .turbo 186 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shango-cli", 3 | "version": "0.0.1", 4 | "description": "Deploy your web app anywhere", 5 | "main": "index.js", 6 | "author": "devalade", 7 | "license": "MIT", 8 | "type": "module", 9 | "keywords": [ 10 | "deploy", 11 | "deployment", 12 | "continous-deployment", 13 | "automation" 14 | ], 15 | "bin": { 16 | "shango": "./dist/index.js" 17 | }, 18 | "scripts": { 19 | "dev": "tsc -w", 20 | "test": "vitest", 21 | "build": "tsc && chmod +x dist/index.js", 22 | "local": "pnpm run build && pnpm unlink && pnpm link . && chmod +x dist/index.js" 23 | }, 24 | "devDependencies": { 25 | "@types/inquirer": "^9.0.7", 26 | "@types/libsodium-wrappers": "^0.7.14", 27 | "@types/sinon": "^17.0.3", 28 | "@types/ssh2": "^1.15.1", 29 | "@types/yaml": "^1.9.7", 30 | "@vitest/coverage-c8": "^0.33.0", 31 | "copyfiles": "^2.4.1", 32 | "sinon": "^19.0.2", 33 | "turbo": "^2.3.4", 34 | "typescript": "^5.5.4", 35 | "vitest": "^2.1.8" 36 | }, 37 | "dependencies": { 38 | "@octokit/auth-app": "^7.1.4", 39 | "@octokit/rest": "^21.1.0", 40 | "chalk": "^5.4.1", 41 | "commander": "^12.1.0", 42 | "deepmerge": "^4.3.1", 43 | "dotenv": "^16.4.7", 44 | "inquirer": "^12.2.0", 45 | "libsodium-wrappers": "^0.7.15", 46 | "ora": "^8.2.0", 47 | "ssh2": "^1.15.0", 48 | "yaml": "^2.6.1", 49 | "zod": "^3.24.1" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/cli/prettier.config.js: -------------------------------------------------------------------------------- 1 | // prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs 2 | 3 | /** 4 | * @see https://prettier.io/docs/en/configuration.html 5 | * @type {import("prettier").Config} 6 | */ 7 | const config = { 8 | semi: true, 9 | singleQuote: true, 10 | } 11 | 12 | export default config 13 | -------------------------------------------------------------------------------- /packages/cli/shango.yml: -------------------------------------------------------------------------------- 1 | app: 2 | name: nextjs-exemple 3 | github_username: devalade 4 | framework: nextjs 5 | domain: test.local 6 | port: 3000 7 | 8 | environment: 9 | - name: production 10 | config: './config/deploy.production.yml' 11 | hosts: 127.0.0.1 12 | servers: 127.0.0.1 13 | 14 | users: 15 | - username: deploy 16 | password: '$6$vXRaYuC1wf3qhNkw$mJDbrjVcYb.v7XRTe45hu0y9YUW9jV9/t1GRwe0OKRtmJ2z2C8lZ1j6.M5uipB14.64EnkVsL7KL3/LQ1ufSe/' 17 | groups: 18 | - docker 19 | - sudo 20 | authorized_keys: 21 | - public_key: 'ssh-ed25519 AAAAC3NzaC1anotherfakekeyIMVIzwQXBzxxD9b8Erd1FKVvu deploy' 22 | 23 | hooks: 24 | pre_deploy: 25 | - command: npm run build 26 | local: true 27 | post_deploy: 28 | - command: npm run db:migrate 29 | remote: true 30 | -------------------------------------------------------------------------------- /packages/cli/src/commands/deploy.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'ssh2'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import { HookManager } from '../lib/hooks/hook-manager.ts'; 5 | import { HookType } from '../lib/hooks/types.ts'; 6 | 7 | interface DeployOptions { 8 | host: string; 9 | username: string; 10 | key: string; 11 | path: string; 12 | } 13 | 14 | export async function deploy(options: DeployOptions): Promise { 15 | const { host, username, key, path: appPath } = options; 16 | 17 | if (!host || !username || !key || !appPath) { 18 | console.error('Missing required parameters'); 19 | process.exit(1); 20 | } 21 | 22 | const conn = new Client(); 23 | const hookManager = new HookManager(); 24 | 25 | try { 26 | await hookManager.executeHooks(HookType.PRE_DEPLOY); 27 | await new Promise((resolve, reject) => { 28 | conn.on('ready', () => { 29 | console.log('SSH Connection established'); 30 | resolve(); 31 | }).on('error', (err) => { 32 | reject(err); 33 | }).connect({ 34 | host, 35 | username, 36 | privateKey: fs.readFileSync(key) 37 | }); 38 | }); 39 | 40 | // TODO: Implement deployment logic here 41 | console.log('Starting deployment...'); 42 | 43 | await hookManager.executeHooks(HookType.POST_DEPLOY, [options.host]); 44 | 45 | 46 | } catch (error) { 47 | console.error('Deployment failed:', error); 48 | process.exit(1); 49 | } finally { 50 | conn.end(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/cli/src/commands/init.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'fs'; 2 | import { join } from 'path'; 3 | import { stringify } from 'yaml'; 4 | import { Framework, DatabaseType, type ShangoConfig } from '../types/index.ts'; 5 | import inquirer from 'inquirer'; 6 | import { executeKamal } from '../util/execute-kamal.ts'; 7 | import { TemplateManager } from '../lib/template/index.ts'; 8 | import { KamalConfigurationManager } from '../lib/kamal-config/index.ts'; 9 | import chalk from 'chalk'; 10 | 11 | export async function init(): Promise { 12 | try { 13 | const answers = await inquirer.prompt([ 14 | { 15 | type: 'list', 16 | name: 'framework', 17 | message: 'Select your framework:', 18 | choices: Object.values(Framework), 19 | }, 20 | { 21 | type: 'list', 22 | name: 'db', 23 | message: 'Select your primary database:', 24 | choices: Object.values(DatabaseType), 25 | }, 26 | { 27 | type: 'confirm', 28 | name: 'kv', 29 | message: 'Do you want to use redis ?', 30 | default: true, 31 | }, 32 | { 33 | type: 'input', 34 | name: 'githubUsername', 35 | message: 'Enter your GitHub username:', 36 | validate: (input) => !!input.trim(), 37 | }, 38 | { 39 | type: 'input', 40 | name: 'appName', 41 | message: 'Enter your application name:', 42 | validate: (input) => !!input.trim(), 43 | }, 44 | { 45 | type: 'input', 46 | name: 'environment', 47 | default: 'dev, staging, prod', 48 | message: 49 | 'Enter the available deployment environment seperate by comma:', 50 | validate: (input) => !!input.trim(), 51 | }, 52 | ]); 53 | 54 | const config: ShangoConfig = { 55 | app: { 56 | name: answers.appName, 57 | github_username: answers.githubUsername, 58 | framework: answers.framework, 59 | port: 3000, 60 | db: answers.db, 61 | kv: answers.kv, 62 | }, 63 | environment: await getEnvironment(answers.environment), 64 | users: [ 65 | { 66 | username: 'deploy', 67 | password: '', 68 | groups: ['docker', 'sudo'], 69 | authorized_keys: [{ public_key: '' }], 70 | }, 71 | ], 72 | hooks: { 73 | pre_deploy: [ 74 | { 75 | command: 'npm run build', 76 | local: true, 77 | }, 78 | ], 79 | post_deploy: [ 80 | { 81 | command: 'npm run db:migrate', 82 | remote: true, 83 | }, 84 | ], 85 | }, 86 | }; 87 | 88 | writeFileSync(join(process.cwd(), 'shango.yml'), stringify(config)); 89 | 90 | executeKamal('init'); 91 | 92 | const configManager = new KamalConfigurationManager(config); 93 | await configManager.update(); 94 | 95 | const templateManager = new TemplateManager({ 96 | framework: answers.framework, 97 | dockerfile: true, 98 | githubAction: true, 99 | templateDir: process.env.SHANGO_TEMPLATE || './.shango-templates', 100 | appName: answers.appName, 101 | githubUsername: answers.githubUsername, 102 | }); 103 | await templateManager.generate(); 104 | 105 | console.log('✨ Project configuration completed successfully!'); 106 | console.log('📁 Configuration files generated:'); 107 | console.log(' - shango.yml'); 108 | console.log(' - .config/deploy.staging.yml'); 109 | console.log(' - .config/deploy.production.yml'); 110 | console.log(' - Dockerfile'); 111 | console.log(' - .github/workflows/deploy.yml'); 112 | } catch (error) { 113 | console.error('Error creating configuration:', error); 114 | process.exit(1); 115 | } 116 | } 117 | 118 | async function getEnvironment( 119 | environment: string, 120 | ): Promise { 121 | const environments: ShangoConfig['environment'] = []; 122 | 123 | for (let index: number = 0; index < environment.split(',').length; index++) { 124 | const name = environment.split(',')[index].trim() as string; 125 | const environmentAnswers = await inquirer.prompt<{ 126 | servers: string; 127 | hosts: string; 128 | }>([ 129 | { 130 | type: 'input', 131 | name: 'servers', 132 | message: `Enter the available ${chalk.bold.red('servers')} for the ${chalk.bold.blue(name)} seperate by comma:`, 133 | validate: (input) => !!input.trim(), 134 | }, 135 | { 136 | type: 'input', 137 | name: 'hosts', 138 | message: `Enter the available ${chalk.bold.red('hosts')} for the ${chalk.bold.blue(name)} seperate by comma:`, 139 | validate: (input) => !!input.trim(), 140 | }, 141 | ]); 142 | environments.push({ 143 | name: environment, 144 | config: `./config/deploy.${environment}.yml`, 145 | hosts: environmentAnswers.hosts.split(',') as string[], 146 | servers: environmentAnswers.servers.split(',') as string[], 147 | }); 148 | } 149 | 150 | return environments; 151 | } 152 | -------------------------------------------------------------------------------- /packages/cli/src/commands/kl.ts: -------------------------------------------------------------------------------- 1 | import { executeKamal } from '../util/execute-kamal.ts'; 2 | 3 | export async function kamal(cmd: string[]): Promise { 4 | console.log('Kamal command is running'); 5 | executeKamal(cmd); 6 | 7 | if (cmd.includes('setup')) { 8 | // TODO: implement setup logic 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/cli/src/commands/provision.ts: -------------------------------------------------------------------------------- 1 | import { type ShangoConfig } from '../types/index.ts'; 2 | import { ServerProvisioner } from '../lib/provisionner/index.ts'; 3 | import { loadConfigFile } from '../util/load-config-file.ts'; 4 | import { ConfigurationMerger } from '../lib/config/merger.ts'; 5 | 6 | export async function provision(options: { 7 | user: string; 8 | environment: string; 9 | port: number; 10 | i: string; 11 | }): Promise { 12 | try { 13 | const baseConfig: ShangoConfig = await loadConfigFile(); 14 | const config = options.environment 15 | ? ConfigurationMerger.mergeWithEnvironment( 16 | baseConfig, 17 | options.environment, 18 | ) 19 | : baseConfig; 20 | 21 | console.log('🚀 Starting server provisioning...'); 22 | 23 | const provisioner = new ServerProvisioner(config, { 24 | username: options.user, 25 | port: options.port, 26 | privateKey: options.i, 27 | }); 28 | await provisioner.provision(); 29 | } catch (error) { 30 | console.error('❌ Error during provisioning:', error); 31 | process.exit(1); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/cli/src/commands/sync-secrets.ts: -------------------------------------------------------------------------------- 1 | import { SecretManager } from '../lib/secrets/secret-manager.ts'; 2 | import { SecretLoader } from '../lib/secrets/secret-loader.ts'; 3 | import { GithubSecretProvider } from '../lib/secrets/providers/github-provider.ts'; 4 | import { SecretProviderType } from '../lib/secrets/types.ts'; 5 | 6 | export async function syncSecrets(options: { owner: string, repo: string, token: string }): Promise { 7 | try { 8 | const secretManager = SecretManager.getInstance(); 9 | 10 | secretManager.registerProvider( 11 | SecretProviderType.GITHUB, 12 | new GithubSecretProvider({ 13 | owner: options.owner, 14 | repo: options.repo, 15 | token: options.token 16 | }) 17 | ); 18 | 19 | const secrets = SecretLoader.loadFromKamalSecrets(); 20 | 21 | await secretManager.syncToGithub(secrets); 22 | 23 | console.log('✨ Secrets synchronized to GitHub successfully!'); 24 | } catch (error) { 25 | console.error('Error synchronizing secrets:', error); 26 | process.exit(1); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/cli/src/commands/update.ts: -------------------------------------------------------------------------------- 1 | import { KamalConfigurationManager } from '../lib/kamal-config/index.ts'; 2 | import { loadConfigFile } from '../util/load-config-file.ts'; 3 | 4 | export async function update(): Promise { 5 | try { 6 | const config = await loadConfigFile(); 7 | 8 | const configManager = new KamalConfigurationManager(config); 9 | await configManager.update(); 10 | 11 | console.log( 12 | '✨ Project configuration has been updated successfully successfully!', 13 | ); 14 | } catch (error) { 15 | console.error('Error updating configuration:', error); 16 | process.exit(1); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Command } from 'commander'; 4 | import { init } from './commands/init.ts'; 5 | import { kamal } from './commands/kl.ts'; 6 | import { provision } from './commands/provision.ts'; 7 | import { syncSecrets } from './commands/sync-secrets.ts'; 8 | import { update } from './commands/update.ts'; 9 | 10 | const program = new Command(); 11 | 12 | program 13 | .name('shango') 14 | .description('Deploy your web app anywhere') 15 | .version('0.0.1'); 16 | 17 | program 18 | .command('provision') 19 | .description('Provision servers with required configurations') 20 | .option( 21 | '-e, --environment ', 22 | 'Target environment (staging/production)', 23 | 'production', 24 | ) 25 | .option('-i ', 'identity file or private key') 26 | .option('-u, --user ', 'The username of your host machine', 'root') 27 | .option( 28 | '-p, --port ', 29 | 'The port to SSH into to the server', 30 | parseInt, 31 | 22, 32 | ) 33 | .action(provision); 34 | 35 | program 36 | .command('init') 37 | .description('Generate a new Shango configuration') 38 | .action(init); 39 | 40 | program 41 | .command('update') 42 | .description('Update your kamal config base on the shango config') 43 | .action(update); 44 | 45 | program 46 | .command('kl') 47 | .argument('[cmd...]') 48 | .description('This is an alias to the kamal deploy') 49 | .enablePositionalOptions() 50 | .allowUnknownOption() 51 | .action(kamal); 52 | 53 | program 54 | .command('sync-secrets') 55 | .description('Synchronize secrets to GitHub') 56 | .requiredOption('--owner ', 'GitHub repository owner') 57 | .requiredOption('--repo ', 'GitHub repository name') 58 | .requiredOption('--token ', 'GitHub personal access token') 59 | .action(syncSecrets); 60 | 61 | program.parse(); 62 | -------------------------------------------------------------------------------- /packages/cli/src/lib/config/loader.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, existsSync } from 'fs'; 2 | import { join } from 'path'; 3 | import { parse as parseYAML } from 'yaml'; 4 | import { type ValidatedShangoConfig, ConfigSchema } from './validator.ts'; 5 | 6 | export class ConfigurationLoader { 7 | private static instance: ConfigurationLoader; 8 | private config: ValidatedShangoConfig | null = null; 9 | 10 | private constructor() {} 11 | 12 | static getInstance(): ConfigurationLoader { 13 | if (!ConfigurationLoader.instance) { 14 | ConfigurationLoader.instance = new ConfigurationLoader(); 15 | } 16 | return ConfigurationLoader.instance; 17 | } 18 | 19 | async load(configPath?: string): Promise { 20 | if (this.config) return this.config; 21 | 22 | const paths = [ 23 | configPath, 24 | join(process.cwd(), 'shango.yml'), 25 | join(process.cwd(), 'shango.yaml'), 26 | join(process.cwd(), 'shango.json'), 27 | ].filter(Boolean) as string[]; 28 | 29 | for (const path of paths) { 30 | if (existsSync(path)) { 31 | const content = readFileSync(path, 'utf8'); 32 | const parsedConfig = path.endsWith('.json') 33 | ? JSON.parse(content) 34 | : parseYAML(content); 35 | 36 | try { 37 | this.config = ConfigSchema.parse(parsedConfig); 38 | return this.config; 39 | } catch (error) { 40 | throw new Error(`Invalid configuration in ${path}: ${error}`); 41 | } 42 | } 43 | } 44 | 45 | throw new Error('No configuration file found'); 46 | } 47 | 48 | getConfig(): ValidatedShangoConfig { 49 | if (!this.config) { 50 | throw new Error('Configuration not loaded'); 51 | } 52 | return this.config; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/cli/src/lib/config/merger.ts: -------------------------------------------------------------------------------- 1 | import { type ValidatedShangoConfig } from './validator.ts'; 2 | import deepmerge from 'deepmerge'; 3 | 4 | export class ConfigurationMerger { 5 | static mergeWithEnvironment( 6 | baseConfig: ValidatedShangoConfig, 7 | environment: string, 8 | ): ValidatedShangoConfig { 9 | const environmentConfig = baseConfig.environment.find( 10 | (env) => env.name === environment, 11 | ); 12 | 13 | if (!environmentConfig) { 14 | throw new Error( 15 | `Environment '${environment}' not found in configuration`, 16 | ); 17 | } 18 | 19 | // Create a new config with environment-specific values 20 | return deepmerge(baseConfig, { 21 | servers: [environmentConfig], 22 | env: { 23 | clear: { 24 | NODE_ENV: environment, 25 | ...baseConfig.env?.clear, 26 | }, 27 | }, 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/cli/src/lib/config/validator.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { Framework } from '../../types/index.ts'; 3 | 4 | const HookSchema = z.object({ 5 | command: z.string(), 6 | local: z.boolean().optional(), 7 | remote: z.boolean().optional(), 8 | }); 9 | 10 | const HooksSchema = z.object({ 11 | pre_deploy: z.array(HookSchema).optional(), 12 | post_deploy: z.array(HookSchema).optional(), 13 | }); 14 | 15 | const UserSchema = z.object({ 16 | username: z.string(), 17 | password: z.string(), 18 | groups: z.array(z.string()), 19 | authorized_keys: z.array(z.object({ public_key: z.string() })), 20 | }); 21 | 22 | const EnvironmentSchema = z.object({ 23 | name: z.string(), 24 | config: z.string(), 25 | hosts: z.union([z.string(), z.array(z.string())]), 26 | servers: z.union([z.string(), z.array(z.string())]), 27 | }); 28 | 29 | export const ConfigSchema = z.object({ 30 | app: z.object({ 31 | name: z.string(), 32 | github_username: z.string(), 33 | framework: z.nativeEnum(Framework), 34 | port: z.number(), 35 | db: z.string(), 36 | kv: z.boolean(), 37 | }), 38 | environment: z.array(EnvironmentSchema), 39 | users: z.array(UserSchema), 40 | hooks: HooksSchema, 41 | }); 42 | 43 | export type ValidatedShangoConfig = z.infer; 44 | -------------------------------------------------------------------------------- /packages/cli/src/lib/hook-executor.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | import { type Hook } from '../types/index.ts'; 3 | import { executeSsh2Command } from '../util/execute-ssh2-command.ts'; 4 | 5 | export class HookExecutor { 6 | async executeHooks(hooks: Hook[], server?: string): Promise { 7 | for (const hook of hooks) { 8 | if (hook.local) { 9 | await this.executeLocal(hook); 10 | } 11 | if (hook.remote && server) { 12 | await this.executeRemote(hook, server); 13 | } 14 | } 15 | } 16 | 17 | private async executeLocal(hook: Hook): Promise { 18 | try { 19 | console.log(`Executing local hook: ${hook.command}`); 20 | execSync(hook.command, { stdio: 'inherit' }); 21 | } catch (error) { 22 | throw new Error(`Failed to execute local hook: ${hook.command}`); 23 | } 24 | } 25 | 26 | private async executeRemote(hook: Hook, server: string): Promise { 27 | try { 28 | console.log(`Executing remote hook on ${server}: ${hook.command}`); 29 | await executeSsh2Command(server, hook.command); 30 | } catch (error) { 31 | throw new Error(`Failed to execute remote hook on ${server}: ${hook.command}`); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/cli/src/lib/hooks/hook-manager.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, existsSync } from 'fs'; 2 | import { join } from 'path'; 3 | import { execSync } from 'child_process'; 4 | import { type Hook, HookType, HookContext, type HookResult, HookError } from './types.ts'; 5 | import { SSHManager } from '../ssh/ssh-manager.ts'; 6 | 7 | export class HookManager { 8 | private readonly hooksDir: string; 9 | private readonly env: Record; 10 | 11 | constructor( 12 | hooksDir: string = join(process.cwd(), '.kamal', 'hooks'), 13 | env: Record = {} 14 | ) { 15 | this.hooksDir = hooksDir; 16 | this.env = { 17 | ...process.env, 18 | ...env, 19 | KAMAL_RECORDED_AT: new Date().toISOString(), 20 | KAMAL_PERFORMER: process.env.USER || 'unknown' 21 | }; 22 | } 23 | 24 | async executeHook(hook: Hook, servers?: string[]): Promise { 25 | const startTime = Date.now(); 26 | 27 | try { 28 | console.log(`Executing ${hook.type} hook: ${hook.name}`); 29 | 30 | if (hook.condition && !this.evaluateCondition(hook.condition)) { 31 | console.log(`Skipping hook ${hook.name}: condition not met`); 32 | return { 33 | success: true, 34 | output: 'Hook skipped: condition not met', 35 | duration: Date.now() - startTime 36 | }; 37 | } 38 | 39 | let output: string; 40 | if (hook.context === HookContext.LOCAL) { 41 | output = await this.executeLocal(hook); 42 | } else { 43 | if (!servers || servers.length === 0) { 44 | throw new HookError('No servers provided for remote hook execution', hook); 45 | } 46 | output = await this.executeRemote(hook, servers); 47 | } 48 | 49 | return { 50 | success: true, 51 | output, 52 | duration: Date.now() - startTime 53 | }; 54 | 55 | } catch (error: any) { 56 | const result = { 57 | success: false, 58 | output: error.message, 59 | error, 60 | duration: Date.now() - startTime 61 | }; 62 | throw new HookError(`Hook execution failed: ${error.message}`, hook, result); 63 | } 64 | } 65 | 66 | async executeHooks(type: HookType, servers?: string[]): Promise { 67 | const hooks = await this.loadHooks(type); 68 | const results: HookResult[] = []; 69 | 70 | for (const hook of hooks) { 71 | try { 72 | const result = await this.executeHook(hook, servers); 73 | results.push(result); 74 | } catch (error) { 75 | if (error instanceof HookError) { 76 | console.error(`Hook ${hook.name} failed: ${error.message}`); 77 | if (error.result) { 78 | results.push(error.result); 79 | } 80 | } 81 | throw error; // Re-throw to stop execution if a hook fails 82 | } 83 | } 84 | 85 | return results; 86 | } 87 | 88 | private async loadHooks(type: HookType): Promise { 89 | const hooks: Hook[] = []; 90 | const hookPath = join(this.hooksDir, `${type}.sh`); 91 | 92 | if (!existsSync(hookPath)) { 93 | return hooks; 94 | } 95 | 96 | const script = readFileSync(hookPath, 'utf8'); 97 | 98 | hooks.push({ 99 | name: type, 100 | type, 101 | script, 102 | context: this.determineContext(script), 103 | timeout: this.parseTimeout(script) 104 | }); 105 | 106 | return hooks; 107 | } 108 | 109 | private async executeLocal(hook: Hook): Promise { 110 | return new Promise((resolve, reject) => { 111 | try { 112 | const output = execSync(hook.script, { 113 | env: this.env, 114 | timeout: hook.timeout || 30000, 115 | encoding: 'utf8' 116 | }); 117 | resolve(output); 118 | } catch (error: any) { 119 | reject(new Error(`Local execution failed: ${error.message}`)); 120 | } 121 | }); 122 | } 123 | 124 | private async executeRemote(hook: Hook, servers: string[]): Promise { 125 | const outputs: string[] = []; 126 | 127 | for (const server of servers) { 128 | const ssh = new SSHManager({ host: server, username: 'root' }); 129 | try { 130 | await ssh.connect(); 131 | const { stdout } = await ssh.executeCommand(hook.script, { 132 | timeout: hook.timeout 133 | }); 134 | outputs.push(`[${server}] ${stdout}`); 135 | } finally { 136 | ssh.disconnect(); 137 | } 138 | } 139 | 140 | return outputs.join('\n'); 141 | } 142 | 143 | private evaluateCondition(condition: string): boolean { 144 | try { 145 | // Simple evaluation - could be enhanced for more complex conditions 146 | return !!eval(condition); 147 | } catch { 148 | return false; 149 | } 150 | } 151 | 152 | private determineContext(script: string): HookContext { 153 | // Look for special comments in the script to determine context 154 | if (script.includes('#@remote')) { 155 | return HookContext.REMOTE; 156 | } 157 | return HookContext.LOCAL; 158 | } 159 | 160 | private parseTimeout(script: string): number | undefined { 161 | const timeoutMatch = script.match(/#@timeout (\d+)/); 162 | return timeoutMatch ? parseInt(timeoutMatch[1], 10) : undefined; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /packages/cli/src/lib/hooks/hook-registry.ts: -------------------------------------------------------------------------------- 1 | import { type Hook, HookType } from './types.ts'; 2 | 3 | export class HookRegistry { 4 | private static instance: HookRegistry; 5 | private hooks: Map; 6 | 7 | private constructor() { 8 | this.hooks = new Map(); 9 | } 10 | 11 | static getInstance(): HookRegistry { 12 | if (!HookRegistry.instance) { 13 | HookRegistry.instance = new HookRegistry(); 14 | } 15 | return HookRegistry.instance; 16 | } 17 | 18 | register(hook: Hook): void { 19 | const hooks = this.hooks.get(hook.type) || []; 20 | hooks.push(hook); 21 | this.hooks.set(hook.type, hooks); 22 | } 23 | 24 | getHooks(type: HookType): Hook[] { 25 | return this.hooks.get(type) || []; 26 | } 27 | 28 | clear(): void { 29 | this.hooks.clear(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/cli/src/lib/hooks/types.ts: -------------------------------------------------------------------------------- 1 | export enum HookType { 2 | PRE_DEPLOY = 'pre-deploy', 3 | POST_DEPLOY = 'post-deploy', 4 | PRE_PROVISION = 'pre-provision', 5 | POST_PROVISION = 'post-provision', 6 | PRE_BUILD = 'pre-build', 7 | POST_BUILD = 'post-build', 8 | PRE_CONNECT = 'pre-connect', 9 | DOCKER_SETUP = 'docker-setup' 10 | } 11 | 12 | export enum HookContext { 13 | LOCAL = 'local', 14 | REMOTE = 'remote' 15 | } 16 | 17 | export interface Hook { 18 | name: string; 19 | type: HookType; 20 | script: string; 21 | context: HookContext; 22 | condition?: string; 23 | timeout?: number; 24 | } 25 | 26 | export interface HookResult { 27 | success: boolean; 28 | output: string; 29 | error?: Error; 30 | duration: number; 31 | } 32 | 33 | export class HookError extends Error { 34 | constructor( 35 | message: string, 36 | public hook: Hook, 37 | public result?: HookResult 38 | ) { 39 | super(message); 40 | this.name = 'HookError'; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/cli/src/lib/kamal-config/__tests__/kamal-configuration-manager.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | describe('KamalConfigurationManager', () => { 4 | it('should load configuration', async () => { 5 | expect(true).toBe(true); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /packages/cli/src/lib/kamal-config/index.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs'; 2 | import { join } from 'path'; 3 | import { parse as parseYAML, stringify } from 'yaml'; 4 | import { type ValidatedShangoConfig } from '../config/validator.ts'; 5 | import { type KamalConfig, KamalConfigurationError } from './types.ts'; 6 | import { mergeConfigurations } from './merger.ts'; 7 | import { DatabaseType } from '../../types/index.ts'; 8 | 9 | export class KamalConfigurationManager { 10 | private config: ValidatedShangoConfig; 11 | private kamalConfigPath: string; 12 | private envConfig: ValidatedShangoConfig['environment'][number]; 13 | 14 | constructor(config: ValidatedShangoConfig) { 15 | this.config = config; 16 | this.kamalConfigPath = join( 17 | process.cwd(), 18 | this.config.environment[0].config, 19 | ); 20 | this.envConfig = this.config.environment[0]; 21 | } 22 | 23 | async update(): Promise { 24 | try { 25 | for (let index = 0; index < this.config.environment.length; index++) { 26 | this.kamalConfigPath = join( 27 | process.cwd(), 28 | this.config.environment[index].config, 29 | ); 30 | 31 | this.envConfig = this.config.environment[0]; 32 | await this.waitForKamalConfig(); 33 | 34 | const updatedConfig = this.generateConfigFromShango(); 35 | 36 | this.writeKamalConfig(updatedConfig); 37 | } 38 | } catch (error) { 39 | throw new KamalConfigurationError( 40 | `Failed to update deployment configuration: ${error}`, 41 | ); 42 | } 43 | } 44 | 45 | private async waitForKamalConfig(timeout: number = 5000): Promise { 46 | const startTime = Date.now(); 47 | while (!this.createIfNotExit(this.kamalConfigPath)) { 48 | if (Date.now() - startTime > timeout) { 49 | throw new KamalConfigurationError( 50 | `Timeout waiting for ${this.kamalConfigPath} to be generated`, 51 | ); 52 | } 53 | await new Promise((resolve) => setTimeout(resolve, 100)); 54 | } 55 | } 56 | 57 | private generateConfigFromShango(): KamalConfig { 58 | this.removeDefaultKamalConfig(); 59 | return { 60 | service: this.config.app.name, 61 | image: `ghcr.io/${this.config.app.github_username}/${this.config.app.name.toLowerCase()}`, 62 | registry: { 63 | server: 'ghcr.io', 64 | username: this.config.app.github_username, 65 | password: ['GITHUB_TOKEN'], 66 | }, 67 | servers: this.generateServersConfig(), 68 | proxy: this.generateProxyConfig(), 69 | env: this.generateEnvConfig(), 70 | accessories: this.generateAccessoriesConfig(), 71 | }; 72 | } 73 | 74 | private generateServersConfig(): KamalConfig['servers'] { 75 | const servers: KamalConfig['servers'] = { 76 | web: Array.isArray(this.envConfig.servers) 77 | ? this.envConfig.servers 78 | : [this.envConfig.servers], 79 | }; 80 | 81 | return servers; 82 | } 83 | 84 | private generateProxyConfig(): KamalConfig['proxy'] { 85 | const proxy: KamalConfig['proxy'] = { 86 | app_port: this.config.app.port, 87 | ssl: true, 88 | healthcheck: { 89 | interval: 3, 90 | path: '/up', 91 | timeout: 3, 92 | }, 93 | }; 94 | 95 | return Array.isArray(this.envConfig.hosts) 96 | ? { ...proxy, hosts: this.envConfig.hosts } 97 | : { ...proxy, host: this.envConfig.hosts }; 98 | } 99 | 100 | private generateEnvConfig() { 101 | return { 102 | clear: { 103 | NODE_ENV: 'production', 104 | }, 105 | secret: [], 106 | }; 107 | } 108 | 109 | private generateAccessoriesConfig() { 110 | const accessories: Record = {}; 111 | 112 | if (this.config.app.db && this.config.app.db.length > 0) { 113 | accessories.db = this.generateDatabaseConfig(); 114 | } 115 | 116 | if (this.config.app.kv) { 117 | accessories.cache = this.generateCacheConfig(); 118 | } 119 | 120 | return accessories; 121 | } 122 | 123 | private generateDatabaseConfig() { 124 | if (this.config.app.db === DatabaseType.POSTGRESQL) { 125 | return this.getPostgreSQLConfig(); 126 | } else if (this.config.app.db === DatabaseType.MYSQL) { 127 | return this.getMysqlConfig(); 128 | } 129 | return {}; 130 | } 131 | 132 | private getPostgreSQLConfig() { 133 | return { 134 | image: 'postgres:15', 135 | host: '192.168.0.2', 136 | port: 5432, 137 | env: { 138 | clear: { 139 | POSTGRES_DB: 'mydatabase', 140 | POSTGRES_USER: 'postgres', 141 | POSTGRES_HOST_AUTH_METHOD: 'trust', 142 | }, 143 | secret: ['POSTGRES_PASSWORD'], 144 | }, 145 | files: [ 146 | 'config/postgres/postgresql.conf:/etc/postgresql/postgresql.conf', 147 | 'config/postgres/pg_hba.conf:/etc/postgresql/pg_hba.conf', 148 | 'db/init.sql:/docker-entrypoint-initdb.d/init.sql', 149 | ], 150 | directories: ['data:/var/lib/postgresql/data'], 151 | }; 152 | } 153 | 154 | private getMysqlConfig() { 155 | return { 156 | image: 'mysql:8.0', 157 | host: '192.168.0.2', 158 | port: 3306, 159 | env: { 160 | clear: { 161 | MYSQL_ROOT_HOST: '%', 162 | }, 163 | secret: ['MYSQL_ROOT_PASSWORD'], 164 | }, 165 | files: [ 166 | 'config/mysql/production.cnf:/etc/mysql/my.cnf', 167 | 'db/production.sql:/docker-entrypoint-initdb.d/setup.sql', 168 | ], 169 | directories: ['data:/var/lib/mysql'], 170 | }; 171 | } 172 | 173 | private generateCacheConfig() { 174 | if (this.config.app.kv) { 175 | return { 176 | image: 'valkey/valkey:8', 177 | host: this.envConfig.servers[0], 178 | port: 6379, 179 | volumes: ['data:/data'], 180 | }; 181 | } 182 | return {}; 183 | } 184 | 185 | private writeKamalConfig(config: KamalConfig): void { 186 | try { 187 | writeFileSync(this.kamalConfigPath, stringify(config)); 188 | } catch (error) { 189 | throw new KamalConfigurationError( 190 | `Failed to write ${this.kamalConfigPath}: ${error}`, 191 | ); 192 | } 193 | } 194 | 195 | private createIfNotExit(path: string) { 196 | try { 197 | if (existsSync(path)) { 198 | } else { 199 | writeFileSync(path, ''); 200 | } 201 | 202 | return true; 203 | } catch (err) { 204 | console.error('Error creating file:', err); 205 | return false; 206 | } 207 | } 208 | 209 | private removeDefaultKamalConfig(): boolean { 210 | const defaultConfigPath = './config/deploy.yml'; 211 | if (existsSync(defaultConfigPath)) { 212 | try { 213 | unlinkSync(defaultConfigPath); 214 | return true; 215 | } catch (err) { 216 | console.error('Error deleting file:', err); 217 | return false; 218 | } 219 | } 220 | return true; 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /packages/cli/src/lib/kamal-config/merger.ts: -------------------------------------------------------------------------------- 1 | import { type KamalConfig } from './types.ts'; 2 | import deepmerge from 'deepmerge'; 3 | 4 | export function mergeConfigurations( 5 | existing: KamalConfig, 6 | updated: Partial 7 | ): KamalConfig { 8 | // Custom merge arrays function to handle server lists 9 | const mergeArrays = (target: any[], source: any[]) => { 10 | return [...new Set([...target, ...source])]; 11 | }; 12 | 13 | return deepmerge(existing, updated, { 14 | arrayMerge: mergeArrays 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /packages/cli/src/lib/kamal-config/types.ts: -------------------------------------------------------------------------------- 1 | export interface KamalConfig { 2 | service: string; 3 | image: string; 4 | servers: { 5 | web: string[]; 6 | job?: { 7 | hosts: string[]; 8 | cmd: string; 9 | }; 10 | cron?: { 11 | hosts: string[]; 12 | cmd: string; 13 | }; 14 | }; 15 | proxy?: { 16 | ssl: boolean; 17 | host?: string; 18 | hosts?: string[]; 19 | app_port?: number; 20 | forward_headers?: boolean; 21 | response_timeout?: number; 22 | healthcheck: { 23 | interval: number; 24 | path: string; 25 | timeout: number; 26 | }; 27 | buffering?: { 28 | requests: boolean; 29 | responses: boolean; 30 | max_request_body: number; 31 | max_response_body: number; 32 | memory: number; 33 | }; 34 | logging?: { 35 | request_headers: string[]; 36 | response_headers: string[]; 37 | }; 38 | }; 39 | registry: { 40 | server?: string; 41 | username: string; 42 | password: string | string[]; 43 | }; 44 | builder?: { 45 | ssh?: string; 46 | driver: string; 47 | arch: 'amd64' | 'arm64' | ('amd64' | 'arm64')[]; 48 | remote?: string; 49 | local?: boolean; 50 | context?: string; 51 | dockerfile?: string; 52 | target?: string; 53 | provenance?: string; 54 | sbom: boolean; 55 | cache?: { 56 | type: 'gha' | 'registry'; 57 | options: string; 58 | image: string; 59 | }; 60 | args?: Record; 61 | secrets?: Record; 62 | }; 63 | env?: { 64 | clear?: Record; 65 | secret?: string[]; 66 | }; 67 | aliases?: Record; 68 | ssh?: { 69 | user?: string; 70 | port?: string; 71 | proxy?: string; 72 | log_level?: string; 73 | keys_only?: boolean; 74 | keys?: string[]; 75 | key_data?: string[]; 76 | config?: boolean; 77 | }; 78 | sshkit?: { 79 | max_concurrent_starts: number; 80 | pool_idle_timeout: number; 81 | }; 82 | volumes?: string[]; 83 | asset_path?: string; 84 | boot?: { 85 | limit: number | string; 86 | wait: number; 87 | }; 88 | accessories?: { 89 | [key: string]: AccessoryService; 90 | }; 91 | loging?: { 92 | driver?: string; 93 | options: { 94 | 'max-size': string; 95 | }; 96 | }; 97 | } 98 | 99 | interface EnvConfig { 100 | clear?: Record; 101 | secret?: string[]; 102 | } 103 | 104 | interface AccessoryService { 105 | image: string; 106 | host: string; 107 | port: number; 108 | env?: EnvConfig; 109 | files?: string[]; 110 | directories?: string[]; 111 | } 112 | 113 | export class KamalConfigurationError extends Error { 114 | constructor(message: string) { 115 | super(message); 116 | this.name = 'KamalConfigurationError'; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /packages/cli/src/lib/kamal-config/validator.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const EnvConfigSchema = z.object({ 4 | clear: z.record(z.string()).optional(), 5 | secret: z.array(z.string()).optional(), 6 | }); 7 | 8 | export const ServerJobSchema = z.object({ 9 | hosts: z.array(z.string()), 10 | cmd: z.string(), 11 | }); 12 | 13 | export const ServersSchema = z.object({ 14 | web: z.array(z.string()), 15 | job: ServerJobSchema.optional(), 16 | cron: ServerJobSchema.optional(), 17 | }); 18 | 19 | export const AccessoryServiceSchema = z.object({ 20 | image: z.string(), 21 | host: z.string(), 22 | port: z.number(), 23 | env: EnvConfigSchema.optional(), 24 | files: z.array(z.string()).optional(), 25 | directories: z.array(z.string()).optional(), 26 | }); 27 | 28 | const RegistrySchema = z.object({ 29 | server: z.string().optional(), 30 | username: z.string(), 31 | password: z.union([z.string(), z.array(z.string())]), 32 | }); 33 | 34 | export const BuilderSchema = z.object({ 35 | ssh: z.string().optional(), 36 | driver: z.string().optional(), 37 | arch: z.union([ 38 | z.literal('amd64'), 39 | z.literal('arm64'), 40 | z.array(z.union([z.literal('amd64'), z.literal('arm64')])), 41 | ]), 42 | remote: z.string().optional(), 43 | local: z.boolean().optional(), 44 | context: z.string().optional(), 45 | dockerfile: z.string().optional(), 46 | target: z.string().optional(), 47 | provenance: z.string().optional(), 48 | sbom: z.boolean().optional(), 49 | cache: z 50 | .object({ 51 | type: z.union([z.literal('gha'), z.literal('registry')]), 52 | options: z.string(), 53 | image: z.string(), 54 | }) 55 | .optional(), 56 | args: z.record(z.string()).optional(), 57 | secrets: z.record(z.string()).optional(), 58 | }); 59 | 60 | export const SSHSchema = z.object({ 61 | user: z.string().optional(), 62 | port: z.string().optional(), 63 | proxy: z.string().optional(), 64 | log_level: z.string().optional(), 65 | keys_only: z.boolean().optional(), 66 | keys: z.array(z.string()).optional(), 67 | key_data: z.array(z.string()).optional(), 68 | config: z.boolean().optional(), 69 | }); 70 | 71 | export const SSHKitSchema = z.object({ 72 | max_concurrent_starts: z.number(), 73 | pool_idle_timeout: z.number(), 74 | }); 75 | 76 | export const BootSchema = z.object({ 77 | limit: z.union([z.number(), z.string()]), 78 | wait: z.number(), 79 | }); 80 | 81 | export const LoggingOptionsSchema = z.object({ 82 | driver: z.string().optional(), 83 | options: z.object({ 84 | 'max-size': z.string(), 85 | }), 86 | }); 87 | 88 | export const KamalConfigSchema = z.object({ 89 | service: z.string(), 90 | image: z.string(), 91 | servers: ServersSchema, 92 | args: z.record(z.string()).optional(), 93 | secrets: z.record(z.string()).optional(), 94 | env: EnvConfigSchema.optional(), 95 | registry: RegistrySchema, 96 | builder: BuilderSchema, 97 | aliases: z.record(z.string()).optional(), 98 | ssh: SSHSchema.optional(), 99 | sshkit: SSHKitSchema.optional(), 100 | volumes: z.array(z.string()).optional(), 101 | asset_path: z.string().optional(), 102 | boot: BootSchema.optional(), 103 | accessories: z.record(AccessoryServiceSchema).optional(), 104 | loging: LoggingOptionsSchema.optional(), 105 | }); 106 | 107 | export type ValidatedShangoConfig = z.infer; 108 | 109 | export class KamalConfigurationError extends Error { 110 | constructor(message: string) { 111 | super(message); 112 | this.name = 'KamalConfigurationError'; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /packages/cli/src/lib/progress-reporter/index.ts: -------------------------------------------------------------------------------- 1 | import ora, { type Ora } from 'ora'; 2 | import chalk from 'chalk'; 3 | 4 | export interface TaskProgress { 5 | step: number; 6 | total: number; 7 | description: string; 8 | status: 'running' | 'success' | 'failed' | 'skipped'; 9 | output?: string; 10 | error?: string; 11 | } 12 | 13 | export class ProgressReporter { 14 | private spinner: Ora | null = null; 15 | private startTime: number = 0; 16 | 17 | startTask(progress: TaskProgress): void { 18 | this.startTime = Date.now(); 19 | const text = this.formatProgressText(progress); 20 | 21 | this.spinner = ora({ 22 | text, 23 | color: 'yellow', 24 | spinner: 'dots', 25 | }).start(); 26 | } 27 | 28 | updateOutput(output: string): void { 29 | if (this.spinner) { 30 | // Only show the last line of output if it's a multi-line string 31 | const lines = output.trim().split('\n'); 32 | const lastLine = lines[lines.length - 1]; 33 | this.spinner.text = `${this.spinner.text} | ${lastLine}`; 34 | } 35 | } 36 | 37 | finishTask(status: 'success' | 'failed' | 'skipped', error?: any): void { 38 | if (!this.spinner) return; 39 | 40 | const duration = ((Date.now() - this.startTime) / 1000).toFixed(1); 41 | const durationText = chalk.gray(`(${duration}s)`); 42 | 43 | switch (status) { 44 | case 'success': 45 | this.spinner.succeed( 46 | chalk.green(`${this.spinner.text} ${durationText}`), 47 | ); 48 | break; 49 | case 'failed': 50 | this.spinner.fail(chalk.red(`${this.spinner.text} ${durationText}`)); 51 | if (error) { 52 | console.error(chalk.red(`Error: ${error}`)); 53 | } 54 | break; 55 | case 'skipped': 56 | this.spinner.info(chalk.blue(`${this.spinner.text} ${durationText}`)); 57 | break; 58 | } 59 | 60 | this.spinner = null; 61 | } 62 | 63 | private formatProgressText(progress: TaskProgress): string { 64 | return `[${progress.step}/${progress.total}] ${progress.description}`; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/cli/src/lib/provisionner/index.ts: -------------------------------------------------------------------------------- 1 | import { type ShangoConfig } from '../../types/index.ts'; 2 | import { ServerSetup } from './server-setup.ts'; 3 | import { HookManager } from '../hooks/hook-manager.ts'; 4 | import { SSHManager, type SSHConfig } from '../ssh/ssh-manager.ts'; 5 | import { HookType } from '../hooks/types.ts'; 6 | 7 | export class ServerProvisioner { 8 | private config: ShangoConfig; 9 | private hookManager: HookManager; 10 | private sshConfig: Omit; 11 | 12 | constructor( 13 | config: ShangoConfig, 14 | sshConfig: Omit, 15 | ) { 16 | this.config = config; 17 | this.hookManager = new HookManager(); 18 | this.sshConfig = sshConfig; 19 | } 20 | 21 | async provision(): Promise { 22 | try { 23 | // TODO: Implement prehook execute 24 | 25 | /// TODO: Merge all the hosts and set them up once 26 | for (const { name, config, hosts } of this.config.environment) { 27 | console.log(`\n🚀 Provisioning ${name} environment...`); 28 | const _hosts = Array.isArray(hosts) ? hosts : [hosts]; 29 | for (const host of _hosts) { 30 | console.log(`\n📦 Setting up server: ${host}`); 31 | 32 | const ssh = new SSHManager({ 33 | host, 34 | ...this.sshConfig, 35 | }); 36 | 37 | try { 38 | await ssh.connect(); 39 | const setup = new ServerSetup(ssh); 40 | 41 | // Run setup steps in sequence 42 | await setup.validateRequirements(); 43 | await setup.updateSystem(); 44 | await setup.setupFirewall(); 45 | await setup.setupDocker(); 46 | await setup.setupUsers(this.config.users); 47 | await setup.setupMonitoring(); 48 | 49 | console.log(`✅ Server ${host} provisioned successfully`); 50 | } catch (error) { 51 | console.error(`❌ Failed to provision ${host}:`, error); 52 | throw error; 53 | } finally { 54 | ssh.disconnect(); 55 | } 56 | } 57 | } 58 | 59 | // Execute post-provision hooks 60 | await this.hookManager.executeHooks(HookType.POST_PROVISION); 61 | 62 | console.log('\n✨ Server provisioning completed successfully!'); 63 | } catch (error) { 64 | console.error('Error during provisioning:', error); 65 | throw error; 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/cli/src/lib/provisionner/requirements-validator.ts: -------------------------------------------------------------------------------- 1 | import { SSHManager } from '../ssh/ssh-manager.ts'; 2 | 3 | export class ServerRequirementsValidator { 4 | private ssh: SSHManager; 5 | 6 | constructor(ssh: SSHManager) { 7 | this.ssh = ssh; 8 | } 9 | 10 | async validateSystem(): Promise { 11 | const checks = [ 12 | this.checkSudoAccess(), 13 | this.checkDiskSpace(), 14 | this.checkMemory(), 15 | this.checkCPU(), 16 | this.checkOS(), 17 | ]; 18 | 19 | await Promise.all(checks); 20 | } 21 | 22 | private async checkSudoAccess(): Promise { 23 | const { stderr } = await this.ssh.executeCommand( 24 | 'sudo -n true 2>/dev/null', 25 | ); 26 | if (stderr === '') { 27 | const { stdout } = await this.ssh.executeCommand('whoami'); 28 | throw new Error( 29 | `Insufficient privileges: ${stdout.replace('\n', '')} doesn't have sudo privileges`, 30 | ); 31 | } 32 | } 33 | private async checkDiskSpace(): Promise { 34 | const { stdout } = await this.ssh.executeCommand( 35 | "df -h / | tail -1 | awk '{print $5}'", 36 | ); 37 | const usedSpace = parseInt(stdout.trim().replace('%', '')); 38 | 39 | if (usedSpace > 85) { 40 | throw new Error(`Insufficient disk space: ${usedSpace}% used`); 41 | } 42 | } 43 | 44 | private async checkMemory(): Promise { 45 | const { stdout } = await this.ssh.executeCommand( 46 | "free -g | awk 'NR==2{print $2}'", 47 | ); 48 | const totalMemGB = parseInt(stdout.trim()); 49 | 50 | if (totalMemGB < 1) { 51 | throw new Error(`Insufficient memory: ${totalMemGB}GB RAM available`); 52 | } 53 | } 54 | 55 | private async checkCPU(): Promise { 56 | const { stdout } = await this.ssh.executeCommand('nproc'); 57 | const cpuCount = parseInt(stdout.trim()); 58 | 59 | if (cpuCount < 1) { 60 | throw new Error(`Insufficient CPU cores: ${cpuCount} cores available`); 61 | } 62 | } 63 | 64 | private async checkOS(): Promise { 65 | const { stdout } = await this.ssh.executeCommand('cat /etc/os-release'); 66 | const isSupported = stdout.includes('Ubuntu') || stdout.includes('Debian'); 67 | 68 | if (!isSupported) { 69 | throw new Error( 70 | 'Unsupported operating system. Only Ubuntu and Debian are supported.', 71 | ); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /packages/cli/src/lib/provisionner/server-setup.ts: -------------------------------------------------------------------------------- 1 | import { SSHManager } from '../ssh/ssh-manager.ts'; 2 | import { ServerRequirementsValidator } from './requirements-validator.ts'; 3 | import { type User } from '../../types/index.ts'; 4 | import { ProgressReporter } from '../progress-reporter/index.ts'; 5 | 6 | interface TaskResult { 7 | changed: boolean; 8 | failed: boolean; 9 | msg: string; 10 | retries?: number; 11 | } 12 | 13 | interface PackageState { 14 | name: string; 15 | state: 'present' | 'absent' | 'latest'; 16 | version?: string; 17 | } 18 | 19 | export class ServerSetup { 20 | private ssh: SSHManager; 21 | private validator: ServerRequirementsValidator; 22 | private maxRetries = 3; 23 | private retryDelay = 5000; // 5 seconds 24 | private reporter: ProgressReporter; 25 | 26 | constructor(ssh: SSHManager) { 27 | this.ssh = ssh; 28 | this.validator = new ServerRequirementsValidator(ssh); 29 | this.reporter = new ProgressReporter(); 30 | } 31 | 32 | private async executeWithRetry( 33 | command: string, 34 | description: string, 35 | step: number, 36 | total: number, 37 | check?: () => Promise, 38 | ): Promise { 39 | let retries = 0; 40 | this.reporter.startTask({ 41 | step, 42 | total, 43 | description, 44 | status: 'running', 45 | }); 46 | 47 | while (retries < this.maxRetries) { 48 | try { 49 | // Check if task needs to be executed 50 | if (check && !(await check())) { 51 | this.reporter.finishTask('skipped'); 52 | return { 53 | changed: false, 54 | failed: false, 55 | msg: `Skipping: ${description} - already in desired state`, 56 | }; 57 | } 58 | 59 | const { stdout } = await this.ssh.executeCommand(command); 60 | this.reporter.updateOutput(stdout); 61 | this.reporter.finishTask('success'); 62 | return { 63 | changed: true, 64 | failed: false, 65 | msg: `Success: ${description}`, 66 | retries, 67 | }; 68 | } catch (error) { 69 | retries++; 70 | if (retries === this.maxRetries) { 71 | this.reporter.finishTask('failed', error); 72 | return { 73 | changed: false, 74 | failed: true, 75 | msg: `Failed: ${description} - ${error}`, 76 | retries, 77 | }; 78 | } 79 | await new Promise((resolve) => setTimeout(resolve, this.retryDelay)); 80 | } 81 | } 82 | return { 83 | changed: false, 84 | failed: true, 85 | msg: `Failed: ${description} - max retries reached`, 86 | retries, 87 | }; 88 | } 89 | 90 | private async checkPackage(packageName: string): Promise { 91 | try { 92 | const { stdout } = await this.ssh.executeCommand( 93 | `dpkg -l ${packageName} | grep -E '^ii'`, 94 | ); 95 | return stdout.trim() === ''; 96 | } catch { 97 | return true; // Package needs to be installed 98 | } 99 | } 100 | 101 | private async checkDockerInstallation(): Promise { 102 | try { 103 | await this.ssh.executeCommand('docker --version'); 104 | return false; // Docker is already installed 105 | } catch { 106 | return true; // Docker needs to be installed 107 | } 108 | } 109 | 110 | private async checkUserExists(username: string): Promise { 111 | try { 112 | await this.ssh.executeCommand(`id -u ${username}`); 113 | return false; // User exists 114 | } catch { 115 | return true; // User needs to be created 116 | } 117 | } 118 | 119 | async validateRequirements(): Promise { 120 | try { 121 | await this.validator.validateSystem(); 122 | return { 123 | changed: false, 124 | failed: false, 125 | msg: 'System requirements validated successfully', 126 | }; 127 | } catch (error) { 128 | return { 129 | changed: false, 130 | failed: true, 131 | msg: `System requirements validation failed: ${error}`, 132 | }; 133 | } 134 | } 135 | 136 | private async clearPackageManagerLocks(): Promise { 137 | const commands = [ 138 | 'rm -f /var/lib/apt/lists/lock', 139 | 'rm -f /var/cache/apt/archives/lock', 140 | 'rm -f /var/lib/dpkg/lock*', 141 | 'dpkg --configure -a', 142 | ]; 143 | 144 | const description = 'Clear package manager locks'; 145 | 146 | try { 147 | for (const command of commands) { 148 | await this.ssh.executeCommand(command); 149 | } 150 | return { 151 | changed: true, 152 | failed: false, 153 | msg: 'Successfully cleared package manager locks', 154 | }; 155 | } catch (error) { 156 | return { 157 | changed: false, 158 | failed: true, 159 | msg: `Failed to clear package manager locks: ${error}`, 160 | }; 161 | } 162 | } 163 | 164 | async updateSystem(): Promise { 165 | console.log('📥 Updating system packages...'); 166 | 167 | // Clear locks first 168 | const clearResult = await this.clearPackageManagerLocks(); 169 | if (clearResult.failed) { 170 | return [clearResult]; 171 | } 172 | 173 | const tasks = [ 174 | { 175 | command: 'apt-get update', 176 | description: 'Update package lists', 177 | }, 178 | { 179 | command: 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y', 180 | description: 'Upgrade system packages', 181 | }, 182 | ]; 183 | 184 | let step = 1; 185 | const results: TaskResult[] = [clearResult]; 186 | for (const task of tasks) { 187 | const result = await this.executeWithRetry( 188 | task.command, 189 | task.description, 190 | step, 191 | tasks.length, 192 | ); 193 | results.push(result); 194 | step++; 195 | if (result.failed) break; 196 | } 197 | return results; 198 | } 199 | 200 | async installPackages(packages: PackageState[]): Promise { 201 | console.log('📦 Installing packages...'); 202 | const results: TaskResult[] = []; 203 | let step = 1; 204 | for (const pkg of packages) { 205 | const needsInstall = await this.checkPackage(pkg.name); 206 | if (!needsInstall && pkg.state !== 'latest') { 207 | results.push({ 208 | changed: false, 209 | failed: false, 210 | msg: `Package ${pkg.name} is already installed`, 211 | }); 212 | continue; 213 | } 214 | 215 | const command = 216 | pkg.state === 'latest' 217 | ? `apt-get install -y --only-upgrade ${pkg.name}` 218 | : `apt-get install -y ${pkg.name}`; 219 | 220 | const result = await this.executeWithRetry( 221 | command, 222 | `Install package ${pkg.name}`, 223 | step, 224 | packages.length, 225 | ); 226 | results.push(result); 227 | step++; 228 | if (result.failed) break; 229 | } 230 | return results; 231 | } 232 | 233 | async setupFirewall(): Promise { 234 | console.log('🛡️ Setting up firewall...'); 235 | const rules = [ 236 | { 237 | command: 'ufw default deny incoming', 238 | description: 'Set default incoming policy', 239 | }, 240 | { 241 | command: 'ufw default allow outgoing', 242 | description: 'Set default outgoing policy', 243 | }, 244 | { command: 'ufw allow ssh', description: 'Allow SSH' }, 245 | { command: 'ufw allow http', description: 'Allow HTTP' }, 246 | { command: 'ufw allow https', description: 'Allow HTTPS' }, 247 | { command: 'echo "y" | ufw enable', description: 'Enable UFW' }, 248 | ]; 249 | 250 | const results: TaskResult[] = []; 251 | let step = 1; 252 | for (const rule of rules) { 253 | const result = await this.executeWithRetry( 254 | rule.command, 255 | rule.description, 256 | step, 257 | rules.length, 258 | ); 259 | results.push(result); 260 | step++; 261 | if (result.failed) break; 262 | } 263 | return results; 264 | } 265 | 266 | async setupDocker(): Promise { 267 | console.log('🐳 Installing Docker...'); 268 | const needsInstall = await this.checkDockerInstallation(); 269 | if (!needsInstall) { 270 | return [ 271 | { 272 | changed: false, 273 | failed: false, 274 | msg: 'Docker is already installed', 275 | }, 276 | ]; 277 | } 278 | 279 | const tasks = [ 280 | // Clean up any previous failed attempts 281 | { 282 | command: 'rm -f get-docker.sh', 283 | description: 'Clean up previous Docker installation attempts', 284 | }, 285 | // Install prerequisites 286 | { 287 | command: 288 | 'DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl gnupg', 289 | description: 'Install Docker prerequisites', 290 | }, 291 | { 292 | command: 'curl -fsSL https://get.docker.com -o get-docker.sh', 293 | description: 'Download Docker installation script', 294 | }, 295 | { 296 | command: 'sh get-docker.sh', 297 | description: 'Install Docker', 298 | }, 299 | { 300 | command: 'systemctl enable docker', 301 | description: 'Enable Docker service', 302 | }, 303 | { 304 | command: 'systemctl start docker', 305 | description: 'Start Docker service', 306 | }, 307 | // Clean up 308 | { 309 | command: 'rm -f get-docker.sh', 310 | description: 'Clean up Docker installation files', 311 | }, 312 | ]; 313 | 314 | const results: TaskResult[] = []; 315 | let step = 1; 316 | for (const task of tasks) { 317 | const result = await this.executeWithRetry( 318 | task.command, 319 | task.description, 320 | step, 321 | tasks.length, 322 | ); 323 | results.push(result); 324 | step++; 325 | if (result.failed) break; 326 | } 327 | return results; 328 | } 329 | 330 | async setupUsers(users: User[]): Promise { 331 | console.log('👥 Setting up users...'); 332 | const results: TaskResult[] = []; 333 | 334 | for (const user of users) { 335 | const needsSetup = await this.checkUserExists(user.username); 336 | if (!needsSetup) { 337 | results.push({ 338 | changed: false, 339 | failed: false, 340 | msg: `User ${user.username} already exists`, 341 | }); 342 | continue; 343 | } 344 | 345 | const tasks = [ 346 | { 347 | command: `useradd -m -s /bin/bash ${user.username}`, 348 | description: `Create user ${user.username}`, 349 | }, 350 | { 351 | command: `usermod -p '${user.password}' ${user.username}`, 352 | description: `Set password for ${user.username}`, 353 | }, 354 | ...user.groups.map((group) => ({ 355 | command: `usermod -aG ${group} ${user.username}`, 356 | description: `Add user ${user.username} to group ${group}`, 357 | })), 358 | { 359 | command: `mkdir -p /home/${user.username}/.ssh`, 360 | description: `Create SSH directory for ${user.username}`, 361 | }, 362 | { 363 | command: `chmod 700 /home/${user.username}/.ssh`, 364 | description: `Set SSH directory permissions for ${user.username}`, 365 | }, 366 | { 367 | command: `echo "${user.authorized_keys.map((key) => key.public_key).join('\n')}" > /home/${user.username}/.ssh/authorized_keys`, 368 | description: `Add SSH keys for ${user.username}`, 369 | }, 370 | { 371 | command: `chmod 600 /home/${user.username}/.ssh/authorized_keys`, 372 | description: `Set authorized_keys permissions for ${user.username}`, 373 | }, 374 | { 375 | command: `chown -R ${user.username}:${user.username} /home/${user.username}/.ssh`, 376 | description: `Set SSH directory ownership for ${user.username}`, 377 | }, 378 | { 379 | command: `chage -d 0 ${user.username}`, 380 | description: `Force password change for ${user.username}`, 381 | }, 382 | { 383 | command: `sed -i 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/' /etc/ssh/sshd_config`, 384 | description: 'Enable SSH public key authentication', 385 | }, 386 | { 387 | command: `sed -i 's/^#*PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config`, 388 | description: 'Disable SSH password authentication', 389 | }, 390 | { 391 | command: 'systemctl restart sshd', 392 | description: 'Restart SSH service to apply changes', 393 | }, 394 | ]; 395 | 396 | let step = 1; 397 | for (const task of tasks) { 398 | const result = await this.executeWithRetry( 399 | task.command, 400 | task.description, 401 | step, 402 | tasks.length, 403 | ); 404 | results.push(result); 405 | step++; 406 | if (result.failed) break; 407 | } 408 | } 409 | return results; 410 | } 411 | 412 | async setupMonitoring(): Promise { 413 | console.log('📊 Setting up monitoring...'); 414 | const packages: PackageState[] = [ 415 | { name: 'prometheus-node-exporter', state: 'present' }, 416 | ]; 417 | 418 | const results = await this.installPackages(packages); 419 | if (results.some((r) => r.failed)) return results; 420 | 421 | const services = [ 422 | { 423 | command: 'systemctl enable prometheus-node-exporter', 424 | description: 'Enable Prometheus Node Exporter', 425 | }, 426 | { 427 | command: 'systemctl start prometheus-node-exporter', 428 | description: 'Start Prometheus Node Exporter', 429 | }, 430 | ]; 431 | 432 | let step = 1; 433 | for (const service of services) { 434 | const result = await this.executeWithRetry( 435 | service.command, 436 | service.description, 437 | step, 438 | services.length, 439 | ); 440 | results.push(result); 441 | step++; 442 | if (result.failed) break; 443 | } 444 | return results; 445 | } 446 | } 447 | -------------------------------------------------------------------------------- /packages/cli/src/lib/secrets/providers/base-provider.ts: -------------------------------------------------------------------------------- 1 | import { type Secret, SecretError } from '../types.ts'; 2 | 3 | export abstract class BaseSecretProvider { 4 | constructor(protected config: Record = {}) { } 5 | 6 | abstract getName(): string; 7 | abstract getSecret(name: string): Promise; 8 | abstract setSecret(name: string, value: string): Promise; 9 | abstract listSecrets(): Promise; 10 | 11 | protected handleError(error: any, operation: string): never { 12 | throw new SecretError( 13 | `Failed to ${operation} secret from ${this.getName()}: ${error.message}` 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/cli/src/lib/secrets/providers/github-provider.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from '@octokit/rest'; 2 | import { createAppAuth } from '@octokit/auth-app'; 3 | import sodium from 'libsodium-wrappers'; 4 | import { BaseSecretProvider } from './base-provider.ts'; 5 | import { type GithubSecretConfig, SecretError } from '../types.ts'; 6 | 7 | export class GithubSecretProvider extends BaseSecretProvider { 8 | private octokit: Octokit; 9 | private owner: string; 10 | private repo: string; 11 | 12 | constructor(config: GithubSecretConfig) { 13 | super(config); 14 | this.owner = config.owner; 15 | this.repo = config.repo; 16 | this.octokit = new Octokit({ 17 | auth: config.token 18 | }); 19 | } 20 | 21 | getName(): string { 22 | return 'GitHub'; 23 | } 24 | 25 | async getSecret(name: string): Promise { 26 | try { 27 | // Note: GitHub doesn't provide API to get secret values 28 | throw new SecretError('Cannot retrieve secret values from GitHub'); 29 | } catch (error) { 30 | this.handleError(error, 'get'); 31 | } 32 | } 33 | 34 | async setSecret(name: string, value: string): Promise { 35 | try { 36 | // Get the public key for the repository 37 | const { data: publicKey } = await this.octokit.actions.getRepoPublicKey({ 38 | owner: this.owner, 39 | repo: this.repo 40 | }); 41 | 42 | // Convert the secret value to an encrypted value using the public key 43 | await sodium.ready; 44 | const binKey = sodium.from_base64(publicKey.key, sodium.base64_variants.ORIGINAL); 45 | const binValue = sodium.from_string(value); 46 | const encBytes = sodium.crypto_box_seal(binValue, binKey); 47 | const encrypted_value = sodium.to_base64(encBytes, sodium.base64_variants.ORIGINAL); 48 | 49 | // Create or update the secret 50 | await this.octokit.actions.createOrUpdateRepoSecret({ 51 | owner: this.owner, 52 | repo: this.repo, 53 | secret_name: name, 54 | encrypted_value, 55 | key_id: publicKey.key_id 56 | }); 57 | } catch (error) { 58 | this.handleError(error, 'set'); 59 | } 60 | } 61 | 62 | async listSecrets(): Promise { 63 | try { 64 | const { data } = await this.octokit.actions.listRepoSecrets({ 65 | owner: this.owner, 66 | repo: this.repo 67 | }); 68 | 69 | return data.secrets.map(secret => secret.name); 70 | } catch (error) { 71 | this.handleError(error, 'list'); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /packages/cli/src/lib/secrets/secret-loader.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { join } from 'path'; 3 | import { parse } from 'dotenv'; 4 | import { type Secret, SecretProviderType } from './types.ts'; 5 | 6 | export class SecretLoader { 7 | static loadFromKamalSecrets(): Secret[] { 8 | const secretsPath = join(process.cwd(), '.kamal', 'secrets'); 9 | const content = readFileSync(secretsPath, 'utf8'); 10 | const parsed = parse(content); 11 | 12 | return Object.entries(parsed).map(([name, value]) => ({ 13 | name, 14 | value, 15 | provider: SecretProviderType.ENV 16 | })); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/cli/src/lib/secrets/secret-manager.ts: -------------------------------------------------------------------------------- 1 | import { BaseSecretProvider } from './providers/base-provider.ts'; 2 | import { GithubSecretProvider } from './providers/github-provider.ts'; 3 | import { type Secret, SecretProviderType, SecretError } from './types.ts'; 4 | 5 | export class SecretManager { 6 | private static instance: SecretManager; 7 | private providers: Map; 8 | 9 | private constructor() { 10 | this.providers = new Map(); 11 | } 12 | 13 | static getInstance(): SecretManager { 14 | if (!SecretManager.instance) { 15 | SecretManager.instance = new SecretManager(); 16 | } 17 | return SecretManager.instance; 18 | } 19 | 20 | registerProvider(type: SecretProviderType, provider: BaseSecretProvider): void { 21 | this.providers.set(type, provider); 22 | } 23 | 24 | async setSecret(secret: Secret): Promise { 25 | const provider = this.providers.get(secret.provider); 26 | if (!provider) { 27 | throw new SecretError(`Provider ${secret.provider} not registered`); 28 | } 29 | 30 | await provider.setSecret(secret.name, secret.value); 31 | } 32 | 33 | async getSecret(name: string, provider: SecretProviderType): Promise { 34 | const secretProvider = this.providers.get(provider); 35 | if (!secretProvider) { 36 | throw new SecretError(`Provider ${provider} not registered`); 37 | } 38 | 39 | return await secretProvider.getSecret(name); 40 | } 41 | 42 | async syncToGithub(secrets: Secret[]): Promise { 43 | const githubProvider = this.providers.get(SecretProviderType.GITHUB); 44 | if (!githubProvider) { 45 | throw new SecretError('GitHub provider not registered'); 46 | } 47 | 48 | for (const secret of secrets) { 49 | await githubProvider.setSecret(secret.name, secret.value); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/cli/src/lib/secrets/types.ts: -------------------------------------------------------------------------------- 1 | export enum SecretProviderType { 2 | ENV = 'env', 3 | FILE = 'file', 4 | ONEPASSWORD = '1password', 5 | LASTPASS = 'lastpass', 6 | GITHUB = 'github' 7 | } 8 | 9 | export interface Secret { 10 | name: string; 11 | value: string; 12 | provider: SecretProviderType; 13 | } 14 | 15 | export interface SecretProviderConfig { 16 | type: SecretProviderType; 17 | config?: Record; 18 | } 19 | 20 | export interface GithubSecretConfig { 21 | owner: string; 22 | repo: string; 23 | token: string; 24 | } 25 | 26 | export class SecretError extends Error { 27 | constructor(message: string) { 28 | super(message); 29 | this.name = 'SecretError'; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/cli/src/lib/ssh/__test__/ssh-manager.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { SSHManager } from '../ssh-manager'; 3 | import sinon from 'sinon'; 4 | 5 | const PORT = 22; 6 | const USERNAME = process.env.USER || 'root'; 7 | const HOST = '127.0.0.1'; 8 | 9 | describe('ssh-manager', () => { 10 | it('should connect to a server', async () => { 11 | const ssh = new SSHManager({ host: HOST, username: USERNAME, port: PORT }); 12 | const connectStub = sinon.stub(ssh, 'connect').resolves(); 13 | await ssh.connect(); 14 | expect(connectStub.calledOnce).toBe(true); 15 | connectStub.restore(); 16 | }); 17 | 18 | it('should fail to connect to a server', async () => { 19 | const ssh = new SSHManager({ host: HOST, username: USERNAME, port: PORT }); 20 | const connectStub = sinon 21 | .stub(ssh, 'connect') 22 | .rejects(new Error('Connection failed')); 23 | try { 24 | await ssh.connect(); 25 | } catch (error) { 26 | expect(error.message).toBe('Connection failed'); 27 | } 28 | expect(connectStub.calledOnce).toBe(true); 29 | connectStub.restore(); 30 | }); 31 | 32 | it('should execute a command on the server', async () => { 33 | const ssh = new SSHManager({ host: HOST, username: USERNAME, port: PORT }); 34 | const connectStub = sinon.stub(ssh, 'connect').resolves(); 35 | const execStub = sinon 36 | .stub(ssh, 'executeCommand') 37 | .resolves({ stdout: 'command output', stderr: '' }); 38 | await ssh.connect(); 39 | const output = await ssh.executeCommand('ls'); 40 | expect(output).toEqual({ stdout: 'command output', stderr: '' }); 41 | expect(execStub.calledOnceWith('ls')).toBe(true); 42 | connectStub.restore(); 43 | execStub.restore(); 44 | }); 45 | 46 | it('should disconnect from the server', async () => { 47 | const ssh = new SSHManager({ host: HOST, username: USERNAME, port: PORT }); 48 | const connectStub = sinon.stub(ssh, 'connect').resolves(); 49 | const disconnectStub = sinon.stub(ssh, 'disconnect').resolves(); 50 | await ssh.connect(); 51 | ssh.disconnect(); 52 | expect(disconnectStub.calledOnce).toBe(true); 53 | connectStub.restore(); 54 | disconnectStub.restore(); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /packages/cli/src/lib/ssh/ssh-manager.ts: -------------------------------------------------------------------------------- 1 | import { Client, type ClientChannel } from 'ssh2'; 2 | import { readFileSync } from 'node:fs'; 3 | import { homedir } from 'node:os'; 4 | import { join } from 'node:path'; 5 | 6 | export interface SSHConfig { 7 | host: string; 8 | username: string; 9 | privateKey?: string; 10 | password?: string; 11 | port?: number; 12 | } 13 | 14 | export class SSHManager { 15 | private config: SSHConfig; 16 | private client: Client; 17 | 18 | constructor(config: SSHConfig) { 19 | this.config = config; 20 | this.client = new Client(); 21 | } 22 | 23 | async connect(): Promise { 24 | return new Promise((resolve, reject) => { 25 | try { 26 | const privateKey = this.getPrivateKey(); 27 | 28 | this.client 29 | .on('ready', () => { 30 | resolve(); 31 | }) 32 | .on('error', (err) => { 33 | reject(new Error(`SSH connection error: ${err.message}`)); 34 | }) 35 | .connect({ 36 | host: this.config.host, 37 | port: this.config.port, 38 | username: this.config.username, 39 | privateKey, 40 | password: this.config.password, 41 | }); 42 | } catch (error) { 43 | reject(new Error(`Failed to establish SSH connection: ${error}`)); 44 | } 45 | }); 46 | } 47 | 48 | async executeCommand( 49 | command: string, 50 | options: { timeout?: number } = {}, 51 | ): Promise<{ stdout: string; stderr: string }> { 52 | return new Promise((resolve, reject) => { 53 | const timeout = options.timeout || 30000; // Default 30s timeout 54 | let timer: NodeJS.Timeout; 55 | 56 | this.client.exec( 57 | command, 58 | (err: Error | undefined, stream: ClientChannel) => { 59 | if (err) { 60 | reject(new Error(`Failed to execute command: ${err.message}`)); 61 | return; 62 | } 63 | 64 | let stdout = ''; 65 | let stderr = ''; 66 | 67 | stream.on('data', (data: Buffer) => { 68 | stdout += data.toString(); 69 | }); 70 | 71 | stream.stderr.on('data', (data: Buffer) => { 72 | stderr += data.toString(); 73 | }); 74 | 75 | stream.on('close', (code: number) => { 76 | clearTimeout(timer); 77 | if (code === 0) { 78 | resolve({ stdout, stderr }); 79 | } else { 80 | reject(new Error(`Command failed with code ${code}: ${stderr}`)); 81 | } 82 | }); 83 | 84 | timer = setTimeout(() => { 85 | stream.destroy(); 86 | reject(new Error(`Command timed out after ${timeout}ms`)); 87 | }, timeout); 88 | }, 89 | ); 90 | }); 91 | } 92 | 93 | disconnect(): void { 94 | this.client.end(); 95 | } 96 | 97 | private getPrivateKey() { 98 | let privateKey: Buffer | undefined; 99 | if (this.config.privateKey) { 100 | privateKey = readFileSync(this.config.privateKey); 101 | } else { 102 | const defaultKeyPaths = [ 103 | join(homedir(), '.ssh', 'id_rsa'), 104 | join(homedir(), '.ssh', 'id_ed25519'), 105 | ]; 106 | 107 | for (const keyPath of defaultKeyPaths) { 108 | try { 109 | privateKey = readFileSync(keyPath); 110 | break; 111 | } catch (error) { 112 | continue; 113 | } 114 | } 115 | } 116 | return privateKey; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /packages/cli/src/lib/template/__tests__/dockerfile.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { DockerfileTemplate } from '../dockerfile.ts'; 3 | import { Framework } from '../../../types/index.ts'; 4 | import { existsSync, mkdirSync, rmdirSync, readFileSync, writeFileSync } from 'fs'; 5 | import { join } from 'path'; 6 | import { tmpdir } from 'os'; 7 | 8 | describe('DockerfileTemplate', () => { 9 | let tempDir: string; 10 | let templateDir: string; 11 | 12 | beforeEach(() => { 13 | tempDir = join(tmpdir(), `test-${Date.now()}`); 14 | templateDir = join(tempDir, 'templates'); 15 | mkdirSync(join(templateDir, 'dockerfiles'), { recursive: true }); 16 | 17 | // Create sample Dockerfile template 18 | writeFileSync( 19 | join(templateDir, 'dockerfiles', 'nex.ts.dockerfile'), 20 | 'FROM node:${NODE_VERSION}\nWORKDIR /app\nCMD ${START_COMMAND}' 21 | ); 22 | }); 23 | 24 | afterEach(() => { 25 | rmdirSync(tempDir, { recursive: true }); 26 | }); 27 | 28 | it('should generate valid Dockerfile for NextJS', async () => { 29 | const template = new DockerfileTemplate({ 30 | framework: Framework.NEXTJS, 31 | dockerfile: true, 32 | githubAction: false, 33 | templateDir, 34 | appName: 'test-app', 35 | githubUsername: 'test-user' 36 | }); 37 | 38 | await template.generate(); 39 | 40 | const dockerfile = readFileSync(join(process.cwd(), 'Dockerfile'), 'utf8'); 41 | expect(dockerfile).toContain('FROM node:18'); 42 | expect(dockerfile).toContain('WORKDIR /app'); 43 | expect(dockerfile).toContain('CMD npm start'); 44 | }); 45 | 46 | // Add more tests for other frameworks and error cases 47 | }); 48 | -------------------------------------------------------------------------------- /packages/cli/src/lib/template/__tests__/template-manager.test.ts: -------------------------------------------------------------------------------- 1 | 2 | import { describe, it, expect, beforeEach, afterEach, vi, Mock } from 'vitest'; 3 | import { TemplateManager } from '../index.ts'; 4 | import { Framework } from '../../../types/index.ts'; 5 | import * as fs from 'fs'; 6 | import { join } from 'path'; 7 | 8 | // Mock fs module 9 | vi.mock('fs', () => ({ 10 | existsSync: vi.fn(), 11 | mkdirSync: vi.fn(), 12 | writeFileSync: vi.fn(), 13 | readFileSync: vi.fn(), 14 | rmdirSync: vi.fn() 15 | })); 16 | 17 | describe('TemplateManager', () => { 18 | const mockTemplateDir = '/mock/template/dir'; 19 | 20 | beforeEach(() => { 21 | // Reset all mocks 22 | vi.clearAllMocks(); 23 | 24 | // Setup mock filesystem responses 25 | (fs.existsSync as Mock).mockImplementation(() => true); 26 | (fs.readFileSync as Mock).mockImplementation((path) => { 27 | if (path.includes('nex.ts.dockerfile')) { 28 | return 'FROM node:${NODE_VERSION}\nWORKDIR /app\nCMD ${START_COMMAND}'; 29 | } 30 | if (path.includes('deploy.yml')) { 31 | return 'name: Deploy\non: push'; 32 | } 33 | return ''; 34 | }); 35 | }); 36 | 37 | afterEach(() => { 38 | vi.resetAllMocks(); 39 | }); 40 | 41 | it('should generate Dockerfile for NextJS', async () => { 42 | const manager = new TemplateManager({ 43 | framework: Framework.NEXTJS, 44 | dockerfile: true, 45 | githubAction: false, 46 | templateDir: mockTemplateDir, 47 | appName: 'test-app', 48 | githubUsername: 'test-user' 49 | }); 50 | 51 | await manager.generate(); 52 | 53 | expect(fs.writeFileSync).toHaveBeenCalled(); 54 | const dockerfileCall = (fs.writeFileSync as Mock).mock.calls.find( 55 | call => call[0].endsWith('Dockerfile') 56 | ); 57 | expect(dockerfileCall).toBeTruthy(); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /packages/cli/src/lib/template/base.ts: -------------------------------------------------------------------------------- 1 | import { type TemplateOptions } from '../../types/index.ts'; 2 | 3 | export abstract class BaseTemplate { 4 | protected options: TemplateOptions; 5 | 6 | constructor(options: TemplateOptions) { 7 | this.options = options; 8 | } 9 | 10 | abstract generate(): Promise; 11 | abstract validate(): boolean; 12 | } 13 | -------------------------------------------------------------------------------- /packages/cli/src/lib/template/dockerfile.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'fs'; 2 | import { join } from 'path'; 3 | import { BaseTemplate } from './base.ts'; 4 | import { TemplateError } from './error.ts'; 5 | import { Framework } from '../../types/index.ts'; 6 | 7 | export class DockerfileTemplate extends BaseTemplate { 8 | private getDockerfileTemplate(): string { 9 | const templatePath = join( 10 | process.env.HOME!, 11 | this.options.templateDir, 12 | this.options.framework, 13 | 'Dockerfile', 14 | ); 15 | 16 | try { 17 | return readFileSync(templatePath, 'utf8'); 18 | } catch (error) { 19 | throw new TemplateError(`Failed to read Dockerfile template: ${error}`); 20 | } 21 | } 22 | 23 | private processTemplate(template: string): string { 24 | // Add template variables based on framework 25 | const variables: Record = { 26 | NODE_VERSION: '18', 27 | ...this.getFrameworkSpecificVariables(), 28 | }; 29 | 30 | return template.replace(/\${(\w+)}/g, (_, key) => variables[key] || ''); 31 | } 32 | 33 | private getFrameworkSpecificVariables(): Record { 34 | switch (this.options.framework) { 35 | case Framework.NEXTJS: 36 | return { 37 | BUILD_COMMAND: 'npm run build', 38 | START_COMMAND: 'npm start', 39 | }; 40 | // Add other frameworks 41 | default: 42 | return {}; 43 | } 44 | } 45 | 46 | validate(): boolean { 47 | if (!this.options.framework) { 48 | throw new TemplateError('Framework is required for Dockerfile template'); 49 | } 50 | return true; 51 | } 52 | 53 | async generate(): Promise { 54 | this.validate(); 55 | 56 | const template = this.getDockerfileTemplate(); 57 | const processedTemplate = this.processTemplate(template); 58 | 59 | try { 60 | writeFileSync(join(process.cwd(), 'Dockerfile'), processedTemplate); 61 | } catch (error) { 62 | throw new TemplateError(`Failed to write Dockerfile: ${error}`); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/cli/src/lib/template/error.ts: -------------------------------------------------------------------------------- 1 | export class TemplateError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = 'TemplateError'; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/cli/src/lib/template/github-action.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync, mkdirSync } from 'fs'; 2 | import { join } from 'path'; 3 | import { BaseTemplate } from './base.ts'; 4 | import { TemplateError } from './error.ts'; 5 | 6 | export class GithubActionsTemplate extends BaseTemplate { 7 | private getWorkflowTemplate(): string { 8 | const templatePath = join( 9 | process.env.HOME!, 10 | this.options.templateDir, 11 | this.options.framework, 12 | 'github-actions', 13 | 'deploy.yml', 14 | ); 15 | 16 | try { 17 | return readFileSync(templatePath, 'utf8'); 18 | } catch (error) { 19 | throw new TemplateError( 20 | `Failed to read GitHub Actions template: ${error}`, 21 | ); 22 | } 23 | } 24 | 25 | private processTemplate(template: string): string { 26 | const variables: Record = { 27 | APP_NAME: this.options.appName, 28 | GITHUB_USERNAME: this.options.githubUsername, 29 | ...this.getEnvironmentVariables(), 30 | }; 31 | 32 | return template.replace(/\${(\w+)}/g, (_, key) => variables[key] || ''); 33 | } 34 | 35 | private getEnvironmentVariables(): Record { 36 | return { 37 | REGISTRY: 'ghcr.io', 38 | DOCKER_IMAGE: `ghcr.io/${this.options.githubUsername}/${this.options.appName}`, 39 | }; 40 | } 41 | 42 | validate(): boolean { 43 | if (!this.options.githubUsername || !this.options.appName) { 44 | throw new TemplateError( 45 | 'GitHub username and app name are required for GitHub Actions template', 46 | ); 47 | } 48 | return true; 49 | } 50 | 51 | async generate(): Promise { 52 | const template = this.getWorkflowTemplate(); 53 | const processedTemplate = this.processTemplate(template); 54 | 55 | try { 56 | const workflowDir = join(process.cwd(), '.github', 'workflows'); 57 | mkdirSync(workflowDir, { recursive: true }); 58 | writeFileSync(join(workflowDir, 'deploy.yml'), processedTemplate); 59 | } catch (error) { 60 | throw new TemplateError( 61 | `Failed to write GitHub Actions workflow: ${error}`, 62 | ); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/cli/src/lib/template/index.ts: -------------------------------------------------------------------------------- 1 | import { Framework, type TemplateOptions } from '../../types/index.ts'; 2 | import { DockerfileTemplate } from './dockerfile.ts'; 3 | import { GithubActionsTemplate } from './github-action.ts'; 4 | import { BaseTemplate } from './base.ts'; 5 | import { validateTemplateOptions } from './validator.ts'; 6 | import { execSync } from 'child_process'; 7 | import { existsSync } from 'fs'; 8 | 9 | export class TemplateManager { 10 | private templateFolder: string = '$HOME/.shango-templates'; 11 | private options: TemplateOptions; 12 | private templates: BaseTemplate[] = []; 13 | 14 | constructor(options: TemplateOptions) { 15 | this.options = validateTemplateOptions(options); 16 | this.setupTemplateFolder(); 17 | this.initializeTemplates(); 18 | } 19 | 20 | private initializeTemplates(): void { 21 | if (this.options.dockerfile) { 22 | this.templates.push(new DockerfileTemplate(this.options)); 23 | } 24 | 25 | if (this.options.githubAction) { 26 | this.templates.push(new GithubActionsTemplate(this.options)); 27 | } 28 | } 29 | 30 | private setupTemplateFolder() { 31 | if (existsSync(this.templateFolder)) return; 32 | try { 33 | execSync( 34 | `git clone https://github.com/devalade/shango-templates ${this.templateFolder}`, 35 | ); 36 | console.log('Repository cloned successfully:'); 37 | } catch (error) { 38 | console.error('Failed to setup the '); 39 | } 40 | } 41 | 42 | async generate(): Promise { 43 | for (const template of this.templates) { 44 | await template.generate(); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/cli/src/lib/template/validator.ts: -------------------------------------------------------------------------------- 1 | import { type TemplateOptions } from '../../types/index.ts'; 2 | import { TemplateError } from './error.ts'; 3 | 4 | export function validateTemplateOptions(options: TemplateOptions): TemplateOptions { 5 | if (!options.framework) { 6 | throw new TemplateError('Framework is required'); 7 | } 8 | 9 | if (!options.templateDir) { 10 | throw new TemplateError('Template directory is required'); 11 | } 12 | 13 | return options; 14 | } 15 | -------------------------------------------------------------------------------- /packages/cli/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export enum Framework { 2 | NEXTJS = 'nextjs', 3 | REMIX = 'react-router', 4 | NUXTJS = 'nuxt', 5 | SVELTE = 'svelte', 6 | ADONISJS = 'adonisjs', 7 | NESTJS = 'nestjs', 8 | } 9 | 10 | export enum DatabaseType { 11 | POSTGRESQL = 'postgresql', 12 | MYSQL = 'mysql', 13 | SQLITE = 'sqlite', 14 | NONE = 'none', 15 | } 16 | 17 | export enum CacheType { 18 | REDIS = 'redis', 19 | MEMCACHED = 'memcached', 20 | NONE = 'none', 21 | } 22 | 23 | export enum PackageManager { 24 | NPM = 'npm', 25 | YARN = 'yarn', 26 | PNPM = 'pnpm', 27 | } 28 | 29 | export interface DatabaseConfig { 30 | type: DatabaseType; 31 | version: string; 32 | host?: string; 33 | port?: number; 34 | username?: string; 35 | password?: string; 36 | database?: string; 37 | } 38 | 39 | export interface ServerConfig { 40 | environment: string; 41 | hosts: string[]; 42 | roles?: string[]; 43 | } 44 | 45 | export interface HealthcheckConfig { 46 | path: string; 47 | port: number; 48 | interval: number; 49 | timeout: number; 50 | retries: number; 51 | } 52 | 53 | export interface DeploymentConfig { 54 | strategy: 'rolling' | 'all-at-once'; 55 | max_parallel: number; 56 | delay: number; 57 | healthcheck: HealthcheckConfig; 58 | } 59 | 60 | export interface Hook { 61 | command: string; 62 | local?: boolean; 63 | remote?: boolean; 64 | condition?: string; 65 | timeout?: number; 66 | } 67 | 68 | export interface Hooks { 69 | pre_deploy?: Hook[]; 70 | post_deploy?: Hook[]; 71 | pre_provision?: Hook[]; 72 | post_provision?: Hook[]; 73 | } 74 | 75 | export interface User { 76 | username: string; 77 | groups: string[]; 78 | password: string; 79 | authorized_keys: { public_key: string }[]; 80 | } 81 | 82 | export interface ShangoConfig { 83 | app: { 84 | name: string; 85 | github_username: string; 86 | framework: Framework; 87 | port: number; 88 | db: string; 89 | kv: boolean; 90 | }; 91 | environment: { 92 | name: string; 93 | config: string; 94 | hosts: string | string[]; 95 | servers: string | string[]; 96 | }[]; 97 | users: User[]; 98 | hooks: Hooks; 99 | } 100 | 101 | export interface TemplateOptions { 102 | framework: Framework; 103 | dockerfile: boolean; 104 | githubAction: boolean; 105 | templateDir: string; 106 | appName: string; 107 | githubUsername: string; 108 | } 109 | -------------------------------------------------------------------------------- /packages/cli/src/util/execute-kamal.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | import { homedir, tmpdir } from 'os'; 3 | import path from 'path'; 4 | 5 | /** 6 | * Executes a Kamal command inside a Docker container. 7 | * 8 | * This function constructs a Docker run command to execute the specified Kamal command 9 | * within a Docker container. It sets up the necessary environment variables and volume 10 | * mounts to ensure the command runs with the correct user permissions and access to 11 | * the current working directory and SSH agent. 12 | * 13 | * @param {string | string[]} command - The Kamal command to execute. Can be a string or an array of strings. 14 | * @returns {string} - The output of the executed command. 15 | * @throws {Error} - Throws an error if the Kamal command execution fails. 16 | */ 17 | export function executeKamal(command: string | string[]) { 18 | const sshPath = path.join(homedir(), '.ssh'); 19 | const dockerConfigPath = path.join(tmpdir(), '.docker'); 20 | const sshAuthSock = process.env.SSH_AUTH_SOCK; 21 | 22 | try { 23 | execSync(`mkdir -p ${dockerConfigPath} && chmod 777 ${dockerConfigPath}`); 24 | } catch (error) { 25 | console.warn('Warning: Could not create .docker directory'); 26 | } 27 | 28 | // Get the group ID of the docker group 29 | const dockerGroupId = execSync('getent group docker | cut -d: -f3').toString().trim(); 30 | 31 | const baseCommand = [ 32 | 'docker run --rm', 33 | '-it', 34 | `-u "$(id -u):${dockerGroupId}"`, 35 | `-v "${process.cwd()}:/workdir"`, 36 | `-v "${sshPath}:/root/.ssh:ro"`, 37 | `-v "${dockerConfigPath}:/.docker"`, 38 | `-v "${sshAuthSock}:/ssh-agent"`, 39 | '-e SSH_AUTH_SOCK=/ssh-agent', 40 | `-e DOCKER_CONFIG=${dockerConfigPath}`, 41 | '-v /var/run/docker.sock:/var/run/docker.sock', 42 | 'ghcr.io/basecamp/kamal:latest' 43 | ].join(' '); 44 | 45 | if (Array.isArray(command)) { 46 | command = command.join(' '); 47 | } 48 | 49 | try { 50 | const output = execSync(`${baseCommand} ${command}`, { 51 | encoding: 'utf8', 52 | stdio: 'inherit', 53 | shell: '/bin/bash', 54 | env: { 55 | ...process.env, 56 | SSH_AUTH_SOCK: '/ssh-agent', 57 | DOCKER_CONFIG: dockerConfigPath 58 | } 59 | }); 60 | return output; 61 | } catch (error: any) { 62 | throw new Error(`Kamal command failed: ${error.message}`); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/cli/src/util/execute-ssh2-command.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'ssh2'; 2 | 3 | function connect(config: { host: string, username: string, agent?: string }): Promise<{ exec: (cmd: string) => Promise<{ stdout: string, stderr: string }>, close: () => void }> { 4 | return new Promise((resolve, reject) => { 5 | const client = new Client(); 6 | 7 | client.on('ready', () => { 8 | const exec = (cmd: string): Promise<{ stdout: string, stderr: string }> => { 9 | return new Promise((resolve, reject) => { 10 | client.exec(cmd, (err, channel) => { 11 | if (err) reject(err); 12 | 13 | let stdout = ''; 14 | let stderr = ''; 15 | 16 | channel.on('data', (data: any) => { 17 | stdout += data.toString(); 18 | }); 19 | 20 | channel.stderr.on('data', (data) => { 21 | stderr += data.toString(); 22 | }); 23 | 24 | channel.on('close', () => { 25 | resolve({ stdout, stderr }); 26 | }); 27 | }); 28 | }); 29 | }; 30 | 31 | resolve({ 32 | exec, 33 | close: () => client.end() 34 | }); 35 | }); 36 | 37 | client.on('error', reject); 38 | client.connect(config); 39 | }); 40 | } 41 | 42 | export async function executeSsh2Command(server: string, command: string): Promise { 43 | const ssh = await connect({ 44 | host: server, 45 | username: 'root', 46 | agent: process.env.SSH_AUTH_SOCK 47 | }); 48 | 49 | try { 50 | const { stdout, stderr } = await ssh.exec(command); 51 | if (stderr) { 52 | console.warn(`Warning: ${stderr}`); 53 | } 54 | return stdout; 55 | } finally { 56 | ssh.close(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/cli/src/util/generate-config.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import * as yaml from 'yaml'; 4 | import { type ShangoConfig } from '../types/index.ts'; 5 | 6 | 7 | 8 | 9 | 10 | /** 11 | * Reads and parses the shango.yml file. 12 | * @returns {Config} The parsed configuration object. 13 | * @throws Will throw an error if the file cannot be read or parsed. 14 | */ 15 | export function parseShangoConfig(configFile: string): ShangoConfig { 16 | const configFilePath = path.resolve(process.cwd(), configFile); 17 | 18 | if (!fs.existsSync(configFilePath)) { 19 | console.error(`Error: ${configFile} file does not exist.`); 20 | process.exit(1); 21 | } 22 | 23 | try { 24 | const fileContents = fs.readFileSync(configFilePath, 'utf8'); 25 | const config = yaml.parse(fileContents) as ShangoConfig; 26 | return config; 27 | } catch (error: any) { 28 | console.error(`Error reading or parsing ${configFile}: ${error.message}`); 29 | process.exit(1); 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /packages/cli/src/util/load-config-file.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurationLoader } from '../lib/config/loader.ts'; 2 | 3 | export async function loadConfigFile(configPath?: string) { 4 | const loader = ConfigurationLoader.getInstance(); 5 | return await loader.load(configPath); 6 | } 7 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | // node type stripping settings 5 | // https://nodejs.org/api/typescript.html#type-stripping 6 | "allowImportingTsExtensions": true, 7 | "rewriteRelativeImportExtensions": true, 8 | "verbatimModuleSyntax": true, 9 | "target": "esnext", 10 | "module": "NodeNext", 11 | "outDir": "./dist", 12 | "rootDir": "./src", 13 | "declaration": true, 14 | "sourceMap": true, 15 | "esModuleInterop": true, 16 | "skipLibCheck": true, 17 | "forceConsistentCasingInFileNames": true 18 | }, 19 | "include": ["src/**/*.ts"], 20 | "exclude": ["node_modules", "dist", "src/**/*.test.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/**' 3 | - 'docs' 4 | - '!**/test/**' 5 | - 'examples/**' 6 | 7 | 8 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "build": { 5 | "outputs": ["dist/**"] 6 | }, 7 | "link": { 8 | "dependsOn": ["^build"], 9 | "outputs": [] 10 | }, 11 | "local": { 12 | "dependsOn": ["^build"], 13 | "outputs": [] 14 | }, 15 | "check-types": { 16 | "dependsOn": ["^check-types"] 17 | }, 18 | "dev": { 19 | "persistent": true, 20 | "cache": false 21 | } 22 | } 23 | } 24 | --------------------------------------------------------------------------------