odysseus-cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83fbeafc2b92417c763330d0917febf88b7f35a283449cd6be25c3a3b6f4de9f
4
- data.tar.gz: aa37ee969336ceb718dde957b5ea8d762a6191ee4f5d629aa7c78e1c9a588721
3
+ metadata.gz: 9ab2bed3fffc294ecb32f75d6e1d63658b155d70a96527b001549650c2ae8097
4
+ data.tar.gz: 78d8ff320554b9e6f882475bfd751848b775412658c681e1ee69395385224969
5
5
  SHA512:
6
- metadata.gz: bc38e11e61cdc48389853434fc7327c3c7ff00e3103cc1399bb4d2bfa943a827e09948794d4760f03b6644c6f07b8b5307d3f5ed00c9a2b2b8c49bbbd710ce93
7
- data.tar.gz: 40e5da0f9800cb2188ebff426b1990bc8aa0061d13a79db4b27a599338912a287bb1369729b8c88fa89de70fd589c19ae2b4cd7d47a7b9ca5cc90907088dbad3
6
+ metadata.gz: 3cf3f0f9160960cee5e9cdad3f69bb805b62931a74fe81c46e2a967a788dd60f7c7beed834f28b6596283d64708c114e50a992b361c199afef4c7eb695e6624d
7
+ data.tar.gz: 5bfe356362ffb83d79330b6327a4417ad5b1ef0f244a3c420d9ed68dac23200545266a82bb4e69c373b7d63bea48cb7c70a87ab66cf2e21fea07a00435fa2d33
data/README.md CHANGED
@@ -461,6 +461,50 @@ registry:
461
461
 
462
462
  For better security, you can store registry credentials in your encrypted secrets file and reference them.
463
463
 
