odysseus-cli 0.2.0 → 0.3.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.
@@ -1,267 +1,158 @@
1
1
  # odysseus-cli/lib/odysseus/cli/cli.rb
2
2
 
3
3
  require 'odysseus'
4
- require 'pastel'
5
4
  require 'yaml'
6
5
  require 'tempfile'
7
- require_relative 'gum'
6
+ require_relative 'ui'
8
7
 
9
8
  module Odysseus
10
9
  module CLI
11
10
  class CLI
12
- def initialize(charm_mode: false)
13
- @pastel = Pastel.new
14
- @charm = charm_mode && Gum.available?
15
- if charm_mode && !Gum.available?
16
- warn @pastel.yellow("Warning: --charm mode requested but 'gum' is not installed")
17
- warn @pastel.dim("Install gum: https://github.com/charmbracelet/gum")
18
- end
19
- end
20
-
21
- def charm?
22
- @charm
11
+ def initialize(debug: false)
12
+ @ui = UI.new(debug: debug)
23
13
  end
24
14
 
25
- # Deploy command - deploys all roles to their configured hosts
26
- # Usage: odysseus deploy [--config FILE] [--image TAG] [--build] [--dry-run] [--verbose]
15
+ # Deploy command
27
16
  def deploy(options = {})
28
17
  config_file = options[:config] || 'deploy.yml'
29
18
  image_tag = options[:image] || 'latest'
30
19
  should_build = options[:build] || false
31
20
  dry_run = options[:'dry-run'] || false
32
- verbose = options[:verbose] || false
21
+ verbose = options[:verbose] || @ui.debug?
33
22
 
34
23
  config = load_config(config_file)
35
24
  uses_registry = config[:registry] && config[:registry][:server]
36
25
 
37
- # Header
38
- if charm?
39
- puts Gum.style("⛵ Odysseus Deploy", border: 'rounded', foreground: '212', padding: '0 1')
40
- else
41
- puts @pastel.cyan("Odysseus Deploy")
42
- end
43
- puts @pastel.blue("Service: #{config[:service]}")
44
- puts @pastel.blue("Image: #{config[:image]}:#{image_tag}")
45
- if should_build
46
- distribution = uses_registry ? "registry (#{config[:registry][:server]})" : "pussh (SSH)"
47
- puts @pastel.blue("Build & distribute via: #{distribution}")
48
- end
49
- puts ""
26
+ distribution = uses_registry ? "registry (#{config[:registry][:server]})" : "pussh (SSH)"
27
+ @ui.deploy_header(
28
+ service: config[:service],
29
+ image: config[:image],
30
+ image_tag: image_tag,
31
+ build: should_build,
32
+ distribution: distribution
33
+ )
50
34
 
51
35
  executor = Odysseus::Deployer::Executor.new(config_file, verbose: verbose)
36
+ start_time = Time.now
52
37
 
53
- # Build and distribute image if requested
54
38
  if should_build
55
- if charm?
56
- result = Gum.spin(title: 'Building and distributing image...') do
57
- executor.build_and_distribute(image_tag: image_tag)
58
- end
59
- else
60
- puts @pastel.cyan("=== Building and distributing image ===")
61
- result = executor.build_and_distribute(image_tag: image_tag)
62
- end
63
-
64
- if result[:build][:success]
65
- puts @pastel.green("✓ Build complete!")
66
- else
67
- puts @pastel.red("✗ Build failed: #{result[:build][:error]}")
68
- exit 1
69
- end
39
+ # Build phase — captured as streaming steps
40
+ @ui.stream_steps(title: "Building and distributing") do
41
+ build_result = executor.build_and_distribute(image_tag: image_tag)
70
42
 
71
- # Handle distribution result (either pussh or registry push)
72
- if uses_registry
73
- if result[:push][:success]
74
- puts @pastel.green("✓ Pushed to registry!")
75
- else
76
- puts @pastel.red("✗ Push to registry failed!")
77
- exit 1
43
+ unless build_result[:build][:success]
44
+ raise Odysseus::BuildError, "Build failed: #{build_result[:build][:error]}"
78
45
  end
79
- else
80
- if result[:pussh][:success]
81
- puts @pastel.green("✓ Pussh complete!")
82
- result[:pussh][:results]&.each do |host, host_result|
83
- status = host_result[:success] ? @pastel.green('✓') : @pastel.red('✗')
84
- puts " #{status} #{host}"
46
+
47
+ if uses_registry
48
+ unless build_result[:push][:success]
49
+ raise Odysseus::BuildError, "Push to registry failed"
85
50
  end
86
51
  else
87
- puts @pastel.red("✗ Pussh failed!")
88
- result[:pussh][:results]&.each do |host, host_result|
89
- status = host_result[:success] ? @pastel.green('✓') : @pastel.red('✗')
90
- puts " #{status} #{host}"
91
- puts " #{host_result[:error]}" unless host_result[:success]
52
+ unless build_result[:pussh][:success]
53
+ raise Odysseus::BuildError, "Image distribution failed"
92
54
  end
93
- exit 1
94
55
  end
95
56
  end
96
- puts ""
97
57
  end
98
58
 
99
- if charm?
100
- Gum.spin(title: 'Deploying to servers...') do
101
- executor.deploy_all(image_tag: image_tag, dry_run: dry_run)
102
- end
103
- else
59
+ # Deploy phase — each orchestrator log line becomes a sub-step
60
+ @ui.stream_steps(title: "Deploying service") do
104
61
  executor.deploy_all(image_tag: image_tag, dry_run: dry_run)
105
62
  end
106
63
 
107
- puts ""
108
- if charm?
109
- puts Gum.style("✓ Deploy complete!", foreground: '82', bold: true)
110
- else
111
- puts @pastel.green("Deploy complete!")
112
- end
64
+ duration = (Time.now - start_time).round(1)
65
+ @ui.deploy_complete(duration: duration)
113
66
  rescue Odysseus::Error => e
114
- puts @pastel.red("Error: #{e.message}")
67
+ @ui.step_fail e.message
115
68
  exit 1
116
69
  end
117
70
 
118
- # Build command - builds Docker image locally or on a build host
119
- # Usage: odysseus build [--config FILE] [--image TAG] [--push] [--context PATH] [--verbose]
71
+ # Build command
120
72
  def build(options = {})
121
73
  config_file = options[:config] || 'deploy.yml'
122
74
  image_tag = options[:image] || 'latest'
123
75
  push = options[:push] || false
124
76
  context_path = options[:context]
125
- verbose = options[:verbose] || false
77
+ verbose = options[:verbose] || @ui.debug?
126
78
 
127
79
  config = load_config(config_file)
128
- builder_config = config[:builder] || {}
129
- strategy = builder_config[:strategy] || :local
130
80
 
131
- if charm?
132
- puts Gum.style("🔨 Odysseus Build", border: 'rounded', foreground: '212', padding: '0 1')
133
- else
134
- puts @pastel.cyan("Odysseus Build")
135
- end
136
- puts @pastel.blue("Service: #{config[:service]}")
137
- puts @pastel.blue("Image: #{config[:image]}:#{image_tag}")
138
- puts @pastel.blue("Strategy: #{strategy}")
139
- puts @pastel.blue("Build host: #{builder_config[:host]}") if builder_config[:host]
140
- puts @pastel.blue("Push: #{push ? 'yes' : 'no'}")
141
- puts ""
81
+ @ui.header "Odysseus Build"
82
+ @ui.info "Image", "#{config[:image]}:#{image_tag}"
83
+ @ui.info "Strategy", (config.dig(:builder, :strategy) || :local).to_s
84
+ @ui.blank
142
85
 
143
86
  executor = Odysseus::Deployer::Executor.new(config_file, verbose: verbose)
144
87
 
145
- if charm?
146
- result = Gum.spin(title: 'Building image...') do
147
- executor.build(image_tag: image_tag, push: push, context_path: context_path)
148
- end
149
- else
150
- result = executor.build(image_tag: image_tag, push: push, context_path: context_path)
88
+ result = @ui.spin_step("Building image #{config[:image]}:#{image_tag}") do
89
+ executor.build(image_tag: image_tag, push: push, context_path: context_path)
151
90
  end
152
91
 
153
92
  if result[:success]
