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 +4 -4
- data/README.md +44 -0
- data/bin/odysseus +9 -1
- data/lib/odysseus/cli/cli.rb +299 -70
- data/lib/odysseus/cli/gum.rb +156 -0
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9ab2bed3fffc294ecb32f75d6e1d63658b155d70a96527b001549650c2ae8097
|
|
4
|
+
data.tar.gz: 78d8ff320554b9e6f882475bfd751848b775412658c681e1ee69395385224969
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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)"
|
data/lib/odysseus/cli/cli.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|