464
+ ## Charm Mode (TUI)
465
+
466
+ Odysseus CLI supports an optional **Charm mode** for a more glamorous terminal experience with spinners, styled output, tables, and interactive confirmations.
467
+
468
+ ### Enabling Charm Mode
469
+
470
+ ```bash
471
+ # Via command-line flag
472
+ odysseus deploy --charm --build
473
+
474
+ # Via environment variable
475
+ ODYSSEUS_CHARM=1 odysseus deploy --build
476
+ ```
477
+
478
+ ### Features in Charm Mode
479
+
480
+ - **Styled headers** with rounded borders and colors
481
+ - **Spinners** for long-running operations (build, deploy, pussh)
482
+ - **Tables** for container and accessory status listings
483
+ - **Confirmation dialogs** for destructive operations (cleanup, accessory remove)
484
+
485
+ ### Installing gum
486
+
487
+ Charm mode requires [gum](https://github.com/charmbracelet/gum) to be installed:
488
+
489
+ ```bash
490
+ # macOS
491
+ brew install gum
492
+
493
+ # Arch Linux
494
+ pacman -S gum
495
+
496
+ # Ubuntu/Debian (via charm tap)
497
+ sudo mkdir -p /etc/apt/keyrings
498
+ curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg
499
+ echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list
500
+ sudo apt update && sudo apt install gum
501
+
502
+ # From source
503
+ go install github.com/charmbracelet/gum@latest
504
+ ```
505
+
506
+ If gum is not installed, Odysseus will warn you and fall back to standard output.
507
+
464
508
  ## Server Requirements
465
509
 
466
510
  Your target servers only need **Docker** installed. Odysseus automatically deploys and manages Caddy as a container (`odysseus-caddy`) - no manual Caddy installation required.
data/bin/odysseus CHANGED
@@ -6,7 +6,11 @@ require 'odysseus/cli/cli'
6
6
  require 'optparse'
7
7
 
8
8
  def main
9
- cli = Odysseus::CLI::CLI.new
9
+ # Check for charm mode via env var or flag
10
+ charm_mode = ENV['ODYSSEUS_CHARM'] == '1' || ARGV.include?('--charm')
11
+ ARGV.delete('--charm') # Remove so it doesn't interfere with command parsing
12
+
13
+ cli = Odysseus::CLI::CLI.new(charm_mode: charm_mode)
10
14
 
11
15
  commands = {
12
16
  'deploy' => { method: :deploy, needs_server: false },
@@ -241,6 +245,10 @@ end
241
245
  def print_help
242
246
  puts "Usage: odysseus <command> [options]"
243
247
  puts ""
248
+ puts "Global options:"
249
+ puts " --charm Enable Charm TUI mode (requires gum)"
250
+ puts " Or set ODYSSEUS_CHARM=1 environment variable"
251
+ puts ""
244
252
  puts "Commands:"
245
253
  puts " deploy Deploy all roles to servers defined in config"
246
254
  puts " build Build Docker image (locally or on build host)"
@@ -4,12 +4,22 @@ require 'odysseus'
4
4
  require 'pastel'
5
5
  require 'yaml'
6
6
  require 'tempfile'
7
+ require_relative 'gum'
7
8
 
8
9
  module Odysseus
9
10
  module CLI
10
11
  class CLI
11
- def initialize
12
+ def initialize(charm_mode: false)
12
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
13
23
  end
14
24
 
15
25
  # Deploy command - deploys all roles to their configured hosts
@@ -24,7 +34,12 @@ module Odysseus
24
34
  config = load_config(config_file)
25
35
  uses_registry = config[:registry] && config[:registry][:server]
26
36
 
27
- puts @pastel.cyan("Odysseus Deploy")
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
28
43
  puts @pastel.blue("Service: #{config[:service]}")
29
44
  puts @pastel.blue("Image: #{config[:image]}:#{image_tag}")
30
45
  if should_build
@@ -37,33 +52,39 @@ module Odysseus
37
52
 
38
53
  # Build and distribute image if requested
39
54
  if should_build
40
- puts @pastel.cyan("=== Building and distributing image ===")
41
- result = executor.build_and_distribute(image_tag: image_tag)
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
42
63
 
43
64
  if result[:build][:success]
44
- puts @pastel.green("Build complete!")
65
+ puts @pastel.green("Build complete!")
45
66
  else
46
- puts @pastel.red("Build failed: #{result[:build][:error]}")
67
+ puts @pastel.red("Build failed: #{result[:build][:error]}")
47
68
  exit 1
48
69
  end
49
70
 
50
71
  # Handle distribution result (either pussh or registry push)
51
72
  if uses_registry
52
73
  if result[:push][:success]
53
- puts @pastel.green("Pushed to registry!")
74
+ puts @pastel.green("Pushed to registry!")
54
75
  else
55
- puts @pastel.red("Push to registry failed!")
76
+ puts @pastel.red("Push to registry failed!")
56
77
  exit 1
57
78
  end
58
79
  else
59
80
  if result[:pussh][:success]
60
- puts @pastel.green("Pussh complete!")
81
+ puts @pastel.green("Pussh complete!")
61
82
  result[:pussh][:results]&.each do |host, host_result|
62
83
  status = host_result[:success] ? @pastel.green('✓') : @pastel.red('✗')
63
84
  puts " #{status} #{host}"
64
85
  end
65
86
  else
66
- puts @pastel.red("Pussh failed!")
87
+ puts @pastel.red("Pussh failed!")
67
88
  result[:pussh][:results]&.each do |host, host_result|
68
89
  status = host_result[:success] ? @pastel.green('✓') : @pastel.red('✗')
69
90
  puts " #{status} #{host}"
@@ -75,10 +96,20 @@ module Odysseus
75
96
  puts ""
76
97
  end
77
98
 
78
- executor.deploy_all(image_tag: image_tag, dry_run: dry_run)
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
104
+ executor.deploy_all(image_tag: image_tag, dry_run: dry_run)
105
+ end
79
106
 
80
107
  puts ""
81
- puts @pastel.green("Deploy complete!")
108
+ if charm?
109
+ puts Gum.style("✓ Deploy complete!", foreground: '82', bold: true)
110
+ else
111
+ puts @pastel.green("Deploy complete!")
112
+ end
82
113
  rescue Odysseus::Error => e
83
114
  puts @pastel.red("Error: #{e.message}")
84
115
  exit 1
@@ -97,7 +128,11 @@ module Odysseus
97
128
  builder_config = config[:builder] || {}
98
129
  strategy = builder_config[:strategy] || :local
99
130
 
100
- puts @pastel.cyan("Odysseus Build")
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
101
136
  puts @pastel.blue("Service: #{config[:service]}")
102
137
  puts @pastel.blue("Image: #{config[:image]}:#{image_tag}")
103
138
  puts @pastel.blue("Strategy: #{strategy}")
@@ -106,18 +141,29 @@ module Odysseus
106
141
  puts ""
107
142
 
108
143
  executor = Odysseus::Deployer::Executor.new(config_file, verbose: verbose)
109
- result = executor.build(image_tag: image_tag, push: push, context_path: context_path)
144
+
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)
151
+ end
110
152
 
111
153
  if result[:success]
112
154
  puts ""
113
- puts @pastel.green("Build complete!")
155
+ if charm?
156
+ puts Gum.style("✓ Build complete!", foreground: '82', bold: true)
157
+ else
158
+ puts @pastel.green("Build complete!")
159
+ end
114
160
  puts @pastel.blue("Image: #{result[:image]}")
115
161
  if result[:pushed]
116
162
  puts @pastel.blue("Pushed to registry: yes")
117
163
  end
118
164
  else
119
165
  puts ""
120
- puts @pastel.red("Build failed: #{result[:error]}")
166
+ puts @pastel.red("Build failed: #{result[:error]}")
121
167
  exit 1
122
168
  end
123
169
  rescue Odysseus::Error => e
@@ -135,7 +181,11 @@ module Odysseus
135
181
 
136
182
  config = load_config(config_file)
137
183
 
138
- puts @pastel.cyan("Odysseus Pussh")
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
139
189
  puts @pastel.blue("Service: #{config[:service]}")
140
190
  puts @pastel.blue("Image: #{config[:image]}:#{image_tag}")
141
191
  puts @pastel.blue("Build first: #{should_build ? 'yes' : 'no'}")
@@ -144,30 +194,46 @@ module Odysseus
144
194
  executor = Odysseus::Deployer::Executor.new(config_file, verbose: verbose)
145
195
 
146
196
  if should_build
147
- result = executor.build_and_pussh(image_tag: image_tag)
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)
203
+ end
148
204
 
