nvoi 0.1.7 → 0.2.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 +4 -4
- data/Gemfile +1 -5
- data/Gemfile.lock +17 -8
- data/Rakefile +1 -1
- data/lib/nvoi/cli/config/command.rb +46 -41
- data/lib/nvoi/cli/credentials/edit/command.rb +20 -20
- data/lib/nvoi/cli/credentials/show/command.rb +1 -1
- data/lib/nvoi/cli/db/command.rb +10 -10
- data/lib/nvoi/cli/delete/command.rb +2 -2
- data/lib/nvoi/cli/deploy/command.rb +29 -13
- data/lib/nvoi/cli/deploy/steps/build_image.rb +48 -6
- data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +2 -2
- data/lib/nvoi/cli/deploy/steps/deploy_service.rb +3 -13
- data/lib/nvoi/cli/deploy/steps/provision_server.rb +1 -1
- data/lib/nvoi/cli/deploy/steps/provision_volume.rb +1 -1
- data/lib/nvoi/cli/exec/command.rb +3 -3
- data/lib/nvoi/cli/logs/command.rb +2 -2
- data/lib/nvoi/cli/onboard/command.rb +176 -622
- data/lib/nvoi/cli/onboard/steps/app.rb +108 -0
- data/lib/nvoi/cli/onboard/steps/app_name.rb +26 -0
- data/lib/nvoi/cli/onboard/steps/compute.rb +139 -0
- data/lib/nvoi/cli/onboard/steps/database.rb +97 -0
- data/lib/nvoi/cli/onboard/steps/domain.rb +48 -0
- data/lib/nvoi/cli/onboard/steps/env.rb +67 -0
- data/lib/nvoi/cli/onboard/ui.rb +84 -0
- data/lib/nvoi/cli/unlock/command.rb +2 -2
- data/lib/nvoi/cli.rb +0 -32
- data/lib/nvoi/configuration/app_service.rb +54 -0
- data/lib/nvoi/configuration/application.rb +44 -0
- data/lib/nvoi/configuration/builder.rb +417 -0
- data/lib/nvoi/configuration/database.rb +56 -0
- data/lib/nvoi/configuration/deploy.rb +15 -0
- data/lib/nvoi/{objects/service_spec.rb → configuration/deployment.rb} +4 -3
- data/lib/nvoi/{objects/config_override.rb → configuration/override.rb} +4 -4
- data/lib/nvoi/configuration/providers.rb +78 -0
- data/lib/nvoi/configuration/result.rb +43 -0
- data/lib/nvoi/configuration/root.rb +234 -0
- data/lib/nvoi/configuration/server.rb +39 -0
- data/lib/nvoi/configuration/service.rb +62 -0
- data/lib/nvoi/external/cloud/aws.rb +12 -12
- data/lib/nvoi/external/cloud/hetzner.rb +7 -7
- data/lib/nvoi/external/cloud/scaleway.rb +7 -7
- data/lib/nvoi/external/cloud/types.rb +42 -0
- data/lib/nvoi/external/containerd.rb +1 -48
- data/lib/nvoi/external/database/mysql.rb +1 -1
- data/lib/nvoi/external/database/postgres.rb +1 -1
- data/lib/nvoi/external/database/provider.rb +1 -1
- data/lib/nvoi/external/database/sqlite.rb +1 -1
- data/lib/nvoi/external/database/types.rb +55 -0
- data/lib/nvoi/external/dns/cloudflare.rb +6 -6
- data/lib/nvoi/external/dns/types.rb +24 -0
- data/lib/nvoi/external/ssh.rb +0 -12
- data/lib/nvoi/external/ssh_tunnel.rb +100 -0
- data/lib/nvoi/utils/config_loader.rb +12 -12
- data/lib/nvoi/utils/credential_store.rb +4 -4
- data/lib/nvoi/utils/env_resolver.rb +3 -3
- data/lib/nvoi/utils/namer.rb +2 -2
- data/lib/nvoi/utils/presence.rb +23 -0
- data/lib/nvoi/version.rb +1 -1
- data/lib/nvoi.rb +2 -17
- metadata +96 -57
- data/.claude/todo/refactor/00-overview.md +0 -171
- data/.claude/todo/refactor/01-objects.md +0 -96
- data/.claude/todo/refactor/02-utils.md +0 -143
- data/.claude/todo/refactor/03-external-cloud.md +0 -164
- data/.claude/todo/refactor/04-external-dns.md +0 -104
- data/.claude/todo/refactor/05-external.md +0 -133
- data/.claude/todo/refactor/06-cli.md +0 -123
- data/.claude/todo/refactor/07-cli-deploy-command.md +0 -177
- data/.claude/todo/refactor/08-cli-deploy-steps.md +0 -201
- data/.claude/todo/refactor/09-cli-delete-command.md +0 -169
- data/.claude/todo/refactor/10-cli-exec-command.md +0 -157
- data/.claude/todo/refactor/11-cli-credentials-command.md +0 -190
- data/.claude/todo/refactor/12-cli-db-command.md +0 -128
- data/.claude/todo/refactor/_target.md +0 -79
- data/.claude/todo/refactor-execution/00-entrypoint.md +0 -49
- data/.claude/todo/refactor-execution/01-objects.md +0 -42
- data/.claude/todo/refactor-execution/02-utils.md +0 -41
- data/.claude/todo/refactor-execution/03-external-cloud.md +0 -38
- data/.claude/todo/refactor-execution/04-external-dns.md +0 -35
- data/.claude/todo/refactor-execution/05-external-other.md +0 -46
- data/.claude/todo/refactor-execution/06-cli-deploy.md +0 -45
- data/.claude/todo/refactor-execution/07-cli-delete.md +0 -43
- data/.claude/todo/refactor-execution/08-cli-exec.md +0 -30
- data/.claude/todo/refactor-execution/09-cli-credentials.md +0 -34
- data/.claude/todo/refactor-execution/10-cli-db.md +0 -31
- data/.claude/todo/refactor-execution/11-cli-router.md +0 -44
- data/.claude/todo/refactor-execution/12-cleanup.md +0 -120
- data/.claude/todo/refactor-execution/_monitoring-strategy.md +0 -126
- data/.claude/todo/scaleway.impl.md +0 -644
- data/.claude/todo/scaleway.reference.md +0 -520
- data/.claude/todos.md +0 -550
- data/ingest +0 -0
- data/lib/nvoi/config_api/actions/app.rb +0 -53
- data/lib/nvoi/config_api/actions/compute_provider.rb +0 -55
- data/lib/nvoi/config_api/actions/database.rb +0 -70
- data/lib/nvoi/config_api/actions/domain_provider.rb +0 -40
- data/lib/nvoi/config_api/actions/env.rb +0 -32
- data/lib/nvoi/config_api/actions/init.rb +0 -67
- data/lib/nvoi/config_api/actions/secret.rb +0 -32
- data/lib/nvoi/config_api/actions/server.rb +0 -66
- data/lib/nvoi/config_api/actions/service.rb +0 -52
- data/lib/nvoi/config_api/actions/volume.rb +0 -40
- data/lib/nvoi/config_api/base.rb +0 -38
- data/lib/nvoi/config_api/result.rb +0 -26
- data/lib/nvoi/config_api.rb +0 -93
- data/lib/nvoi/objects/configuration.rb +0 -483
- data/lib/nvoi/objects/database.rb +0 -56
- data/lib/nvoi/objects/dns.rb +0 -14
- data/lib/nvoi/objects/firewall.rb +0 -11
- data/lib/nvoi/objects/network.rb +0 -11
- data/lib/nvoi/objects/server.rb +0 -14
- data/lib/nvoi/objects/tunnel.rb +0 -14
- data/lib/nvoi/objects/volume.rb +0 -17
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Onboard
|
|
6
|
+
module Steps
|
|
7
|
+
# Single app form - used for add and edit
|
|
8
|
+
class App
|
|
9
|
+
include Onboard::Ui
|
|
10
|
+
|
|
11
|
+
def initialize(prompt, test_mode: false)
|
|
12
|
+
@prompt = prompt
|
|
13
|
+
@test_mode = test_mode
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Returns [name, config] tuple
|
|
17
|
+
def call(existing_name: nil, existing: nil, zones: [], cloudflare_client: nil)
|
|
18
|
+
@zones = zones
|
|
19
|
+
@cloudflare_client = cloudflare_client
|
|
20
|
+
|
|
21
|
+
name = @prompt.ask("App name:", default: existing_name) { |q| q.required true }
|
|
22
|
+
|
|
23
|
+
command = prompt_optional("Run command", existing&.dig("command"),
|
|
24
|
+
placeholder: "leave blank for Docker entrypoint")
|
|
25
|
+
|
|
26
|
+
port = prompt_optional("Port", existing&.dig("port")&.to_s,
|
|
27
|
+
placeholder: "leave blank for background workers")
|
|
28
|
+
port = port.to_i if port && !port.blank?
|
|
29
|
+
|
|
30
|
+
config = { "servers" => existing&.dig("servers") || ["main"] }
|
|
31
|
+
config["command"] = command unless command.blank?
|
|
32
|
+
config["port"] = port if port && port.to_i > 0
|
|
33
|
+
|
|
34
|
+
# Domain selection only if port is set and cloudflare configured
|
|
35
|
+
if port && port.to_i > 0 && @zones.any?
|
|
36
|
+
domain, subdomain = prompt_domain_selection
|
|
37
|
+
if domain
|
|
38
|
+
config["domain"] = domain
|
|
39
|
+
config["subdomain"] = subdomain unless subdomain.blank?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
pre_run = prompt_optional("Pre-run command", existing&.dig("pre_run_command"),
|
|
44
|
+
placeholder: "e.g. migrations")
|
|
45
|
+
config["pre_run_command"] = pre_run unless pre_run.blank?
|
|
46
|
+
|
|
47
|
+
[name, config]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def prompt_optional(label, default, placeholder: nil)
|
|
53
|
+
hint = placeholder ? " (#{placeholder})" : ""
|
|
54
|
+
if default
|
|
55
|
+
@prompt.ask("#{label}#{hint}:", default:)
|
|
56
|
+
else
|
|
57
|
+
@prompt.ask("#{label}#{hint}:")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def prompt_domain_selection
|
|
62
|
+
domain_choices = @zones.map { |z| { name: z[:name], value: z } }
|
|
63
|
+
domain_choices << { name: "Skip (no domain)", value: nil }
|
|
64
|
+
|
|
65
|
+
selected = @prompt.select("Domain:", domain_choices)
|
|
66
|
+
return [nil, nil] unless selected
|
|
67
|
+
|
|
68
|
+
zone_id = selected[:id]
|
|
69
|
+
domain = selected[:name]
|
|
70
|
+
subdomain = prompt_subdomain(zone_id, domain)
|
|
71
|
+
|
|
72
|
+
[domain, subdomain]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def prompt_subdomain(zone_id, domain)
|
|
76
|
+
loop do
|
|
77
|
+
subdomain = @prompt.ask("Subdomain (leave blank for #{domain} + *.#{domain}):")
|
|
78
|
+
subdomain = subdomain.to_s.strip.downcase
|
|
79
|
+
|
|
80
|
+
if !subdomain.empty? && !subdomain.match?(/\A[a-z0-9]([a-z0-9-]*[a-z0-9])?\z/)
|
|
81
|
+
error("Invalid subdomain format. Use lowercase letters, numbers, and hyphens.")
|
|
82
|
+
next
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
hostnames = Utils::Namer.build_hostnames(subdomain.empty? ? nil : subdomain, domain)
|
|
86
|
+
all_available = true
|
|
87
|
+
|
|
88
|
+
hostnames.each do |hostname|
|
|
89
|
+
available = with_spinner("Checking #{hostname}...") do
|
|
90
|
+
check_subdomain = hostname == domain ? "" : hostname.sub(".#{domain}", "")
|
|
91
|
+
@cloudflare_client.subdomain_available?(zone_id, check_subdomain, domain)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
unless available
|
|
95
|
+
error("#{hostname} already has a DNS record. Choose a different subdomain.")
|
|
96
|
+
all_available = false
|
|
97
|
+
break
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
return subdomain if all_available
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Onboard
|
|
6
|
+
module Steps
|
|
7
|
+
# Collects application name
|
|
8
|
+
class AppName
|
|
9
|
+
include Onboard::Ui
|
|
10
|
+
|
|
11
|
+
def initialize(prompt, test_mode: false)
|
|
12
|
+
@prompt = prompt
|
|
13
|
+
@test_mode = test_mode
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(existing: nil)
|
|
17
|
+
@prompt.ask("Application name:", default: existing) do |q|
|
|
18
|
+
q.required true
|
|
19
|
+
q.validate(/\A[a-z0-9_-]+\z/i, "Only letters, numbers, dashes, underscores")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Onboard
|
|
6
|
+
module Steps
|
|
7
|
+
# Collects compute provider configuration
|
|
8
|
+
class Compute
|
|
9
|
+
include Onboard::Ui
|
|
10
|
+
|
|
11
|
+
PROVIDERS = [
|
|
12
|
+
{ name: "Hetzner (recommended)", value: :hetzner },
|
|
13
|
+
{ name: "AWS", value: :aws },
|
|
14
|
+
{ name: "Scaleway", value: :scaleway }
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(prompt, test_mode: false)
|
|
18
|
+
@prompt = prompt
|
|
19
|
+
@test_mode = test_mode
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(existing: nil)
|
|
23
|
+
section "Compute Provider"
|
|
24
|
+
|
|
25
|
+
provider = @prompt.select("Select provider:", PROVIDERS)
|
|
26
|
+
|
|
27
|
+
case provider
|
|
28
|
+
when :hetzner then setup_hetzner
|
|
29
|
+
when :aws then setup_aws
|
|
30
|
+
when :scaleway then setup_scaleway
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def setup_hetzner
|
|
37
|
+
token = prompt_with_retry("Hetzner API Token:", mask: true) do |t|
|
|
38
|
+
client = External::Cloud::Hetzner.new(t)
|
|
39
|
+
client.validate_credentials
|
|
40
|
+
@client = client
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
types, locations = with_spinner("Fetching options...") do
|
|
44
|
+
[@client.list_server_types, @client.list_locations]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
type_choices = types.sort_by { |t| t[:name] }.map do |t|
|
|
48
|
+
price = t[:price] ? " - #{t[:price]}/mo" : ""
|
|
49
|
+
{ name: "#{t[:name]} (#{t[:cores]} vCPU, #{t[:memory] / 1024}GB#{price})", value: t[:name] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
location_choices = locations.map do |l|
|
|
53
|
+
{ name: "#{l[:name]} (#{l[:city]}, #{l[:country]})", value: l[:name] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
server_type = @prompt.select("Server type:", type_choices, per_page: 10)
|
|
57
|
+
location = @prompt.select("Location:", location_choices)
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
"hetzner" => {
|
|
61
|
+
"api_token" => token,
|
|
62
|
+
"server_type" => server_type,
|
|
63
|
+
"server_location" => location
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def setup_aws
|
|
69
|
+
access_key = prompt_with_retry("AWS Access Key ID:") do |k|
|
|
70
|
+
raise Errors::ValidationError, "Invalid format" unless k.match?(/\AAKIA/)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
secret_key = @prompt.mask("AWS Secret Access Key:")
|
|
74
|
+
|
|
75
|
+
temp_client = External::Cloud::Aws.new(access_key, secret_key, "us-east-1")
|
|
76
|
+
regions = with_spinner("Validating credentials...") do
|
|
77
|
+
temp_client.validate_credentials
|
|
78
|
+
temp_client.list_regions
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
region_choices = regions.map { |r| r[:name] }.sort
|
|
82
|
+
region = @prompt.select("Region:", region_choices, per_page: 10, filter: true)
|
|
83
|
+
|
|
84
|
+
client = External::Cloud::Aws.new(access_key, secret_key, region)
|
|
85
|
+
types = client.list_instance_types
|
|
86
|
+
|
|
87
|
+
type_choices = types.map do |t|
|
|
88
|
+
mem = t[:memory] ? " #{t[:memory] / 1024}GB" : ""
|
|
89
|
+
{ name: "#{t[:name]} (#{t[:vcpus]} vCPU#{mem})", value: t[:name] }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
instance_type = @prompt.select("Instance type:", type_choices)
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
"aws" => {
|
|
96
|
+
"access_key_id" => access_key,
|
|
97
|
+
"secret_access_key" => secret_key,
|
|
98
|
+
"region" => region,
|
|
99
|
+
"instance_type" => instance_type
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def setup_scaleway
|
|
105
|
+
secret_key = prompt_with_retry("Scaleway Secret Key:", mask: true)
|
|
106
|
+
project_id = @prompt.ask("Scaleway Project ID:") { |q| q.required true }
|
|
107
|
+
|
|
108
|
+
temp_client = External::Cloud::Scaleway.new(secret_key, project_id)
|
|
109
|
+
zones = temp_client.list_zones
|
|
110
|
+
|
|
111
|
+
zone_choices = zones.map { |z| { name: "#{z[:name]} (#{z[:city]})", value: z[:name] } }
|
|
112
|
+
zone = @prompt.select("Zone:", zone_choices)
|
|
113
|
+
|
|
114
|
+
client = External::Cloud::Scaleway.new(secret_key, project_id, zone:)
|
|
115
|
+
types = with_spinner("Validating credentials...") do
|
|
116
|
+
client.validate_credentials
|
|
117
|
+
client.list_server_types
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
type_choices = types.map do |t|
|
|
121
|
+
{ name: "#{t[:name]} (#{t[:cores]} cores)", value: t[:name] }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
server_type = @prompt.select("Server type:", type_choices, per_page: 10, filter: true)
|
|
125
|
+
|
|
126
|
+
{
|
|
127
|
+
"scaleway" => {
|
|
128
|
+
"secret_key" => secret_key,
|
|
129
|
+
"project_id" => project_id,
|
|
130
|
+
"zone" => zone,
|
|
131
|
+
"server_type" => server_type
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Onboard
|
|
6
|
+
module Steps
|
|
7
|
+
# Collects database configuration
|
|
8
|
+
class Database
|
|
9
|
+
include Onboard::Ui
|
|
10
|
+
|
|
11
|
+
ADAPTERS = [
|
|
12
|
+
{ name: "PostgreSQL", value: "postgres" },
|
|
13
|
+
{ name: "MySQL", value: "mysql" },
|
|
14
|
+
{ name: "SQLite", value: "sqlite3" },
|
|
15
|
+
{ name: "None (skip)", value: nil }
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
def initialize(prompt, test_mode: false)
|
|
19
|
+
@prompt = prompt
|
|
20
|
+
@test_mode = test_mode
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns [db_config, volume_config] tuple
|
|
24
|
+
def call(app_name:, existing: nil)
|
|
25
|
+
section "Database"
|
|
26
|
+
|
|
27
|
+
adapter = @prompt.select("Database:", ADAPTERS)
|
|
28
|
+
return [nil, nil] unless adapter
|
|
29
|
+
|
|
30
|
+
case adapter
|
|
31
|
+
when "postgres" then setup_postgres(app_name)
|
|
32
|
+
when "mysql" then setup_mysql(app_name)
|
|
33
|
+
when "sqlite3" then setup_sqlite
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def setup_postgres(app_name)
|
|
40
|
+
db_name = @prompt.ask("Database name:", default: "#{app_name}_production")
|
|
41
|
+
user = @prompt.ask("Database user:", default: app_name)
|
|
42
|
+
password = @prompt.mask("Database password:") { |q| q.required true }
|
|
43
|
+
|
|
44
|
+
config = {
|
|
45
|
+
"servers" => ["main"],
|
|
46
|
+
"adapter" => "postgres",
|
|
47
|
+
"secrets" => {
|
|
48
|
+
"POSTGRES_DB" => db_name,
|
|
49
|
+
"POSTGRES_USER" => user,
|
|
50
|
+
"POSTGRES_PASSWORD" => password
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
volume = { "postgres_data" => { "size" => 10 } }
|
|
55
|
+
|
|
56
|
+
[config, volume]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def setup_mysql(app_name)
|
|
60
|
+
db_name = @prompt.ask("Database name:", default: "#{app_name}_production")
|
|
61
|
+
user = @prompt.ask("Database user:", default: app_name)
|
|
62
|
+
password = @prompt.mask("Database password:") { |q| q.required true }
|
|
63
|
+
|
|
64
|
+
config = {
|
|
65
|
+
"servers" => ["main"],
|
|
66
|
+
"adapter" => "mysql",
|
|
67
|
+
"secrets" => {
|
|
68
|
+
"MYSQL_DATABASE" => db_name,
|
|
69
|
+
"MYSQL_USER" => user,
|
|
70
|
+
"MYSQL_PASSWORD" => password
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
volume = { "mysql_data" => { "size" => 10 } }
|
|
75
|
+
|
|
76
|
+
[config, volume]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def setup_sqlite
|
|
80
|
+
path = @prompt.ask("Database path:", default: "/app/data/production.sqlite3")
|
|
81
|
+
|
|
82
|
+
config = {
|
|
83
|
+
"servers" => ["main"],
|
|
84
|
+
"adapter" => "sqlite3",
|
|
85
|
+
"path" => path,
|
|
86
|
+
"mount" => { "data" => "/app/data" }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
volume = { "sqlite_data" => { "size" => 10 } }
|
|
90
|
+
|
|
91
|
+
[config, volume]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Onboard
|
|
6
|
+
module Steps
|
|
7
|
+
# Collects domain provider (Cloudflare) configuration
|
|
8
|
+
class Domain
|
|
9
|
+
include Onboard::Ui
|
|
10
|
+
|
|
11
|
+
attr_reader :client, :zones
|
|
12
|
+
|
|
13
|
+
def initialize(prompt, test_mode: false)
|
|
14
|
+
@prompt = prompt
|
|
15
|
+
@test_mode = test_mode
|
|
16
|
+
@client = nil
|
|
17
|
+
@zones = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(existing: nil)
|
|
21
|
+
section "Domain Provider"
|
|
22
|
+
|
|
23
|
+
return nil unless @prompt.yes?("Configure Cloudflare for domains/tunnels?")
|
|
24
|
+
|
|
25
|
+
token = prompt_with_retry("Cloudflare API Token:", mask: true)
|
|
26
|
+
account_id = @prompt.ask("Cloudflare Account ID:") { |q| q.required true }
|
|
27
|
+
|
|
28
|
+
@client = External::Dns::Cloudflare.new(token, account_id)
|
|
29
|
+
|
|
30
|
+
@zones = with_spinner("Fetching domains...") do
|
|
31
|
+
@client.validate_credentials
|
|
32
|
+
@client.list_zones.select { |z| z[:status] == "active" }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
output.puts "No active domains found in Cloudflare account" if @zones.empty?
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
"cloudflare" => {
|
|
39
|
+
"api_token" => token,
|
|
40
|
+
"account_id" => account_id
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nvoi
|
|
4
|
+
class Cli
|
|
5
|
+
module Onboard
|
|
6
|
+
module Steps
|
|
7
|
+
# Collects environment variables and secrets
|
|
8
|
+
class Env
|
|
9
|
+
include Onboard::Ui
|
|
10
|
+
|
|
11
|
+
ACTIONS = [
|
|
12
|
+
{ name: "Add variable", value: :add },
|
|
13
|
+
{ name: "Add secret (masked)", value: :secret },
|
|
14
|
+
{ name: "Done", value: :done }
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(prompt, test_mode: false)
|
|
18
|
+
@prompt = prompt
|
|
19
|
+
@test_mode = test_mode
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Returns [env, secrets] tuple
|
|
23
|
+
def call(existing_env: nil, existing_secrets: nil)
|
|
24
|
+
section "Environment Variables"
|
|
25
|
+
|
|
26
|
+
env = (existing_env || {}).dup
|
|
27
|
+
secrets = (existing_secrets || {}).dup
|
|
28
|
+
|
|
29
|
+
# Add default
|
|
30
|
+
env["RAILS_ENV"] ||= "production"
|
|
31
|
+
|
|
32
|
+
loop do
|
|
33
|
+
show_table(env, secrets) unless env.empty? && secrets.empty?
|
|
34
|
+
|
|
35
|
+
case @prompt.select("Action:", ACTIONS)
|
|
36
|
+
when :add
|
|
37
|
+
key = @prompt.ask("Variable name:") { |q| q.required true }
|
|
38
|
+
value = @prompt.ask("Value:") { |q| q.required true }
|
|
39
|
+
env[key] = value
|
|
40
|
+
|
|
41
|
+
when :secret
|
|
42
|
+
key = @prompt.ask("Secret name:") { |q| q.required true }
|
|
43
|
+
value = @prompt.mask("Value:") { |q| q.required true }
|
|
44
|
+
secrets[key] = value
|
|
45
|
+
|
|
46
|
+
when :done
|
|
47
|
+
break
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
[env, secrets]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def show_table(env, secrets)
|
|
57
|
+
rows = []
|
|
58
|
+
env.each { |k, v| rows << [k, v] }
|
|
59
|
+
secrets.each { |k, _| rows << [k, "********"] }
|
|
60
|
+
|
|
61
|
+
table(rows:, header: %w[Key Value])
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "tty-spinner"
|
|
5
|
+
require "tty-table"
|
|
6
|
+
require "tty-box"
|
|
7
|
+
|
|
8
|
+
module Nvoi
|
|
9
|
+
class Cli
|
|
10
|
+
module Onboard
|
|
11
|
+
# Shared UI helpers for onboard steps
|
|
12
|
+
module Ui
|
|
13
|
+
MAX_RETRIES = 3
|
|
14
|
+
|
|
15
|
+
def section(title)
|
|
16
|
+
output.puts
|
|
17
|
+
output.puts pastel.bold("─── #{title} ───")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def success(msg)
|
|
21
|
+
output.puts "#{pastel.green("✓")} #{msg}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def error(msg)
|
|
25
|
+
output.puts "#{pastel.red("✗")} #{msg}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def with_spinner(message)
|
|
29
|
+
return yield if @test_mode
|
|
30
|
+
|
|
31
|
+
spinner = TTY::Spinner.new("[:spinner] #{message}", format: :dots)
|
|
32
|
+
spinner.auto_spin
|
|
33
|
+
begin
|
|
34
|
+
result = yield
|
|
35
|
+
spinner.success("done")
|
|
36
|
+
result
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
spinner.error("failed")
|
|
39
|
+
raise
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def prompt_with_retry(message, mask: false, max: MAX_RETRIES)
|
|
44
|
+
retries = 0
|
|
45
|
+
loop do
|
|
46
|
+
value = mask ? @prompt.mask(message) : @prompt.ask(message) { |q| q.required true }
|
|
47
|
+
begin
|
|
48
|
+
yield(value) if block_given?
|
|
49
|
+
return value
|
|
50
|
+
rescue Errors::ValidationError, Errors::AuthenticationError => e
|
|
51
|
+
retries += 1
|
|
52
|
+
if retries >= max
|
|
53
|
+
error("Failed after #{max} attempts: #{e.message}")
|
|
54
|
+
raise
|
|
55
|
+
end
|
|
56
|
+
output.puts("#{e.message}. Please try again. (#{retries}/#{max})")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def table(rows:, header: nil)
|
|
62
|
+
t = TTY::Table.new(header:, rows:)
|
|
63
|
+
output.puts t.render(:unicode, padding: [0, 1])
|
|
64
|
+
output.puts
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def box(text)
|
|
68
|
+
output.puts TTY::Box.frame(text, padding: [0, 2], align: :center, border: :light)
|
|
69
|
+
output.puts
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def output
|
|
73
|
+
@prompt.output
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def pastel
|
|
79
|
+
@pastel ||= Pastel.new
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -61,9 +61,9 @@ module Nvoi
|
|
|
61
61
|
|
|
62
62
|
def apply_branch_override
|
|
63
63
|
branch = @options[:branch]
|
|
64
|
-
return if branch.
|
|
64
|
+
return if branch.blank?
|
|
65
65
|
|
|
66
|
-
override =
|
|
66
|
+
override = Configuration::Override.new(branch:)
|
|
67
67
|
override.apply(@config)
|
|
68
68
|
end
|
|
69
69
|
end
|