154
- puts ""
155
- if charm?
156
- puts Gum.style("✓ Build complete!", foreground: '82', bold: true)
157
- else
158
- puts @pastel.green("Build complete!")
159
- end
160
- puts @pastel.blue("Image: #{result[:image]}")
161
- if result[:pushed]
162
- puts @pastel.blue("Pushed to registry: yes")
163
- end
93
+ @ui.step_ok "Pushed to registry" if result[:pushed]
94
+ @ui.step_ok "Build complete"
164
95
  else
165
- puts ""
166
- puts @pastel.red("✗ Build failed: #{result[:error]}")
96
+ @ui.step_fail "Build failed: #{result[:error]}"
167
97
  exit 1
168
98
  end
169
99
  rescue Odysseus::Error => e
170
- puts @pastel.red("Error: #{e.message}")
100
+ @ui.step_fail e.message
171
101
  exit 1
172
102
  end
173
103
 
174
- # Pussh command - push image to hosts via SSH (no registry needed)
175
- # Usage: odysseus pussh [--config FILE] [--image TAG] [--build] [--verbose]
104
+ # Pussh command
176
105
  def pussh(options = {})
177
106
  config_file = options[:config] || 'deploy.yml'
178
107
  image_tag = options[:image] || 'latest'
179
108
  should_build = options[:build] || false
180
- verbose = options[:verbose] || false
109
+ verbose = options[:verbose] || @ui.debug?
181
110
 
182
111
  config = load_config(config_file)
183
112
 
184
- if charm?
185
- puts Gum.style("📦 Odysseus Pussh", border: 'rounded', foreground: '212', padding: '0 1')
186
- else
187
- puts @pastel.cyan("Odysseus Pussh")
188
- end
189
- puts @pastel.blue("Service: #{config[:service]}")
190
- puts @pastel.blue("Image: #{config[:image]}:#{image_tag}")
191
- puts @pastel.blue("Build first: #{should_build ? 'yes' : 'no'}")
192
- puts ""
113
+ @ui.header "Odysseus Pussh"
114
+ @ui.info "Image", "#{config[:image]}:#{image_tag}"
115
+ @ui.blank
193
116
 
194
117
  executor = Odysseus::Deployer::Executor.new(config_file, verbose: verbose)
195
118
 
196
119
  if should_build
197
- if charm?
198
- result = Gum.spin(title: 'Building and pushing image via SSH...') do
199
- executor.build_and_pussh(image_tag: image_tag)
200
- end
201
- else
202
- result = executor.build_and_pussh(image_tag: image_tag)
120
+ result = @ui.spin_step("Building image #{config[:image]}:#{image_tag}") do
121
+ executor.build_and_pussh(image_tag: image_tag)
203
122
  end
204
123
 
205
- if result[:build][:success]
206
- puts @pastel.green("Build complete!")
207
- else
208
- puts @pastel.red("✗ Build failed: #{result[:build][:error]}")
124
+ unless result[:build][:success]
125
+ @ui.step_fail "Build failed: #{result[:build][:error]}"
209
126
  exit 1
210
127
  end
128
+ pussh_result = result[:pussh]
211
129
  else
212
- if charm?
213
- result = Gum.spin(title: 'Pushing image via SSH...') do
214
- executor.pussh(image_tag: image_tag)
215
- end
216
- else
217
- result = executor.pussh(image_tag: image_tag)
130
+ pussh_result = @ui.spin_step("Pushing image via SSH...") do
131
+ executor.pussh(image_tag: image_tag)
218
132
  end
219
133
  end
220
134
 
221
- pussh_result = should_build ? result[:pussh] : result
222
-
223
135
  if pussh_result[:success]
224
- puts ""
225
- if charm?
226
- puts Gum.style("✓ Pussh complete!", foreground: '82', bold: true)
227
- else
228
- puts @pastel.green("Pussh complete!")
229
- end
230
- pussh_result[:results]&.each do |host, host_result|
231
- status = host_result[:success] ? @pastel.green('✓') : @pastel.red('✗')
232
- puts " #{status} #{host}"
233
- end
136
+ @ui.step_ok "Pussh complete (#{pussh_result[:results]&.size || 0} host(s))"
234
137
  else
235
- puts ""
236
- puts @pastel.red("✗ Pussh failed!")
237
- pussh_result[:results]&.each do |host, host_result|
238
- status = host_result[:success] ? @pastel.green('✓') : @pastel.red('✗')
239
- puts " #{status} #{host}"
240
- puts " #{host_result[:error]}" unless host_result[:success]
241
- end
138
+ @ui.step_fail "Pussh failed"
242
139
  exit 1
243
140
  end
244
141
  rescue Odysseus::Error => e
245
- puts @pastel.red("Error: #{e.message}")
142
+ @ui.step_fail e.message
246
143
  exit 1
247
144
  end
248
145
 
249
- # Status command - show full service status on a server
250
- # Usage: odysseus status <server> [--config FILE]
146
+ # Status command
251
147
  def status(server, options = {})
252
148
  config_file = options[:config] || 'deploy.yml'
253
-
254
149
  config = load_config(config_file)
255
150
  service_name = config[:service]
256
151
 
257
- if charm?
258
- puts Gum.style("⛵ Odysseus Status", border: 'rounded', foreground: '212', padding: '0 1')
259
- else
260
- puts @pastel.cyan("Odysseus Status: #{service_name}")
261
- end
262
- puts @pastel.blue("Service: #{service_name}")
263
- puts @pastel.blue("Server: #{server}")
264
- puts ""
152
+ @ui.header "Odysseus Status"
153
+ @ui.info "Service", service_name
154
+ @ui.info "Server", server
155
+ @ui.blank
265
156
 
266
157
  ssh = connect_to_server(server, config)
267
158
 
@@ -270,162 +161,100 @@ module Odysseus
270
161
  caddy = Odysseus::Caddy::Client.new(ssh: ssh, docker: docker)
271
162
 
272
163
  # Web containers
273
- if charm?
274
- puts Gum.style(" Web ", foreground: '212', bold: true)
275
- else
276
- puts @pastel.cyan("Web:")
277
- end
164
+ @ui.section "Web"
278
165
  web_containers = docker.list(service: service_name)
279
166
  if web_containers.empty?
280
- puts " (no containers running)"
281
- elsif charm?
167
+ @ui.step "(no containers running)"
168
+ else
282
169
  rows = web_containers.map do |c|
283
170
  health = c['Status'].include?('healthy') ? '✓' : ''
284
171
  [c['Names'], c['State'], c['Image'], health]
285
172
  end
286
- puts Gum.table(headers: ['Name', 'State', 'Image', 'Health'], rows: rows)
287
- else
288
- web_containers.each do |c|
289
- status_color = c['State'] == 'running' ? :green : :red
290
- health = c['Status'].include?('healthy') ? ' (healthy)' : ''
291
- puts " #{@pastel.send(status_color, c['State'])} #{c['Names']}#{health}"
292
- puts " Image: #{c['Image']}"
293
- end
173
+ @ui.table(headers: ['Name', 'State', 'Image', 'Health'], rows: rows)
294
174
  end
295
175
 
296
- # Show Caddy route for this service
297
176
  caddy_status = caddy.status
298
177
  if caddy_status[:running]
299
178
  svc_route = caddy_status[:services].find { |s| s[:service] == service_name }
300
179
  if svc_route
301
- puts " Proxy: #{svc_route[:hosts].join(', ')}"
302
- puts " Upstreams: #{svc_route[:upstreams].join(', ')}"
180
+ @ui.info "Proxy", svc_route[:hosts].join(', ')
181
+ @ui.info "Upstreams", svc_route[:upstreams].join(', ')
303
182
  end
304
183
  end
184
+ @ui.blank
305
185
 
306
- puts ""
307
-
308
- # Job/worker containers (non-web roles)
186
+ # Workers
309
187
  non_web_roles = config[:servers].keys.reject { |r| r == :web }
310
188
  if non_web_roles.any?