149
205
  if result[:build][:success]
150
- puts @pastel.green("Build complete!")
206
+ puts @pastel.green("Build complete!")
151
207
  else
152
- puts @pastel.red("Build failed: #{result[:build][:error]}")
208
+ puts @pastel.red("Build failed: #{result[:build][:error]}")
153
209
  exit 1
154
210
  end
155
211
  else
156
- result = executor.pussh(image_tag: image_tag)
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)
218
+ end
157
219
  end
158
220
 
159
221
  pussh_result = should_build ? result[:pussh] : result
160
222
 
161
223
  if pussh_result[:success]
162
224
  puts ""
163
- puts @pastel.green("Pussh complete!")
225
+ if charm?
226
+ puts Gum.style("✓ Pussh complete!", foreground: '82', bold: true)
227
+ else
228
+ puts @pastel.green("Pussh complete!")
229
+ end
164
230
  pussh_result[:results]&.each do |host, host_result|
165
231
  status = host_result[:success] ? @pastel.green('✓') : @pastel.red('✗')
166
232
  puts " #{status} #{host}"
167
233
  end
168
234
  else
169
235
  puts ""
170
- puts @pastel.red("Pussh failed!")
236
+ puts @pastel.red("Pussh failed!")
171
237
  pussh_result[:results]&.each do |host, host_result|
