nvoi 0.1.6 → 0.1.7

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/todo/refactor/12-cli-db-command.md +128 -0
  3. data/.claude/todo/refactor-execution/00-entrypoint.md +49 -0
  4. data/.claude/todo/refactor-execution/01-objects.md +42 -0
  5. data/.claude/todo/refactor-execution/02-utils.md +41 -0
  6. data/.claude/todo/refactor-execution/03-external-cloud.md +38 -0
  7. data/.claude/todo/refactor-execution/04-external-dns.md +35 -0
  8. data/.claude/todo/refactor-execution/05-external-other.md +46 -0
  9. data/.claude/todo/refactor-execution/06-cli-deploy.md +45 -0
  10. data/.claude/todo/refactor-execution/07-cli-delete.md +43 -0
  11. data/.claude/todo/refactor-execution/08-cli-exec.md +30 -0
  12. data/.claude/todo/refactor-execution/09-cli-credentials.md +34 -0
  13. data/.claude/todo/refactor-execution/10-cli-db.md +31 -0
  14. data/.claude/todo/refactor-execution/11-cli-router.md +44 -0
  15. data/.claude/todo/refactor-execution/12-cleanup.md +120 -0
  16. data/.claude/todo/refactor-execution/_monitoring-strategy.md +126 -0
  17. data/.claude/todos.md +550 -0
  18. data/Gemfile +5 -0
  19. data/Gemfile.lock +35 -4
  20. data/Rakefile +1 -1
  21. data/ingest +0 -0
  22. data/lib/nvoi/cli/config/command.rb +219 -0
  23. data/lib/nvoi/cli/delete/steps/teardown_dns.rb +12 -11
  24. data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +15 -13
  25. data/lib/nvoi/cli/deploy/steps/deploy_service.rb +5 -2
  26. data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +10 -1
  27. data/lib/nvoi/cli/logs/command.rb +66 -0
  28. data/lib/nvoi/cli/onboard/command.rb +761 -0
  29. data/lib/nvoi/cli/unlock/command.rb +72 -0
  30. data/lib/nvoi/cli.rb +257 -0
  31. data/lib/nvoi/config_api/actions/app.rb +30 -30
  32. data/lib/nvoi/config_api/actions/compute_provider.rb +31 -31
  33. data/lib/nvoi/config_api/actions/database.rb +42 -42
  34. data/lib/nvoi/config_api/actions/domain_provider.rb +40 -0
  35. data/lib/nvoi/config_api/actions/env.rb +12 -12
  36. data/lib/nvoi/config_api/actions/init.rb +67 -0
  37. data/lib/nvoi/config_api/actions/secret.rb +12 -12
  38. data/lib/nvoi/config_api/actions/server.rb +35 -35
  39. data/lib/nvoi/config_api/actions/service.rb +52 -0
  40. data/lib/nvoi/config_api/actions/volume.rb +18 -18
  41. data/lib/nvoi/config_api/base.rb +15 -21
  42. data/lib/nvoi/config_api/result.rb +5 -5
  43. data/lib/nvoi/config_api.rb +51 -28
  44. data/lib/nvoi/external/cloud/aws.rb +26 -1
  45. data/lib/nvoi/external/cloud/hetzner.rb +27 -1
  46. data/lib/nvoi/external/cloud/scaleway.rb +32 -6
  47. data/lib/nvoi/external/containerd.rb +4 -0
  48. data/lib/nvoi/external/dns/cloudflare.rb +34 -16
  49. data/lib/nvoi/objects/configuration.rb +20 -0
  50. data/lib/nvoi/utils/namer.rb +9 -0
  51. data/lib/nvoi/utils/retry.rb +1 -1
  52. data/lib/nvoi/version.rb +1 -1
  53. data/lib/nvoi.rb +16 -0
  54. data/templates/app-ingress.yaml.erb +3 -1
  55. metadata +25 -1