311
- if charm?
312
- puts Gum.style(" Jobs/Workers ", foreground: '212', bold: true)
313
- else
314
- puts @pastel.cyan("Jobs/Workers:")
315
- end
316
-
317
- if charm?
318
- rows = []
319
- non_web_roles.each do |role|
320
- role_service = "#{service_name}-#{role}"
321
- containers = docker.list(service: role_service)
322
- if containers.empty?
323
- rows << [role.to_s, 'stopped', '-', '-']
324
- else
325
- containers.each do |c|
326
- rows << [role.to_s, c['State'], c['Names'], c['Image']]
327
- end
328
- end
329
- end
330
- puts Gum.table(headers: ['Role', 'State', 'Name', 'Image'], rows: rows)
331
- else
332
- non_web_roles.each do |role|
333
- role_service = "#{service_name}-#{role}"
334
- containers = docker.list(service: role_service)
335
- if containers.empty?
336
- puts " #{@pastel.yellow(role.to_s)}: #{@pastel.red('not running')}"
337
- else
338
- containers.each do |c|
339
- status_color = c['State'] == 'running' ? :green : :red
340
- puts " #{@pastel.yellow(role.to_s)}: #{@pastel.send(status_color, c['State'])} #{c['Names']}"
341
- puts " Image: #{c['Image']}"
342
- end
189
+ @ui.section "Workers"
190
+ rows = []
191
+ non_web_roles.each do |role|
192
+ role_service = "#{service_name}-#{role}"
193
+ containers = docker.list(service: role_service)
194
+ if containers.empty?
195
+ rows << [role.to_s, 'stopped', '-', '-']
196
+ else
197
+ containers.each do |c|
198
+ rows << [role.to_s, c['State'], c['Names'], c['Image']]
343
199
  end
344
200
  end
345
201
  end
346
- puts ""
202
+ @ui.table(headers: ['Role', 'State', 'Name', 'Image'], rows: rows)
203
+ @ui.blank
347
204
  end
348
205
 
349
206
  # Accessories
350
207
  if config[:accessories]&.any?
351
- if charm?
352
- puts Gum.style(" Accessories ", foreground: '212', bold: true)
353
- rows = config[:accessories].map do |name, acc_config|
354
- acc_service = "#{service_name}-#{name}"
355
- containers = docker.list(service: acc_service, all: true)
356
- running = containers.find { |c| c['State'] == 'running' }
357
- if running
358
- health = running['Status'].include?('healthy') ? '' : ''
359
- [name.to_s, 'running', acc_config[:image], running['ID'][0..11], health]
360
- else
361
- [name.to_s, 'stopped', acc_config[:image], '-', '']
362
- end
363
- end
364
- puts Gum.table(headers: ['Name', 'State', 'Image', 'Container', 'Health'], rows: rows)
365
- else
366
- puts @pastel.cyan("Accessories:")
367
- config[:accessories].each do |name, acc_config|
368
- acc_service = "#{service_name}-#{name}"
369
- containers = docker.list(service: acc_service, all: true)
370
- running = containers.find { |c| c['State'] == 'running' }
371
-
372
- if running
373
- health = running['Status'].include?('healthy') ? ' (healthy)' : ''
374
- puts " #{@pastel.yellow(name.to_s)}: #{@pastel.green('running')}#{health}"
375
- puts " Image: #{acc_config[:image]}"
376
- puts " Container: #{running['ID'][0..11]}"
377
- else
378
- puts " #{@pastel.yellow(name.to_s)}: #{@pastel.red('stopped')}"
379
- puts " Image: #{acc_config[:image]}"
380
- end
208
+ @ui.section "Accessories"
209
+ rows = config[:accessories].map do |name, acc_config|
210
+ acc_service = "#{service_name}-#{name}"
211
+ containers = docker.list(service: acc_service, all: true)
212
+ running = containers.find { |c| c['State'] == 'running' }
213
+ if running
214
+ health = running['Status'].include?('healthy') ? '✓' : ''
215
+ [name.to_s, 'running', acc_config[:image], running['ID'][0..11], health]
216
+ else
217
+ [name.to_s, 'stopped', acc_config[:image], '-', '']
381
218
  end
382
219
  end
383
- puts ""
220
+ @ui.table(headers: ['Name', 'State', 'Image', 'Container', 'Health'], rows: rows)
221
+ @ui.blank
384
222
  end
385
223
 
386
- # TLS status for this service's domains
224
+ # TLS
387
225
  if config[:proxy][:hosts]&.any?
388
- if charm?
389
- puts Gum.style(" TLS ", foreground: '212', bold: true)
390
- else
391
- puts @pastel.cyan("TLS:")
392
- end
226
+ @ui.section "TLS"
393
227
  if caddy_status[:running] && caddy_status[:tls][:enabled]
394
228
  service_hosts = config[:proxy][:hosts]
395
229
  relevant_policy = caddy_status[:tls][:policies].find do |p|
396
230
  (p[:subjects] & service_hosts).any?
397
231
  end
398
232
  if relevant_policy
399
- puts " Domains: #{(relevant_policy[:subjects] & service_hosts).join(', ')}"
400
- puts " Issuer: #{relevant_policy[:issuer]}"
401
- puts " Email: #{relevant_policy[:email] || '(not set)'}"
233
+ @ui.info "Domains", (relevant_policy[:subjects] & service_hosts).join(', ')
234
+ @ui.info "Issuer", relevant_policy[:issuer]
235
+ @ui.info "Email", relevant_policy[:email] || '(not set)'
402
236
  else
403
- puts " (not configured for this service)"
237
+ @ui.step "(not configured for this service)"
404
238
  end
405
239
  else
406
- puts " (Caddy not running or TLS not configured)"
240
+ @ui.step "(Caddy not running or TLS not configured)"
407
241
  end
408
242
  end
409
243
  ensure
410
244
  ssh.close
411
245
  end
412
246
  rescue Odysseus::Error => e
413
- puts @pastel.red("Error: #{e.message}")
247
+ @ui.error e.message
414
248
  exit 1
415
249
  end
416
250
 
417
- # Containers command - list running containers for a service
418
- # Usage: odysseus containers <server> [--config FILE] [--service NAME]
251
+ # Containers command
419
252
  def containers(server, options = {})
420
253
  config_file = options[:config] || 'deploy.yml'
421
254
 
422
- if charm?
423
- puts Gum.style("📦 Odysseus Containers", border: 'rounded', foreground: '212', padding: '0 1')
424
- else
425
- puts @pastel.cyan("Odysseus Containers")
426
- end
427
- puts @pastel.blue("Server: #{server}")
428
- puts ""
255
+ @ui.header "Odysseus Containers"
256
+ @ui.info "Server", server
257
+ @ui.blank
429
258
 
430
259
  config = load_config(config_file)
431
260
  service_name = options[:service] || config[:service]
@@ -436,270 +265,144 @@ module Odysseus
436
265
  containers = docker.list(service: service_name)
437
266
 
438
267
  if containers.empty?
439
- puts "No containers found for service: #{service_name}"
440
- elsif charm?
441
- puts Gum.style(" #{service_name} ", foreground: '212', bold: true)
268
+ @ui.step "No containers found for #{service_name}"
269
+ else
442
270
  rows = containers.map do |c|
443
271
  [c['ID'][0..11], c['State'], c['Names'], c['Image'], c['Status']]
444
272
  end
445
- puts Gum.table(headers: ['ID', 'State', 'Name', 'Image', 'Status'], rows: rows)
446
- else
447
- puts @pastel.cyan("Containers for #{service_name}:")
448
- containers.each do |c|
449
- status_color = c['State'] == 'running' ? :green : :red
450
- puts " #{c['ID'][0..11]} #{@pastel.send(status_color, c['State'])} #{c['Names']} (#{c['Image']})"
451
- puts " Created: #{c['CreatedAt']}"
452
- puts " Status: #{c['Status']}"
453
- end
273
+ @ui.table(headers: ['ID', 'State', 'Name', 'Image', 'Status'], rows: rows)
454
274
  end
455
275
  ensure
456
276
  ssh.close
457
277
  end
458
278
  rescue Odysseus::Error => e
459
- puts @pastel.red("Error: #{e.message}")
279
+ @ui.error e.message
460
280
  exit 1
461
281
  end
462
282
 
463
283
  # Validate command
464
- # Usage: odysseus validate [--config FILE]
465
284
  def validate(options = {})
466
285
  config_file = options[:config] || 'deploy.yml'
467
286
 
468
- puts @pastel.cyan("Validating #{config_file}...")
469
-
287
+ @ui.header "Validating #{config_file}"
470
288
  config = load_config(config_file)
