kdeploy 1.2.1 → 1.2.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d8190b20e37d5dcf26ea9834e8ccf2f2a9b9c396dd44a48b294e7d0c643bdcd8
4
- data.tar.gz: 0af02ac4d1fbb6c76983e30841ec6940d9fb8539a3e642095cc305f108b94eae
3
+ metadata.gz: a675dda751570dd0f5fce2042bfc5a68411e03e869d78bf8569739644bf8021f
4
+ data.tar.gz: 3b4861d5ba0c55f57ab2ace6a9e4bf3ae963076767e0db23f5a444b380efb861
5
5
  SHA512:
6
- metadata.gz: 468ff349e53c460a39e5bd0553dab1ed747c9038fb319ff0e2e4f6ad6d5bdb766e3e6a2072b6ba4ac06b6d78967ba7e3f2955e984bbac21f1fc7431fde3df6c4
7
- data.tar.gz: 6f5bb7bf536f1945dec267c6a816de9f29f1bdc1dea8a43e8b103f3e670f274c9f998a435308492f2fc56850cc81cc88b868982eb9447b06f35bed9f32aa08e7
6
+ metadata.gz: 4e2952dfbc5956c61ad1f58cdc8fb45a9d050a331f1f224ea3233d8f9e6618d06f5c86382e6446fecbdcc86f624a24600c672dda5fcd2ea26c535d3f16068fc9
7
+ data.tar.gz: 7edd9ae73dfe74015798197cd9cf03309aeb4a6d3ca065a1224710f4dc02b42fdbc4a6a17a541cba40a83f4637e324746627b023e7ce5475a3481a04d163e81e
data/lib/kdeploy/cli.rb CHANGED
@@ -28,41 +28,7 @@ module Kdeploy
28
28
  if command
29
29
  super
30
30
  else
31
- pastel = Pastel.new
32
- puts Kdeploy::Banner.show
33
- puts <<~HELP
34
- #{pastel.bright_white('📖 Available Commands:')}
35
-
36
- #{pastel.bright_yellow('🚀')} #{pastel.bright_white('execute TASK_FILE [TASK]')} Execute deployment tasks from file
37
- #{pastel.dim(' --limit HOSTS')} Limit to specific hosts (comma-separated)
38
- #{pastel.dim(' --parallel NUM')} Number of parallel executions (default: 5)
39
- #{pastel.dim(' --dry-run')} Show what would be done without executing
40
-
41
- #{pastel.bright_yellow('🆕')} #{pastel.bright_white('init [DIR]')} Initialize new deployment project
42
- #{pastel.bright_yellow('ℹ️')} #{pastel.bright_white('version')} Show version information
43
- #{pastel.bright_yellow('❓')} #{pastel.bright_white('help [COMMAND]')} Show help information
44
-
45
- #{pastel.bright_white('💡 Examples:')}
46
-
47
- #{pastel.dim('# Initialize a new project')}
48
- #{pastel.bright_cyan('kdeploy init my-deployment')}
49
-
50
- #{pastel.dim('# Deploy to web servers')}
51
- #{pastel.bright_cyan('kdeploy execute deploy.rb deploy_web')}
52
-
53
- #{pastel.dim('# Backup database')}
54
- #{pastel.bright_cyan('kdeploy execute deploy.rb backup_db')}
55
-
56
- #{pastel.dim('# Run maintenance on specific hosts')}
57
- #{pastel.bright_cyan('kdeploy execute deploy.rb maintenance --limit web01')}
58
-
59
- #{pastel.dim('# Preview deployment')}
60
- #{pastel.bright_cyan('kdeploy execute deploy.rb deploy_web --dry-run')}
61
-
62
- #{pastel.bright_white('📚 Documentation:')}
63
- #{pastel.bright_cyan('https://github.com/kevin197011/kdeploy')}
64
-
65
- HELP
31
+ show_general_help
66
32
  end
67
33
  end
68
34
 
@@ -80,40 +46,11 @@ module Kdeploy
80
46
  method_option :parallel, type: :numeric, default: 10, desc: 'Number of parallel executions'
81
47
  method_option :dry_run, type: :boolean, desc: 'Show what would be done'
82
48
  def execute(task_file, task_name = nil)
83
- # 只在最前面输出一次 banner
84
- @banner_printed ||= false
85
- unless @banner_printed
86
- puts Kdeploy::Banner.show
87
- @banner_printed = true
88
- end
89
-
49
+ show_banner_once
90
50
  load_task_file(task_file)
91
51
 
92
- tasks_to_run = if task_name
93
- [task_name.to_sym]
94
- else
95
- self.class.kdeploy_tasks.keys
96
- end
97
-
98
- tasks_to_run.each do |task|
99
- task_hosts = self.class.get_task_hosts(task)
100
- hosts = filter_hosts(options[:limit], task_hosts)
101
-
102
- if hosts.empty?
103
- puts Kdeploy::Banner.show_error("No hosts found for task: #{task}")
104
- next
105
- end
106
-
107
- if options[:dry_run]
108
- print_dry_run(hosts, task)
109
- next
110
- end
111
-
112
- output = ConsoleOutput.new
113
- runner = Runner.new(hosts, self.class.kdeploy_tasks, parallel: options[:parallel], output: output)
114
- results = runner.run(task)
115
- print_results(results, task)
116
- end
52
+ tasks_to_run = determine_tasks(task_name)
53
+ execute_tasks(tasks_to_run)
117
54
  rescue StandardError => e
118
55
  puts Kdeploy::Banner.show_error(e.message)
119
56
  exit 1
@@ -122,11 +59,7 @@ module Kdeploy
122
59
  private
123
60
 
124
61
  def load_task_file(file)