172
238
  status = host_result[:success] ? @pastel.green('✓') : @pastel.red('✗')
173
239
  puts " #{status} #{host}"
@@ -188,7 +254,12 @@ module Odysseus
188
254
  config = load_config(config_file)
189
255
  service_name = config[:service]
190
256
 
191
- puts @pastel.cyan("Odysseus Status: #{service_name}")
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}")
192
263
  puts @pastel.blue("Server: #{server}")
193
264
  puts ""
194
265
 
@@ -199,10 +270,20 @@ module Odysseus
199
270
  caddy = Odysseus::Caddy::Client.new(ssh: ssh, docker: docker)
200
271
 
201
272
  # Web containers
202
- puts @pastel.cyan("Web:")
273
+ if charm?
274
+ puts Gum.style(" Web ", foreground: '212', bold: true)
275
+ else
276
+ puts @pastel.cyan("Web:")
277
+ end
203
278
  web_containers = docker.list(service: service_name)
204
279
  if web_containers.empty?
205
280
  puts " (no containers running)"
281
+ elsif charm?
282
+ rows = web_containers.map do |c|
283
+ health = c['Status'].include?('healthy') ? '✓' : ''
284
+ [c['Names'], c['State'], c['Image'], health]
285
+ end
286
+ puts Gum.table(headers: ['Name', 'State', 'Image', 'Health'], rows: rows)
206
287
  else
207
288
  web_containers.each do |c|
208
289
  status_color = c['State'] == 'running' ? :green : :red
@@ -227,17 +308,38 @@ module Odysseus
227
308
  # Job/worker containers (non-web roles)
228
309
  non_web_roles = config[:servers].keys.reject { |r| r == :web }
229
310
  if non_web_roles.any?
230
- puts @pastel.cyan("Jobs/Workers:")
231
- non_web_roles.each do |role|
232
- role_service = "#{service_name}-#{role}"
233
- containers = docker.list(service: role_service)
234
- if containers.empty?
235
- puts " #{@pastel.yellow(role.to_s)}: #{@pastel.red('not running')}"
236
- else
237
- 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']}"
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
241
343
  end
242
344
  end
243
345
  end
@@ -246,20 +348,36 @@ module Odysseus
246
348
 
247
349
  # Accessories
248
350
  if config[:accessories]&.any?
249
- puts @pastel.cyan("Accessories:")
250
- config[:accessories].each do |name, acc_config|
251
- acc_service = "#{service_name}-#{name}"
252
- containers = docker.list(service: acc_service, all: true)
253
- running = containers.find { |c| c['State'] == 'running' }
254
-
255
- 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]}"
260
- else
261
- puts " #{@pastel.yellow(name.to_s)}: #{@pastel.red('stopped')}"
262
- puts " Image: #{acc_config[:image]}"
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
263
381
  end
264
382
  end
265
383
  puts ""
@@ -267,7 +385,11 @@ module Odysseus
267
385
 
268
386
  # TLS status for this service's domains
269
387
  if config[:proxy][:hosts]&.any?
270
- puts @pastel.cyan("TLS:")
388
+ if charm?
389
+ puts Gum.style(" TLS ", foreground: '212', bold: true)
390
+ else
391
+ puts @pastel.cyan("TLS:")
392
+ end
271
393
  if caddy_status[:running] && caddy_status[:tls][:enabled]
272
394
  service_hosts = config[:proxy][:hosts]
273
395
  relevant_policy = caddy_status[:tls][:policies].find do |p|
@@ -297,7 +419,11 @@ module Odysseus
297
419
  def containers(server, options = {})
298
420
  config_file = options[:config] || 'deploy.yml'
299
421
 
300
- puts @pastel.cyan("Odysseus Containers")
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
301
427
  puts @pastel.blue("Server: #{server}")
302
428
  puts ""
303
429
 
