nvoi 0.1.8 → 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 (110) 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 +2 -2
  11. data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +2 -2
  12. data/lib/nvoi/cli/deploy/steps/provision_server.rb +1 -1
  13. data/lib/nvoi/cli/deploy/steps/provision_volume.rb +1 -1
  14. data/lib/nvoi/cli/exec/command.rb +3 -3
  15. data/lib/nvoi/cli/logs/command.rb +2 -2
  16. data/lib/nvoi/cli/onboard/command.rb +176 -622
  17. data/lib/nvoi/cli/onboard/steps/app.rb +108 -0
  18. data/lib/nvoi/cli/onboard/steps/app_name.rb +26 -0
  19. data/lib/nvoi/cli/onboard/steps/compute.rb +139 -0
  20. data/lib/nvoi/cli/onboard/steps/database.rb +97 -0
  21. data/lib/nvoi/cli/onboard/steps/domain.rb +48 -0
  22. data/lib/nvoi/cli/onboard/steps/env.rb +67 -0
  23. data/lib/nvoi/cli/onboard/ui.rb +84 -0
  24. data/lib/nvoi/cli/unlock/command.rb +2 -2
  25. data/lib/nvoi/cli.rb +0 -32
  26. data/lib/nvoi/configuration/app_service.rb +54 -0
  27. data/lib/nvoi/configuration/application.rb +44 -0
  28. data/lib/nvoi/configuration/builder.rb +417 -0
  29. data/lib/nvoi/configuration/database.rb +56 -0
  30. data/lib/nvoi/configuration/deploy.rb +15 -0
  31. data/lib/nvoi/{objects/service_spec.rb → configuration/deployment.rb} +4 -3
  32. data/lib/nvoi/{objects/config_override.rb → configuration/override.rb} +4 -4
  33. data/lib/nvoi/configuration/providers.rb +78 -0
  34. data/lib/nvoi/configuration/result.rb +43 -0
  35. data/lib/nvoi/configuration/root.rb +234 -0
  36. data/lib/nvoi/configuration/server.rb +39 -0
  37. data/lib/nvoi/configuration/service.rb +62 -0
  38. data/lib/nvoi/external/cloud/aws.rb +12 -12
  39. data/lib/nvoi/external/cloud/hetzner.rb +7 -7
  40. data/lib/nvoi/external/cloud/scaleway.rb +7 -7
  41. data/lib/nvoi/external/cloud/types.rb +42 -0
  42. data/lib/nvoi/external/database/mysql.rb +1 -1
  43. data/lib/nvoi/external/database/postgres.rb +1 -1
  44. data/lib/nvoi/external/database/provider.rb +1 -1
  45. data/lib/nvoi/external/database/sqlite.rb +1 -1
  46. data/lib/nvoi/external/database/types.rb +55 -0
  47. data/lib/nvoi/external/dns/cloudflare.rb +6 -6
  48. data/lib/nvoi/external/dns/types.rb +24 -0
  49. data/lib/nvoi/utils/config_loader.rb +12 -12
  50. data/lib/nvoi/utils/credential_store.rb +4 -4
  51. data/lib/nvoi/utils/env_resolver.rb +3 -3
  52. data/lib/nvoi/utils/namer.rb +2 -2
  53. data/lib/nvoi/utils/presence.rb +23 -0
  54. data/lib/nvoi/version.rb +1 -1
  55. data/lib/nvoi.rb +2 -17
  56. metadata +95 -58
  57. data/.claude/todo/refactor/00-overview.md +0 -171
  58. data/.claude/todo/refactor/01-objects.md +0 -96
  59. data/.claude/todo/refactor/02-utils.md +0 -143
  60. data/.claude/todo/refactor/03-external-cloud.md +0 -164
  61. data/.claude/todo/refactor/04-external-dns.md +0 -104
  62. data/.claude/todo/refactor/05-external.md +0 -133
  63. data/.claude/todo/refactor/06-cli.md +0 -123
  64. data/.claude/todo/refactor/07-cli-deploy-command.md +0 -177
  65. data/.claude/todo/refactor/08-cli-deploy-steps.md +0 -201
  66. data/.claude/todo/refactor/09-cli-delete-command.md +0 -169
  67. data/.claude/todo/refactor/10-cli-exec-command.md +0 -157
  68. data/.claude/todo/refactor/11-cli-credentials-command.md +0 -190
  69. data/.claude/todo/refactor/12-cli-db-command.md +0 -128
  70. data/.claude/todo/refactor/_target.md +0 -79
  71. data/.claude/todo/refactor-execution/00-entrypoint.md +0 -49
  72. data/.claude/todo/refactor-execution/01-objects.md +0 -42
  73. data/.claude/todo/refactor-execution/02-utils.md +0 -41
  74. data/.claude/todo/refactor-execution/03-external-cloud.md +0 -38
  75. data/.claude/todo/refactor-execution/04-external-dns.md +0 -35
  76. data/.claude/todo/refactor-execution/05-external-other.md +0 -46
  77. data/.claude/todo/refactor-execution/06-cli-deploy.md +0 -45
  78. data/.claude/todo/refactor-execution/07-cli-delete.md +0 -43
  79. data/.claude/todo/refactor-execution/08-cli-exec.md +0 -30
  80. data/.claude/todo/refactor-execution/09-cli-credentials.md +0 -34
  81. data/.claude/todo/refactor-execution/10-cli-db.md +0 -31
  82. data/.claude/todo/refactor-execution/11-cli-router.md +0 -44
  83. data/.claude/todo/refactor-execution/12-cleanup.md +0 -120
  84. data/.claude/todo/refactor-execution/_monitoring-strategy.md +0 -126
  85. data/.claude/todo/scaleway.impl.md +0 -644
  86. data/.claude/todo/scaleway.reference.md +0 -520
  87. data/.claude/todos/buckets.md +0 -41
  88. data/.claude/todos.md +0 -550
  89. data/ingest +0 -0
  90. data/lib/nvoi/config_api/actions/app.rb +0 -53
  91. data/lib/nvoi/config_api/actions/compute_provider.rb +0 -55
  92. data/lib/nvoi/config_api/actions/database.rb +0 -70
  93. data/lib/nvoi/config_api/actions/domain_provider.rb +0 -40
  94. data/lib/nvoi/config_api/actions/env.rb +0 -32
  95. data/lib/nvoi/config_api/actions/init.rb +0 -67
  96. data/lib/nvoi/config_api/actions/secret.rb +0 -32
  97. data/lib/nvoi/config_api/actions/server.rb +0 -66
  98. data/lib/nvoi/config_api/actions/service.rb +0 -52
  99. data/lib/nvoi/config_api/actions/volume.rb +0 -40
  100. data/lib/nvoi/config_api/base.rb +0 -38
  101. data/lib/nvoi/config_api/result.rb +0 -26
  102. data/lib/nvoi/config_api.rb +0 -93
  103. data/lib/nvoi/objects/configuration.rb +0 -483
  104. data/lib/nvoi/objects/database.rb +0 -56
  105. data/lib/nvoi/objects/dns.rb +0 -14
  106. data/lib/nvoi/objects/firewall.rb +0 -11
  107. data/lib/nvoi/objects/network.rb +0 -11
  108. data/lib/nvoi/objects/server.rb +0 -14
  109. data/lib/nvoi/objects/tunnel.rb +0 -14
  110. data/lib/nvoi/objects/volume.rb +0 -17