@@ -0,0 +1,761 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "tty-prompt"
5
+ require "tty-box"
6
+ require "tty-spinner"
7
+ require "tty-table"
8
+ require "nvoi/utils/credential_store"
9
+
10
+ module Nvoi
11
+ class Cli
12
+ module Onboard
13
+ # Interactive onboarding wizard for quick setup
14
+ class Command
15
+ MAX_RETRIES = 3
16
+
17
+ def initialize(prompt: nil)
18
+ @prompt = prompt || TTY::Prompt.new
19
+ @data = { "application" => {} }
20
+ @test_mode = prompt&.input.is_a?(StringIO)
21
+ end
22
+
23
+ def run
24
+ 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
+
33
+ summary_loop
34
+
35
+ show_next_steps
36
+ rescue TTY::Reader::InputInterrupt
37
+ puts "\n\nSetup cancelled."
38
+ exit 1
39
+ end
40
+
41
+ private
42
+
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
+ }
120
+ }
121
+ end
122
+
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
148
+
149
+ instance_type = @prompt.select("Instance type:", type_choices)
150
+
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
+ }
159
+ end
160
+
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"] ||= {}
241
+
242
+ 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
265
+
266
+ break unless @prompt.yes?("Add another app?")
267
+ end
268
+ end
269
+
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
290
+
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
316
+ end
317
+
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
385
+ end
386
+
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
489
+
490
+ def summary_loop
491
+ loop do
492
+ show_summary
493
+
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
507
+ when :save
508
+ save_config
509
+ return
510
+ when :cancel
511
+ return if @prompt.yes?("Discard all changes?")
512
+ when :app_name
513
+ step_app_name
514
+ when :compute
515
+ step_compute_provider
516
+ when :domain
517
+ step_domain_provider
518
+ when :apps
519
+ edit_apps
520
+ when :database
521
+ step_database
522
+ when :env
523
+ step_env
524
+ 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
536
+ end
537
+ end
538
+ end
539
+
540
+ def edit_apps
541
+ loop do
542
+ app_names = @data["application"]["app"]&.keys || []
543
+
544
+ choices = app_names.map { |name| { name:, value: name } }
545
+ choices << { name: "Add new app", value: :add }
546
+ choices << { name: "Done", value: :done }
547
+
548
+ selected = @prompt.select("Apps:", choices)
549
+
550
+ case selected
551
+ when :add
552
+ add_single_app
553
+ when :done
554
+ return
555
+ 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
569
+ end
570
+ end
571
+ end
572
+
573
+ 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
602
+ end
603
+
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?
611
+
612
+ # Handle rename
613
+ @data["application"]["app"].delete(name) if new_name != name
614
+ @data["application"]["app"][new_name] = new_config
615
+ end
616
+
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
634
+ end
635
+
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?
638
+
639
+ @data["application"]["app"][name] = app_config
640
+ end
641
+
642
+ def save_config
643
+ 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
+ )
649
+
650
+ if result.failure?
651
+ raise Errors::ConfigError, "Failed to initialize: #{result.error_message}"
652
+ end
653
+
654
+ # Now we need to apply all our config on top
655
+ # Decrypt the init result, merge our data, re-encrypt
656
+ yaml = Utils::Crypto.decrypt(result.config, result.master_key)
657
+ init_data = YAML.safe_load(yaml, permitted_classes: [Symbol])
658
+
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
674
+ end
675
+
676
+ puts
677
+ success("Created #{Utils::DEFAULT_ENCRYPTED_FILE}")
678
+ success("Created #{Utils::DEFAULT_KEY_FILE}")
679
+ end
680
+
681
+ def show_next_steps
682
+ puts
683
+ puts "Next: #{pastel.cyan("nvoi deploy")}"
684
+ end
685
+
686
+ # ─────────────────────────────────────────────────────────────────
687
+ # Helpers
688
+ # ─────────────────────────────────────────────────────────────────
689
+
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
706
+ end
707
+ end
708
+
709
+ def section(title)
710
+ pastel.bold("─── #{title} ───")
711
+ end
712
+
713
+ def with_spinner(message)
714
+ if @test_mode
715
+ result = yield
716
+ return result
717
+ end
718
+
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
730
+
731
+ def success(msg)
732
+ puts "#{pastel.green("✓")} #{msg}"
733
+ end
734
+
735
+ def error(msg)
736
+ warn "#{pastel.red("✗")} #{msg}"
737
+ end
738
+
739
+ def pastel
740
+ @pastel ||= Pastel.new
741
+ end
742
+
743
+ def update_gitignore
744
+ gitignore_path = ".gitignore"
745
+ entries = ["deploy.key", ".env", ".env.*", "!.env.example"]
746
+
747
+ existing = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
748
+ additions = entries.reject { |e| existing.include?(e) }
749
+
750
+ return if additions.empty?
751
+
752
+ File.open(gitignore_path, "a") do |f|
753
+ f.puts "" unless existing.end_with?("\n") || existing.empty?
754
+ f.puts "# NVOI"
755
+ additions.each { |e| f.puts e }
756
+ end
757
+ end
758
+ end
759
+ end
760
+ end
761
+ end