125
- unless File.exist?(file)
126
- puts Kdeploy::Banner.show_error("Task file not found: #{file}")
127
- exit 1
128
- end
129
-
62
+ validate_task_file(file)
130
63
  # 用 instance_eval 并传递顶层 binding,兼容 heredoc
131
64
  self.class.module_eval(File.read(file), file)
132
65
  rescue StandardError => e
@@ -135,6 +68,19 @@ module Kdeploy
135
68
  raise
136
69
  end
137
70
 
71
+ def validate_task_file(file)
72
+ return if File.exist?(file)
73
+
74
+ puts Kdeploy::Banner.show_error("Task file not found: #{file}")
75
+ exit 1
76
+ end
77
+
78
+ def show_general_help
79
+ formatter = HelpFormatter.new
80
+ puts Kdeploy::Banner.show
81
+ puts formatter.format_help
82
+ end
83
+
138
84
  def filter_hosts(limit, task_hosts)
139
85
  hosts = self.class.kdeploy_hosts.slice(*task_hosts)
140
86
  return hosts unless limit
@@ -144,139 +90,137 @@ module Kdeploy
144
90
  end
145
91
 
146
92
  def print_dry_run(hosts, task_name)
93
+ formatter = OutputFormatter.new
147
94
  puts Kdeploy::Banner.show
148
- pastel = Pastel.new
149
- puts TTY::Box.frame(
150
- 'Showing what would be done without executing any commands',
151
- title: { top_left: ' Dry Run Mode ', bottom_right: ' Kdeploy ' },
152
- style: {
153
- border: {
154
- fg: :blue
155
- }
156
- }
157
- )
95
+ puts formatter.format_dry_run_header
158
96
  puts
159
97
 
160
98
  hosts.each do |name, config|
161
- commands = self.class.kdeploy_tasks[task_name][:block].call
162
- output = commands.map do |command|
163
- case command[:type]
164
- when :run
165
- "#{pastel.green('>')} #{command[:command]}"
166
- when :upload
167
- "#{pastel.blue('>')} Upload: #{command[:source]} -> #{command[:destination]}"
168
- end
169
- end.join("\n")
170
-
171
- puts TTY::Box.frame(
172
- output,
173
- title: { top_left: " #{name} (#{config[:ip]}) " },
174
- style: {
175
- border: {
176
- fg: :yellow
177
- }
178
- }
179
- )
180
- puts
99
+ print_dry_run_for_host(name, config, task_name, formatter)
181
100
  end
182
101
  end
183
102
 
103
+ def print_dry_run_for_host(name, config, task_name, formatter)
104
+ commands = self.class.kdeploy_tasks[task_name][:block].call
105
+ output = commands.map { |cmd| formatter.format_command_for_dry_run(cmd) }.join("\n")
106
+ title = "#{name} (#{config[:ip]})"
107
+ puts formatter.format_dry_run_box(title, output)
108
+ puts
109
+ end
110
+
184
111
  def print_results(results, task_name)
185
- pastel = Pastel.new
186
- puts pastel.cyan("\nPLAY [#{task_name}] " + ('*' * 64))
112
+ formatter = OutputFormatter.new
113
+ puts formatter.format_task_header(task_name)
187
114
 
188
115
  results.each do |host, result|
189
- status = case result[:status]
190
- when :success then pastel.green('ok')
191
- when :changed then pastel.yellow('changed')
192
- else pastel.red('failed')
193
- end
194
- puts pastel.bright_white("\n#{host.ljust(24)} : #{status}")
195
-
196
- if %i[success changed].include?(result[:status])
197
- shown = {}
198
- grouped = result[:output].group_by { |step| step[:type] || :run }
199
- grouped.each do |type, steps|
200
- case type
201
- when :upload
202
- puts pastel.green(' === Upload ===')
203
- steps.each do |step|
204
- key = [step[:command], type].hash
205
- next if shown[key]
206
-
207
- shown[key] = true
208
- duration_str = step[:duration] ? pastel.dim(" [#{format('%.2f', step[:duration])}s]") : ''
209
- puts pastel.green(" [upload] #{step[:command].sub('upload: ', '')}#{duration_str}")
210
- end
211
- when :upload_template
212
- puts pastel.yellow(' === Template ===')
213
- steps.each do |step|
214
- key = [step[:command], type].hash
215
- next if shown[key]
216
-
217
- shown[key] = true
218
- duration_str = step[:duration] ? pastel.dim(" [#{format('%.2f', step[:duration])}s]") : ''
219
- puts pastel.yellow(" [template] #{step[:command].sub('upload_template: ', '')}#{duration_str}")
220
- end
221
- when :run
222
- puts pastel.cyan(' === Run ===')
223
- steps.each do |step|
224
- key = [step[:command], type].hash
225
- next if shown[key]
226
-
227
- shown[key] = true
228
- duration_str = step[:duration] ? pastel.dim(" [#{format('%.2f', step[:duration])}s]") : ''
229
- puts pastel.cyan(" [run] #{step[:command].to_s.lines.first.strip}#{duration_str}")
230
- # 多行命令内容高亮
231
- cmd_lines = step[:command].to_s.lines[1..].map(&:strip).reject(&:empty?)
232
- cmd_lines.each { |line| puts pastel.cyan(" > #{line}") } if cmd_lines.any?
233
- if step[:output].is_a?(Hash) && step[:output][:stdout]
234
- step[:output][:stdout].each_line do |line|
235
- puts pastel.green(" #{line.rstrip}") unless line.strip.empty?
236
- end
237
- elsif step[:output].is_a?(String)
238
- step[:output].each_line do |line|
239
- puts pastel.green(" #{line.rstrip}") unless line.strip.empty?
240
- end
241
- end
242
- end
243
- end
244
- end
245
- else
246
- # 失败主机高亮错误
247
- err = result[:error] || (if result[:output].is_a?(Array)
248
- result[:output].map do |o|
249
- o[:output][:stderr] if o[:output].is_a?(Hash)
250
- end.compact.join("\n")
251
- else
252
- result[:output].to_s
253
- end)
254
- puts pastel.red(" ERROR: #{err}")
255
- end
116
+ puts formatter.format_host_status(host, result[:status])
117
+ print_host_result(host, result, formatter)
118
+ end
119
+
120
+ print_summary(results, formatter)
121
+ end
122
+
123
+ def print_host_result(_host, result, formatter)
124
+ if %i[success changed].include?(result[:status])
125
+ print_success_result(result, formatter)
126
+ else
127
+ print_failure_result(result, formatter)
128
+ end
129
+ end
130
+
131
+ def print_success_result(result, formatter)
132
+ shown = {}
133
+ grouped = group_output_by_type(result[:output])
134
+
135
+ grouped.each do |type, steps|
136
+ output_lines = format_steps_by_type(type, steps, shown, formatter)
137
+ output_lines.each { |line| puts line }
256
138
  end