@@ -2,9 +2,6 @@
2
2
 
3
3
  require "stringio"
4
4
  require "tty-prompt"
5
- require "tty-box"
6
- require "tty-spinner"
7
- require "tty-table"
8
5
  require "nvoi/utils/credential_store"
9
6
 
10
7
  module Nvoi
@@ -12,536 +9,126 @@ module Nvoi
12
9
  module Onboard
13
10
  # Interactive onboarding wizard for quick setup
14
11
  class Command
15
- MAX_RETRIES = 3
12
+ include Onboard::Ui
13
+
14
+ SUMMARY_ACTIONS = [
15
+ { name: "Save configuration", value: :save },
16
+ { name: "Edit application name", value: :app_name },
17
+ { name: "Edit compute provider", value: :compute },
18
+ { name: "Edit domain provider", value: :domain },
19
+ { name: "Edit apps", value: :apps },
20
+ { name: "Edit database", value: :database },
21
+ { name: "Edit environment variables", value: :env },
22
+ { name: "Start over", value: :restart },
23
+ { name: "Cancel (discard)", value: :cancel }
24
+ ].freeze
16
25
 
17
26
  def initialize(prompt: nil)
18
27
  @prompt = prompt || TTY::Prompt.new
19
- @data = { "application" => {} }
20
28
  @test_mode = prompt&.input.is_a?(StringIO)
29
+ @data = default_data
30
+ @domain_step = nil
21
31
  end
22
32
 
23
33
  def run
24
34
  show_welcome
25
-
26
- step_app_name
27
- step_compute_provider
28
- step_domain_provider
29
- step_apps
30
- step_database
31
- step_env
32
-
35
+ collect_all
33
36
  summary_loop
34
-
35
37
  show_next_steps
36
38
  rescue TTY::Reader::InputInterrupt
37
- puts "\n\nSetup cancelled."
39
+ output.puts "\n\nSetup cancelled."
38
40
  exit 1
39
41
  end
40
42
 
41
43
  private
42
44
 
