kamalx 1.0.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.
Files changed (7) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +9 -0
  3. data/LICENSE.md +21 -0
  4. data/README.md +51 -0
  5. data/bin/kamalx +5 -0
  6. data/lib/kamalx.rb +320 -0
  7. metadata +92 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3bcaac29127c31cdde9712eb8822f7d342ee1a9bc1c0a9307637cfe528399b1a
4
+ data.tar.gz: efa6caefd5abe432d67bd249e77676516cdb250ea7730d908322a4a3853073cc
5
+ SHA512:
6
+ metadata.gz: 898d3ff7ac1016e1ccbcc6dd0c2559c9d4e9e6166f95b3dc921667aa2adbac1e5b4014b1e09f24882473b404fcf3873df7cfc7c492220223bca04e049399dbc3
7
+ data.tar.gz: c379ecbb7db8a11d65d4fa22c4c78e61035721ae53d0f32040d1d3553d2808d71a7e30c0ec5369fb9875fe93328cfe69cb49738a618cf7573c29ec00c20a780a
data/Gemfile ADDED
@@ -0,0 +1,9 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
3
+
4
+ gem 'curses'
5
+ gem 'eventmachine'
6
+ gem 'kamal'
7
+ gem 'rspec', '~> 3.10', group: [:development, :test]
8
+ gem 'rake'
9
+
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Lucas Carlson <lucas@carlson.net>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # KamalX
2
+
3
+ KamalX is a command-line tool that enhances the user experience of the [kamal](https://github.com/basecamp/kamal) deploy tool from Basecamp by making it more user-friendly and easier to watch and understand.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'kamalx'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```
16
+ $ bundle install
17
+ ```
18
+
19
+ Or install it yourself as:
20
+
21
+ ```
22
+ $ gem install kamalx
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ After installation, you can use KamalX by running:
28
+
29
+ ```
30
+ $ kamalx [kamal_commands]
31
+ ```
32
+
33
+ Replace `[kamal_commands]` with any commands you would normally pass to kamal.
34
+
35
+ ## Features
36
+
37
+ - Interactive progress bar
38
+ - Colored output for better readability
39
+ - Separated stage history and command output windows
40
+
41
+ ## Development
42
+
43
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `rake spec` to run the tests.
44
+
45
+ ## Contributing
46
+
47
+ Bug reports and pull requests are welcome on GitHub at https://github.com/cardmagic/kamalx/issues
48
+
49
+ ## License
50
+
51
+ The gem is available as open source under the terms of the MIT License. See `LICENSE.md` for more details.
data/bin/kamalx ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/kamalx'
4
+
5
+ KamalX.run(ARGV)
data/lib/kamalx.rb ADDED
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'eventmachine'
4
+ require 'curses'
5
+
6
+ # Displays a progress bar for a given set of stages.
7
+ class ProgressBar
8
+ # All stages Kamal goes through.
9
+ STAGES = [
10
+ 'Log into image registry',
11
+ 'Build and push app image',
12
+ 'Acquiring the deploy lock',
13
+ 'Ensure Traefik is running',
14
+ 'Detect stale containers',
15
+ 'Start container',
16
+ 'Prune old containers and images',
17
+ 'Releasing the deploy lock',
18
+ 'Finished all'
19
+ ].freeze
20
+
21
+ # Initializes the progress bar with the given window.
22
+ #
23
+ # @param window [Curses::Window]
24
+ def initialize(window)
25
+ @window = window
26
+ @progress = 0
27
+ @total_steps = STAGES.size
28
+ @blink_state = true
29
+ end
30
+
31
+ # Updates the progress bar with the given line.
32
+ #
33
+ # @param line [String]
34
+ def update(line)
35
+ STAGES.each_with_index do |stage, index|
36
+ next unless line.include?(stage)
37
+
38
+ @progress = index + 1
39
+ draw
40
+ break
41
+ end
42
+ end
43
+
44
+ # Draws the progress bar based on the current progress.
45
+ def draw
46
+ @window.clear
47
+ width = @window.maxx - 2
48
+ progress_width = [(width * (@progress.to_f / @total_steps)).to_i, 1].max
49
+
50
+ # Draw progress bar
51
+ @window.setpos(0, 0)
52
+ @window.attron(Curses.color_pair(6)) do # Use green color
53
+ progress_bar = '=' * (progress_width - 1)
54
+ cursor = @blink_state ? '>' : ' '
55
+ progress_bar += @progress == @total_steps ? '>' : cursor
56
+ @window.addstr("[#{progress_bar}#{' ' * [width - progress_width, 0].max}]")
57
+ end
58
+
59
+ # Draw stage information
60
+ stage_info = "Current Stage: #{STAGES[@progress - 1]}"
61
+ @window.setpos(1, (width - stage_info.length) / 2)
62
+ @window.attron(Curses::A_BOLD)
63
+ @window.addstr('Current Stage:')
64
+ @window.attroff(Curses::A_BOLD)
65
+ @window.addstr(" #{STAGES[@progress - 1]}")
66
+
67
+ @window.refresh
68
+ @blink_state = !@blink_state
69
+ end
70
+
71
+ # Finishes the progress bar by setting it to the last stage.
72
+ def finish
73
+ @progress = @total_steps
74
+ draw
75
+ end
76
+ end
77
+
78
+ # Parse lines of log output and extract relevant information from them.
79
+ class LogParser
80
+ def initialize
81
+ @hostnames = {}
82
+ end
83
+
84
+ def parse(line)
85
+ case line
86
+ when /^(\w+.+)\.{3}$/
87
+ [:white, [:bold, 'Stage:'], " #{::Regexp.last_match(1)}"]
88
+ when /INFO \[(\w+)\] Running (.+) on (.+)/
89
+ command_id = ::Regexp.last_match(1)
90
+ hostname = ::Regexp.last_match(3)
91
+ @hostnames[command_id] = hostname
92
+ [:green, [:bold, "Command[#{command_id}@#{hostname}]"], " #{::Regexp.last_match(2)}"]
93
+ when /INFO \[(\w+)\] Finished in ([\d.]+) seconds with exit status (\d+)/
94
+ command_id = ::Regexp.last_match(1)
95
+ hostname = @hostnames[command_id] || @hostnames.values.first || 'localhost'
96
+ status_color = ::Regexp.last_match(3).to_i == 0 ? :green : :red
97
+ [:yellow, [:bold, "Command[#{command_id}@#{hostname}]"], ' Returned Status: ', status_color,
98
+ ::Regexp.last_match(3)]
99
+ when /DEBUG \[(\w+)\] (.+)/
100
+ [:yellow, [:bold, "Command[#{::Regexp.last_match(1)}@localhost]"], " #{::Regexp.last_match(2)}"]
101
+ when /INFO (.+)/
102
+ [:blue, [:bold, 'Info:'], " #{::Regexp.last_match(1)}"]
103
+ else
104
+ [:white, line]
105
+ end
106
+ end
107
+ end
108
+
109
+ # Provides the entry point for the command-line utility.
110
+ #
111
+ # @example
112
+ # KamalX.run(ARGV)
113
+ #
114
+ # @api public
115
+ module KamalX
116
+ class ConnectionHandler < EventMachine::Connection
117
+ def initialize(progress_bar)
118
+ super()
119
+ @progress_bar = progress_bar
120
+ end
121
+
122
+ def receive_data(data)
123
+ parser = LogParser.new
124
+ data.each_line do |line|
125
+ parsed_line = parser.parse(line)
126
+ KamalX.display_line(parsed_line)
127
+ @progress_bar.update(line)
128
+ end
129
+ end
130
+
131
+ def unbind
132
+ @progress_bar.finish
133
+ KamalX.command_finished
134
+ end
135
+ end
136
+
137
+ def self.run(args)
138
+ loop do
139
+ setup_curses
140
+ setup_signal_trap
141
+ @command_finished = false
142
+ EventMachine.run do
143
+ command = ['kamal', *args].join(' ')
144
+ @connection_handler = EventMachine.popen(command, ConnectionHandler, @@progress_bar)
145
+
146
+ # Add a timer to blink the cursor
147
+ EventMachine.add_periodic_timer(0.5) do
148
+ @@progress_bar.draw unless @command_finished
149
+ end
150
+ end
151
+ end
152
+ end
153
+
154
+ def self.command_finished
155
+ @command_finished = true
156
+ @@progress_bar.finish
157
+ @progress_window.refresh
158
+ display_line([:red, [:bold, "Kamal finished. Press 'ctrl+c' to exit."]])
159
+ EventMachine.run {} # Keep the event loop running
160
+ end
161
+
162
+ def self.refresh_display
163
+ @stage_window.refresh
164
+ @output_window.refresh
165
+ @output_box.refresh
166
+ @progress_window.refresh
167
+ end
168
+
169
+ def self.setup_signal_trap
170
+ Signal.trap('INT') do
171
+ Thread.new do
172
+ sleep 2
173
+ exit!(1)
174
+ end
175
+
176
+ EventMachine.stop
177
+ exit(0)
178
+ end
179
+ end
180
+
181
+ def self.setup_curses
182
+ Curses.init_screen
183
+ Curses.start_color
184
+ Curses.use_default_colors
185
+ Curses.cbreak
186
+ Curses.noecho
187
+ Curses.stdscr.keypad(true)
188
+ Curses.timeout = 0 # Non-blocking getch
189
+
190
+ # Initialize color pairs
191
+ Curses.init_pair(Curses::COLOR_BLUE, Curses::COLOR_BLUE, -1)
192
+ Curses.init_pair(Curses::COLOR_GREEN, Curses::COLOR_GREEN, -1)
193
+ Curses.init_pair(Curses::COLOR_YELLOW, Curses::COLOR_YELLOW, -1)
194
+ Curses.init_pair(Curses::COLOR_RED, Curses::COLOR_RED, -1)
195
+ Curses.init_pair(Curses::COLOR_WHITE, Curses::COLOR_WHITE, -1)
196
+ Curses.init_pair(6, Curses::COLOR_GREEN, -1) # New color pair for green progress bar
197
+
198
+ total_lines = Curses.lines
199
+ progress_height = 4
200
+ space_height = 1
201
+ empty_line_height = 1
202
+ remaining_height = total_lines - progress_height - space_height - empty_line_height
203
+
204
+ # Create a boxed window for the progress bar
205
+ progress_box = Curses::Window.new(progress_height, Curses.cols, 0, 0)
206
+ progress_box.box('|', '-')
207
+ progress_box.setpos(0, 2)
208
+ progress_box.addstr(' Progress ')
209
+ progress_box.refresh
210
+ @progress_window = progress_box.subwin(2, Curses.cols - 2, 1, 1)
211
+
212
+ # Calculate equal heights for stages and output
213
+ section_height = remaining_height / 2
214
+
215
+ # Create a boxed window for the stages
216
+ stages_start = progress_height + space_height
217
+ stage_box = Curses::Window.new(section_height, Curses.cols, stages_start, 0)
218
+ stage_box.box('|', '-')
219
+ stage_box.setpos(0, 2)
220
+ stage_box.addstr(' Stage History ')
221
+ stage_box.refresh
222
+ @stage_window = stage_box.subwin(section_height - 2, Curses.cols - 2, stages_start + 1, 1)
223
+
224
+ # Create a boxed window for the output area
225
+ output_start = stages_start + section_height + empty_line_height
226
+ @output_box = Curses::Window.new(section_height, Curses.cols, output_start, 0)
227
+ @output_box.box('|', '-')
228
+ @output_box.setpos(0, 2)
229
+ @output_box.addstr(' Command Outputs ')
230
+ @output_box.refresh
231
+
232
+ # Create a subwindow inside the box for scrollable content
233
+ @output_window = @output_box.subwin(section_height - 2, Curses.cols - 2, output_start + 1, 1)
234
+
235
+ @stage_window.scrollok(true)
236
+ @output_window.scrollok(true)
237
+
238
+ @@progress_bar = ProgressBar.new(@progress_window)
239
+ end
240
+
241
+ def self.color_map
242
+ @color_map ||= {
243
+ blue: Curses::COLOR_BLUE,
244
+ green: Curses::COLOR_GREEN,
245
+ yellow: Curses::COLOR_YELLOW,
246
+ red: Curses::COLOR_RED,
247
+ white: Curses::COLOR_WHITE
248
+ }
249
+ end
250
+
251
+ # @private
252
+ #
253
+ # @api private
254
+ def receive_data(data)
255
+ parser = LogParser.new
256
+ data.each_line do |line|
257
+ parsed_line = parser.parse(line)
258
+ KamalX.display_line(parsed_line)
259
+ @progress_bar.update(line)
260
+ end
261
+ end
262
+
263
+ def self.display_stage(parsed_line)
264
+ @stage_window.scroll
265
+ @stage_window.setpos(@stage_window.maxy - 1, 0)
266
+
267
+ current_color = nil
268
+
269
+ parsed_line.each do |element|
270
+ if element.is_a?(Symbol)
271
+ current_color = color_map[element]
272
+ @stage_window.attron(Curses.color_pair(current_color)) if current_color
273
+ elsif element.is_a?(Array) && element[0] == :bold
274
+ @stage_window.attron(Curses::A_BOLD)
275
+ @stage_window.addstr(element[1].to_s)
276
+ @stage_window.attroff(Curses::A_BOLD)
277
+ else
278
+ @stage_window.addstr(element.to_s)
279
+ end
280
+ end
281
+
282
+ @stage_window.attroff(Curses.color_pair(current_color)) if current_color
283
+ @stage_window.refresh
284
+ end
285
+
286
+ def self.display_line(parsed_line)
287
+ # Check if the line is a stage command
288
+ if parsed_line.any? { |element| element.is_a?(Array) && element[1] == 'Stage:' }
289
+ display_stage(parsed_line)
290
+ else
291
+ @output_window.scroll
292
+ @output_window.setpos(@output_window.maxy - 1, 0)
293
+
294
+ current_color = nil
295
+
296
+ parsed_line.each do |element|
297
+ if element.is_a?(Symbol)
298
+ current_color = color_map[element]
299
+ elsif element.is_a?(Array)
300
+ if element[0] == :bold
301
+ @output_window.attron(Curses::A_BOLD) do
302
+ @output_window.attron(Curses.color_pair(current_color)) if current_color
303
+ @output_window.addstr(element[1].to_s)
304
+ @output_window.attroff(Curses.color_pair(current_color)) if current_color
305
+ end
306
+ else
307
+ @output_window.addstr(element[1].to_s)
308
+ end
309
+ else
310
+ @output_window.attron(Curses.color_pair(current_color)) if current_color
311
+ @output_window.addstr(element.to_s)
312
+ @output_window.attroff(Curses.color_pair(current_color)) if current_color
313
+ end
314
+ end
315
+
316
+ @output_window.refresh
317
+ @output_box.refresh
318
+ end
319
+ end
320
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kamalx
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Lucas Carlson
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-08-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: curses
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: eventmachine
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: kamal
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description: kamalx makes the kamal deploy tool more user-friendly and easier to watch
56
+ and understand.
57
+ email:
58
+ - lucas@carlson.net
59
+ executables:
60
+ - kamalx
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - Gemfile
65
+ - LICENSE.md
66
+ - README.md
67
+ - bin/kamalx
68
+ - lib/kamalx.rb
69
+ homepage: https://github.com/cardmagic/kamalx
70
+ licenses:
71
+ - MIT
72
+ metadata: {}
73
+ post_install_message:
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 3.5.17
89
+ signing_key:
90
+ specification_version: 4
91
+ summary: A command-line tool for parsing logs.
92
+ test_files: []