471
289
 
472
- puts @pastel.green("Configuration is valid!")
473
- puts ""
474
- puts "Service: #{config[:service]}"
475
- puts "Image: #{config[:image]}"
476
- puts "Servers: #{config[:servers].keys.join(', ')}"
477
- puts "Proxy hosts: #{config[:proxy][:hosts]&.join(', ')}"
478
- if config[:accessories]&.any?
479
- puts "Accessories: #{config[:accessories].keys.join(', ')}"
480
- end
290
+ @ui.success "Configuration is valid"
291
+ @ui.info "Service", config[:service]
292
+ @ui.info "Image", config[:image]
293
+ @ui.info "Servers", config[:servers].keys.join(', ')
294
+ @ui.info "Proxy", config[:proxy][:hosts]&.join(', ')
295
+ @ui.info "Accessories", config[:accessories].keys.join(', ') if config[:accessories]&.any?
481
296
  rescue Odysseus::Error => e
482
- puts @pastel.red("Validation failed: #{e.message}")
297
+ @ui.error "Validation failed: #{e.message}"
483
298
  exit 1
484
299
  end
485
300
 
486
- # Accessory boot command
487
- # Usage: odysseus accessory boot --name NAME [--config FILE]
301
+ # Accessory commands
488
302
  def accessory_boot(options = {})
489
303
  config_file = options[:config] || 'deploy.yml'
490
- name = options[:name]
304
+ name = require_name!(options)
491
305
 
492
- unless name
493
- puts @pastel.red("Error: accessory name required (--name)")
494
- exit 1
495
- end
496
-
497
- if charm?
498
- puts Gum.style("🔌 Accessory Boot", border: 'rounded', foreground: '212', padding: '0 1')
499
- else
500
- puts @pastel.cyan("Odysseus Accessory Boot")
501
- end
502
- puts @pastel.blue("Accessory: #{name}")
503
- puts ""
306
+ @ui.header "Accessory Boot"
307
+ @ui.info "Accessory", name
308
+ @ui.blank
504
309
 
505
310
  executor = Odysseus::Deployer::Executor.new(config_file)
506
-
507
- if charm?
508
- Gum.spin(title: "Booting #{name}...") do
509
- executor.deploy_accessory(name: name)
510
- end
511
- puts Gum.style("✓ Accessory #{name} deployed!", foreground: '82', bold: true)
512
- else
513
- executor.deploy_accessory(name: name)
514
- puts @pastel.green("Accessory #{name} deployed!")
515
- end
311
+ @ui.spin_step("Booting #{name}...") { executor.deploy_accessory(name: name) }
312
+ @ui.step_ok "Accessory #{name} deployed"
516
313
  rescue Odysseus::Error => e
517
- puts @pastel.red("Error: #{e.message}")
314
+ @ui.step_fail e.message
518
315
  exit 1
519
316
  end
520
317
 
521
- # Boot all accessories
522
- # Usage: odysseus accessory boot-all [--config FILE]
523
318
  def accessory_boot_all(options = {})
524
319
  config_file = options[:config] || 'deploy.yml'
525
320
 
526
- if charm?
527
- puts Gum.style("🔌 Accessory Boot All", border: 'rounded', foreground: '212', padding: '0 1')
528
- else
529
- puts @pastel.cyan("Odysseus Accessory Boot All")
530
- end
531
- puts ""
321
+ @ui.header "Accessory Boot All"
322
+ @ui.blank
532
323
 
533
324
  executor = Odysseus::Deployer::Executor.new(config_file)
534
-
535
- if charm?
536
- Gum.spin(title: 'Booting all accessories...') do
537
- executor.boot_accessories
538
- end
539
- puts Gum.style("✓ All accessories deployed!", foreground: '82', bold: true)
540
- else
541
- executor.boot_accessories
542
- puts @pastel.green("All accessories deployed!")
543
- end
325
+ @ui.spin_step("Booting all accessories...") { executor.boot_accessories }
326
+ @ui.step_ok "All accessories deployed"
544
327
  rescue Odysseus::Error => e
545
- puts @pastel.red("Error: #{e.message}")
328
+ @ui.step_fail e.message
546
329
  exit 1
547
330
  end
548
331
 
549
- # Accessory remove command
550
- # Usage: odysseus accessory remove --name NAME [--config FILE]
551
332
  def accessory_remove(options = {})
552
333
  config_file = options[:config] || 'deploy.yml'
553
- name = options[:name]
334
+ name = require_name!(options)
554
335
 
555
- unless name
556
- puts @pastel.red("Error: accessory name required (--name)")
557
- exit 1
558
- end
559
-
560
- if charm?
561
- puts Gum.style("🗑️ Accessory Remove", border: 'rounded', foreground: '212', padding: '0 1')
562
- else
563
- puts @pastel.cyan("Odysseus Accessory Remove")
564
- end
565
- puts @pastel.blue("Accessory: #{name}")
566
- puts ""
567
-
568
- # Charm mode: ask for confirmation
569
- if charm?
570
- unless Gum.confirm("Remove accessory #{name}?")
571
- puts @pastel.yellow("Cancelled.")
572
- return
573
- end
574
- end
336
+ @ui.header "Accessory Remove"
337
+ @ui.info "Accessory", name
338
+ @ui.blank
575
339
 
576
340
  executor = Odysseus::Deployer::Executor.new(config_file)
577
-
578
- if charm?
579
- Gum.spin(title: "Removing #{name}...") do
580
- executor.remove_accessory(name: name)
581
- end
582
- puts Gum.style("✓ Accessory #{name} removed!", foreground: '82', bold: true)
583
- else
584
- executor.remove_accessory(name: name)
585
- puts @pastel.green("Accessory #{name} removed!")
586
- end
341
+ @ui.spin_step("Removing #{name}...") { executor.remove_accessory(name: name) }
342
+ @ui.step_ok "Accessory #{name} removed"
587
343
  rescue Odysseus::Error => e
588
- puts @pastel.red("Error: #{e.message}")
344
+ @ui.step_fail e.message
589
345
  exit 1
590
346
  end
591
347
 
592
- # Accessory restart command
593
- # Usage: odysseus accessory restart --name NAME [--config FILE]
594
348
  def accessory_restart(options = {})
595
349
  config_file = options[:config] || 'deploy.yml'
596
- name = options[:name]
597
-
598
- unless name
599
- puts @pastel.red("Error: accessory name required (--name)")
600
- exit 1
601
- end
350
+ name = require_name!(options)
602
351
 
603
- if charm?
604
- puts Gum.style("🔄 Accessory Restart", border: 'rounded', foreground: '212', padding: '0 1')
605
- else
606
- puts @pastel.cyan("Odysseus Accessory Restart")
607
- end
608
- puts @pastel.blue("Accessory: #{name}")
609
- puts ""
352
+ @ui.header "Accessory Restart"
353
+ @ui.info "Accessory", name
354
+ @ui.blank
610
355
 
611
356
  executor = Odysseus::Deployer::Executor.new(config_file)
612
-
613
- if charm?
614
- Gum.spin(title: "Restarting #{name}...") do
615
- executor.restart_accessory(name: name)
616
- end
617
- puts Gum.style("✓ Accessory #{name} restarted!", foreground: '82', bold: true)
618
- else
619
- executor.restart_accessory(name: name)
620
- puts @pastel.green("Accessory #{name} restarted!")
621
- end
357
+ @ui.spin_step("Restarting #{name}...") { executor.restart_accessory(name: name) }
358
+ @ui.step_ok "Accessory #{name} restarted"
622
359
  rescue Odysseus::Error => e
623
- puts @pastel.red("Error: #{e.message}")
360
+ @ui.step_fail e.message
624
361
  exit 1
625
362
  end
626
363
 
627
- # Accessory upgrade command - upgrade to new image version (preserves volumes)
628
- # Usage: odysseus accessory upgrade --name NAME [--config FILE]
629
364
  def accessory_upgrade(options = {})
630
365
  config_file = options[:config] || 'deploy.yml'
631
- name = options[:name]
366
+ name = require_name!(options)
632
367
 
