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