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.
@@ -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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
- - Your Name
7
+ - Thomas
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
@@ -15,28 +15,28 @@ 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
- name: pastel
27
+ name: ratatui_ruby
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '0.8'
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: '0.8'
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
- - your@email.com
70
+ - thomas@imfiny.com
71
71
  executables:
72
72
  - odysseus
73
73
  extensions: []
@@ -76,7 +76,8 @@ files:
76
76
  - README.md
77
77
  - bin/odysseus
78
78
  - lib/odysseus/cli/cli.rb
79
- homepage: https://github.com/WaSystems/odysseus
79
+ - lib/odysseus/cli/ui.rb
80
+ homepage: https://github.com/WA-Systems-EU/odysseus
80
81
  licenses:
81
82
  - LGPL-3.0-only
82
83
  metadata: {}