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.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -5
  3. data/Gemfile.lock +17 -8
  4. data/Rakefile +1 -1
  5. data/lib/nvoi/cli/config/command.rb +46 -41
  6. data/lib/nvoi/cli/credentials/edit/command.rb +20 -20
  7. data/lib/nvoi/cli/credentials/show/command.rb +1 -1
  8. data/lib/nvoi/cli/db/command.rb +10 -10
  9. data/lib/nvoi/cli/delete/command.rb +2 -2
  10. data/lib/nvoi/cli/deploy/command.rb +29 -13
  11. data/lib/nvoi/cli/deploy/steps/build_image.rb +48 -6
  12. data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +2 -2
  13. data/lib/nvoi/cli/deploy/steps/deploy_service.rb +3 -13
  14. data/lib/nvoi/cli/deploy/steps/provision_server.rb +1 -1
  15. data/lib/nvoi/cli/deploy/steps/provision_volume.rb +1 -1
  16. data/lib/nvoi/cli/exec/command.rb +3 -3
  17. data/lib/nvoi/cli/logs/command.rb +2 -2
  18. data/lib/nvoi/cli/onboard/command.rb +176 -622
  19. data/lib/nvoi/cli/onboard/steps/app.rb +108 -0
  20. data/lib/nvoi/cli/onboard/steps/app_name.rb +26 -0
  21. data/lib/nvoi/cli/onboard/steps/compute.rb +139 -0
  22. data/lib/nvoi/cli/onboard/steps/database.rb +97 -0
  23. data/lib/nvoi/cli/onboard/steps/domain.rb +48 -0
  24. data/lib/nvoi/cli/onboard/steps/env.rb +67 -0
  25. data/lib/nvoi/cli/onboard/ui.rb +84 -0
  26. data/lib/nvoi/cli/unlock/command.rb +2 -2
  27. data/lib/nvoi/cli.rb +0 -32
  28. data/lib/nvoi/configuration/app_service.rb +54 -0
  29. data/lib/nvoi/configuration/application.rb +44 -0
  30. data/lib/nvoi/configuration/builder.rb +417 -0
  31. data/lib/nvoi/configuration/database.rb +56 -0
  32. data/lib/nvoi/configuration/deploy.rb +15 -0
  33. data/lib/nvoi/{objects/service_spec.rb → configuration/deployment.rb} +4 -3
  34. data/lib/nvoi/{objects/config_override.rb → configuration/override.rb} +4 -4
  35. data/lib/nvoi/configuration/providers.rb +78 -0
  36. data/lib/nvoi/configuration/result.rb +43 -0
  37. data/lib/nvoi/configuration/root.rb +234 -0
  38. data/lib/nvoi/configuration/server.rb +39 -0
  39. data/lib/nvoi/configuration/service.rb +62 -0
  40. data/lib/nvoi/external/cloud/aws.rb +12 -12
  41. data/lib/nvoi/external/cloud/hetzner.rb +7 -7
  42. data/lib/nvoi/external/cloud/scaleway.rb +7 -7
  43. data/lib/nvoi/external/cloud/types.rb +42 -0
  44. data/lib/nvoi/external/containerd.rb +1 -48
  45. data/lib/nvoi/external/database/mysql.rb +1 -1
  46. data/lib/nvoi/external/database/postgres.rb +1 -1
  47. data/lib/nvoi/external/database/provider.rb +1 -1
  48. data/lib/nvoi/external/database/sqlite.rb +1 -1
  49. data/lib/nvoi/external/database/types.rb +55 -0
  50. data/lib/nvoi/external/dns/cloudflare.rb +6 -6
  51. data/lib/nvoi/external/dns/types.rb +24 -0
  52. data/lib/nvoi/external/ssh.rb +0 -12
  53. data/lib/nvoi/external/ssh_tunnel.rb +100 -0
  54. data/lib/nvoi/utils/config_loader.rb +12 -12
  55. data/lib/nvoi/utils/credential_store.rb +4 -4
  56. data/lib/nvoi/utils/env_resolver.rb +3 -3
  57. data/lib/nvoi/utils/namer.rb +2 -2
  58. data/lib/nvoi/utils/presence.rb +23 -0
  59. data/lib/nvoi/version.rb +1 -1
  60. data/lib/nvoi.rb +2 -17
  61. metadata +96 -57
  62. data/.claude/todo/refactor/00-overview.md +0 -171
  63. data/.claude/todo/refactor/01-objects.md +0 -96
  64. data/.claude/todo/refactor/02-utils.md +0 -143
  65. data/.claude/todo/refactor/03-external-cloud.md +0 -164
  66. data/.claude/todo/refactor/04-external-dns.md +0 -104
  67. data/.claude/todo/refactor/05-external.md +0 -133
  68. data/.claude/todo/refactor/06-cli.md +0 -123
  69. data/.claude/todo/refactor/07-cli-deploy-command.md +0 -177
  70. data/.claude/todo/refactor/08-cli-deploy-steps.md +0 -201
  71. data/.claude/todo/refactor/09-cli-delete-command.md +0 -169
  72. data/.claude/todo/refactor/10-cli-exec-command.md +0 -157
  73. data/.claude/todo/refactor/11-cli-credentials-command.md +0 -190
  74. data/.claude/todo/refactor/12-cli-db-command.md +0 -128
  75. data/.claude/todo/refactor/_target.md +0 -79
  76. data/.claude/todo/refactor-execution/00-entrypoint.md +0 -49
  77. data/.claude/todo/refactor-execution/01-objects.md +0 -42
  78. data/.claude/todo/refactor-execution/02-utils.md +0 -41
  79. data/.claude/todo/refactor-execution/03-external-cloud.md +0 -38
  80. data/.claude/todo/refactor-execution/04-external-dns.md +0 -35
  81. data/.claude/todo/refactor-execution/05-external-other.md +0 -46
  82. data/.claude/todo/refactor-execution/06-cli-deploy.md +0 -45
  83. data/.claude/todo/refactor-execution/07-cli-delete.md +0 -43
  84. data/.claude/todo/refactor-execution/08-cli-exec.md +0 -30
  85. data/.claude/todo/refactor-execution/09-cli-credentials.md +0 -34
  86. data/.claude/todo/refactor-execution/10-cli-db.md +0 -31
  87. data/.claude/todo/refactor-execution/11-cli-router.md +0 -44
  88. data/.claude/todo/refactor-execution/12-cleanup.md +0 -120
  89. data/.claude/todo/refactor-execution/_monitoring-strategy.md +0 -126
  90. data/.claude/todo/scaleway.impl.md +0 -644
  91. data/.claude/todo/scaleway.reference.md +0 -520
  92. data/.claude/todos.md +0 -550
  93. data/ingest +0 -0
  94. data/lib/nvoi/config_api/actions/app.rb +0 -53
  95. data/lib/nvoi/config_api/actions/compute_provider.rb +0 -55
  96. data/lib/nvoi/config_api/actions/database.rb +0 -70
  97. data/lib/nvoi/config_api/actions/domain_provider.rb +0 -40
  98. data/lib/nvoi/config_api/actions/env.rb +0 -32
  99. data/lib/nvoi/config_api/actions/init.rb +0 -67
  100. data/lib/nvoi/config_api/actions/secret.rb +0 -32
  101. data/lib/nvoi/config_api/actions/server.rb +0 -66
  102. data/lib/nvoi/config_api/actions/service.rb +0 -52
  103. data/lib/nvoi/config_api/actions/volume.rb +0 -40
  104. data/lib/nvoi/config_api/base.rb +0 -38
  105. data/lib/nvoi/config_api/result.rb +0 -26
  106. data/lib/nvoi/config_api.rb +0 -93
  107. data/lib/nvoi/objects/configuration.rb +0 -483
  108. data/lib/nvoi/objects/database.rb +0 -56
  109. data/lib/nvoi/objects/dns.rb +0 -14
  110. data/lib/nvoi/objects/firewall.rb +0 -11
  111. data/lib/nvoi/objects/network.rb +0 -11
  112. data/lib/nvoi/objects/server.rb +0 -14
  113. data/lib/nvoi/objects/tunnel.rb +0 -14
  114. 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.nil? || branch.empty?
64
+ return if branch.blank?
65
65
 
66
- override = Objects::ConfigOverride.new(branch:)
66
+ override = Configuration::Override.new(branch:)
67
67
  override.apply(@config)
68
68
  end
69
69
  end