633
- unless name
634
- puts @pastel.red("Error: accessory name required (--name)")
635
- exit 1
636
- end
637
-
638
- if charm?
639
- puts Gum.style("⬆️ Accessory Upgrade", border: 'rounded', foreground: '212', padding: '0 1')
640
- else
641
- puts @pastel.cyan("Odysseus Accessory Upgrade")
642
- end
643
- puts @pastel.blue("Accessory: #{name}")
644
- puts ""
368
+ @ui.header "Accessory Upgrade"
369
+ @ui.info "Accessory", name
370
+ @ui.blank
645
371
 
646
372
  executor = Odysseus::Deployer::Executor.new(config_file)
647
-
648
- if charm?
649
- Gum.spin(title: "Upgrading #{name}...") do
650
- executor.upgrade_accessory(name: name)
651
- end
652
- puts Gum.style("✓ Accessory #{name} upgraded!", foreground: '82', bold: true)
653
- else
654
- executor.upgrade_accessory(name: name)
655
- puts @pastel.green("Accessory #{name} upgraded!")
656
- end
373
+ @ui.spin_step("Upgrading #{name}...") { executor.upgrade_accessory(name: name) }
374
+ @ui.step_ok "Accessory #{name} upgraded"
657
375
  rescue Odysseus::Error => e
658
- puts @pastel.red("Error: #{e.message}")
376
+ @ui.step_fail e.message
659
377
  exit 1
660
378
  end
661
379
 
662
- # Accessory status command
663
- # Usage: odysseus accessory status [--config FILE]
664
380
  def accessory_status(options = {})
665
381
  config_file = options[:config] || 'deploy.yml'
666
382
 
667
- if charm?
668
- puts Gum.style("🔌 Accessory Status", border: 'rounded', foreground: '212', padding: '0 1')
669
- else
670
- puts @pastel.cyan("Odysseus Accessory Status")
671
- end
672
- puts ""
383
+ @ui.header "Accessory Status"
384
+ @ui.blank
673
385
 
674
386
  executor = Odysseus::Deployer::Executor.new(config_file)
675
387
  statuses = executor.accessory_status
676
388
 
677
389
  if statuses.empty?
678
- puts "No accessories configured"
679
- elsif charm?
680
- rows = statuses.map do |status|
681
- state = status[:running] ? 'running' : 'stopped'
682
- container = status[:container_id] ? status[:container_id][0..11] : '-'
683
- proxy = status[:has_proxy] ? '✓' : ''
684
- [status[:name].to_s, status[:host], state, status[:image], container, proxy]
685
- end
686
- puts Gum.table(headers: ['Name', 'Host', 'State', 'Image', 'Container', 'Proxy'], rows: rows)
390
+ @ui.step "No accessories configured"
687
391
  else
688
- statuses.each do |status|
689
- status_text = status[:running] ? @pastel.green('running') : @pastel.red('stopped')
690
- puts " #{@pastel.yellow(status[:name].to_s)} @ #{status[:host]}: #{status_text}"
691
- puts " Image: #{status[:image]}"
692
- puts " Container: #{status[:container_id] ? status[:container_id][0..11] : '(none)'}"
693
- puts " Has proxy: #{status[:has_proxy] ? 'yes' : 'no'}"
392
+ rows = statuses.map do |s|
393
+ state = s[:running] ? 'running' : 'stopped'
394
+ container = s[:container_id] ? s[:container_id][0..11] : '-'
395
+ proxy = s[:has_proxy] ? '✓' : ''
396
+ [s[:name].to_s, s[:host], state, s[:image], container, proxy]
694
397
  end
398
+ @ui.table(headers: ['Name', 'Host', 'State', 'Image', 'Container', 'Proxy'], rows: rows)
695
399
  end
696
400
  rescue Odysseus::Error => e
697
- puts @pastel.red("Error: #{e.message}")
401
+ @ui.error e.message
698
402
  exit 1
699
403
  end
700
404
 
701
- # Logs command - tail logs for a service
702
- # Usage: odysseus logs <server> [--config FILE] [--role ROLE] [--follow] [--lines N] [--since TIME]
405
+ # Logs command
703
406
  def logs(server, options = {})
704
407
  config_file = options[:config] || 'deploy.yml'
705
408
  role = (options[:role] || 'web').to_sym
@@ -710,9 +413,9 @@ module Odysseus
710
413
  config = load_config(config_file)
711
414
  service_name = role == :web ? config[:service] : "#{config[:service]}-#{role}"
712
415
 
713
- puts @pastel.cyan("Odysseus Logs: #{service_name}")
714
- puts @pastel.blue("Server: #{server}")
715
- puts ""
416
+ @ui.header "Logs: #{service_name}"
417
+ @ui.info "Server", server
418
+ @ui.blank
716
419
 
717
420
  ssh = connect_to_server(server, config)
718
421
 
@@ -721,51 +424,41 @@ module Odysseus
721
424
  containers = docker.list(service: service_name)
722
425
 
723
426
  if containers.empty?
724
- puts @pastel.yellow("No running containers found for #{service_name}")
427
+ @ui.warn "No running containers found for #{service_name}"
725
428
  return
726
429
  end
727
430
 
728
- container = containers.first
729
- container_id = container['ID']
431
+ container_id = containers.first['ID']
730
432
 
731
433
  if follow
732
- puts @pastel.dim("Following logs (Ctrl+C to stop)...")
733
- puts ""
734
- docker.logs(container_id, follow: true, tail: lines, since: since) do |line|
735
- print line
736
- end
434
+ @ui.step "Following logs (Ctrl+C to stop)..."
435
+ @ui.blank
436
+ docker.logs(container_id, follow: true, tail: lines, since: since) { |line| print line }
737
437
  else
738
- output = docker.logs(container_id, tail: lines, since: since)
739
- puts output
438
+ puts docker.logs(container_id, tail: lines, since: since)
740
439
  end
741
440
  ensure
742
441
  ssh.close
743
442
  end
744
443
  rescue Odysseus::Error => e
745
- puts @pastel.red("Error: #{e.message}")
444
+ @ui.error e.message
746
445
  exit 1
747
446
  end
748
447
 
749
- # Accessory logs command
750
- # Usage: odysseus accessory logs <server> --name NAME [--config FILE] [--follow] [--lines N] [--since TIME]
448
+ # Accessory logs
751
449
  def accessory_logs(server, options = {})
752
450
  config_file = options[:config] || 'deploy.yml'
753
- name = options[:name]
451
+ name = require_name!(options)
754
452
  follow = options[:follow] || false
755
453
  lines = options[:lines] || 100
756
454
  since = options[:since]
757
455
 
758
- unless name
759
- puts @pastel.red("Error: accessory name required (--name)")
760
- exit 1
761
- end
762
-
763
456
  config = load_config(config_file)
764
457
  service_name = "#{config[:service]}-#{name}"
765
458
 
766
- puts @pastel.cyan("Odysseus Accessory Logs: #{service_name}")
767
- puts @pastel.blue("Server: #{server}")
768
- puts ""
459
+ @ui.header "Accessory Logs: #{service_name}"
460
+ @ui.info "Server", server
461
+ @ui.blank
769
462
 
770
463
  ssh = connect_to_server(server, config)
771
464
 
@@ -774,156 +467,107 @@ module Odysseus
774
467
  containers = docker.list(service: service_name)
775
468
 
776
469
  if containers.empty?
777
- puts @pastel.yellow("No running containers found for #{service_name}")
470
+ @ui.warn "No running containers found for #{service_name}"
778
471
  return
779
472
  end
780
473
 
781
- container = containers.first
782
- container_id = container['ID']
474
+ container_id = containers.first['ID']
783
475
 
784
476
  if follow
785
- puts @pastel.dim("Following logs (Ctrl+C to stop)...")
786
- puts ""
787
- docker.logs(container_id, follow: true, tail: lines, since: since) do |line|
788
- print line
789
- end
477
+ @ui.step "Following logs (Ctrl+C to stop)..."
478
+ @ui.blank
479
+ docker.logs(container_id, follow: true, tail: lines, since: since) { |line| print line }
790
480
  else
791
- output = docker.logs(container_id, tail: lines, since: since)
792
- puts output
481
+ puts docker.logs(container_id, tail: lines, since: since)
793
482
  end
794
483
  ensure
795
484
  ssh.close
796
485
  end
797
486
  rescue Odysseus::Error => e