@@ -311,6 +437,12 @@ module Odysseus
311
437
 
312
438
  if containers.empty?
313
439
  puts "No containers found for service: #{service_name}"
440
+ elsif charm?
441
+ puts Gum.style(" #{service_name} ", foreground: '212', bold: true)
442
+ rows = containers.map do |c|
443
+ [c['ID'][0..11], c['State'], c['Names'], c['Image'], c['Status']]
444
+ end
445
+ puts Gum.table(headers: ['ID', 'State', 'Name', 'Image', 'Status'], rows: rows)
314
446
  else
315
447
  puts @pastel.cyan("Containers for #{service_name}:")
316
448
  containers.each do |c|
@@ -362,14 +494,25 @@ module Odysseus
362
494
  exit 1
363
495
  end
364
496
 
365
- puts @pastel.cyan("Odysseus Accessory Boot")
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
366
502
  puts @pastel.blue("Accessory: #{name}")
367
503
  puts ""
368
504
 
369
505
  executor = Odysseus::Deployer::Executor.new(config_file)
370
- executor.deploy_accessory(name: name)
371
506
 
372
- puts @pastel.green("Accessory #{name} deployed!")
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
373
516
  rescue Odysseus::Error => e
374
517
  puts @pastel.red("Error: #{e.message}")
375
518
  exit 1
@@ -380,13 +523,24 @@ module Odysseus
380
523
  def accessory_boot_all(options = {})
381
524
  config_file = options[:config] || 'deploy.yml'
382
525
 
383
- puts @pastel.cyan("Odysseus Accessory Boot All")
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
384
531
  puts ""
385
532
 
386
533
  executor = Odysseus::Deployer::Executor.new(config_file)
387
- executor.boot_accessories
388
534
 
389
- puts @pastel.green("All accessories deployed!")
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
390
544
  rescue Odysseus::Error => e
391
545
  puts @pastel.red("Error: #{e.message}")
392
546
  exit 1
@@ -403,14 +557,33 @@ module Odysseus
403
557
  exit 1
404
558
  end
405
559
 
406
- puts @pastel.cyan("Odysseus Accessory Remove")
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
407
565
  puts @pastel.blue("Accessory: #{name}")
408
566
  puts ""
409
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
575
+
410
576
  executor = Odysseus::Deployer::Executor.new(config_file)
411
- executor.remove_accessory(name: name)
412
577
 
413
- puts @pastel.green("Accessory #{name} removed!")
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
414
587
  rescue Odysseus::Error => e
415
588
  puts @pastel.red("Error: #{e.message}")
416
589
  exit 1
@@ -427,14 +600,25 @@ module Odysseus
427
600
  exit 1
428
601
  end
429
602
 
430
- puts @pastel.cyan("Odysseus Accessory Restart")
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
431
608
  puts @pastel.blue("Accessory: #{name}")
432
609
  puts ""
433
610
 
434
611
  executor = Odysseus::Deployer::Executor.new(config_file)
435
- executor.restart_accessory(name: name)
436
612
 
437
- puts @pastel.green("Accessory #{name} restarted!")
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
438
622
  rescue Odysseus::Error => e
439
623
  puts @pastel.red("Error: #{e.message}")
440
624
  exit 1
@@ -451,14 +635,25 @@ module Odysseus
451
635
  exit 1
452
636
  end
453
637
 
454
- puts @pastel.cyan("Odysseus Accessory Upgrade")
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
455
643
  puts @pastel.blue("Accessory: #{name}")
456
644
  puts ""
457
645
 
458
646
  executor = Odysseus::Deployer::Executor.new(config_file)
459
- executor.upgrade_accessory(name: name)
460
647
 
461
- puts @pastel.green("Accessory #{name} upgraded!")
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
462
657
  rescue Odysseus::Error => e
463
658
  puts @pastel.red("Error: #{e.message}")
464
659
  exit 1
