brasa 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 80e53e0d5fb933d533d94ab5863759651936979cf39b41bee334e2558e3fc343
4
+ data.tar.gz: 3274b32a209a87202587d48a9acc8f25df7f05b6f59472805ebad6b0ed646971
5
+ SHA512:
6
+ metadata.gz: 0ee91bdc2edea4b69d84a9d788b525f5275c5b0483055e8235bc201fb9308751df3e7697dcd6c9d423612d243431ff651d8d243aaf72ef5ad1561e37570d190e
7
+ data.tar.gz: 55e7419b734c594a766e8e48ebc0ceba35a1ac83be0364b50f8d087b6a450b16fdfd4c461a0b8653d4d0174a25c2a69d17327ed959309905e8d29f8498a34b11
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Brasa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # Brasa CLI
2
+
3
+ CLI para deploy de aplicações na nuvem brasileira (Magalu Cloud). Soberania de dados, faturamento em reais.
4
+
5
+ ## Instalacao
6
+
7
+ ```bash
8
+ gem install brasa
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ # Autenticar na plataforma
15
+ brasa login
16
+
17
+ # Inicializar projeto (cria .brasa.yml)
18
+ brasa init
19
+
20
+ # Criar app e fazer primeiro deploy
21
+ brasa up
22
+ ```
23
+
24
+ ## Comandos
25
+
26
+ | Comando | Descricao |
27
+ |---------|-----------|
28
+ | `brasa login` | Autenticar na plataforma |
29
+ | `brasa init` | Inicializar projeto no diretorio atual |
30
+ | `brasa up` | Criar app e fazer primeiro deploy |
31
+ | `brasa deploy` | Fazer deploy da aplicacao |
32
+ | `brasa status` | Ver status da aplicacao |
33
+ | `brasa logs` | Ver logs da aplicacao |
34
+ | `brasa env list` | Listar variaveis de ambiente |
35
+ | `brasa env set KEY=VALUE` | Definir variavel de ambiente |
36
+ | `brasa env unset KEY` | Remover variavel de ambiente |
37
+ | `brasa db info` | Informacoes do banco de dados |
38
+ | `brasa db backup` | Criar backup do banco |
39
+ | `brasa db restore BACKUP_ID` | Restaurar backup |
40
+ | `brasa domains list` | Listar dominios customizados |
41
+ | `brasa domains add HOSTNAME` | Adicionar dominio |
42
+ | `brasa domains remove HOSTNAME` | Remover dominio |
43
+ | `brasa domains verify HOSTNAME` | Verificar dominio |
44
+ | `brasa scale` | Escalar a aplicacao |
45
+ | `brasa destroy` | Destruir aplicacao e recursos |
46
+ | `brasa version` | Exibir versao da CLI |
47
+
48
+ ## Configuracao
49
+
50
+ O comando `brasa init` cria um arquivo `.brasa.yml` na raiz do projeto:
51
+
52
+ ```yaml
53
+ app: meu-app
54
+ stack: rails
55
+ ```
56
+
57
+ ## Variaveis de Ambiente
58
+
59
+ | Variavel | Descricao | Padrao |
60
+ |----------|-----------|--------|
61
+ | `BRASA_API_URL` | URL da API | `https://api.brasa.com.br` |
62
+
63
+ ## Licenca
64
+
65
+ MIT License - veja [LICENSE.txt](LICENSE.txt).
data/exe/brasa ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require "brasa"
3
+
4
+ Brasa::CLI.start(ARGV)
@@ -0,0 +1,83 @@
1
+ require "faraday"
2
+ require "json"
3
+
4
+ module Brasa
5
+ module Api
6
+ class Client
7
+ class ApiError < StandardError
8
+ attr_reader :status, :body
9
+
10
+ def initialize(message, status: nil, body: nil)
11
+ @status = status
12
+ @body = body
13
+ super(message)
14
+ end
15
+ end
16
+
17
+ class AuthenticationError < ApiError; end
18
+ class NotFoundError < ApiError; end
19
+ class ValidationError < ApiError; end
20
+
21
+ def initialize(token: nil, api_url: nil)
22
+ @token = token || Config.token
23
+ @api_url = api_url || Config.api_url
24
+ end
25
+
26
+ def get(path, params: {})
27
+ request(:get, path, params: params)
28
+ end
29
+
30
+ def post(path, body: {})
31
+ request(:post, path, body: body)
32
+ end
33
+
34
+ def put(path, body: {})
35
+ request(:put, path, body: body)
36
+ end
37
+
38
+ def patch(path, body: {})
39
+ request(:patch, path, body: body)
40
+ end
41
+
42
+ def delete(path)
43
+ request(:delete, path)
44
+ end
45
+
46
+ private
47
+
48
+ def request(method, path, body: nil, params: nil)
49
+ response = connection.public_send(method, path) do |req|
50
+ req.params = params if params && !params.empty?
51
+ req.body = body.to_json if body
52
+ end
53
+ handle_response(response)
54
+ end
55
+
56
+ def connection
57
+ @connection ||= Faraday.new(url: @api_url) do |conn|
58
+ conn.request :json
59
+ conn.response :json
60
+ conn.headers["Content-Type"] = "application/json"
61
+ conn.headers["Authorization"] = "Bearer #{@token}" if @token
62
+ conn.adapter Faraday.default_adapter
63
+ end
64
+ end
65
+
66
+ def handle_response(response)
67
+ case response.status
68
+ when 200..299
69
+ response.body
70
+ when 401
71
+ raise AuthenticationError.new("Não autenticado. Execute `brasa login`.", status: 401, body: response.body)
72
+ when 404
73
+ raise NotFoundError.new("Recurso não encontrado.", status: 404, body: response.body)
74
+ when 422
75
+ msg = response.body&.dig("errors")&.join(", ") || "Dados inválidos"
76
+ raise ValidationError.new(msg, status: 422, body: response.body)
77
+ else
78
+ raise ApiError.new("Erro na API (#{response.status})", status: response.status, body: response.body)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
data/lib/brasa/cli.rb ADDED
@@ -0,0 +1,81 @@
1
+ require "thor"
2
+ require "brasa/commands/login"
3
+ require "brasa/commands/init"
4
+ require "brasa/commands/up"
5
+ require "brasa/commands/deploy"
6
+ require "brasa/commands/logs"
7
+ require "brasa/commands/env"
8
+ require "brasa/commands/database"
9
+ require "brasa/commands/scale"
10
+ require "brasa/commands/status"
11
+ require "brasa/commands/destroy"
12
+ require "brasa/commands/domains"
13
+
14
+ module Brasa
15
+ class CLI < Thor
16
+ def self.exit_on_failure?
17
+ true
18
+ end
19
+
20
+ desc "login", "Autenticar na plataforma Brasa"
21
+ def login
22
+ Commands::Login.new.execute
23
+ end
24
+
25
+ desc "init", "Inicializar projeto no diretório atual"
26
+ def init
27
+ Commands::Init.new.execute
28
+ end
29
+
30
+ desc "up", "Criar app e fazer primeiro deploy"
31
+ def up
32
+ Commands::Up.new.execute
33
+ end
34
+
35
+ desc "deploy", "Fazer deploy da aplicação"
36
+ option :branch, type: :string, desc: "Branch para deploy"
37
+ def deploy
38
+ Commands::Deploy.new.execute(options)
39
+ end
40
+
41
+ desc "logs", "Ver logs da aplicação em tempo real"
42
+ option :tail, type: :numeric, default: 100, desc: "Número de linhas"
43
+ def logs
44
+ Commands::Logs.new.execute(options)
45
+ end
46
+
47
+ desc "env SUBCOMMAND", "Gerenciar variáveis de ambiente"
48
+ subcommand "env", Commands::Env
49
+
50
+ desc "db SUBCOMMAND", "Gerenciar banco de dados"
51
+ subcommand "db", Commands::Database
52
+
53
+ desc "domains SUBCOMMAND", "Gerenciar domínios customizados"
54
+ subcommand "domains", Commands::Domains
55
+
56
+ desc "scale", "Escalar a aplicação"
57
+ option :web, type: :numeric, desc: "Número de instâncias web"
58
+ option :preset, type: :string, desc: "Preset (hobby, production, scale)"
59
+ def scale
60
+ Commands::Scale.new.execute(options)
61
+ end
62
+
63
+ desc "status", "Ver status da aplicação"
64
+ def status
65
+ Commands::Status.new.execute
66
+ end
67
+
68
+ desc "destroy", "Destruir aplicação e recursos"
69
+ def destroy
70
+ Commands::Destroy.new.execute
71
+ end
72
+
73
+ desc "version", "Exibir versão da CLI"
74
+ def version
75
+ puts "brasa #{Brasa::VERSION}"
76
+ end
77
+
78
+ map "-v" => :version
79
+ map "--version" => :version
80
+ end
81
+ end
@@ -0,0 +1,47 @@
1
+ require "yaml"
2
+ require "pastel"
3
+
4
+ module Brasa
5
+ module Commands
6
+ class Base
7
+ def pastel
8
+ @pastel ||= Pastel.new
9
+ end
10
+
11
+ def api
12
+ @api ||= Api::Client.new
13
+ end
14
+
15
+ def project_config
16
+ @project_config ||= begin
17
+ config_path = File.join(Dir.pwd, Config::PROJECT_FILE)
18
+ unless File.exist?(config_path)
19
+ abort pastel.red("Arquivo .brasa.yml não encontrado. Execute `brasa init` primeiro.")
20
+ end
21
+ YAML.safe_load(File.read(config_path), symbolize_names: true)
22
+ end
23
+ end
24
+
25
+ def app_slug
26
+ project_config[:app]
27
+ end
28
+
29
+ def require_auth!
30
+ return if Config.token
31
+ abort pastel.red("Não autenticado. Execute `brasa login` primeiro.")
32
+ end
33
+
34
+ def success(msg)
35
+ puts pastel.green(msg)
36
+ end
37
+
38
+ def error(msg)
39
+ puts pastel.red(msg)
40
+ end
41
+
42
+ def info(msg)
43
+ puts pastel.cyan(msg)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,64 @@
1
+ require "thor"
2
+ require "tty-prompt"
3
+ require "brasa/commands/base"
4
+
5
+ module Brasa
6
+ module Commands
7
+ class Database < Thor
8
+ namespace :db
9
+
10
+ desc "info", "Informações do banco de dados"
11
+ def info
12
+ helper = DatabaseHelper.new
13
+ helper.require_auth!
14
+ slug = helper.app_slug
15
+
16
+ db = helper.api.get("/api/v1/apps/#{slug}/database")
17
+
18
+ puts helper.pastel.bold("Banco de dados de #{slug}")
19
+ puts " Host: #{db["host"]}"
20
+ puts " Porta: #{db["port"]}"
21
+ puts " Nome: #{db["name"]}"
22
+ puts " Usuário: #{db["username"]}"
23
+ puts " Status: #{db["status"]}"
24
+ puts " Tamanho: #{db["size"]}" if db["size"]
25
+ rescue Api::Client::ApiError => e
26
+ DatabaseHelper.new.error("Erro: #{e.message}")
27
+ end
28
+
29
+ desc "backup", "Criar backup do banco de dados"
30
+ def backup
31
+ helper = DatabaseHelper.new
32
+ helper.require_auth!
33
+ slug = helper.app_slug
34
+
35
+ helper.info("Criando backup do banco de dados...")
36
+ result = helper.api.post("/api/v1/apps/#{slug}/database/backups")
37
+ helper.success("Backup criado: #{result["filename"]}")
38
+ rescue Api::Client::ApiError => e
39
+ DatabaseHelper.new.error("Erro: #{e.message}")
40
+ end
41
+
42
+ desc "restore BACKUP_ID", "Restaurar backup do banco de dados"
43
+ def restore(backup_id)
44
+ helper = DatabaseHelper.new
45
+ helper.require_auth!
46
+ slug = helper.app_slug
47
+
48
+ prompt = TTY::Prompt.new
49
+ unless prompt.yes?("Restaurar backup #{backup_id}? Isso substituirá os dados atuais.")
50
+ helper.info("Restauração cancelada.")
51
+ return
52
+ end
53
+
54
+ helper.info("Restaurando backup...")
55
+ helper.api.post("/api/v1/apps/#{slug}/database/backups/#{backup_id}/restore")
56
+ helper.success("Backup restaurado com sucesso!")
57
+ rescue Api::Client::ApiError => e
58
+ DatabaseHelper.new.error("Erro: #{e.message}")
59
+ end
60
+ end
61
+
62
+ class DatabaseHelper < Base; end
63
+ end
64
+ end
@@ -0,0 +1,48 @@
1
+ require "brasa/commands/base"
2
+
3
+ module Brasa
4
+ module Commands
5
+ class Deploy < Base
6
+ POLL_INTERVAL = 3
7
+ MAX_POLLS = 120
8
+
9
+ def execute(options = {})
10
+ require_auth!
11
+ slug = app_slug
12
+
13
+ info("Iniciando deploy de #{slug}...")
14
+
15
+ body = {}
16
+ body[:branch] = options["branch"] if options["branch"]
17
+
18
+ deploy = api.post("/api/v1/apps/#{slug}/deploys", body: body.empty? ? {} : body)
19
+ info("Deploy ##{deploy["id"]} criado. Aguardando...")
20
+
21
+ wait_for_deploy(slug, deploy["id"])
22
+ rescue Api::Client::ApiError => e
23
+ error("Erro: #{e.message}")
24
+ end
25
+
26
+ private
27
+
28
+ def wait_for_deploy(slug, deploy_id)
29
+ MAX_POLLS.times do
30
+ deploy = api.get("/api/v1/apps/#{slug}/deploys/#{deploy_id}")
31
+
32
+ case deploy["status"]
33
+ when "live"
34
+ success("Deploy concluído com sucesso!")
35
+ return
36
+ when "failed"
37
+ error("Deploy falhou.")
38
+ return
39
+ end
40
+
41
+ print "."
42
+ sleep(POLL_INTERVAL)
43
+ end
44
+ error("Timeout aguardando deploy.")
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,31 @@
1
+ require "brasa/commands/base"
2
+ require "tty-prompt"
3
+
4
+ module Brasa
5
+ module Commands
6
+ class Destroy < Base
7
+ def execute(options = {})
8
+ require_auth!
9
+ slug = app_slug
10
+
11
+ prompt = TTY::Prompt.new
12
+ unless prompt.yes?("Tem certeza que deseja destruir #{slug}? Esta ação é irreversível.")
13
+ info("Operação cancelada.")
14
+ return
15
+ end
16
+
17
+ confirmation = prompt.ask("Digite o nome do app para confirmar (#{slug}):")
18
+ unless confirmation == slug
19
+ error("Nome não confere. Operação cancelada.")
20
+ return
21
+ end
22
+
23
+ info("Destruindo #{slug}...")
24
+ api.delete("/api/v1/apps/#{slug}")
25
+ success("App #{slug} destruído com sucesso.")
26
+ rescue Api::Client::ApiError => e
27
+ error("Erro: #{e.message}")
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,95 @@
1
+ require "thor"
2
+ require "brasa/commands/base"
3
+
4
+ module Brasa
5
+ module Commands
6
+ class Domains < Thor
7
+ namespace :domains
8
+
9
+ desc "list", "Listar domínios customizados"
10
+ def list
11
+ helper = DomainsHelper.new
12
+ helper.require_auth!
13
+ slug = helper.app_slug
14
+
15
+ domains = helper.api.get("/api/v1/apps/#{slug}/domains")
16
+
17
+ if domains.nil? || domains.empty?
18
+ helper.info("Nenhum domínio configurado.")
19
+ return
20
+ end
21
+
22
+ domains.each do |domain|
23
+ status_icon = domain["status"] == "active" ? helper.pastel.green("●") : helper.pastel.yellow("○")
24
+ puts "#{status_icon} #{helper.pastel.bold(domain["hostname"])} (#{domain["status"]})"
25
+ puts " Token: #{domain["verification_token"]}" if domain["status"] == "pending"
26
+ end
27
+ rescue Api::Client::ApiError => e
28
+ DomainsHelper.new.error("Erro: #{e.message}")
29
+ end
30
+
31
+ desc "add HOSTNAME", "Adicionar domínio customizado"
32
+ def add(hostname)
33
+ helper = DomainsHelper.new
34
+ helper.require_auth!
35
+ slug = helper.app_slug
36
+
37
+ result = helper.api.post("/api/v1/apps/#{slug}/domains", body: { domain: { hostname: hostname } })
38
+ helper.success("Domínio #{hostname} adicionado.")
39
+ helper.info("Token de verificação: #{result["verification_token"]}")
40
+ helper.info("Adicione um registro CNAME ou TXT apontando para #{slug}.brasa.com.br")
41
+ rescue Api::Client::ValidationError => e
42
+ DomainsHelper.new.error("Erro: #{e.message}")
43
+ rescue Api::Client::ApiError => e
44
+ DomainsHelper.new.error("Erro: #{e.message}")
45
+ end
46
+
47
+ desc "remove HOSTNAME", "Remover domínio customizado"
48
+ def remove(hostname)
49
+ helper = DomainsHelper.new
50
+ helper.require_auth!
51
+ slug = helper.app_slug
52
+
53
+ domains = helper.api.get("/api/v1/apps/#{slug}/domains")
54
+ domain = domains&.find { |d| d["hostname"] == hostname }
55
+
56
+ unless domain
57
+ helper.error("Domínio #{hostname} não encontrado.")
58
+ return
59
+ end
60
+
61
+ helper.api.delete("/api/v1/apps/#{slug}/domains/#{domain["id"]}")
62
+ helper.success("Domínio #{hostname} removido.")
63
+ rescue Api::Client::ApiError => e
64
+ DomainsHelper.new.error("Erro: #{e.message}")
65
+ end
66
+
67
+ desc "verify HOSTNAME", "Verificar domínio customizado"
68
+ def verify(hostname)
69
+ helper = DomainsHelper.new
70
+ helper.require_auth!
71
+ slug = helper.app_slug
72
+
73
+ domains = helper.api.get("/api/v1/apps/#{slug}/domains")
74
+ domain = domains&.find { |d| d["hostname"] == hostname }
75
+
76
+ unless domain
77
+ helper.error("Domínio #{hostname} não encontrado.")
78
+ return
79
+ end
80
+
81
+ result = helper.api.post("/api/v1/apps/#{slug}/domains/#{domain["id"]}/verify")
82
+
83
+ if result["status"] == "active"
84
+ helper.success("Domínio #{hostname} verificado com sucesso!")
85
+ else
86
+ helper.error("Verificação falhou. Certifique-se de que o registro DNS está configurado.")
87
+ end
88
+ rescue Api::Client::ApiError => e
89
+ DomainsHelper.new.error("Erro: #{e.message}")
90
+ end
91
+ end
92
+
93
+ class DomainsHelper < Base; end
94
+ end
95
+ end
@@ -0,0 +1,66 @@
1
+ require "thor"
2
+ require "brasa/commands/base"
3
+
4
+ module Brasa
5
+ module Commands
6
+ class Env < Thor
7
+ namespace :env
8
+
9
+ desc "list", "Listar variáveis de ambiente"
10
+ def list
11
+ helper = EnvHelper.new
12
+ helper.require_auth!
13
+ slug = helper.app_slug
14
+
15
+ vars = helper.api.get("/api/v1/apps/#{slug}/env")
16
+
17
+ if vars.nil? || vars.empty?
18
+ helper.info("Nenhuma variável de ambiente configurada.")
19
+ return
20
+ end
21
+
22
+ vars.each do |key, value|
23
+ puts "#{helper.pastel.bold(key)}=#{value}"
24
+ end
25
+ rescue Api::Client::ApiError => e
26
+ EnvHelper.new.error("Erro: #{e.message}")
27
+ end
28
+
29
+ desc "set KEY=VALUE [KEY=VALUE...]", "Definir variáveis de ambiente"
30
+ def set(*pairs)
31
+ helper = EnvHelper.new
32
+ helper.require_auth!
33
+ slug = helper.app_slug
34
+
35
+ env_vars = {}
36
+ pairs.each do |pair|
37
+ key, value = pair.split("=", 2)
38
+ if key.nil? || key.empty? || value.nil?
39
+ helper.error("Formato inválido: #{pair}. Use KEY=VALUE.")
40
+ return
41
+ end
42
+ env_vars[key] = value
43
+ end
44
+
45
+ helper.api.put("/api/v1/apps/#{slug}/env", body: { env: env_vars })
46
+ helper.success("Variáveis de ambiente atualizadas.")
47
+ rescue Api::Client::ApiError => e
48
+ EnvHelper.new.error("Erro: #{e.message}")
49
+ end
50
+
51
+ desc "unset KEY [KEY...]", "Remover variáveis de ambiente"
52
+ def unset(*keys)
53
+ helper = EnvHelper.new
54
+ helper.require_auth!
55
+ slug = helper.app_slug
56
+
57
+ helper.api.delete("/api/v1/apps/#{slug}/env/#{keys.join(",")}")
58
+ helper.success("Variáveis removidas: #{keys.join(", ")}")
59
+ rescue Api::Client::ApiError => e
60
+ EnvHelper.new.error("Erro: #{e.message}")
61
+ end
62
+ end
63
+
64
+ class EnvHelper < Base; end
65
+ end
66
+ end
@@ -0,0 +1,59 @@
1
+ require "brasa/commands/base"
2
+ require "tty-prompt"
3
+ require "yaml"
4
+
5
+ module Brasa
6
+ module Commands
7
+ class Init < Base
8
+ PRESETS = %w[hobby production scale].freeze
9
+ REGIONS = { "br-se1" => "São Paulo", "br-ne1" => "Nordeste" }.freeze
10
+
11
+ def execute(options = {})
12
+ prompt = TTY::Prompt.new
13
+ config_path = File.join(Dir.pwd, Config::PROJECT_FILE)
14
+
15
+ if File.exist?(config_path)
16
+ return unless prompt.yes?("Arquivo .brasa.yml já existe. Sobrescrever?")
17
+ end
18
+
19
+ stack = detect_stack
20
+ unless stack
21
+ error("Não foi possível detectar o stack do projeto. Certifique-se de ter um Gemfile (Rails) ou package.json (Node.js).")
22
+ return
23
+ end
24
+
25
+ info("Stack detectado: #{stack == "rails" ? "Rails" : "Node.js"}")
26
+
27
+ name = prompt.ask("Nome do app:")
28
+ preset = prompt.select("Preset:", PRESETS)
29
+ region = prompt.select("Região:", REGIONS.map { |k, v| { name: "#{v} (#{k})", value: k } })
30
+
31
+ config = {
32
+ "app" => name,
33
+ "stack" => stack,
34
+ "preset" => preset,
35
+ "region" => region,
36
+ "branch" => "main"
37
+ }
38
+
39
+ File.write(config_path, YAML.dump(config))
40
+ success("Arquivo .brasa.yml criado com sucesso!")
41
+ end
42
+
43
+ private
44
+
45
+ def detect_stack
46
+ if File.exist?(File.join(Dir.pwd, "Gemfile"))
47
+ gemfile = File.read(File.join(Dir.pwd, "Gemfile"))
48
+ return "rails" if gemfile.include?("rails")
49
+ end
50
+
51
+ if File.exist?(File.join(Dir.pwd, "package.json"))
52
+ return "node"
53
+ end
54
+
55
+ nil
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,25 @@
1
+ require "brasa/commands/base"
2
+ require "tty-prompt"
3
+
4
+ module Brasa
5
+ module Commands
6
+ class Login < Base
7
+ def execute(options = {})
8
+ prompt = TTY::Prompt.new
9
+
10
+ email = prompt.ask("Email:")
11
+ password = prompt.mask("Senha:")
12
+
13
+ client = Api::Client.new(token: nil)
14
+ response = client.post("/users/tokens/sign_in", body: { email: email, password: password })
15
+
16
+ Config.save_token(response["token"])
17
+ success("Login realizado com sucesso!")
18
+ rescue Api::Client::AuthenticationError
19
+ error("Credenciais inválidas. Verifique email e senha.")
20
+ rescue Api::Client::ApiError => e
21
+ error("Erro ao fazer login: #{e.message}")
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,32 @@
1
+ require "brasa/commands/base"
2
+
3
+ module Brasa
4
+ module Commands
5
+ class Logs < Base
6
+ DEFAULT_TAIL = 100
7
+
8
+ def execute(options = {})
9
+ require_auth!
10
+ slug = app_slug
11
+ tail = options.fetch("tail", DEFAULT_TAIL).to_s
12
+
13
+ logs = api.get("/api/v1/apps/#{slug}/logs", params: { tail: tail })
14
+
15
+ if logs.nil? || logs.empty?
16
+ info("Nenhum log disponível.")
17
+ return
18
+ end
19
+
20
+ logs.each do |entry|
21
+ timestamp = entry["timestamp"]
22
+ source = entry["source"]
23
+ message = entry["message"]
24
+
25
+ puts "#{pastel.dim(timestamp)} #{pastel.cyan(source)} #{message}"
26
+ end
27
+ rescue Api::Client::ApiError => e
28
+ error("Erro: #{e.message}")
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,36 @@
1
+ require "brasa/commands/base"
2
+ require "tty-prompt"
3
+
4
+ module Brasa
5
+ module Commands
6
+ class Scale < Base
7
+ def execute(options = {})
8
+ require_auth!
9
+ slug = app_slug
10
+
11
+ body = {}
12
+ body[:web] = options["web"].to_i if options["web"]
13
+ body[:preset] = options["preset"] if options["preset"]
14
+
15
+ if body.empty?
16
+ error("Informe ao menos --web ou --preset. Ex: brasa scale --web 3")
17
+ return
18
+ end
19
+
20
+ estimate = api.post("/api/v1/apps/#{slug}/scale/estimate", body: body)
21
+ info("Custo estimado: R$ #{estimate["monthly_cost"]}/mês")
22
+
23
+ prompt = TTY::Prompt.new
24
+ unless prompt.yes?("Confirmar escalonamento?")
25
+ info("Escalonamento cancelado.")
26
+ return
27
+ end
28
+
29
+ api.put("/api/v1/apps/#{slug}/scale", body: body)
30
+ success("Escalonamento aplicado com sucesso!")
31
+ rescue Api::Client::ApiError => e
32
+ error("Erro: #{e.message}")
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,49 @@
1
+ require "brasa/commands/base"
2
+
3
+ module Brasa
4
+ module Commands
5
+ class Status < Base
6
+ def execute(options = {})
7
+ require_auth!
8
+ slug = app_slug
9
+
10
+ app = api.get("/api/v1/apps/#{slug}")
11
+
12
+ puts pastel.bold("App: #{app["name"]}")
13
+ puts " Slug: #{app["slug"]}"
14
+ puts " Stack: #{app["stack"]}"
15
+ puts " Status: #{colorize_status(app["status"])}"
16
+ puts " Região: #{app["region"]}"
17
+ puts " Preset: #{app["preset"]}"
18
+ puts " URL: https://#{app["slug"]}.brasa.com.br"
19
+
20
+ if app["last_deploy"]
21
+ deploy = app["last_deploy"]
22
+ puts ""
23
+ puts pastel.bold("Último deploy:")
24
+ puts " ID: #{deploy["id"]}"
25
+ puts " Status: #{colorize_status(deploy["status"])}"
26
+ puts " Commit: #{deploy["commit_sha"]}" if deploy["commit_sha"]
27
+ puts " Data: #{deploy["created_at"]}"
28
+ end
29
+ rescue Api::Client::ApiError => e
30
+ error("Erro: #{e.message}")
31
+ end
32
+
33
+ private
34
+
35
+ def colorize_status(status)
36
+ case status
37
+ when "active", "live"
38
+ pastel.green(status)
39
+ when "failed", "error"
40
+ pastel.red(status)
41
+ when "building", "deploying", "provisioning"
42
+ pastel.yellow(status)
43
+ else
44
+ status
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,77 @@
1
+ require "brasa/commands/base"
2
+
3
+ module Brasa
4
+ module Commands
5
+ class Up < Base
6
+ POLL_INTERVAL = 3
7
+ MAX_POLLS = 60
8
+
9
+ def execute(options = {})
10
+ require_auth!
11
+ config = project_config
12
+
13
+ info("Criando app #{config[:app]}...")
14
+
15
+ app = create_app(config)
16
+ info("App #{app["slug"]} criado. Provisionando infraestrutura...")
17
+
18
+ wait_for_provisioning(app["slug"])
19
+ success("Infraestrutura provisionada!")
20
+
21
+ info("Iniciando primeiro deploy...")
22
+ deploy = trigger_deploy(app["slug"])
23
+ wait_for_deploy(app["slug"], deploy["id"])
24
+
25
+ success("Deploy concluído! App disponível em https://#{app["slug"]}.brasa.com.br")
26
+ rescue Api::Client::ValidationError => e
27
+ error("Erro ao criar app: #{e.message}")
28
+ rescue Api::Client::ApiError => e
29
+ error("Erro: #{e.message}")
30
+ end
31
+
32
+ private
33
+
34
+ def create_app(config)
35
+ api.post("/api/v1/apps", body: {
36
+ name: config[:app],
37
+ stack: config[:stack],
38
+ preset: config[:preset],
39
+ region: config[:region],
40
+ repo_branch: config[:branch]
41
+ })
42
+ end
43
+
44
+ def trigger_deploy(slug)
45
+ api.post("/api/v1/apps/#{slug}/deploys")
46
+ end
47
+
48
+ def wait_for_provisioning(slug)
49
+ MAX_POLLS.times do
50
+ app = api.get("/api/v1/apps/#{slug}")
51
+ return if app["status"] == "active"
52
+ if app["status"] == "error"
53
+ error("Erro no provisionamento.")
54
+ return
55
+ end
56
+ print "."
57
+ sleep(POLL_INTERVAL)
58
+ end
59
+ error("Timeout aguardando provisionamento.")
60
+ end
61
+
62
+ def wait_for_deploy(slug, deploy_id)
63
+ MAX_POLLS.times do
64
+ deploy = api.get("/api/v1/apps/#{slug}/deploys/#{deploy_id}")
65
+ return if deploy["status"] == "live"
66
+ if deploy["status"] == "failed"
67
+ error("Deploy falhou.")
68
+ return
69
+ end
70
+ print "."
71
+ sleep(POLL_INTERVAL)
72
+ end
73
+ error("Timeout aguardando deploy.")
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,45 @@
1
+ require "json"
2
+ require "fileutils"
3
+
4
+ module Brasa
5
+ class Config
6
+ CONFIG_DIR = File.expand_path("~/.brasa").freeze
7
+ CREDENTIALS_FILE = File.join(CONFIG_DIR, "credentials.json").freeze
8
+ PROJECT_FILE = ".brasa.yml".freeze
9
+
10
+ DEFAULT_API_URL = "https://api.brasa.com.br".freeze
11
+
12
+ class << self
13
+ def api_url
14
+ ENV.fetch("BRASA_API_URL", DEFAULT_API_URL)
15
+ end
16
+
17
+ def token
18
+ credentials["token"]
19
+ end
20
+
21
+ def save_token(token)
22
+ FileUtils.mkdir_p(CONFIG_DIR)
23
+ File.write(CREDENTIALS_FILE, JSON.generate({ token: token }))
24
+ File.chmod(0600, CREDENTIALS_FILE)
25
+ end
26
+
27
+ def clear_token
28
+ File.delete(CREDENTIALS_FILE) if File.exist?(CREDENTIALS_FILE)
29
+ end
30
+
31
+ def logged_in?
32
+ !token.nil? && !token.empty?
33
+ end
34
+
35
+ private
36
+
37
+ def credentials
38
+ return {} unless File.exist?(CREDENTIALS_FILE)
39
+ JSON.parse(File.read(CREDENTIALS_FILE))
40
+ rescue JSON::ParserError
41
+ {}
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,3 @@
1
+ module Brasa
2
+ VERSION = "0.1.0"
3
+ end
data/lib/brasa.rb ADDED
@@ -0,0 +1,7 @@
1
+ require "brasa/version"
2
+ require "brasa/config"
3
+ require "brasa/api/client"
4
+ require "brasa/cli"
5
+
6
+ module Brasa
7
+ end
metadata ADDED
@@ -0,0 +1,174 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: brasa
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Brasa
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: thor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: tty-prompt
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.23'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.23'
54
+ - !ruby/object:Gem::Dependency
55
+ name: tty-spinner
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.9'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.9'
68
+ - !ruby/object:Gem::Dependency
69
+ name: tty-table
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.12'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.12'
82
+ - !ruby/object:Gem::Dependency
83
+ name: pastel
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.8'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.8'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rspec
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '3.13'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '3.13'
110
+ - !ruby/object:Gem::Dependency
111
+ name: webmock
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '3.24'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '3.24'
124
+ description: Deploy apps Rails e Node.js na nuvem brasileira com um comando. Soberania
125
+ de dados, faturamento em reais.
126
+ email:
127
+ - contato@brasa.com.br
128
+ executables:
129
+ - brasa
130
+ extensions: []
131
+ extra_rdoc_files: []
132
+ files:
133
+ - LICENSE.txt
134
+ - README.md
135
+ - exe/brasa
136
+ - lib/brasa.rb
137
+ - lib/brasa/api/client.rb
138
+ - lib/brasa/cli.rb
139
+ - lib/brasa/commands/base.rb
140
+ - lib/brasa/commands/database.rb
141
+ - lib/brasa/commands/deploy.rb
142
+ - lib/brasa/commands/destroy.rb
143
+ - lib/brasa/commands/domains.rb
144
+ - lib/brasa/commands/env.rb
145
+ - lib/brasa/commands/init.rb
146
+ - lib/brasa/commands/login.rb
147
+ - lib/brasa/commands/logs.rb
148
+ - lib/brasa/commands/scale.rb
149
+ - lib/brasa/commands/status.rb
150
+ - lib/brasa/commands/up.rb
151
+ - lib/brasa/config.rb
152
+ - lib/brasa/version.rb
153
+ homepage: https://brasa.com.br
154
+ licenses:
155
+ - MIT
156
+ metadata: {}
157
+ rdoc_options: []
158
+ require_paths:
159
+ - lib
160
+ required_ruby_version: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - ">="
163
+ - !ruby/object:Gem::Version
164
+ version: '3.1'
165
+ required_rubygems_version: !ruby/object:Gem::Requirement
166
+ requirements:
167
+ - - ">="
168
+ - !ruby/object:Gem::Version
169
+ version: '0'
170
+ requirements: []
171
+ rubygems_version: 3.6.9
172
+ specification_version: 4
173
+ summary: CLI para deploy na Magalu Cloud
174
+ test_files: []