798
- puts @pastel.red("Error: #{e.message}")
487
+ @ui.error e.message
799
488
  exit 1
800
489
  end
801
490
 
802
- # App exec command - run a command in a new container using the app image
803
- # Usage: odysseus app exec <server> <command> [--config FILE]
491
+ # App exec
804
492
  def app_exec(server, options = {})
805
493
  config_file = options[:config] || 'deploy.yml'
806
494
  command = options[:command]
807
495
 
808
496
  unless command
809
- puts @pastel.red("Error: command required")
497
+ @ui.error "Command required (--command)"
810
498
  exit 1
811
499
  end
812
500
 
813
501
  config = load_config(config_file)
814
502
  image = "#{config[:image]}:latest"
815
503
 
816
- puts @pastel.cyan("Odysseus App Exec")
817
- puts @pastel.blue("Server: #{server}")
818
- puts @pastel.blue("Image: #{image}")
819
- puts @pastel.blue("Command: #{command}")
820
- puts ""
504
+ @ui.header "App Exec"
505
+ @ui.info "Server", server
506
+ @ui.info "Command", command
507
+ @ui.blank
821
508
 
822
509
  ssh = connect_to_server(server, config)
823
510
 
824
511
  begin
825
512
  docker = Odysseus::Docker::Client.new(ssh)
826
-
827
- # Build environment from config
828
513
  env = {}
829
514
  config[:env][:clear]&.each { |k, v| env[k.to_s] = v.to_s }
830
515
 
831
- output = docker.run_once(
832
- image: image,
833
- command: command,
834
- options: {
835
- env: env,
836
- network: 'odysseus'
837
- }
838
- )
839
-
840
- puts output
516
+ puts docker.run_once(image: image, command: command, options: { env: env, network: 'odysseus' })
841
517
  ensure
842
518
  ssh.close
843
519
  end
844
520
  rescue Odysseus::Error => e
845
- puts @pastel.red("Error: #{e.message}")
521
+ @ui.error e.message
846
522
  exit 1
847
523
  end
848
524
 
849
- # App shell command - open an interactive shell in a temporary container
850
- # Usage: odysseus app shell <server> [--config FILE]
525
+ # App shell
851
526
  def app_shell(server, options = {})
852
527
  config_file = options[:config] || 'deploy.yml'
853
-
854
528
  config = load_config(config_file)
855
529
  image = "#{config[:image]}:latest"
856
530
 
857
- puts @pastel.cyan("Odysseus App Shell")
858
- puts @pastel.blue("Server: #{server}")
859
- puts @pastel.blue("Image: #{image}")
860
- puts ""
861
-
862
531
  ssh_keys = config[:ssh][:keys].map { |k| "-i #{File.expand_path(k)}" }.join(' ')
863
532
  env_flags = config[:env][:clear]&.map { |k, v| "-e #{k}=#{v}" }&.join(' ') || ''
864
533
 
865
534
  system("ssh #{ssh_keys} -t #{config[:ssh][:user]}@#{server} 'docker run -it --rm --network odysseus #{env_flags} #{image} /bin/sh'")
866
535
  rescue Odysseus::Error => e
867
- puts @pastel.red("Error: #{e.message}")
536
+ @ui.error e.message
868
537
  exit 1
869
538
  end
870
539
 
871
- # App console command - run an interactive console in a new container
872
- # Usage: odysseus app console <server> [--config FILE] [--cmd COMMAND]
540
+ # App console
873
541
  def app_console(server, options = {})
874
542
  config_file = options[:config] || 'deploy.yml'
875
543
  console_cmd = options[:cmd] || '/bin/sh'
876
-
877
544
  config = load_config(config_file)
878
545
  image = "#{config[:image]}:latest"
879
546
 
880
- puts @pastel.cyan("Odysseus App Console")
881
- puts @pastel.blue("Server: #{server}")
882
- puts @pastel.blue("Image: #{image}")
883
- puts @pastel.blue("Console: #{console_cmd}")
884
- puts ""
885
- puts @pastel.dim("Note: This runs 'docker run -it' via SSH. For full interactivity, use:")
886
- puts @pastel.dim(" ssh #{config[:ssh][:user]}@#{server} -t 'docker run -it --rm --network odysseus #{image} #{console_cmd}'")
887
- puts ""
888
-
889
- # For truly interactive sessions, we need to exec through SSH directly
890
- # The CLI can't easily support full TTY passthrough
891
547
  ssh_keys = config[:ssh][:keys].map { |k| "-i #{File.expand_path(k)}" }.join(' ')
892
548
  env_flags = config[:env][:clear]&.map { |k, v| "-e #{k}=#{v}" }&.join(' ') || ''
893
549
 
894
550
  system("ssh #{ssh_keys} -t #{config[:ssh][:user]}@#{server} 'docker run -it --rm --network odysseus #{env_flags} #{image} #{console_cmd}'")
895
551
  rescue Odysseus::Error => e
896
- puts @pastel.red("Error: #{e.message}")
552
+ @ui.error e.message
897
553
  exit 1
898
554
  end
899
555
 
900
- # Accessory shell command - open an interactive shell in a running accessory container
901
- # Usage: odysseus accessory shell <server> --name NAME [--config FILE]
556
+ # Accessory shell
902
557
  def accessory_shell(server, options = {})
903
558
  config_file = options[:config] || 'deploy.yml'
904
- name = options[:name]
905
-
906
- unless name
907
- puts @pastel.red("Error: accessory name required (--name)")
908
- exit 1
909
- end
559
+ name = require_name!(options)
910
560
 
911
561
  config = load_config(config_file)
912
562
  service_name = "#{config[:service]}-#{name}"
913
563
 
914
- puts @pastel.cyan("Odysseus Accessory Shell")
915
- puts @pastel.blue("Server: #{server}")
916
- puts @pastel.blue("Accessory: #{name}")
917
- puts ""
918
-
919
- # Get container ID first
920
564
  ssh = connect_to_server(server, config)
921
565
  begin
922
566
  docker = Odysseus::Docker::Client.new(ssh)
923
567
  containers = docker.list(service: service_name)
924
568
 
925
569
  if containers.empty?
926
- puts @pastel.red("No running containers found for #{service_name}")
570
+ @ui.error "No running containers found for #{service_name}"
927
571
  exit 1
928
572
  end
929
573
 
@@ -932,16 +576,53 @@ module Odysseus
932
576
  ssh.close
933
577
  end
934
578
 
935
- # Now exec with SSH passthrough for TTY
936
579
  ssh_keys = config[:ssh][:keys].map { |k| "-i #{File.expand_path(k)}" }.join(' ')
937
580
  system("ssh #{ssh_keys} -t #{config[:ssh][:user]}@#{server} 'docker exec -it #{container_id} /bin/sh'")
938
581
  rescue Odysseus::Error => e
939
- puts @pastel.red("Error: #{e.message}")
582
+ @ui.error e.message
583
+ exit 1
584
+ end
585
+
586
+ # Accessory exec
587
+ def accessory_exec(server, options = {})
588
+ config_file = options[:config] || 'deploy.yml'
589
+ name = require_name!(options)
590
+ command = options[:command]
591
+
592
+ unless command
593
+ @ui.error "Command required (--command)"
594
+ exit 1
595
+ end
596
+
597
+ config = load_config(config_file)
598
+ service_name = "#{config[:service]}-#{name}"
599
+
600
+ @ui.header "Accessory Exec"
601
+ @ui.info "Accessory", name
602
+ @ui.info "Command", command
603
+ @ui.blank
604
+
605
+ ssh = connect_to_server(server, config)
606
+
607
+ begin
608
+ docker = Odysseus::Docker::Client.new(ssh)
609
+ containers = docker.list(service: service_name)
610
+
611
+ if containers.empty?
612
+ @ui.error "No running containers found for #{service_name}"
613
+ exit 1
614
+ end
615
+
616
+ puts docker.exec(containers.first['ID'], command)
617
+ ensure
618
+ ssh.close
619
+ end
620
+ rescue Odysseus::Error => e
621
+ @ui.error e.message
940
622
  exit 1
941
623
  end
942
624
 
943
- # Cleanup command - remove containers for this service
944
- # Usage: odysseus cleanup <server> [--config FILE] [--prune-images]
625
+ # Cleanup command
945
626
  def cleanup(server, options = {})