139
+ end
140
+
141
+ def group_output_by_type(output)
142
+ output.group_by { |step| step[:type] || :run }
143
+ end
144
+
145
+ def format_steps_by_type(type, steps, shown, formatter)
146
+ case type
147
+ when :upload
148
+ formatter.format_upload_steps(steps, shown)
149
+ when :upload_template
150
+ formatter.format_template_steps(steps, shown)
151
+ when :run
152
+ formatter.format_run_steps(steps, shown)
153
+ else
154
+ []
155
+ end
156
+ end
157
+
158
+ def print_failure_result(result, formatter)
159
+ error_message = extract_error_message(result)
160
+ puts formatter.format_error(error_message)
161
+ end
257
162
 
258
- # summary
259
- puts pastel.cyan("\nPLAY RECAP #{'*' * 64}")
163
+ def print_summary(results, formatter)
164
+ puts formatter.format_summary_header
260
165
  max_host_len = results.keys.map(&:length).max || 16
261
- ok_w = 7
262
- changed_w = 11
263
- failed_w = 10
166
+
264
167
  results.keys.sort.each do |host|
265
168
  result = results[host]
266
- ok = %i[success changed].include?(result[:status]) ? result[:output].size : 0
267
- failed = result[:status] == :failed ? 1 : 0
268
- changed = result[:status] == :changed ? result[:output].size : 0
269
- ok_str = pastel.green("ok=#{ok.to_s.ljust(ok_w - 3)}")
270
- changed_str = pastel.yellow("changed=#{changed.to_s.ljust(changed_w - 8)}")
271
- failed_str = pastel.red("failed=#{failed.to_s.ljust(failed_w - 7)}")
272
- line = "#{host.ljust(max_host_len)} : #{ok_str} #{changed_str} #{failed_str}"
273
- if failed.positive?
274
- puts pastel.red(line)
275
- elsif ok.positive? && failed.zero?
276
- puts pastel.green(line)
277
- else
278
- puts line
279
- end
169
+ puts formatter.format_summary_line(host, result, max_host_len)
170
+ end
171
+ end
172
+
173
+ def show_banner_once
174
+ @banner_printed ||= false
175
+ return if @banner_printed
176
+
177
+ puts Kdeploy::Banner.show
178
+ @banner_printed = true
179
+ end
180
+
181
+ def determine_tasks(task_name)
182
+ task_name ? [task_name.to_sym] : self.class.kdeploy_tasks.keys
183
+ end
184
+
185
+ def execute_tasks(tasks_to_run)
186
+ tasks_to_run.each do |task|
187
+ execute_single_task(task)
188
+ end
189
+ end
190
+
191
+ def execute_single_task(task)
192
+ task_hosts = self.class.get_task_hosts(task)
193
+ hosts = filter_hosts(options[:limit], task_hosts)
194
+
195
+ if hosts.empty?
196
+ puts Kdeploy::Banner.show_error("No hosts found for task: #{task}")
197
+ return
198
+ end
199
+
200
+ if options[:dry_run]
201
+ print_dry_run(hosts, task)
202
+ return
203
+ end
204
+
205
+ run_task(hosts, task)
206
+ end
207
+
208
+ def run_task(hosts, task)
209
+ output = ConsoleOutput.new
210
+ runner = Runner.new(hosts, self.class.kdeploy_tasks, parallel: options[:parallel], output: output)
211
+ results = runner.run(task)
212
+ print_results(results, task)
213
+ end
214
+
215
+ def extract_error_message(result)
216
+ return result[:error] if result[:error]
217
+
218
+ if result[:output].is_a?(Array)
219
+ result[:output].map do |o|
220
+ o[:output][:stderr] if o[:output].is_a?(Hash)
221
+ end.compact.join("\n")
222
+ else
223
+ result[:output].to_s
280
224
  end
281
225
  end
282
226
  end
@@ -54,35 +54,52 @@ module Kdeploy
54
54
  return unless output.is_a?(Hash)
55
55
 
56
56
  pastel = @output.respond_to?(:pastel) ? @output.pastel : Pastel.new
57
+ show_stdout(output[:stdout])
58
+ show_stderr(output[:stderr], pastel)
59
+ end
60
+
61
+ def show_stdout(stdout)
62
+ return unless stdout && !stdout.empty?
57
63
 
58
- if output[:stdout] && !output[:stdout].empty?
59
- output[:stdout].each_line do |line|
60
- @output.write_line(" #{line.rstrip}") unless line.strip.empty?
61
- end
64
+ stdout.each_line do |line|
65
+ @output.write_line(" #{line.rstrip}") unless line.strip.empty?
62
66
  end
67
+ end
63
68
 
64
- return unless output[:stderr] && !output[:stderr].empty?
69
+ def show_stderr(stderr, pastel)
70
+ return unless stderr && !stderr.empty?
65
71
 