43
- # ─────────────────────────────────────────────────────────────────
44
- # Welcome
45
- # ─────────────────────────────────────────────────────────────────
46
-
47
- def show_welcome
48
- box = TTY::Box.frame(
49
- "NVOI Quick Setup",
50
- padding: [0, 2],
51
- align: :center,
52
- border: :light
53
- )
54
- puts box
55
- puts
56
- end
57
-
58
- # ─────────────────────────────────────────────────────────────────
59
- # Step 1: App Name
60
- # ─────────────────────────────────────────────────────────────────
61
-
62
- def step_app_name
63
- name = @prompt.ask("Application name:") do |q|
64
- q.required true
65
- q.validate(/\A[a-z0-9_-]+\z/i, "Only letters, numbers, dashes, underscores")
66
- end
67
- @data["application"]["name"] = name
68
- end
69
-
70
- # ─────────────────────────────────────────────────────────────────
71
- # Step 2: Compute Provider
72
- # ─────────────────────────────────────────────────────────────────
73
-
74
- def step_compute_provider
75
- puts
76
- puts section("Compute Provider")
77
-
78
- provider = @prompt.select("Select provider:") do |menu|
79
- menu.choice "Hetzner (recommended)", :hetzner
80
- menu.choice "AWS", :aws
81
- menu.choice "Scaleway", :scaleway
82
- end
83
-
84
- case provider
85
- when :hetzner then setup_hetzner
86
- when :aws then setup_aws
87
- when :scaleway then setup_scaleway
88
- end
89
- end
90
-
91
- def setup_hetzner
92
- token = prompt_with_retry("Hetzner API Token:", mask: true) do |t|
93
- client = External::Cloud::Hetzner.new(t)
94
- client.validate_credentials
95
- @hetzner_client = client
96
- end
97
-
98
- types, locations = with_spinner("Fetching options...") do
99
- [@hetzner_client.list_server_types, @hetzner_client.list_locations]
100
- end
101
-
102
- type_choices = types.sort_by { |t| t[:name] }.map do |t|
103
- price = t[:price] ? " - #{t[:price]}/mo" : ""
104
- { name: "#{t[:name]} (#{t[:cores]} vCPU, #{t[:memory] / 1024}GB#{price})", value: t[:name] }
105
- end
106
-
107
- location_choices = locations.map do |l|
108
- { name: "#{l[:name]} (#{l[:city]}, #{l[:country]})", value: l[:name] }
109
- end
110
-
111
- server_type = @prompt.select("Server type:", type_choices, per_page: 10)
112
- location = @prompt.select("Location:", location_choices)
113
-
114
- @data["application"]["compute_provider"] = {
115
- "hetzner" => {
116
- "api_token" => token,
117
- "server_type" => server_type,
118
- "server_location" => location
119
- }
45
+ def default_data
46
+ {
47
+ name: nil,
48
+ compute: nil,
49
+ domain: nil,
50
+ apps: {},
51
+ database: nil,
52
+ volumes: nil,
53
+ env: {},
54
+ secrets: {}
120
55
  }
121
56
  end
122
57
 
123
- def setup_aws
124
- access_key = prompt_with_retry("AWS Access Key ID:") do |k|
125
- raise Errors::ValidationError, "Invalid format" unless k.match?(/\AAKIA/)
126
- end
127
-
128
- secret_key = @prompt.mask("AWS Secret Access Key:")
129
-
130
- # Get regions first with temp client
131
- temp_client = External::Cloud::Aws.new(access_key, secret_key, "us-east-1")
132
- regions = with_spinner("Validating credentials...") do
133
- temp_client.validate_credentials
134
- temp_client.list_regions
135
- end
136
-
137
- region_choices = regions.map { |r| r[:name] }.sort
138
- region = @prompt.select("Region:", region_choices, per_page: 10, filter: true)
139
-
140
- # Now get instance types for selected region
141
- client = External::Cloud::Aws.new(access_key, secret_key, region)
142
- types = client.list_instance_types
143
-
144
- type_choices = types.map do |t|
145
- mem = t[:memory] ? " #{t[:memory] / 1024}GB" : ""
146
- { name: "#{t[:name]} (#{t[:vcpus]} vCPU#{mem})", value: t[:name] }
147
- end
58
+ def collect_all
59
+ @data[:name] = Steps::AppName.new(@prompt, test_mode: @test_mode).call
60
+ @data[:compute] = Steps::Compute.new(@prompt, test_mode: @test_mode).call
148
61
 
149
- instance_type = @prompt.select("Instance type:", type_choices)
62
+ @domain_step = Steps::Domain.new(@prompt, test_mode: @test_mode)
63
+ @data[:domain] = @domain_step.call
150
64
 
151
- @data["application"]["compute_provider"] = {
152
- "aws" => {
153
- "access_key_id" => access_key,
154
- "secret_access_key" => secret_key,
155
- "region" => region,
156
- "instance_type" => instance_type
157
- }
158
- }
65
+ collect_apps
66
+ collect_database
67
+ collect_env
159
68
  end
160
69
 
161
- def setup_scaleway
162
- secret_key = prompt_with_retry("Scaleway Secret Key:", mask: true)
163
- project_id = @prompt.ask("Scaleway Project ID:") { |q| q.required true }
164
-
165
- # Get zones (static list)
166
- temp_client = External::Cloud::Scaleway.new(secret_key, project_id)
167
- zones = temp_client.list_zones
168
-
169
- zone_choices = zones.map { |z| { name: "#{z[:name]} (#{z[:city]})", value: z[:name] } }
170
- zone = @prompt.select("Zone:", zone_choices)
171
-
172
- # Validate and get server types
173
- client = External::Cloud::Scaleway.new(secret_key, project_id, zone:)
174
- types = with_spinner("Validating credentials...") do
175
- client.validate_credentials
176
- client.list_server_types
177
- end
178
-
179
- type_choices = types.map do |t|
180
- { name: "#{t[:name]} (#{t[:cores]} cores)", value: t[:name] }
181
- end
182
-
183
- server_type = @prompt.select("Server type:", type_choices, per_page: 10, filter: true)
184
-
185
- @data["application"]["compute_provider"] = {
186
- "scaleway" => {
187
- "secret_key" => secret_key,
188
- "project_id" => project_id,
189
- "zone" => zone,
190
- "server_type" => server_type
191
- }
192
- }
193
- end
194
-
195
- # ─────────────────────────────────────────────────────────────────
196
- # Step 3: Domain Provider
197
- # ─────────────────────────────────────────────────────────────────
198
-
199
- def step_domain_provider
200
- puts
201
- puts section("Domain Provider")
202
-
203
- setup = @prompt.yes?("Configure Cloudflare for domains/tunnels?")
204
- return unless setup
205
-
206
- token = prompt_with_retry("Cloudflare API Token:", mask: true)
207
- account_id = @prompt.ask("Cloudflare Account ID:") { |q| q.required true }
208
-
209
- @cloudflare_client = External::Dns::Cloudflare.new(token, account_id)
210
-
211
- @cloudflare_zones = with_spinner("Fetching domains...") do
212
- @cloudflare_client.validate_credentials
213
- @cloudflare_client.list_zones.select { |z| z[:status] == "active" }
214
- end
215
-
216
- if @cloudflare_zones.empty?
217
- warn "No active domains found in Cloudflare account"
218
- end
219
-
220
- @data["application"]["domain_provider"] = {
221
- "cloudflare" => {
222
- "api_token" => token,
223
- "account_id" => account_id
224
- }
225
- }
226
- end
227
-
228
- # ─────────────────────────────────────────────────────────────────
229
- # Step 4: Apps (loop)
230
- # ─────────────────────────────────────────────────────────────────
231
-
232
- def step_apps
233
- puts
234
- puts section("Applications")
235
-
236
- # Ensure we have a server
237
- @data["application"]["servers"] ||= {}
238
- @data["application"]["servers"]["main"] = { "master" => true, "count" => 1 }
239
-
240
- @data["application"]["app"] ||= {}
70
+ def collect_apps
71
+ section "Applications"
241
72
 
242
73
  loop do
243
- name = @prompt.ask("App name:") { |q| q.required true }
244
- command = @prompt.ask("Run command (optional, leave blank for Docker entrypoint):")
245
- port = @prompt.ask("Port (optional, leave blank for background workers):")
246
- port = port.to_i if port && !port.to_s.empty?
247
-
248
- app_config = { "servers" => ["main"] }
249
- app_config["command"] = command unless command.to_s.empty?
250
- app_config["port"] = port if port && port > 0
251
-
252
- # Domain selection only if port is set (web-facing) and Cloudflare configured
253
- if port && port > 0 && @cloudflare_zones&.any?
254
- domain, subdomain = prompt_domain_selection
255
- if domain
256
- app_config["domain"] = domain
257
- app_config["subdomain"] = subdomain unless subdomain.to_s.empty?
258
- end
259
- end
260
-
261
- pre_run = @prompt.ask("Pre-run command (e.g. migrations):")
262
- app_config["pre_run_command"] = pre_run unless pre_run.to_s.empty?
263
-
264
- @data["application"]["app"][name] = app_config
74
+ name, config = Steps::App.new(@prompt, test_mode: @test_mode).call(
75
+ zones: @domain_step&.zones || [],
76
+ cloudflare_client: @domain_step&.client
77
+ )
78
+ @data[:apps][name] = config
265
79
 
266
80
  break unless @prompt.yes?("Add another app?")
267
81
  end
268
82
  end
269
83
 
270
- def prompt_domain_selection
271
- domain_choices = @cloudflare_zones.map { |z| { name: z[:name], value: z } }
272
- domain_choices << { name: "Skip (no domain)", value: nil }
273
-
274
- selected = @prompt.select("Domain:", domain_choices)
275
- return [nil, nil] unless selected
276
-
277
- zone_id = selected[:id]
278
- domain = selected[:name]
279
-
280
- # Prompt for subdomain with validation
281
- subdomain = prompt_subdomain(zone_id, domain)
282
-
283
- [domain, subdomain]
284
- end
285
-
286
- def prompt_subdomain(zone_id, domain)
287
- loop do
288
- subdomain = @prompt.ask("Subdomain (leave blank for #{domain} + *.#{domain}):")
289
- subdomain = subdomain.to_s.strip.downcase
84
+ def collect_database
85
+ db_config, volume_config = Steps::Database.new(@prompt, test_mode: @test_mode)
86
+ .call(app_name: @data[:name])
290
87
 
291
- # Validate subdomain format if provided
292
- if !subdomain.empty? && !subdomain.match?(/\A[a-z0-9]([a-z0-9-]*[a-z0-9])?\z/)
293
- error("Invalid subdomain format. Use lowercase letters, numbers, and hyphens.")
294
- next
295
- end
296
-
297
- # Check availability for all hostnames that will be created
298
- hostnames = Utils::Namer.build_hostnames(subdomain.empty? ? nil : subdomain, domain)
299
- all_available = true
300
-
301
- hostnames.each do |hostname|
302
- available = with_spinner("Checking #{hostname}...") do
303
- check_subdomain = hostname == domain ? "" : hostname.sub(".#{domain}", "")
304
- @cloudflare_client.subdomain_available?(zone_id, check_subdomain, domain)
305
- end
306
-
307
- unless available
308
- error("#{hostname} already has a DNS record. Choose a different subdomain.")
309
- all_available = false
310
- break
311
- end
312
- end
313
-
314
- return subdomain if all_available
315
- end
88
+ @data[:database] = db_config
89
+ @data[:volumes] = volume_config
316
90
  end
317
91
 
318
- # ─────────────────────────────────────────────────────────────────
319
- # Step 5: Database
320
- # ─────────────────────────────────────────────────────────────────
321
-
322
- def step_database
323
- puts
324
- puts section("Database")
325
-
326
- adapter = @prompt.select("Database:") do |menu|
327
- menu.choice "PostgreSQL", "postgres"
328
- menu.choice "MySQL", "mysql"
329
- menu.choice "SQLite", "sqlite3"
330
- menu.choice "None (skip)", nil
331
- end
332
-
333
- return unless adapter
334
-
335
- db_config = {
336
- "servers" => ["main"],
337
- "adapter" => adapter
338
- }
339
-
340
- case adapter
341
- when "postgres"
342
- db_name = @prompt.ask("Database name:", default: "#{@data["application"]["name"]}_production")
343
- user = @prompt.ask("Database user:", default: @data["application"]["name"])
344
- password = @prompt.mask("Database password:") { |q| q.required true }
345
-
346
- db_config["secrets"] = {
347
- "POSTGRES_DB" => db_name,
348
- "POSTGRES_USER" => user,
349
- "POSTGRES_PASSWORD" => password
350
- }
351
-
352
- # Auto-add volume
353
- @data["application"]["servers"]["main"]["volumes"] = {
354
- "postgres_data" => { "size" => 10 }
355
- }
356
-
357
- when "mysql"
358
- db_name = @prompt.ask("Database name:", default: "#{@data["application"]["name"]}_production")
359
- user = @prompt.ask("Database user:", default: @data["application"]["name"])
360
- password = @prompt.mask("Database password:") { |q| q.required true }
361
-
362
- db_config["secrets"] = {
363
- "MYSQL_DATABASE" => db_name,
364
- "MYSQL_USER" => user,
365
- "MYSQL_PASSWORD" => password
366
- }
367
-
368
- # Auto-add volume
369
- @data["application"]["servers"]["main"]["volumes"] = {
370
- "mysql_data" => { "size" => 10 }
371
- }
372
-
373
- when "sqlite3"
374
- path = @prompt.ask("Database path:", default: "/app/data/production.sqlite3")
375
- db_config["path"] = path
376
- db_config["mount"] = { "data" => "/app/data" }
377
-
378
- # Auto-add volume
379
- @data["application"]["servers"]["main"]["volumes"] = {
380
- "sqlite_data" => { "size" => 10 }
381
- }
382
- end
383
-
384
- @data["application"]["database"] = db_config
92
+ def collect_env
93
+ @data[:env], @data[:secrets] = Steps::Env.new(@prompt, test_mode: @test_mode)
94
+ .call(existing_env: @data[:env], existing_secrets: @data[:secrets])
385
95
  end
386
96
 
387
- # ─────────────────────────────────────────────────────────────────
388
- # Step 6: Environment Variables
389
- # ─────────────────────────────────────────────────────────────────
390
-
391
- def step_env
392
- puts
393
- puts section("Environment Variables")
394
-
395
- @data["application"]["env"] ||= {}
396
- @data["application"]["secrets"] ||= {}
397
-
398
- # Add default
399
- @data["application"]["env"]["RAILS_ENV"] = "production"
400
-
401
- loop do
402
- show_env_table
403
-
404
- choice = @prompt.select("Action:") do |menu|
405
- menu.choice "Add variable", :add
406
- menu.choice "Add secret (masked)", :secret
407
- menu.choice "Done", :done
408
- end
409
-
410
- case choice
411
- when :add
412
- key = @prompt.ask("Variable name:") { |q| q.required true }
413
- value = @prompt.ask("Value:") { |q| q.required true }
414
- @data["application"]["env"][key] = value
415
-
416
- when :secret
417
- key = @prompt.ask("Secret name:") { |q| q.required true }
418
- value = @prompt.mask("Value:") { |q| q.required true }
419
- @data["application"]["secrets"][key] = value
420
-
421
- when :done
422
- break
423
- end
424
- end
425
- end
426
-
427
- def show_env_table
428
- return if @data["application"]["env"].empty? && @data["application"]["secrets"].empty?
429
-
430
- rows = []
431
- @data["application"]["env"].each { |k, v| rows << [k, v] }
432
- @data["application"]["secrets"].each { |k, _| rows << [k, "********"] }
433
-
434
- table = TTY::Table.new(header: %w[Key Value], rows:)
435
- puts table.render(:unicode, padding: [0, 1])
436
- puts
437
- end
438
-
439
- # ─────────────────────────────────────────────────────────────────
440
- # Summary & Save
441
- # ─────────────────────────────────────────────────────────────────
442
-
443
- def show_summary
444
- puts
445
- puts section("Summary")
446
-
447
- provider_name = @data["application"]["compute_provider"]&.keys&.first || "none"
448
- provider_info = case provider_name
449
- when "hetzner"
450
- cfg = @data["application"]["compute_provider"]["hetzner"]
451
- "#{cfg["server_type"]} @ #{cfg["server_location"]}"
452
- when "aws"
453
- cfg = @data["application"]["compute_provider"]["aws"]
454
- "#{cfg["instance_type"]} @ #{cfg["region"]}"
455
- when "scaleway"
456
- cfg = @data["application"]["compute_provider"]["scaleway"]
457
- "#{cfg["server_type"]} @ #{cfg["zone"]}"
458
- else
459
- "not configured"
460
- end
461
-
462
- domain_ok = @data["application"]["domain_provider"]&.any? ? "configured" : "not configured"
463
-
464
- # Build app list with domains
465
- app_list = @data["application"]["app"]&.map do |name, cfg|
466
- if cfg["domain"]
467
- fqdn = cfg["subdomain"] ? "#{cfg["subdomain"]}.#{cfg["domain"]}" : cfg["domain"]
468
- "#{name} (#{fqdn})"
469
- else
470
- name
471
- end
472
- end&.join(", ") || "none"
473
- db = @data["application"]["database"]&.dig("adapter") || "none"
474
- env_count = (@data["application"]["env"]&.size || 0) + (@data["application"]["secrets"]&.size || 0)
475
-
476
- rows = [
477
- ["Application", @data["application"]["name"]],
478
- ["Provider", "#{provider_name} (#{provider_info})"],
479
- ["Domain", "Cloudflare #{domain_ok}"],
480
- ["Apps", app_list],
481
- ["Database", db],
482
- ["Env/Secrets", "#{env_count} variables"]
483
- ]
484
-
485
- table = TTY::Table.new(rows:)
486
- puts table.render(:unicode, padding: [0, 1])
487
- puts
488
- end
97
+ # ─── Summary Loop ───
489
98
 
490
99
  def summary_loop
491
100
  loop do
492
101
  show_summary
493
102
 
494
- action = @prompt.select("What would you like to do?") do |menu|
495
- menu.choice "Save configuration", :save
496
- menu.choice "Edit application name", :app_name
497
- menu.choice "Edit compute provider", :compute
498
- menu.choice "Edit domain provider", :domain
499
- menu.choice "Edit apps", :apps
500
- menu.choice "Edit database", :database
501
- menu.choice "Edit environment variables", :env
502
- menu.choice "Start over", :restart
503
- menu.choice "Cancel (discard)", :cancel
504
- end
505
-
506
- case action
103
+ case @prompt.select("What would you like to do?", SUMMARY_ACTIONS)
507
104
  when :save
508
105
  save_config
509
106
  return
510
107
  when :cancel
511
108
  return if @prompt.yes?("Discard all changes?")
512
109
  when :app_name
513
- step_app_name
110
+ @data[:name] = Steps::AppName.new(@prompt, test_mode: @test_mode)
111
+ .call(existing: @data[:name])
514
112
  when :compute
515
- step_compute_provider
113
+ @data[:compute] = Steps::Compute.new(@prompt, test_mode: @test_mode).call
516
114
  when :domain
517
- step_domain_provider
115
+ @domain_step = Steps::Domain.new(@prompt, test_mode: @test_mode)
116
+ @data[:domain] = @domain_step.call
518
117
  when :apps
519
118
  edit_apps
520
119
  when :database
521
- step_database
120
+ collect_database
522
121
  when :env
523
- step_env
122
+ collect_env
524
123
  when :restart
525
- if @prompt.yes?("This will clear all data. Continue?")
526
- @data = { "application" => {} }
527
- @cloudflare_client = nil
528
- @cloudflare_zones = nil
529
- step_app_name
530
- step_compute_provider
531
- step_domain_provider
532
- step_apps
533
- step_database
534
- step_env
535
- end
124
+ restart_wizard
536
125
  end
537
126
  end
538
127
  end
539
128
 
540
129
  def edit_apps
541
130
  loop do
542
- app_names = @data["application"]["app"]&.keys || []
543
-
544
- choices = app_names.map { |name| { name:, value: name } }
131
+ choices = @data[:apps].keys.map { |name| { name:, value: name } }
545
132
  choices << { name: "Add new app", value: :add }
546
133
  choices << { name: "Done", value: :done }
547
134
 
@@ -549,195 +136,157 @@ module Nvoi
549
136
 
550
137
  case selected
551
138
  when :add
552
- add_single_app
139
+ name, config = Steps::App.new(@prompt, test_mode: @test_mode).call(
140
+ zones: @domain_step&.zones || [],
141
+ cloudflare_client: @domain_step&.client
142
+ )
143
+ @data[:apps][name] = config
553
144
  when :done
554
145
  return
555
146
  else
556
- # Selected an app name - show actions
557
- app_action = @prompt.select("#{selected}:") do |menu|
558
- menu.choice "Edit", :edit
559
- menu.choice "Delete", :delete
560
- menu.choice "Back", :back
561
- end
562
-
563
- case app_action
564
- when :edit
565
- edit_single_app(selected)
566
- when :delete
567
- @data["application"]["app"].delete(selected) if @prompt.yes?("Delete #{selected}?")
568
- end
147
+ edit_single_app(selected)
569
148
  end
570
149
  end
571
150
  end
572
151
 
573
152
  def edit_single_app(name)
574
- app = @data["application"]["app"][name]
575
-
576
- new_name = @prompt.ask("App name:", default: name) { |q| q.required true }
577
- existing_cmd = app["command"]
578
- command = if existing_cmd
579
- @prompt.ask("Run command (optional):", default: existing_cmd)
580
- else
581
- @prompt.ask("Run command (optional, leave blank for Docker entrypoint):")
582
- end
583
- existing_port = app["port"]
584
- port = if existing_port
585
- @prompt.ask("Port (optional):", default: existing_port.to_s)
586
- else
587
- @prompt.ask("Port (optional, leave blank for background workers):")
588
- end
589
- port = port.to_i if port && !port.to_s.empty?
590
-
591
- new_config = { "servers" => app["servers"] || ["main"] }
592
- new_config["command"] = command unless command.to_s.empty?
593
- new_config["port"] = port if port && port > 0
594
-
595
- # Domain selection only if port is set (web-facing)
596
- if port && port > 0 && @cloudflare_zones&.any?
597
- domain, subdomain = prompt_domain_selection
598
- if domain
599
- new_config["domain"] = domain
600
- new_config["subdomain"] = subdomain unless subdomain.to_s.empty?
601
- end
153
+ action = @prompt.select("#{name}:") do |menu|
154
+ menu.choice "Edit", :edit
155
+ menu.choice "Delete", :delete
156
+ menu.choice "Back", :back
157
+ end
158
+
159
+ case action
160
+ when :edit
161
+ new_name, config = Steps::App.new(@prompt, test_mode: @test_mode).call(
162
+ existing_name: name,
163
+ existing: @data[:apps][name],
164
+ zones: @domain_step&.zones || [],
165
+ cloudflare_client: @domain_step&.client
166
+ )
167
+ @data[:apps].delete(name) if new_name != name
168
+ @data[:apps][new_name] = config
169
+ when :delete
170
+ @data[:apps].delete(name) if @prompt.yes?("Delete #{name}?")
602
171
  end
172
+ end
603
173
 
604
- existing_pre_run = app["pre_run_command"]
605
- pre_run = if existing_pre_run
606
- @prompt.ask("Pre-run command (optional):", default: existing_pre_run)
607
- else
608
- @prompt.ask("Pre-run command (optional):")
609
- end
610
- new_config["pre_run_command"] = pre_run unless pre_run.to_s.empty?
174
+ def restart_wizard
175
+ return unless @prompt.yes?("This will clear all data. Continue?")
611
176
 
612
- # Handle rename
613
- @data["application"]["app"].delete(name) if new_name != name
614
- @data["application"]["app"][new_name] = new_config
177
+ @data = default_data
178
+ @domain_step = nil
179
+ collect_all
615
180
  end
616
181
 
617
- def add_single_app
618
- name = @prompt.ask("App name:") { |q| q.required true }
619
- command = @prompt.ask("Run command (optional, leave blank for Docker entrypoint):")
620
- port = @prompt.ask("Port (optional, leave blank for background workers):")
621
- port = port.to_i if port && !port.to_s.empty?
622
-
623
- app_config = { "servers" => ["main"] }
624
- app_config["command"] = command unless command.to_s.empty?
625
- app_config["port"] = port if port && port > 0
626
-
627
- # Domain selection only if port is set (web-facing)
628
- if port && port > 0 && @cloudflare_zones&.any?
629
- domain, subdomain = prompt_domain_selection
630
- if domain
631
- app_config["domain"] = domain
632
- app_config["subdomain"] = subdomain unless subdomain.to_s.empty?
633
- end
182
+ # ─── Summary Display ───
183
+
184
+ def show_welcome
185
+ box "NVOI Quick Setup"
186
+ end
187
+
188
+ def show_summary
189
+ section "Summary"
190
+
191
+ rows = [
192
+ ["Application", @data[:name]],
193
+ ["Provider", format_provider],
194
+ ["Domain", format_domain],
195
+ ["Apps", format_apps],
196
+ ["Database", @data[:database]&.dig("adapter") || "none"],
197
+ ["Env/Secrets", "#{@data[:env].size + @data[:secrets].size} variables"]
198
+ ]
199
+
200
+ table(rows:)
201
+ end
202
+
203
+ def format_provider
204
+ return "not configured" unless @data[:compute]
205
+
206
+ provider_name = @data[:compute].keys.first
207
+ cfg = @data[:compute][provider_name]
208
+
209
+ info = case provider_name
210
+ when "hetzner" then "#{cfg["server_type"]} @ #{cfg["server_location"]}"
211
+ when "aws" then "#{cfg["instance_type"]} @ #{cfg["region"]}"
212
+ when "scaleway" then "#{cfg["server_type"]} @ #{cfg["zone"]}"
213
+ else "configured"
634
214
  end
635
215
 
636
- pre_run = @prompt.ask("Pre-run command (e.g. migrations):")
637
- app_config["pre_run_command"] = pre_run unless pre_run.to_s.empty?
216
+ "#{provider_name} (#{info})"
217
+ end
218
+
219
+ def format_domain
220
+ @data[:domain] ? "Cloudflare configured" : "Cloudflare not configured"
221
+ end
222
+
223
+ def format_apps
224
+ return "none" if @data[:apps].empty?
638
225
 
639
- @data["application"]["app"][name] = app_config
226
+ @data[:apps].map do |name, cfg|
227
+ if cfg["domain"]
228
+ fqdn = cfg["subdomain"] ? "#{cfg["subdomain"]}.#{cfg["domain"]}" : cfg["domain"]
229
+ "#{name} (#{fqdn})"
230
+ else
231
+ name
232
+ end
233
+ end.join(", ")
640
234
  end
641
235
 
236
+ # ─── Save ───
237
+
642
238
  def save_config
643
239
  with_spinner("Generating SSH keys...") do
644
- # Use ConfigApi.init to generate keys and encrypt
645
- result = ConfigApi.init(
646
- name: @data["application"]["name"],
647
- environment: "production"
648
- )
240
+ result = Configuration::Builder.init(name: @data[:name], environment: "production")
649
241
 
650
242
  if result.failure?
651
243
  raise Errors::ConfigError, "Failed to initialize: #{result.error_message}"
652
244
  end
653
245
 
654
- # Now we need to apply all our config on top
655
- # Decrypt the init result, merge our data, re-encrypt
656
246
  yaml = Utils::Crypto.decrypt(result.config, result.master_key)
657
247
  init_data = YAML.safe_load(yaml, permitted_classes: [Symbol])
658
248
 
659
- # Merge our data into init_data (keep ssh_keys from init)
660
- init_data["application"].merge!(@data["application"])
661
- init_data["application"]["ssh_keys"] = YAML.safe_load(yaml)["application"]["ssh_keys"]
662
-
663
- # Write files
664
- config_path = File.join(".", Utils::DEFAULT_ENCRYPTED_FILE)
665
- key_path = File.join(".", Utils::DEFAULT_KEY_FILE)
666
-
667
- final_yaml = YAML.dump(init_data)
668
- encrypted = Utils::Crypto.encrypt(final_yaml, result.master_key)
669
-
670
- File.binwrite(config_path, encrypted)
671
- File.write(key_path, "#{result.master_key}\n", perm: 0o600)
672
-
673
- update_gitignore
249
+ final_data = build_final_config(init_data)
250
+ write_config_files(final_data, result.master_key)
674
251
  end
675
252
 
676
- puts
677
- success("Created #{Utils::DEFAULT_ENCRYPTED_FILE}")
678
- success("Created #{Utils::DEFAULT_KEY_FILE}")
253
+ output.puts
254
+ success "Created #{Utils::DEFAULT_ENCRYPTED_FILE}"
255
+ success "Created #{Utils::DEFAULT_KEY_FILE}"
679
256
  end
680
257
 
681
- def show_next_steps
682
- puts
683
- puts "Next: #{pastel.cyan("nvoi deploy")}"
684
- end
258
+ def build_final_config(init_data)
259
+ app_data = {
260
+ "name" => @data[:name],
261
+ "ssh_keys" => init_data["application"]["ssh_keys"],
262
+ "servers" => { "main" => { "master" => true, "count" => 1 } },
263
+ "app" => @data[:apps],
264
+ "env" => @data[:env],
265
+ "secrets" => @data[:secrets]
266
+ }
685
267
 
686
- # ─────────────────────────────────────────────────────────────────
687
- # Helpers
688
- # ─────────────────────────────────────────────────────────────────
268
+ app_data["compute_provider"] = @data[:compute] if @data[:compute]
269
+ app_data["domain_provider"] = @data[:domain] if @data[:domain]
270
+ app_data["database"] = @data[:database] if @data[:database]
689
271
 
690
- def prompt_with_retry(message, mask: false, &validation)
691
- retries = 0
692
- loop do
693
- value = mask ? @prompt.mask(message) : @prompt.ask(message) { |q| q.required true }
694
-
695
- begin
696
- yield(value) if block_given?
697
- return value
698
- rescue Errors::ValidationError, Errors::AuthenticationError => e
699
- retries += 1
700
- if retries >= MAX_RETRIES
701
- error("Failed after #{MAX_RETRIES} attempts: #{e.message}")
702
- raise
703
- end
704
- warn("#{e.message}. Please try again. (#{retries}/#{MAX_RETRIES})")
705
- end
272
+ if @data[:volumes]
273
+ app_data["servers"]["main"]["volumes"] = @data[:volumes]
706
274
  end
707
- end
708
275
 
709
- def section(title)
710
- pastel.bold("─── #{title} ───")
276
+ { "application" => app_data }
711
277
  end
712
278
 
713
- def with_spinner(message)
714
- if @test_mode
715
- result = yield
716
- return result
717
- end
279
+ def write_config_files(data, master_key)
280
+ config_path = File.join(".", Utils::DEFAULT_ENCRYPTED_FILE)
281
+ key_path = File.join(".", Utils::DEFAULT_KEY_FILE)
718
282
 
719
- spinner = TTY::Spinner.new("[:spinner] #{message}", format: :dots)
720
- spinner.auto_spin
721
- begin
722
- result = yield
723
- spinner.success("done")
724
- result
725
- rescue StandardError => e
726
- spinner.error("failed")
727
- raise e
728
- end
729
- end
283
+ final_yaml = YAML.dump(data)
284
+ encrypted = Utils::Crypto.encrypt(final_yaml, master_key)
730
285
 
731
- def success(msg)
732
- puts "#{pastel.green("✓")} #{msg}"
733
- end
286
+ File.binwrite(config_path, encrypted)
287
+ File.write(key_path, "#{master_key}\n", perm: 0o600)
734
288
 
735
- def error(msg)
736
- warn "#{pastel.red("✗")} #{msg}"
737
- end
738
-
739
- def pastel
740
- @pastel ||= Pastel.new
289
+ update_gitignore
741
290
  end
742
291
 
743
292
  def update_gitignore
@@ -755,6 +304,11 @@ module Nvoi
755
304
  additions.each { |e| f.puts e }
756
305
  end
757
306
  end
307
+
308
+ def show_next_steps
309
+ output.puts
310
+ output.puts "Next: #{pastel.cyan("nvoi deploy")}"
311
+ end
758
312
  end
759
313
  end
760
314
  end