946
627
  config_file = options[:config] || 'deploy.yml'
947
628
  prune_images = options[:all] || false
@@ -949,14 +630,10 @@ module Odysseus
949
630
  config = load_config(config_file)
950
631
  service_name = config[:service]
951
632
 
952
- if charm?
953
- puts Gum.style("🧹 Odysseus Cleanup", border: 'rounded', foreground: '212', padding: '0 1')
954
- else
955
- puts @pastel.cyan("Odysseus Cleanup: #{service_name}")
956
- end
957
- puts @pastel.blue("Service: #{service_name}")
958
- puts @pastel.blue("Server: #{server}")
959
- puts ""
633
+ @ui.header "Odysseus Cleanup"
634
+ @ui.info "Service", service_name
635
+ @ui.info "Server", server
636
+ @ui.blank
960
637
 
961
638
  ssh = connect_to_server(server, config)
962
639
 
@@ -964,38 +641,17 @@ module Odysseus
964
641
  docker = Odysseus::Docker::Client.new(ssh)
965
642
  caddy = Odysseus::Caddy::Client.new(ssh: ssh, docker: docker)
966
643
 
967
- # Show current disk usage
968
- puts @pastel.cyan("Current disk usage:")
969
- puts docker.disk_usage
970
- puts ""
971
-
972
- # Collect all service names for this deploy.yml
973
- service_names = [service_name]
974
- config[:servers].keys.reject { |r| r == :web }.each do |role|
975
- service_names << "#{service_name}-#{role}"
976
- end
977
- config[:accessories]&.each_key do |name|
978
- service_names << "#{service_name}-#{name}"
979
- end
980
-
981
- # Count containers to be removed
982
- total_to_remove = service_names.sum do |svc|
983
- docker.list(service: svc, all: true).size
644
+ if @ui.debug?
645
+ @ui.section "Disk usage (before)"
646
+ puts docker.disk_usage
647
+ @ui.blank
984
648
  end
985
649
 
986
- # Charm mode: ask for confirmation
987
- if charm? && total_to_remove > 0
988
- unless Gum.confirm("Remove #{total_to_remove} container(s) for #{service_name}?")
989
- puts @pastel.yellow("Cleanup cancelled.")
990
- return
991
- end
992
- end
993
-
994
- puts @pastel.yellow("Removing containers for #{service_name}...")
650
+ service_names = [service_name]
651
+ config[:servers].keys.reject { |r| r == :web }.each { |role| service_names << "#{service_name}-#{role}" }
652
+ config[:accessories]&.each_key { |name| service_names << "#{service_name}-#{name}" }
995
653
 
996
654
  total_removed = 0
997
-
998
- # Remove all containers for each service (not just old ones)
999
655
  service_names.each do |svc|
1000
656
  containers = docker.list(service: svc, all: true)
1001
657
  containers.each do |c|
@@ -1005,249 +661,147 @@ module Odysseus
1005
661
  end
1006
662
  end
1007
663
 
1008
- puts " Removed #{total_removed} container(s)"
1009
-
1010
- # Remove routes from Caddy for this service
1011
- puts ""
1012
- puts @pastel.yellow("Removing Caddy routes for #{service_name}...")
664
+ @ui.step "Removed #{total_removed} container(s)"
1013
665
 
666
+ # Clean Caddy routes
1014
667
  if caddy.running?
1015
- # Remove web service route
1016
668
  caddy.remove_upstream(service: service_name, upstream: nil) rescue nil
1017
-
1018
- # Remove accessory routes
1019
669
  config[:accessories]&.each do |name, acc_config|
1020
670
  if acc_config[:proxy]
1021
- acc_service = "#{service_name}-#{name}"
1022
- caddy.remove_upstream(service: acc_service, upstream: nil) rescue nil
671
+ caddy.remove_upstream(service: "#{service_name}-#{name}", upstream: nil) rescue nil
1023
672
  end
1024
673
  end
1025
674
 
1026
- # Check if Caddy still has other services
1027
- remaining_services = caddy.list_services
1028
- remaining_services.reject! { |s| s[:service] == 'unknown' || s[:service].empty? }
1029
-
1030
- if remaining_services.empty?
1031
- puts @pastel.yellow("No other services using Caddy, stopping Caddy...")
675
+ remaining = caddy.list_services.reject { |s| s[:service] == 'unknown' || s[:service].empty? }
676
+ if remaining.empty?
1032
677
  docker.stop('odysseus-caddy', timeout: 10) rescue nil
1033
678
  docker.remove('odysseus-caddy', force: true) rescue nil
1034
- puts " Caddy removed"
679
+ @ui.step "Caddy removed (no other services)"
1035
680
  else
1036
- puts @pastel.dim(" Keeping Caddy (#{remaining_services.size} other service(s) configured)")
681
+ @ui.step "Caddy kept (#{remaining.size} other service(s))"
1037
682
  end
1038
683
  end
1039
684
 
1040
- # Optionally prune dangling images
1041
685
  if prune_images
1042
- puts ""
1043
- puts @pastel.yellow("Pruning dangling images...")
1044
- results = docker.prune(containers: false, images: true, volumes: false, networks: false)
1045
- puts results[:images]
686
+ @ui.step "Pruning dangling images..."
687
+ docker.prune(containers: false, images: true, volumes: false, networks: false)
1046
688
  end
1047
689
 
1048
- puts ""
1049
- puts @pastel.cyan("Disk usage after cleanup:")
1050
- puts docker.disk_usage
690
+ @ui.blank
691
+ @ui.success "Cleanup complete"
1051
692
  ensure
1052
693
  ssh.close
1053
694
  end
1054
-
1055
- puts ""
1056
- if charm?
1057
- puts Gum.style("✓ Cleanup complete!", foreground: '82', bold: true)
1058
- else
1059
- puts @pastel.green("Cleanup complete!")
1060
- end
1061
695
  rescue Odysseus::Error => e
1062
- puts @pastel.red("Error: #{e.message}")
696
+ @ui.error e.message
1063
697
  exit 1
1064
698
  end
1065
699
 
1066
- # Accessory exec command - run a command in a running accessory container
1067
- # Usage: odysseus accessory exec <server> --name NAME <command> [--config FILE]
1068
- def accessory_exec(server, options = {})
1069
- config_file = options[:config] || 'deploy.yml'
1070
- name = options[:name]
1071
- command = options[:command]
1072
-
1073
- unless name
1074
- puts @pastel.red("Error: accessory name required (--name)")
1075
- exit 1
1076
- end
1077
-
1078
- unless command
1079
- puts @pastel.red("Error: command required")
1080
- exit 1
1081
- end
1082
-
1083
- config = load_config(config_file)
1084
- service_name = "#{config[:service]}-#{name}"
1085
-
1086
- puts @pastel.cyan("Odysseus Accessory Exec")
1087
- puts @pastel.blue("Server: #{server}")
1088
- puts @pastel.blue("Accessory: #{name}")
1089
- puts @pastel.blue("Command: #{command}")
1090
- puts ""
1091
-
1092
- ssh = connect_to_server(server, config)
1093
-
1094
- begin
1095
- docker = Odysseus::Docker::Client.new(ssh)
1096
- containers = docker.list(service: service_name)
1097
-
1098
- if containers.empty?
1099
- puts @pastel.red("No running containers found for #{service_name}")
1100
- exit 1
1101
- end
1102
-
1103
- container_id = containers.first['ID']
1104
- output = docker.exec(container_id, command)
1105
- puts output
1106
- ensure
1107
- ssh.close
1108
- end
1109
- rescue Odysseus::Error => e
1110
- puts @pastel.red("Error: #{e.message}")
1111
- exit 1
1112
- end
1113
-
1114
- # Generate a new master key for encrypting secrets
1115
- # Usage: odysseus secrets generate-key
700
+ # Secrets commands
1116
701
  def secrets_generate_key(_options = {})
1117
- puts @pastel.cyan("Odysseus Secrets: Generate Key")
1118
- puts ""
702
+ @ui.header "Secrets: Generate Key"
703
+ @ui.blank
1119
704
 
1120
705
  key = Odysseus::Secrets::EncryptedFile.generate_key
1121
-
1122
- puts @pastel.green("Generated master key:")
1123
- puts ""
706
+ @ui.success "Generated master key:"
707
+ @ui.blank
1124
708
  puts " #{key}"