66
- output[:stderr].each_line do |line|
72
+ stderr.each_line do |line|
67
73
  @output.write_line(pastel.green(" #{line.rstrip}")) unless line.strip.empty?
68
74
  end
69
75
  end
70
76
 
71
77
  def show_command_header(host_name, type, description)
72
- pastel = @output.respond_to?(:pastel) ? @output.pastel : Pastel.new
78
+ pastel = pastel_instance
73
79
  @output.write_line(pastel.bright_white("\n#{host_name.ljust(24)} : "))
80
+ format_command_by_type(type, description, pastel)
81
+ end
74
82
 
83
+ def pastel_instance
84
+ @output.respond_to?(:pastel) ? @output.pastel : Pastel.new
85
+ end
86
+
87
+ def format_command_by_type(type, description, pastel)
75
88
  case type
76
89
  when :run
77
- @output.write_line(pastel.cyan(" [run] #{description.lines.first.strip}"))
78
- description.lines[1..].each do |line|
79
- @output.write_line(" > #{line.strip}") unless line.strip.empty?
80
- end
90
+ format_run_command(description, pastel)
81
91
  when :upload
82
92
  @output.write_line(pastel.green(" [upload] #{description}"))
83
93
  when :upload_template
84
94
  @output.write_line(pastel.yellow(" [template] #{description}"))
85
95
  end
86
96
  end
97
+
98
+ def format_run_command(description, pastel)
99
+ @output.write_line(pastel.cyan(" [run] #{description.lines.first.strip}"))
100
+ description.lines[1..].each do |line|
101
+ @output.write_line(" > #{line.strip}") unless line.strip.empty?
102
+ end
103
+ end
87
104
  end
88
105
  end
data/lib/kdeploy/dsl.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'set'
4
4
 
5
5
  module Kdeploy
6
+ # Domain-specific language for defining hosts, roles, and tasks
6
7
  module DSL
7
8
  def self.extended(base)
8
9
  base.instance_variable_set(:@kdeploy_hosts, {})
@@ -32,21 +33,33 @@ module Kdeploy
32
33
 
33
34
  def task(name, on: nil, roles: nil, &block)
34
35
  kdeploy_tasks[name] = {
35
- hosts: if on.is_a?(Array)
36
- on
37
- else
38
- (on ? [on] : nil)
39
- end,
40
- roles: if roles.is_a?(Array)
41
- roles
42
- else
43
- (roles ? [roles] : nil)
44
- end,
45
- block: lambda {
46
- @kdeploy_commands = []
47
- instance_eval(&block)
48
- @kdeploy_commands
49
- }
36
+ hosts: normalize_hosts_option(on),
37
+ roles: normalize_roles_option(roles),
38
+ block: create_task_block(block)
39
+ }
40
+ end
41
+
42
+ def normalize_hosts_option(on)
43
+ return on if on.is_a?(Array)
44
+
45
+ return [on] if on
46
+
47
+ nil
48
+ end
49
+
50
+ def normalize_roles_option(roles)
51
+ return roles if roles.is_a?(Array)
52
+
53
+ return [roles] if roles
54
+
55
+ nil
56
+ end
57
+
58
+ def create_task_block(block)
59
+ lambda {
60
+ @kdeploy_commands = []
61
+ instance_eval(&block)
62
+ @kdeploy_commands
50
63
  }
51
64
  end
52
65
 
@@ -76,25 +89,33 @@ module Kdeploy
76
89
 
77
90
  def get_task_hosts(task_name)
78
91
  task = kdeploy_tasks[task_name]
79
- return kdeploy_hosts.keys if !task || (!task[:hosts] && !task[:roles])
92
+ return kdeploy_hosts.keys if task_empty?(task)
80
93
 
81
94
  hosts = Set.new
95
+ add_explicit_hosts(task, hosts)
96
+ add_role_hosts(task, hosts)
97
+ hosts.to_a
98
+ end
82
99
 
83
- # 添加指定的主机
100
+ def task_empty?(task)
101
+ !task || (!task[:hosts] && !task[:roles])
102
+ end
103
+
104
+ def add_explicit_hosts(task, hosts)
84
105
  task[:hosts]&.each do |host|
85
106
  hosts.add(host) if kdeploy_hosts.key?(host)
86
107
  end
108
+ end
87
109
 
88
- # 添加角色中的主机
110
+ def add_role_hosts(task, hosts)
89
111
  task[:roles]&.each do |role|
90
- next unless (role_hosts = kdeploy_roles[role])
112
+ role_hosts = kdeploy_roles[role]
113
+ next unless role_hosts
91
114
 
92
115
  role_hosts.each do |host|
93
116
  hosts.add(host) if kdeploy_hosts.key?(host)
94
117
  end
95
118
  end
96
-
97
- hosts.to_a
98
119
  end
99
120
  end
100
121
  end
@@ -17,28 +17,7 @@ module Kdeploy
17
17
 
18
18
  def execute(command)
19
19
  Net::SSH.start(@ip, @user, ssh_options) do |ssh|
20
- stdout = String.new
21
- stderr = String.new
22
-
23
- ssh.open_channel do |channel|
24
- channel.exec(command) do |_ch, success|
25
- raise SSHError, "Could not execute command: #{command}" unless success
26
-
27
- channel.on_data do |_ch, data|
28
- stdout << data
29
- end
30
-
31
- channel.on_extended_data do |_ch, _type, data|
32
- stderr << data
33
- end
34
- end
35
- end
36
- ssh.loop
37
- {
38
- stdout: stdout.strip,
39
- stderr: stderr.strip,
40
- command: command
41
- }
20
+ execute_command_on_ssh(ssh, command)
42
21
  end
43
22
  rescue Net::SSH::AuthenticationFailed => e
44
23
  raise SSHError.new("SSH authentication failed: #{e.message}", e)
