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