@@ -469,7 +664,11 @@ module Odysseus
469
664
  def accessory_status(options = {})
470
665
  config_file = options[:config] || 'deploy.yml'
471
666
 
472
- puts @pastel.cyan("Odysseus Accessory Status")
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
473
672
  puts ""
474
673
 
475
674
  executor = Odysseus::Deployer::Executor.new(config_file)
@@ -477,6 +676,14 @@ module Odysseus
477
676
 
478
677
  if statuses.empty?
479
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)
480
687
  else
481
688
  statuses.each do |status|
482
689
  status_text = status[:running] ? @pastel.green('running') : @pastel.red('stopped')
@@ -742,7 +949,12 @@ module Odysseus
742
949
  config = load_config(config_file)
743
950
  service_name = config[:service]
744
951
 
745
- puts @pastel.cyan("Odysseus Cleanup: #{service_name}")
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}")
746
958
  puts @pastel.blue("Server: #{server}")
747
959
  puts ""
748
960
 
@@ -766,6 +978,19 @@ module Odysseus
766
978
  service_names << "#{service_name}-#{name}"
767
979
  end
768
980
 
981
+ # Count containers to be removed
982
+ total_to_remove = service_names.sum do |svc|
983
+ docker.list(service: svc, all: true).size
984
+ end
985
+
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
+
769
994
  puts @pastel.yellow("Removing containers for #{service_name}...")
770
995
 
771
996
  total_removed = 0
@@ -828,7 +1053,11 @@ module Odysseus
828
1053
  end
829
1054
 
830
1055
  puts ""
831
- puts @pastel.green("Cleanup complete!")
1056
+ if charm?
1057
+ puts Gum.style("✓ Cleanup complete!", foreground: '82', bold: true)
1058
+ else
1059
+ puts @pastel.green("Cleanup complete!")
1060
+ end
832
1061
  rescue Odysseus::Error => e
833
1062
  puts @pastel.red("Error: #{e.message}")
834
1063
  exit 1