@@ -46,6 +25,39 @@ module Kdeploy
46
25
  raise SSHError.new("SSH execution failed: #{e.message}", e)
47
26
  end
48
27
 
28
+ def execute_command_on_ssh(ssh, command)
29
+ stdout = String.new
30
+ stderr = String.new
31
+
32
+ ssh.open_channel do |channel|
33
+ channel.exec(command) do |_ch, success|
34
+ raise SSHError, "Could not execute command: #{command}" unless success
35
+
36
+ setup_channel_handlers(channel, stdout, stderr)
37
+ end
38
+ end
39
+ ssh.loop
40
+ build_command_result(stdout, stderr, command)
41
+ end
42
+
43
+ def setup_channel_handlers(channel, stdout, stderr)
44
+ channel.on_data do |_ch, data|
45
+ stdout << data
46
+ end
47
+
48
+ channel.on_extended_data do |_ch, _type, data|
49
+ stderr << data
50
+ end
51
+ end
52
+
53
+ def build_command_result(stdout, stderr, command)
54
+ {
55
+ stdout: stdout.strip,
56
+ stderr: stderr.strip,
57
+ command: command
58
+ }
59
+ end
60
+
49
61
  def upload(source, destination)
50
62
  Net::SCP.start(@ip, @user, ssh_options) do |scp|
51
63
  scp.upload!(source, destination)
@@ -63,18 +75,29 @@ module Kdeploy
63
75
  private
64
76
 
65
77
  def ssh_options
