odysseus-cli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/bin/odysseus +6 -6
- data/lib/odysseus/cli/cli.rb +310 -747
- data/lib/odysseus/cli/ui.rb +447 -0
- metadata +8 -8
- data/lib/odysseus/cli/gum.rb +0 -156
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
# odysseus-cli/lib/odysseus/cli/ui.rb
|
|
2
|
+
#
|
|
3
|
+
# Terminal UI renderer for Odysseus CLI.
|
|
4
|
+
#
|
|
5
|
+
# Default mode: numbered steps with animated spinners that resolve to ✓/✗.
|
|
6
|
+
# Debug mode (--debug): verbose plain-text output, no spinners.
|
|
7
|
+
|
|
8
|
+
module Odysseus
|
|
9
|
+
module CLI
|
|
10
|
+
# IO wrapper that redacts sensitive values before writing
|
|
11
|
+
class RedactingIO
|
|
12
|
+
def initialize(io, redact_fn)
|
|
13
|
+
@io = io
|
|
14
|
+
@redact = redact_fn
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def write(str)
|
|
18
|
+
@io.write(@redact.call(str.to_s))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def puts(*args)
|
|
22
|
+
args.each { |a| @io.puts(@redact.call(a.to_s)) }
|
|
23
|
+
@io.puts if args.empty?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def print(*args)
|
|
27
|
+
args.each { |a| @io.print(@redact.call(a.to_s)) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def flush
|
|
31
|
+
@io.flush
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def respond_to_missing?(method, include_private = false)
|
|
35
|
+
@io.respond_to?(method, include_private)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def method_missing(method, *args, &block)
|
|
39
|
+
@io.send(method, *args, &block)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class UI
|
|
44
|
+
SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
45
|
+
|
|
46
|
+
# ANSI color helpers
|
|
47
|
+
COPPER = "\e[38;2;255;183;123m".freeze
|
|
48
|
+
MINT = "\e[38;2;112;216;200m".freeze
|
|
49
|
+
RED = "\e[38;2;255;100;100m".freeze
|
|
50
|
+
DIM = "\e[2m".freeze
|
|
51
|
+
RESET = "\e[0m".freeze
|
|
52
|
+
|
|
53
|
+
def initialize(debug: false)
|
|
54
|
+
@debug = debug
|
|
55
|
+
@step_number = 0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def debug?
|
|
59
|
+
@debug
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def reset_steps!
|
|
63
|
+
@step_number = 0
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def next_step!
|
|
67
|
+
@step_number += 1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# --- Header ---
|
|
71
|
+
|
|
72
|
+
def header(title)
|
|
73
|
+
reset_steps!
|
|
74
|
+
if debug?
|
|
75
|
+
puts "\e[36m#{title}\e[0m"
|
|
76
|
+
else
|
|
77
|
+
puts ""
|
|
78
|
+
puts " #{COPPER}#{title}#{RESET}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def info(label, value)
|
|
83
|
+
if debug?
|
|
84
|
+
puts " #{label}: #{value}"
|
|
85
|
+
else
|
|
86
|
+
puts " #{DIM}#{label}:#{RESET} #{value}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def blank
|
|
91
|
+
puts ""
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# --- Single spin step ---
|
|
95
|
+
# Shows spinner while block runs, resolves to ✓/✗.
|
|
96
|
+
# Captures stdout from the block so it doesn't leak.
|
|
97
|
+
|
|
98
|
+
def spin_step(message)
|
|
99
|
+
next_step!
|
|
100
|
+
num = step_num_str
|
|
101
|
+
|
|
102
|
+
if debug?
|
|
103
|
+
puts " #{num} #{redact(message)}"
|
|
104
|
+
result = yield
|
|
105
|
+
puts " #{num} ✓ #{redact(message)}"
|
|
106
|
+
return result
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
result = nil
|
|
110
|
+
err = nil
|
|
111
|
+
done = false
|
|
112
|
+
|
|
113
|
+
# Capture stdout from the block
|
|
114
|
+
old_stdout = $stdout
|
|
115
|
+
rd, wr = IO.pipe
|
|
116
|
+
$stdout = wr
|
|
117
|
+
|
|
118
|
+
thread = Thread.new do
|
|
119
|
+
begin
|
|
120
|
+
result = yield
|
|
121
|
+
rescue => e
|
|
122
|
+
err = e
|
|
123
|
+
ensure
|
|
124
|
+
done = true
|
|
125
|
+
wr.close
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
frame_idx = 0
|
|
130
|
+
loop do
|
|
131
|
+
break if done
|
|
132
|
+
frame = SPINNER_FRAMES[frame_idx % SPINNER_FRAMES.size]
|
|
133
|
+
old_stdout.print "\r #{DIM}#{num}#{RESET} #{COPPER}#{frame}#{RESET} #{message}"
|
|
134
|
+
old_stdout.flush
|
|
135
|
+
frame_idx += 1
|
|
136
|
+
sleep 0.08
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
rd.close
|
|
140
|
+
$stdout = old_stdout
|
|
141
|
+
thread.join
|
|
142
|
+
|
|
143
|
+
print "\r\e[K"
|
|
144
|
+
|
|
145
|
+
if err
|
|
146
|
+
puts " #{DIM}#{num}#{RESET} #{RED}✗#{RESET} #{message}"
|
|
147
|
+
raise err
|
|
148
|
+
else
|
|
149
|
+
puts " #{DIM}#{num}#{RESET} #{MINT}✓#{RESET} #{message}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
result
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# --- Streaming steps ---
|
|
156
|
+
# Runs a block, captures its stdout line by line, and renders each
|
|
157
|
+
# meaningful line as a sub-step with spinner → ✓.
|
|
158
|
+
# All sub-steps share the same step number.
|
|
159
|
+
|
|
160
|
+
def stream_steps(title: nil)
|
|
161
|
+
next_step!
|
|
162
|
+
num = step_num_str
|
|
163
|
+
|
|
164
|
+
if debug?
|
|
165
|
+
puts " #{num} > #{title}" if title
|
|
166
|
+
# In debug mode, let output flow but redact sensitive values
|
|
167
|
+
old_stdout = $stdout
|
|
168
|
+
$stdout = RedactingIO.new(old_stdout, method(:redact))
|
|
169
|
+
begin
|
|
170
|
+
yield
|
|
171
|
+
ensure
|
|
172
|
+
$stdout = old_stdout
|
|
173
|
+
end
|
|
174
|
+
return
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Show section header if provided
|
|
178
|
+
puts " #{DIM}#{num}#{RESET} #{COPPER}>#{RESET} #{title}" if title
|
|
179
|
+
|
|
180
|
+
result = nil
|
|
181
|
+
err = nil
|
|
182
|
+
done = false
|
|
183
|
+
current_line = nil
|
|
184
|
+
|
|
185
|
+
# Capture stdout
|
|
186
|
+
old_stdout = $stdout
|
|
187
|
+
rd, wr = IO.pipe
|
|
188
|
+
|
|
189
|
+
thread = Thread.new do
|
|
190
|
+
$stdout = wr
|
|
191
|
+
begin
|
|
192
|
+
result = yield
|
|
193
|
+
rescue => e
|
|
194
|
+
err = e
|
|
195
|
+
ensure
|
|
196
|
+
done = true
|
|
197
|
+
wr.close
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
frame_idx = 0
|
|
202
|
+
buf = ""
|
|
203
|
+
|
|
204
|
+
loop do
|
|
205
|
+
# Non-blocking read from pipe
|
|
206
|
+
begin
|
|
207
|
+
chunk = rd.read_nonblock(4096)
|
|
208
|
+
buf << chunk
|
|
209
|
+
rescue IO::WaitReadable
|
|
210
|
+
# No data available yet
|
|
211
|
+
rescue EOFError
|
|
212
|
+
break
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Process complete lines
|
|
216
|
+
while (nl = buf.index("\n"))
|
|
217
|
+
line = buf.slice!(0..nl).strip
|
|
218
|
+
next if line.empty?
|
|
219
|
+
line = clean_line(line)
|
|
220
|
+
next unless line
|
|
221
|
+
|
|
222
|
+
# Note lines (prefixed with ~) render as indented grey text, no spinner
|
|
223
|
+
if line.start_with?('~')
|
|
224
|
+
if current_line
|
|
225
|
+
old_stdout.print "\r\e[K"
|
|
226
|
+
old_stdout.puts " #{DIM}#{num}#{RESET} #{MINT}✓#{RESET} #{current_line}"
|
|
227
|
+
current_line = nil
|
|
228
|
+
end
|
|
229
|
+
old_stdout.puts " #{DIM}#{num}#{RESET} #{DIM}#{line[1..]}#{RESET}"
|
|
230
|
+
next
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Resolve previous sub-step
|
|
234
|
+
if current_line
|
|
235
|
+
old_stdout.print "\r\e[K"
|
|
236
|
+
old_stdout.puts " #{DIM}#{num}#{RESET} #{MINT}✓#{RESET} #{current_line}"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
current_line = line
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Animate spinner on current line
|
|
243
|
+
if current_line
|
|
244
|
+
frame = SPINNER_FRAMES[frame_idx % SPINNER_FRAMES.size]
|
|
245
|
+
old_stdout.print "\r #{DIM}#{num}#{RESET} #{COPPER}#{frame}#{RESET} #{current_line}"
|
|
246
|
+
old_stdout.flush
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
frame_idx += 1
|
|
250
|
+
sleep 0.06
|
|
251
|
+
|
|
252
|
+
break if done && buf.empty?
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
rd.close
|
|
256
|
+
$stdout = old_stdout
|
|
257
|
+
thread.join
|
|
258
|
+
|
|
259
|
+
# Resolve the final sub-step
|
|
260
|
+
if current_line
|
|
261
|
+
print "\r\e[K"
|
|
262
|
+
puts " #{DIM}#{num}#{RESET} #{MINT}✓#{RESET} #{current_line}"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
raise err if err
|
|
266
|
+
result
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# --- Immediate steps (no async) ---
|
|
270
|
+
|
|
271
|
+
def step_ok(message)
|
|
272
|
+
next_step!
|
|
273
|
+
puts " #{DIM}#{step_num_str}#{RESET} #{MINT}✓#{RESET} #{message}"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def step_fail(message)
|
|
277
|
+
next_step!
|
|
278
|
+
puts " #{DIM}#{step_num_str}#{RESET} #{RED}✗#{RESET} #{message}"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def step_info(message)
|
|
282
|
+
next_step!
|
|
283
|
+
puts " #{DIM}#{step_num_str}#{RESET} #{COPPER}➜#{RESET} #{message}"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# --- Simple output (no numbering) ---
|
|
287
|
+
|
|
288
|
+
def success(message)
|
|
289
|
+
puts " #{MINT}✓#{RESET} #{message}"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def error(message)
|
|
293
|
+
puts " #{RED}✗#{RESET} #{message}"
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def warn(message)
|
|
297
|
+
puts " \e[33m!#{RESET} #{message}"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def step(message)
|
|
301
|
+
if debug?
|
|
302
|
+
puts " #{redact(message)}"
|
|
303
|
+
else
|
|
304
|
+
puts " #{DIM}›#{RESET} #{message}"
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def detail(message)
|
|
309
|
+
puts " #{DIM}#{redact(message)}#{RESET}" if debug?
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# --- Tables ---
|
|
313
|
+
|
|
314
|
+
def table(headers:, rows:)
|
|
315
|
+
return if rows.empty?
|
|
316
|
+
|
|
317
|
+
widths = headers.map.with_index do |h, i|
|
|
318
|
+
[h.to_s.length, rows.map { |r| r[i].to_s.length }.max || 0].max
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
header_line = headers.map.with_index { |h, i| h.to_s.ljust(widths[i]) }.join(" ")
|
|
322
|
+
puts " #{COPPER}#{header_line}#{RESET}"
|
|
323
|
+
puts " #{widths.map { |w| '─' * w }.join(' ')}"
|
|
324
|
+
|
|
325
|
+
rows.each do |row|
|
|
326
|
+
line = row.map.with_index { |c, i| c.to_s.ljust(widths[i]) }.join(" ")
|
|
327
|
+
puts " #{line}"
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# --- Section divider ---
|
|
332
|
+
|
|
333
|
+
def section(title)
|
|
334
|
+
if debug?
|
|
335
|
+
puts "\e[36m=== #{title} ===\e[0m"
|
|
336
|
+
else
|
|
337
|
+
puts " #{COPPER}▸ #{title}#{RESET}"
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# --- Deploy-specific helpers ---
|
|
342
|
+
|
|
343
|
+
def deploy_header(service:, image:, image_tag:, build: false, distribution: nil)
|
|
344
|
+
header "Odysseus Deploy"
|
|
345
|
+
info "Service", service
|
|
346
|
+
info "Image", "#{image}:#{image_tag}"
|
|
347
|
+
info "Distribute", distribution if build && distribution
|
|
348
|
+
blank
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def deploy_complete(duration: nil)
|
|
352
|
+
msg = "Deployment successful"
|
|
353
|
+
msg += " in #{duration}s" if duration
|
|
354
|
+
step_ok msg
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# --- Logger adapter ---
|
|
358
|
+
|
|
359
|
+
def build_logger
|
|
360
|
+
ui = self
|
|
361
|
+
Object.new.tap do |l|
|
|
362
|
+
l.define_singleton_method(:info) { |msg| ui.step(msg) }
|
|
363
|
+
l.define_singleton_method(:warn) { |msg| ui.warn(msg) }
|
|
364
|
+
l.define_singleton_method(:error) { |msg| ui.error(msg) }
|
|
365
|
+
l.define_singleton_method(:debug) { |msg| ui.detail(msg) }
|
|
366
|
+
l.define_singleton_method(:verbose?) { ui.debug? }
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
private
|
|
371
|
+
|
|
372
|
+
def step_num_str
|
|
373
|
+
format('%02d', @step_number)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Redact sensitive values from output.
|
|
377
|
+
# Matches common patterns for API keys, tokens, passwords, and secrets
|
|
378
|
+
# passed as env vars or command flags.
|
|
379
|
+
def redact(text)
|
|
380
|
+
text
|
|
381
|
+
.gsub(/(-e\s+\w*(?:KEY|TOKEN|SECRET|PASSWORD|MASTER_KEY|API_KEY|CREDENTIALS)\s*=\s*)\S+/i, '\1[REDACTED]')
|
|
382
|
+
.gsub(/((?:KEY|TOKEN|SECRET|PASSWORD|MASTER_KEY|API_KEY|CREDENTIALS)\s*[=:]\s*)\S+/i, '\1[REDACTED]')
|
|
383
|
+
.gsub(/(-p\s+)\S+/, '\1[REDACTED]')
|
|
384
|
+
.gsub(/(--password\s+)\S+/, '\1[REDACTED]')
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Clean up raw output lines from core orchestrators.
|
|
388
|
+
# Returns nil for lines we should skip.
|
|
389
|
+
def clean_line(line)
|
|
390
|
+
# Strip leading whitespace
|
|
391
|
+
line = line.sub(/^\s+/, '')
|
|
392
|
+
|
|
393
|
+
# Skip empty / decorative / noise
|
|
394
|
+
return nil if line.empty?
|
|
395
|
+
return nil if line.start_with?('===', '---', '[WARN]', '[ERROR]')
|
|
396
|
+
|
|
397
|
+
# Skip verbose detail lines
|
|
398
|
+
return nil if line.match?(/^Image: /)
|
|
399
|
+
return nil if line.match?(/^Environment: /)
|
|
400
|
+
return nil if line.match?(/^Resources: /)
|
|
401
|
+
return nil if line.match?(/^Volumes: /)
|
|
402
|
+
return nil if line.match?(/^Found \d+ existing container/)
|
|
403
|
+
return nil if line.match?(/^Deploying .+ \(role: .+\)/)
|
|
404
|
+
return nil if line.match?(/^Building locally/)
|
|
405
|
+
return nil if line.match?(/^Pushing image via SSH to/)
|
|
406
|
+
return nil if line.match?(/^Deploy complete for /)
|
|
407
|
+
return nil if line.match?(/^Rolling deploy complete/)
|
|
408
|
+
|
|
409
|
+
# Skip "done" echo lines — the spinner→✓ already shows completion
|
|
410
|
+
return nil if line.match?(/^Container started: /)
|
|
411
|
+
return nil if line.match?(/^Health check passed$/)
|
|
412
|
+
return nil if line.match?(/^Caddy routing configured$/)
|
|
413
|
+
return nil if line.match?(/^Old container removed$/)
|
|
414
|
+
return nil if line.match?(/^Image pulled$/)
|
|
415
|
+
return nil if line.match?(/^Attached to proxy$/)
|
|
416
|
+
|
|
417
|
+
# Note lines — indented grey text under the previous step
|
|
418
|
+
return '~Caddy already running' if line.match?(/^Caddy already running$/)
|
|
419
|
+
return '~Caddy started' if line.match?(/^Caddy started$/)
|
|
420
|
+
return '~Caddy is ready' if line.match?(/^Caddy is ready$/)
|
|
421
|
+
|
|
422
|
+
# Map known messages to clean versions
|
|
423
|
+
CLEAN_MESSAGES.each do |pattern, replacement|
|
|
424
|
+
if line.match?(pattern)
|
|
425
|
+
return line.sub(pattern, replacement)
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
line
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
CLEAN_MESSAGES = {
|
|
433
|
+
/^Ensuring Caddy proxy is running\.\.\./ => 'Starting Caddy',
|
|
434
|
+
/^Starting new container\.\.\.$/ => 'Starting container',
|
|
435
|
+
/^Waiting for health check.*/ => 'Health check',
|
|
436
|
+
/^Adding to Caddy proxy.*/ => 'Caddy route update',
|
|
437
|
+
/^Draining old container.*/ => 'Draining old container',
|
|
438
|
+
/^Pulling image\.\.\.$/ => 'Pulling image',
|
|
439
|
+
/^Building image: .+$/ => 'Building image',
|
|
440
|
+
/^Pushing to (.+)\.\.\./ => 'Pushing image to \1',
|
|
441
|
+
/^Starting (.+)\.\.\.$/ => 'Starting \1',
|
|
442
|
+
/^Stopping (.+) .*/ => 'Stopping \1',
|
|
443
|
+
/^Attaching (.+) to proxy.*/ => 'Attaching \1 to proxy',
|
|
444
|
+
}.freeze
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
end
|
metadata
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: odysseus-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
7
|
+
- Thomas
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
@@ -24,19 +24,19 @@ dependencies:
|
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '0.2'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
|
-
name:
|
|
27
|
+
name: ratatui_ruby
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - "~>"
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '
|
|
32
|
+
version: '1.4'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '
|
|
39
|
+
version: '1.4'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: rspec
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -67,7 +67,7 @@ dependencies:
|
|
|
67
67
|
version: '3.10'
|
|
68
68
|
description: Command-line interface for deploying with Odysseus
|
|
69
69
|
email:
|
|
70
|
-
-
|
|
70
|
+
- thomas@imfiny.com
|
|
71
71
|
executables:
|
|
72
72
|
- odysseus
|
|
73
73
|
extensions: []
|
|
@@ -76,8 +76,8 @@ files:
|
|
|
76
76
|
- README.md
|
|
77
77
|
- bin/odysseus
|
|
78
78
|
- lib/odysseus/cli/cli.rb
|
|
79
|
-
- lib/odysseus/cli/
|
|
80
|
-
homepage: https://github.com/
|
|
79
|
+
- lib/odysseus/cli/ui.rb
|
|
80
|
+
homepage: https://github.com/WA-Systems-EU/odysseus
|
|
81
81
|
licenses:
|
|
82
82
|
- LGPL-3.0-only
|
|
83
83
|
metadata: {}
|
data/lib/odysseus/cli/gum.rb
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
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
|