1125
- puts ""
1126
- puts @pastel.yellow("Save this key securely!")
1127
- puts @pastel.dim("Set it as ODYSSEUS_MASTER_KEY environment variable for encrypt/decrypt operations.")
709
+ @ui.blank
710
+ @ui.warn "Save this key securely!"
711
+ @ui.step "Set as ODYSSEUS_MASTER_KEY environment variable"
1128
712
  end
1129
713
 
1130
- # Encrypt a secrets file
1131
- # Usage: odysseus secrets encrypt --input secrets.yml --file secrets.yml.enc
1132
714
  def secrets_encrypt(options = {})
1133
715
  input_file = options[:input]
1134
716
  output_file = options[:file] || 'secrets.yml.enc'
1135
717
 
1136
718
  unless input_file
1137
- puts @pastel.red("Error: input file required (--input)")
719
+ @ui.error "Input file required (--input)"
1138
720
  exit 1
1139
721
  end
1140
722
 
1141
723
  unless File.exist?(input_file)
1142
- puts @pastel.red("Error: input file not found: #{input_file}")
724
+ @ui.error "Input file not found: #{input_file}"
1143
725
  exit 1
1144
726
  end
1145
727
 
1146
- puts @pastel.cyan("Odysseus Secrets: Encrypt")
1147
- puts @pastel.blue("Input: #{input_file}")
1148
- puts @pastel.blue("Output: #{output_file}")
1149
- puts ""
728
+ @ui.header "Secrets: Encrypt"
729
+ @ui.info "Input", input_file
730
+ @ui.info "Output", output_file
731
+ @ui.blank
1150
732
 
1151
733
  secrets = YAML.load_file(input_file)
1152
734
  encrypted_file = Odysseus::Secrets::EncryptedFile.new(output_file)
1153
735
  encrypted_file.write(secrets)
1154
736
 
1155
- puts @pastel.green("Secrets encrypted to #{output_file}")
1156
- puts ""
1157
- puts @pastel.dim("You can now delete the plaintext file: rm #{input_file}")
1158
- puts @pastel.dim("Add to deploy.yml: secrets_file: #{output_file}")
737
+ @ui.success "Secrets encrypted to #{output_file}"
1159
738
  rescue Odysseus::Secrets::EncryptedFile::MissingKeyError => e
1160
- puts @pastel.red("Error: #{e.message}")
1161
- puts @pastel.dim("Generate a key with: odysseus secrets generate-key")
739
+ @ui.error e.message
740
+ @ui.step "Generate a key with: odysseus secrets generate-key"
1162
741
  exit 1
1163
742
  rescue Odysseus::Error => e
1164
- puts @pastel.red("Error: #{e.message}")
743
+ @ui.error e.message
1165
744
  exit 1
1166
745
  end
1167
746
 
1168
- # Decrypt and display secrets
1169
- # Usage: odysseus secrets decrypt --file secrets.yml.enc
1170
747
  def secrets_decrypt(options = {})
1171
748
  secrets_file = options[:file] || 'secrets.yml.enc'
1172
749
 
1173
750
  unless File.exist?(secrets_file)
1174
- puts @pastel.red("Error: secrets file not found: #{secrets_file}")
751
+ @ui.error "Secrets file not found: #{secrets_file}"
1175
752
  exit 1
1176
753
  end
1177
754
 
1178
- puts @pastel.cyan("Odysseus Secrets: Decrypt")
1179
- puts @pastel.blue("File: #{secrets_file}")
1180
- puts ""
755
+ @ui.header "Secrets: Decrypt"
756
+ @ui.info "File", secrets_file
757
+ @ui.blank
1181
758
 
1182
759
  encrypted_file = Odysseus::Secrets::EncryptedFile.new(secrets_file)
1183
760
  secrets = encrypted_file.read
1184
761
 
1185
- puts @pastel.yellow("Decrypted secrets:")
1186
- puts ""
1187
762
  secrets.each do |key, value|
1188
- # Mask values for display
1189
- masked_value = value.to_s.length > 4 ? "#{value[0..3]}#{'*' * (value.length - 4)}" : '****'
1190
- puts " #{key}: #{masked_value}"
763
+ masked = value.to_s.length > 4 ? "#{value[0..3]}#{'*' * (value.length - 4)}" : '****'
764
+ @ui.info key, masked
1191
765
  end
1192
- puts ""
1193
- puts @pastel.dim("(Values are masked for security)")
1194
- rescue Odysseus::Secrets::EncryptedFile::MissingKeyError => e
1195
- puts @pastel.red("Error: #{e.message}")
1196
- exit 1
1197
- rescue Odysseus::Secrets::EncryptedFile::DecryptionError => e
1198
- puts @pastel.red("Error: #{e.message}")
1199
- exit 1
1200
- rescue Odysseus::Error => e
1201
- puts @pastel.red("Error: #{e.message}")
766
+ @ui.blank
767
+ @ui.step "(values masked)"
768
+ rescue Odysseus::Secrets::EncryptedFile::MissingKeyError, Odysseus::Secrets::EncryptedFile::DecryptionError => e
769
+ @ui.error e.message
1202
770
  exit 1
1203
771
  end
1204
772
 
1205
- # Edit encrypted secrets using $EDITOR
1206
- # Usage: odysseus secrets edit --file secrets.yml.enc
1207
773
  def secrets_edit(options = {})
1208
774
  secrets_file = options[:file] || 'secrets.yml.enc'
1209
775
  editor = ENV['EDITOR'] || 'vi'
1210
776
 
1211
- puts @pastel.cyan("Odysseus Secrets: Edit")
1212
- puts @pastel.blue("File: #{secrets_file}")
1213
- puts @pastel.blue("Editor: #{editor}")
1214
- puts ""
777
+ @ui.header "Secrets: Edit"
778
+ @ui.info "File", secrets_file
779
+ @ui.info "Editor", editor
780
+ @ui.blank
1215
781
 
1216
782
  encrypted_file = Odysseus::Secrets::EncryptedFile.new(secrets_file)
783
+ secrets = encrypted_file.exists? ? encrypted_file.read : {}
1217
784
 
1218
- # Load existing secrets or start with empty hash
1219
- secrets = if encrypted_file.exists?
1220
- encrypted_file.read
1221
- else
1222
- {}
1223
- end
1224
-
1225
- # Write to temp file for editing
1226
785
  temp_file = Tempfile.new(['secrets', '.yml'])
1227
786
  begin
1228
787
  temp_file.write(YAML.dump(secrets))
1229
788
  temp_file.close
1230
789
 
1231
- # Open in editor
1232
790
  system("#{editor} #{temp_file.path}")
1233
791
 
1234
- # Read back and encrypt
1235
792
  edited_secrets = YAML.load_file(temp_file.path)
1236
793
  encrypted_file.write(edited_secrets)
1237
794
 
1238
- puts @pastel.green("Secrets updated and encrypted to #{secrets_file}")
795
+ @ui.success "Secrets updated and encrypted"
1239
796
  ensure
1240
797
  temp_file.unlink
1241
798
  end
1242
799
  rescue Odysseus::Secrets::EncryptedFile::MissingKeyError => e
1243
- puts @pastel.red("Error: #{e.message}")
1244
- puts @pastel.dim("Generate a key with: odysseus secrets generate-key")
1245
- exit 1
1246
- rescue Odysseus::Secrets::EncryptedFile::DecryptionError => e
1247
- puts @pastel.red("Error: #{e.message}")
800
+ @ui.error e.message
801
+ @ui.step "Generate a key with: odysseus secrets generate-key"
1248
802
  exit 1
1249
803
  rescue Odysseus::Error => e
1250
- puts @pastel.red("Error: #{e.message}")
804
+ @ui.error e.message
1251
805
  exit 1
1252
806
  end
1253
807
 
@@ -1266,6 +820,15 @@ module Odysseus
1266
820
  use_tailscale: true
1267
821
  )
1268
822
  end
823
+
824
+ def require_name!(options)
825
+ name = options[:name]
826
+ unless name
827
+ @ui.error "Accessory name required (--name)"
828
+ exit 1
829
+ end
830
+ name
831
+ end
1269
832
  end
1270
833
  end
1271
834
  end