@@ -0,0 +1,156 @@
1
+ # odysseus-cli/lib/odysseus/cli/gum.rb
2
+ # Wrapper for Charm's gum CLI tool
3
+ # https://github.com/charmbracelet/gum
4
+
5
+ require 'open3'
6
+ require 'tempfile'
7
+
8
+ module Odysseus
9
+ module CLI
10
+ module Gum
11
+ class << self
12
+ # Check if gum is installed and available
13
+ def available?
14
+ @available ||= system('which gum > /dev/null 2>&1')
15
+ end
16
+
17
+ # Display a spinner while executing a block
18
+ # Returns the block's result
19
+ def spin(title:, spinner: 'dot')
20
+ return yield unless available?
21
+
22
+ result = nil
23
+ error = nil
24
+
25
+ # We can't use gum spin directly with Ruby blocks, so we show spinner
26
+ # and run the block in a thread
27
+ spin_pid = spawn("gum spin --spinner #{spinner} --title #{shell_escape(title)} -- sleep infinity",
28
+ out: '/dev/null', err: '/dev/null')
29
+
30
+ begin
31
+ result = yield
32
+ rescue => e
33
+ error = e
34
+ ensure
35
+ Process.kill('TERM', spin_pid) rescue nil
36
+ Process.wait(spin_pid) rescue nil
37
+ end
38
+
39
+ raise error if error
40
+ result
41
+ end
42
+
43
+ # Interactive selection menu
44
+ # Returns selected option or nil if cancelled
45
+ def choose(options, header: nil)
46
+ return nil unless available?
47
+
48
+ args = ['gum', 'choose']
49
+ args += ['--header', header] if header
50
+ args += options
51
+
52
+ stdout, status = Open3.capture2(*args)
53
+ return nil unless status.success?
54
+
55
+ stdout.strip
56
+ end
57
+
58
+ # Yes/No confirmation dialog
59
+ # Returns true for yes, false for no
60
+ def confirm(message)
61
+ return true unless available?
62
+
63
+ system('gum', 'confirm', message)
64
+ end
65
+
66
+ # Style text with borders, colors, padding
67
+ def style(text, border: nil, foreground: nil, background: nil, padding: nil, margin: nil, bold: false)
68
+ return text unless available?
69
+
70
+ args = ['gum', 'style']
71
+ args += ['--border', border] if border
72
+ args += ['--foreground', foreground.to_s] if foreground
73
+ args += ['--background', background.to_s] if background
74
+ args += ['--padding', padding.to_s] if padding
75
+ args += ['--margin', margin.to_s] if margin
76
+ args << '--bold' if bold
77
+ args << text
78
+
79
+ stdout, status = Open3.capture2(*args)
80
+ status.success? ? stdout : text
81
+ end
82
+
83
+ # Display a table from headers and rows
84
+ # Returns formatted table string
85
+ def table(headers:, rows:)
86
+ return simple_table(headers, rows) unless available?
87
+
88
+ # gum table reads CSV from stdin
89
+ csv_data = [headers.join(',')]
90
+ rows.each do |row|
91
+ csv_data << row.map { |cell| csv_escape(cell.to_s) }.join(',')
92
+ end
93
+
94
+ stdout, status = Open3.capture2('gum', 'table', stdin_data: csv_data.join("\n"))
95
+ status.success? ? stdout : simple_table(headers, rows)
96
+ end
97
+
98
+ # Format text (markdown, code, etc.)
99
+ def format(text, type: 'markdown')
100
+ return text unless available?
101
+
102
+ stdout, status = Open3.capture2('gum', 'format', '-t', type, stdin_data: text)
103
+ status.success? ? stdout : text
104
+ end
105
+
106
+ # Display a log message with level styling
107
+ def log(message, level: 'info')
108
+ return puts(message) unless available?
109
+
110
+ system('gum', 'log', '-l', level, message)
111
+ end
112
+
113
+ # Join multiple styled blocks horizontally or vertically
114
+ def join(*texts, horizontal: false)
115
+ return texts.join("\n") unless available?
116
+
117
+ args = ['gum', 'join']
118
+ args << '--horizontal' if horizontal
119
+ args += texts
120
+
121
+ stdout, status = Open3.capture2(*args)
122
+ status.success? ? stdout : texts.join(horizontal ? ' ' : "\n")
123
+ end
124
+
125
+ private
126
+
127
+ def shell_escape(str)
128
+ "'#{str.gsub("'", "'\\\\''")}'"
129
+ end
130
+
131
+ def csv_escape(str)
132
+ if str.include?(',') || str.include?('"') || str.include?("\n")
133
+ "\"#{str.gsub('"', '""')}\""
134
+ else
135
+ str
136
+ end
137
+ end
138
+
139
+ # Fallback simple table for when gum is not available
140
+ def simple_table(headers, rows)
141
+ widths = headers.map.with_index do |h, i|
142
+ [h.to_s.length, rows.map { |r| r[i].to_s.length }.max || 0].max
143
+ end
144
+
145
+ lines = []
146
+ lines << headers.map.with_index { |h, i| h.to_s.ljust(widths[i]) }.join(' ')
147
+ lines << widths.map { |w| '-' * w }.join(' ')
148
+ rows.each do |row|
149
+ lines << row.map.with_index { |c, i| c.to_s.ljust(widths[i]) }.join(' ')
150
+ end
151
+ lines.join("\n")
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: odysseus-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Your Name
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.1'
18
+ version: '0.2'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.1'
25
+ version: '0.2'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: pastel
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -76,6 +76,7 @@ files:
76
76
  - README.md
77
77
  - bin/odysseus
78
78
  - lib/odysseus/cli/cli.rb
79
+ - lib/odysseus/cli/gum.rb
79
80
  homepage: https://github.com/WaSystems/odysseus
80
81
  licenses:
81
82
  - LGPL-3.0-only