kamalx 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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: []