66
- options = {
78
+ options = base_ssh_options
79
+ add_authentication(options)
80
+ add_port_option(options)
81
+ options
82
+ end
83
+
84
+ def base_ssh_options
85
+ {
67
86
  verify_host_key: :never,
68
87
  timeout: 30
69
88
  }
89
+ end
70
90
 
91
+ def add_authentication(options)
71
92
  if @password
72
93
  options[:password] = @password
73
94
  elsif @key
74
95
  options[:keys] = [@key]
75
96
  end
76
- options[:port] = @port if @port # 新增端口传递
77
- options
97
+ end
98
+
99
+ def add_port_option(options)
100
+ options[:port] = @port if @port
78
101
  end
79
102
  end
80
103
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pastel'
4
+
5
+ module Kdeploy
6
+ # Formats help text for CLI
7
+ class HelpFormatter
8
+ def initialize
9
+ @pastel = Pastel.new
10
+ end
11
+
12
+ def format_help
13
+ <<~HELP
14
+ #{@pastel.bright_white('📖 Available Commands:')}
15
+
16
+ #{format_commands}
17
+
18
+ #{format_examples}
19
+
20
+ #{format_documentation}
21
+ HELP
22
+ end
23
+
24
+ private
25
+
26
+ def format_commands
27
+ <<~COMMANDS
28
+ #{@pastel.bright_yellow('🚀')} #{@pastel.bright_white('execute TASK_FILE [TASK]')} Execute deployment tasks from file
29
+ #{@pastel.dim(' --limit HOSTS')} Limit to specific hosts (comma-separated)
30
+ #{@pastel.dim(' --parallel NUM')} Number of parallel executions (default: 5)
31
+ #{@pastel.dim(' --dry-run')} Show what would be done without executing
32
+
33
+ #{@pastel.bright_yellow('🆕')} #{@pastel.bright_white('init [DIR]')} Initialize new deployment project
34
+ #{@pastel.bright_yellow('ℹ️')} #{@pastel.bright_white('version')} Show version information
35
+ #{@pastel.bright_yellow('❓')} #{@pastel.bright_white('help [COMMAND]')} Show help information
36
+ COMMANDS
37
+ end
38
+
39
+ def format_examples
40
+ <<~EXAMPLES
41
+ #{@pastel.bright_white('💡 Examples:')}
42
+
43
+ #{@pastel.dim('# Initialize a new project')}
44
+ #{@pastel.bright_cyan('kdeploy init my-deployment')}
45
+
46
+ #{@pastel.dim('# Deploy to web servers')}
47
+ #{@pastel.bright_cyan('kdeploy execute deploy.rb deploy_web')}
48
+
49
+ #{@pastel.dim('# Backup database')}
50
+ #{@pastel.bright_cyan('kdeploy execute deploy.rb backup_db')}
51
+
52
+ #{@pastel.dim('# Run maintenance on specific hosts')}
53
+ #{@pastel.bright_cyan('kdeploy execute deploy.rb maintenance --limit web01')}
54
+
55
+ #{@pastel.dim('# Preview deployment')}
56
+ #{@pastel.bright_cyan('kdeploy execute deploy.rb deploy_web --dry-run')}
57
+ EXAMPLES
58
+ end
59
+
60
+ def format_documentation
61
+ <<~DOCS
62
+ #{@pastel.bright_white('📚 Documentation:')}
63
+ #{@pastel.bright_cyan('https://github.com/kevin197011/kdeploy')}
64
+ DOCS
65
+ end
66
+ end
67
+ end
@@ -21,6 +21,7 @@ module Kdeploy
21
21
  # Console output implementation
22
22
  class ConsoleOutput < Output
23
23
  def initialize
24
+ super
24
25
  @pastel = Pastel.new
25
26
  end
26
27
 
@@ -44,6 +45,7 @@ module Kdeploy
44
45
  attr_reader :messages, :errors
45
46
 
46
47
  def initialize
48
+ super
47
49
  @messages = []
48
50
  @errors = []
49
51
  end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pastel'
4
+ require 'tty-box'
5
+
6
+ module Kdeploy
7
+ # Formats and displays execution results
8
+ class OutputFormatter
9
+ def initialize
10
+ @pastel = Pastel.new
11
+ end
12
+
13
+ def format_task_header(task_name)
14
+ @pastel.cyan("\nPLAY [#{task_name}] " + ('*' * 64))
15
+ end
16
+
17
+ def format_host_status(host, status)
18
+ status_str = case status
19
+ when :success then @pastel.green('ok')
20
+ when :changed then @pastel.yellow('changed')
21
+ else @pastel.red('failed')
22
+ end
23
+ @pastel.bright_white("\n#{host.ljust(24)} : #{status_str}")
24
+ end
25
+
26
+ def format_upload_steps(steps, shown)
27
+ format_file_steps(steps, shown, :upload, @pastel.green(' === Upload ==='), 'upload: ')
28
+ end
29
+
30
+ def format_template_steps(steps, shown)
31
+ format_file_steps(steps, shown, :upload_template, @pastel.yellow(' === Template ==='), 'upload_template: ')
32
+ end
33
+
34
+ def format_file_steps(steps, shown, type, header, prefix)
35
+ output = [header]
36
+ steps.each do |step|
37
+ next if step_already_shown?(step, type, shown)
38
+
39
+ mark_step_as_shown(step, type, shown)
40
+ output << format_file_step(step, type, prefix)
41
+ end
42
+ output
43
+ end
44
+
45
+ def format_file_step(step, type, prefix)
46
+ duration_str = format_duration(step[:duration])
47
+ label = type == :upload ? '[upload]' : '[template]'
48
+ color(" #{label} #{step[:command].sub(prefix, '')}#{duration_str}")
49
+ end
50
+
51
+ def format_run_steps(steps, shown)
52
+ output = []
53
+ output << @pastel.cyan(' === Run ===')
54
+ steps.each do |step|
55
+ next if step_already_shown?(step, :run, shown)
56
+
57
+ mark_step_as_shown(step, :run, shown)
58
+ output.concat(format_single_run_step(step))
59
+ end
60
+ output
61
+ end
62
+
63
+ def format_single_run_step(step)
64
+ output = []
65
+ duration_str = format_duration(step[:duration])
66
+ command_line = step[:command].to_s.lines.first.strip
67
+ output << @pastel.cyan(" [run] #{command_line}#{duration_str}")
68
+ output.concat(format_multiline_command(step[:command]))
69
+ output.concat(format_command_output(step[:output]))
70
+ output
71
+ end
72
+
73
+ def format_error(error_message)
74
+ @pastel.red(" ERROR: #{error_message}")
75
+ end
76
+
77
+ def format_summary_header
78
+ @pastel.cyan("\nPLAY RECAP #{'*' * 64}")
79
+ end
80
+
81
+ def format_summary_line(host, result, max_host_len)
82
+ counts = calculate_summary_counts(result)
83
+ line = build_summary_line(host, counts, max_host_len)
84
+ colorize_summary_line(line, counts)
85
+ end
86
+
87
+ def calculate_summary_counts(result)
88
+ ok = %i[success changed].include?(result[:status]) ? result[:output].size : 0
89
+ failed = result[:status] == :failed ? 1 : 0
90
+ changed = result[:status] == :changed ? result[:output].size : 0
91
+ { ok: ok, failed: failed, changed: changed }
92
+ end
93
+
94
+ def build_summary_line(host, counts, max_host_len)
95
+ ok_w = 7
96
+ changed_w = 11
97
+ failed_w = 10
98
+
99
+ ok_str = @pastel.green("ok=#{counts[:ok].to_s.ljust(ok_w - 3)}")
100
+ changed_str = @pastel.yellow("changed=#{counts[:changed].to_s.ljust(changed_w - 8)}")
101
+ failed_str = @pastel.red("failed=#{counts[:failed].to_s.ljust(failed_w - 7)}")
102
+ "#{host.ljust(max_host_len)} : #{ok_str} #{changed_str} #{failed_str}"
103
+ end
104
+
105
+ def colorize_summary_line(line, counts)
106
+ if counts[:failed].positive?
107
+ @pastel.red(line)
108
+ elsif counts[:ok].positive? && counts[:failed].zero?
109
+ @pastel.green(line)
110
+ else
111
+ line
112
+ end
113
+ end
114
+
115
+ def format_dry_run_box(title, content)
116
+ TTY::Box.frame(
117
+ content,
118
+ title: { top_left: " #{title} " },
119
+ style: {
120
+ border: {
121
+ fg: :yellow
122
+ }
123
+ }
124
+ )
125
+ end
126
+
127
+ def format_dry_run_header
128
+ TTY::Box.frame(
129
+ 'Showing what would be done without executing any commands',
130
+ title: { top_left: ' Dry Run Mode ', bottom_right: ' Kdeploy ' },
131
+ style: {
132
+ border: {
133
+ fg: :blue
134
+ }
135
+ }
136
+ )
137
+ end
138
+
139
+ def format_command_for_dry_run(command)
140
+ case command[:type]
141
+ when :run
142
+ "#{@pastel.green('>')} #{command[:command]}"
143
+ when :upload
144
+ "#{@pastel.blue('>')} Upload: #{command[:source]} -> #{command[:destination]}"
145
+ when :upload_template
146
+ "#{@pastel.blue('>')} Template: #{command[:source]} -> #{command[:destination]}"
147
+ else
148
+ "#{@pastel.blue('>')} #{command[:type]}: #{command}"
149
+ end
150
+ end
151
+
152
+ private
153
+
154
+ def format_duration(duration)
155
+ duration ? @pastel.dim(" [#{format('%.2f', duration)}s]") : ''
156
+ end
157
+
158
+ def format_multiline_command(command)
159
+ output = []
160
+ cmd_lines = command.to_s.lines[1..].map(&:strip).reject(&:empty?)
161
+ cmd_lines.each { |line| output << @pastel.cyan(" > #{line}") } if cmd_lines.any?
162
+ output
163
+ end
164
+
165
+ def format_command_output(output)
166
+ result = []
167
+ return result unless output
168
+
169
+ if output.is_a?(Hash) && output[:stdout]
170
+ format_stdout_lines(output[:stdout], result)
171
+ elsif output.is_a?(String)
172
+ format_stdout_lines(output, result)
173
+ end
174
+ result
175
+ end
176
+
177
+ def format_stdout_lines(stdout, result)
178
+ stdout.each_line do |line|
179
+ result << @pastel.green(" #{line.rstrip}") unless line.strip.empty?
180
+ end
181
+ end
182
+
183
+ def step_already_shown?(step, type, shown)
184
+ key = [step[:command], type].hash
185
+ shown[key]
186
+ end
187
+
188
+ def mark_step_as_shown(step, type, shown)
189
+ key = [step[:command], type].hash
190
+ shown[key] = true
191
+ end
192
+ end
193
+ end
@@ -22,20 +22,7 @@ module Kdeploy
22
22
  return if completion_path.nil?
23
23
 
24
24
  source_line = "source \"#{completion_path}\""
25
-
26
- # 检查是否已经配置
27
- content = File.read(bashrc_path)
28
- if content.match?(/source.*kdeploy\.bash/)
29
- # 更新现有的配置
30
- new_content = content.gsub(/source.*kdeploy\.bash.*$/, source_line)
31
- File.write(bashrc_path, new_content)
32
- else
33
- # 添加新配置
34
- File.open(bashrc_path, 'a') do |f|
35
- f.puts "\n# Kdeploy completion"
36
- f.puts source_line
37
- end
38
- end
25
+ update_shell_config(bashrc_path, source_line, /source.*kdeploy\.bash/)
39
26
  puts "✅ Bash completion configured in #{bashrc_path}"
40
27
  rescue StandardError => e
41
28
  puts "⚠️ Failed to configure Bash completion: #{e.message}"
@@ -47,64 +34,97 @@ module Kdeploy
47
34
  completion_path = find_completion_file('kdeploy.zsh')
48
35
  return if completion_path.nil?
49
36
 
50
- source_lines = [
37
+ source_lines = build_zsh_source_lines(completion_path)
38
+ update_zsh_config(zshrc_path, source_lines)
39
+ puts "✅ Zsh completion configured in #{zshrc_path}"
40
+ rescue StandardError => e
41
+ puts "⚠️ Failed to configure Zsh completion: #{e.message}"
42
+ end
43
+
44
+ def find_completion_file(filename)
45
+ paths = build_completion_paths(filename)
46
+
47
+ paths.each do |path|
48
+ found_path = search_path(path)
49
+ return found_path if found_path
50
+ end
51
+
52
+ report_completion_file_not_found(filename, paths)
53
+ nil
54
+ end
55
+
56
+ def bashrc_path
57
+ File.join(Dir.home, '.bashrc')
58
+ end
59
+
60
+ def zshrc_path
61
+ File.join(Dir.home, '.zshrc')
62
+ end
63
+
64
+ def update_shell_config(config_path, source_line, pattern)
65
+ content = File.read(config_path)
66
+ if content.match?(pattern)
67
+ new_content = content.gsub(/#{pattern.source}.*$/, source_line)
68
+ File.write(config_path, new_content)
69
+ else
70
+ append_shell_config(config_path, source_line)
71
+ end
72
+ end
73
+
74
+ def append_shell_config(config_path, source_line)
75
+ File.open(config_path, 'a') do |f|
76
+ f.puts "\n# Kdeploy completion"
77
+ f.puts source_line
78
+ end
79
+ end
80
+
81
+ def build_zsh_source_lines(completion_path)
82
+ [
51
83
  "source \"#{completion_path}\"",
52
84
  'autoload -Uz compinit && compinit'
53
85
  ]
86
+ end
54
87
 
88
+ def update_zsh_config(zshrc_path, source_lines)
55
89
  content = File.read(zshrc_path)
56
-
57
- # 检查是否已经配置
58
90
  if content.match?(/source.*kdeploy\.zsh/)
59
- # 更新现有的配置
60
91
  new_content = content.gsub(/source.*kdeploy\.zsh.*$/, source_lines[0])
61
92
  File.write(zshrc_path, new_content)
62
93
  else
63
- # 添加新配置
64
- File.open(zshrc_path, 'a') do |f|
65
- f.puts "\n# Kdeploy completion"
66
- source_lines.each { |line| f.puts line unless content.include?(line) }
67
- end
94
+ append_zsh_config(zshrc_path, source_lines, content)
68
95
  end
69
- puts "✅ Zsh completion configured in #{zshrc_path}"
70
- rescue StandardError => e
71
- puts "⚠️ Failed to configure Zsh completion: #{e.message}"
72
96
  end
73
97
 
74
- def find_completion_file(filename)
75
- # 尝试所有可能的路径
76
- paths = [
77
- # 开发环境路径
98
+ def append_zsh_config(zshrc_path, source_lines, content)
99
+ File.open(zshrc_path, 'a') do |f|
100
+ f.puts "\n# Kdeploy completion"
101
+ source_lines.each { |line| f.puts line unless content.include?(line) }
102
+ end
103
+ end
104
+
105
+ def build_completion_paths(filename)
106
+ [
78
107
  File.expand_path("../completions/#{filename}", __FILE__),
79
- # RubyGems 安装路径
80
108
  *Gem.path.map { |path| File.join(path, "gems/kdeploy-*/lib/kdeploy/completions/#{filename}") },
81
- # 系统路径
82
109
  "/usr/local/share/kdeploy/completions/#{filename}",
83
110
  "/usr/share/kdeploy/completions/#{filename}"
84
111
  ]
112
+ end
85
113
 
86
- # 使用 Dir.glob 处理通配符路径
87
- paths.each do |path|
88
- if path.include?('*')
89
- matches = Dir.glob(path)
90
- return matches.first if matches.any?
91
- elsif File.exist?(path)
92
- return path
93
- end
114
+ def search_path(path)
115
+ if path.include?('*')
116
+ matches = Dir.glob(path)
117
+ return matches.first if matches.any?
118
+ elsif File.exist?(path)
119
+ return path
94
120
  end
121
+ nil
122
+ end
95
123
 
124
+ def report_completion_file_not_found(filename, paths)
96
125
  puts "⚠️ Could not find completion file: #{filename}"
97
126
  puts 'Searched paths:'
98
127
  paths.each { |path| puts " - #{path}" }
99
- nil
100
- end
101
-
102
- def bashrc_path
103
- File.join(Dir.home, '.bashrc')
104
- end
105
-
106
- def zshrc_path
107
- File.join(Dir.home, '.zshrc')
108
128
  end
109
129
  end
110
130
  end
@@ -15,19 +15,32 @@ module Kdeploy
15
15
  end
16
16
 
17
17
  def run(task_name)
18
+ task = find_task(task_name)
19
+ execute_concurrent_tasks(task)
20
+ ensure
21
+ @pool.shutdown
22
+ end
23
+
24
+ def find_task(task_name)
18
25
  task = @tasks[task_name]
26
+
19
27
  raise TaskNotFoundError, task_name unless task
20
28
 
21
- futures = @hosts.map do |name, config|
29
+ task
30
+ end
31
+
32
+ def execute_concurrent_tasks(task)
33
+ futures = create_task_futures(task)
34
+ futures.each(&:wait)
35
+ @results
36
+ end
37
+
38
+ def create_task_futures(task)
39
+ @hosts.map do |name, config|
22
40
  Concurrent::Future.execute(executor: @pool) do
23
41
  execute_task_for_host(name, config, task)
24
42
  end
25
43
  end
26
-
27
- futures.each(&:wait)
28
- @results
29
- ensure
30
- @pool.shutdown
31
44
  end
32
45
 
33
46
  private
@@ -37,23 +50,30 @@ module Kdeploy
37
50
  command_executor = CommandExecutor.new(executor, @output)
38
51
  result = { status: :success, output: [] }
39
52
 
53
+ execute_grouped_commands(task, command_executor, name, result)
54
+ @results[name] = result
55
+ rescue StandardError => e
56
+ @results[name] = { status: :failed, error: e.message }
57
+ end
58
+
59
+ def execute_grouped_commands(task, command_executor, name, result)
40
60
  commands = task[:block].call
41
61
  grouped_commands = CommandGrouper.group(commands)
42
62
 
43
63
  grouped_commands.each_value do |command_group|
44
- first_cmd = command_group.first
45
- task_desc = CommandGrouper.task_description(first_cmd)
46
- show_task_header(task_desc)
47
-
48
- command_group.each do |command|
49
- step_result = execute_command(command_executor, command, name)
50
- result[:output] << step_result
51
- end
64
+ execute_command_group(command_group, command_executor, name, result)
52
65
  end
66
+ end
53
67
 
54
- @results[name] = result
55
- rescue StandardError => e
56
- @results[name] = { status: :failed, error: e.message }
68
+ def execute_command_group(command_group, command_executor, name, result)
69
+ first_cmd = command_group.first
70
+ task_desc = CommandGrouper.task_description(first_cmd)
71
+ show_task_header(task_desc)
72
+
73
+ command_group.each do |command|
74
+ step_result = execute_command(command_executor, command, name)
75
+ result[:output] << step_result
76
+ end
57
77
  end
58
78
 
59
79
  def execute_command(command_executor, command, host_name)
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'erb'
4
- require 'ostruct'
5
4
  require 'tempfile'
6
5
 
7
6
  module Kdeploy
@@ -9,10 +8,19 @@ module Kdeploy
9
8
  class Template
10
9
  def self.render(template_path, variables = {})
11
10
  template_content = File.read(template_path)
12
- context = OpenStruct.new(variables)
11
+ context = create_template_context(variables)
13
12
  ERB.new(template_content).result(context.instance_eval { binding })
14
13
  end
15
14
 
15
+ def self.create_template_context(variables)
16
+ # Use a simple class instead of OpenStruct for better performance
17
+ context_class = Class.new
18
+ variables.each do |key, value|
19
+ context_class.define_method(key) { value }
20
+ end
21
+ context_class.new
22
+ end
23
+
16
24
  def self.render_and_upload(executor, template_path, destination, variables = {})
17
25
  rendered_content = render(template_path, variables)
18
26
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kdeploy
4
- VERSION = '1.2.1'
4
+ VERSION = '1.2.3'
5
5
  end
data/lib/kdeploy.rb CHANGED
@@ -9,6 +9,8 @@ require_relative 'kdeploy/dsl'
9
9
  require_relative 'kdeploy/executor'
10
10
  require_relative 'kdeploy/command_grouper'
11
11
  require_relative 'kdeploy/command_executor'
12
+ require_relative 'kdeploy/output_formatter'
13
+ require_relative 'kdeploy/help_formatter'
12
14
  require_relative 'kdeploy/runner'
13
15
  require_relative 'kdeploy/initializer'
14
16
  require_relative 'kdeploy/template'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kdeploy
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kk
@@ -146,8 +146,10 @@ files:
146
146
  - lib/kdeploy/dsl.rb
147
147
  - lib/kdeploy/errors.rb
148
148
  - lib/kdeploy/executor.rb
149
+ - lib/kdeploy/help_formatter.rb
149
150
  - lib/kdeploy/initializer.rb
150
151
  - lib/kdeploy/output.rb
152
+ - lib/kdeploy/output_formatter.rb
151
153
  - lib/kdeploy/post_install.rb
152
154
  - lib/kdeploy/runner.rb
153
155
  - lib/kdeploy/template.rb