wip-runner 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,518 @@
1
+ require 'open3'
2
+ require 'yaml'
3
+
4
+ module WIP
5
+ module Runner
6
+ module Workflow
7
+ class Runner
8
+ def initialize(io, workflow)
9
+ @io = io
10
+ @workflow = workflow
11
+ end
12
+
13
+ def run(options)
14
+ indent_size = @io.indent_size
15
+ @io.indent_size = 2
16
+ @options = options
17
+ @context = []
18
+ @env = {}
19
+
20
+ process_overview
21
+ process_workflow unless @options.overview
22
+
23
+ @io.indent_size = indent_size
24
+ end
25
+
26
+ private
27
+
28
+ def stylize(text, style)
29
+ stylize? ? @io.color(text, style) : text
30
+ end
31
+
32
+ def stylize?
33
+ true
34
+ end
35
+
36
+ def process_overview
37
+ @io.newline
38
+ @io.indent do
39
+ @io.say "# #{stylize(@workflow.heading, :underline)}"
40
+
41
+ unless @workflow.overview.nil?
42
+ @io.newline
43
+ @io.say @workflow.overview
44
+ end
45
+
46
+ unless @workflow.prologue.nil?
47
+ @io.newline
48
+ @io.say @workflow.prologue
49
+ end
50
+ end
51
+ end
52
+
53
+ def process_workflow
54
+ @context.push({ sources: [] })
55
+
56
+ @io.indent do
57
+ process_configs unless @options.preview
58
+ process_guards unless @options.preview
59
+
60
+ @workflow.shells.each do |mode, content|
61
+ process_shell(content, mode)
62
+ end
63
+
64
+ @workflow.tasks.each do |task|
65
+ process_task(task)
66
+ end
67
+ end
68
+
69
+ @context.pop
70
+ rescue GuardError, HaltSignal
71
+ # no-op (execution already blocked)
72
+ end
73
+
74
+ def process_task(task, overview = true)
75
+ process_block('##', task, overview, :underline) do
76
+ if overview && ! (task.shells.empty? && task.steps.empty?)
77
+ @io.newline
78
+ @io.say 'Steps...'
79
+ end
80
+
81
+ if @options.preview
82
+ task.shells.each do |mode, content|
83
+ process_shell(content, mode)
84
+ end
85
+
86
+ task.steps.each do |step|
87
+ process_step(step)
88
+ end
89
+ else
90
+ if overview
91
+ @options.preview = true
92
+ task.shells.each do |mode, content|
93
+ process_shell(content, mode)
94
+ end
95
+ @options.preview = false
96
+
97
+ task.steps.each do |step|
98
+ @io.newline
99
+ @io.say("- [ ] #{step.heading}")
100
+ end
101
+ end
102
+
103
+ @io.newline
104
+ choice = @io.choose('yes', 'no', 'skip', 'step', 'preview') do |menu|
105
+ menu.header = 'Continue?'
106
+ menu.flow = :inline
107
+ menu.index = :none
108
+ end
109
+
110
+ case choice
111
+ when 'yes'
112
+ proceed_with_task(task)
113
+ when 'no'
114
+ raise HaltSignal
115
+ when 'skip'
116
+ @io.indent_level -= 1
117
+ return
118
+ when 'step'
119
+ @options.stepwise = true
120
+ task.shells.each do |mode, content|
121
+ process_shell(content, mode)
122
+ end
123
+
124
+ task.steps.each do |step|
125
+ process_step(step)
126
+ end
127
+ @options.stepwise = false
128
+ when 'preview'
129
+ @options.preview = true
130
+ task.shells.each do |mode, content|
131
+ process_shell(content, mode)
132
+ end
133
+ task.steps.each do |step|
134
+ process_step(step)
135
+ end
136
+ @options.preview = false
137
+
138
+ @io.indent(-1) do
139
+ process_task(task, false)
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+
146
+ def process_step(step, overview = true)
147
+ process_block('- [ ]', step, overview) do
148
+ if @options.preview
149
+ step.shells.each do |mode, content|
150
+ process_shell(content, mode)
151
+ end
152
+ else
153
+ if @options.stepwise
154
+ @options.preview = true
155
+ step.shells.each do |content, mode|
156
+ process_shell(mode, content)
157
+ end
158
+ @options.preview = false
159
+
160
+ @io.newline
161
+ choice = @io.choose('yes', 'no', 'skip') do |menu|
162
+ menu.header = 'Continue?'
163
+ menu.flow = :inline
164
+ menu.index = :none
165
+ end
166
+
167
+ case choice
168
+ when 'yes'
169
+ proceed_with_step(step)
170
+ when 'no'
171
+ raise HaltSignal
172
+ when 'skip'
173
+ @io.indent_level -= 1
174
+ return
175
+ end
176
+
177
+
178
+ else
179
+ proceed_with_step(step)
180
+ end
181
+ end
182
+ end
183
+ end
184
+
185
+ def process_configs
186
+ unless @workflow.configs.empty?
187
+ @io.newline
188
+ @io.say "## #{stylize('Configuration', :underline)}"
189
+ @io.indent do
190
+ @io.newline
191
+ @io.say 'Please provide values for the following...'
192
+
193
+ @workflow.configs.each do |key, options|
194
+ answer = @io.ask("- #{key}: ") do |q|
195
+ q.default = (options[:default] || ENV[key])
196
+ if options[:required]
197
+ # q.validate = Proc.new { |a| ! a.empty? }
198
+ q.validate = /^.+$/
199
+ end
200
+ end
201
+ @env[key] = answer unless answer.empty?
202
+ end
203
+ end
204
+ end
205
+ end
206
+
207
+ def process_guards
208
+ @workflow.guards.each do |description, command, check|
209
+ Open3.popen2e(@env, command) do |stdin, stdoe, wait_thread|
210
+ status = wait_thread.value
211
+
212
+ if status.success?
213
+ expected = check
214
+ unless expected.nil?
215
+ actual = stdoe.readlines.join.strip
216
+
217
+ if check.is_a?(Regexp)
218
+ operation = :=~
219
+ else
220
+ operation = :==
221
+ expected = clean(expected)
222
+ end
223
+
224
+ unless actual.send(operation, expected)
225
+ guard_error(description, command, expected, actual)
226
+ end
227
+ end
228
+ else
229
+ guard_error(description, command, nil, status.exitstatus)
230
+ end
231
+ end
232
+ end
233
+ end
234
+
235
+ def process_block(prefix, component, overview = true, style = nil)
236
+ @context.push({ sources: [] })
237
+
238
+ if overview
239
+ @io.newline
240
+ @io.say("#{prefix} #{stylize(component.heading, style)}")
241
+ end
242
+
243
+ @io.indent do
244
+ unless component.prologue.nil?
245
+ @io.newline
246
+ @io.say component.prologue
247
+ end if overview
248
+
249
+ yield if block_given?
250
+ end
251
+
252
+ @context.pop
253
+ end
254
+
255
+ def process_shell(content, mode)
256
+ if @options.preview
257
+ preview(content, mode) unless [:export, :source].include?(mode)
258
+ else
259
+ execute(content, mode)
260
+ end
261
+ end
262
+
263
+ # ---
264
+
265
+ def preview(content, mode)
266
+ if mode == :script
267
+ prefix = nil
268
+ content = "```\n#{content}\n```"
269
+ else
270
+ prefix = '→ '
271
+ end
272
+
273
+ @io.newline
274
+ content.split("\n").each do |action|
275
+ if action.empty?
276
+ @io.newline
277
+ else
278
+ @io.say("#{prefix}#{stylize(action, :bold)}")
279
+ end
280
+ end
281
+ end
282
+
283
+ # ---
284
+
285
+ def execute(content, mode)
286
+ case mode
287
+ when :export
288
+ @context.last[:sources] << load_export(content)
289
+ when :source
290
+ @context.last[:sources] << load_source(content)
291
+ when :script
292
+ execute_script(content)
293
+ when :lines
294
+ execute_lines(content)
295
+ when :popen
296
+ execute_popen(content)
297
+ when :ticks
298
+ execute_ticks(content)
299
+ end
300
+ end
301
+
302
+ def load_export(content)
303
+ command = content.gsub(/"/, '\"').gsub(/\$/, "\\$").lstrip
304
+ command = %Q{bash -c "#{command}"}
305
+
306
+ Open3.popen3(@env, command) do |stdin, stdout, stderr, wait_thread|
307
+ status = wait_thread.value
308
+
309
+ unless status.success?
310
+ while line = stderr.gets
311
+ error(line)
312
+ end
313
+ exit 1
314
+ end
315
+
316
+ YAML.load(clean(stdout.read)).map do |key, value|
317
+ %Q(export #{key.upcase}="#{value}")
318
+ end.join("\n").gsub(/"/, '\"').gsub(/\$/, "\\$")
319
+ end
320
+ end
321
+
322
+ def load_source(content)
323
+ content.gsub(/"/, '\"').gsub(/\$/, "\\$")
324
+ end
325
+
326
+ def execute_script(content)
327
+ preview(content, :script)
328
+
329
+ if @options.stepwise
330
+ @io.newline
331
+ choice = @io.choose('yes', 'no', 'skip') do |menu|
332
+ menu.header = 'Continue?'
333
+ menu.flow = :inline
334
+ menu.index = :none
335
+ end
336
+
337
+ case choice
338
+ when 'yes'
339
+ @io.newline
340
+ proceed_with_script(content)
341
+ when 'no'
342
+ raise HaltSignal
343
+ when 'skip'
344
+ @io.newline
345
+ end
346
+ else
347
+ proceed_with_script(content)
348
+ end
349
+ end
350
+
351
+ def execute_lines(content)
352
+ actions = content.split("\n")
353
+ actions.each do |action|
354
+ preview(action, :lines)
355
+
356
+ if @options.stepwise
357
+ @io.newline
358
+ choice = @io.choose('yes', 'no', 'skip') do |menu|
359
+ menu.header = 'Continue?'
360
+ menu.flow = :inline
361
+ menu.index = :none
362
+ end
363
+
364
+ case choice
365
+ when 'yes'
366
+ @io.newline
367
+ proceed_with_line(action)
368
+ next
369
+ when 'no'
370
+ raise HaltSignal
371
+ when 'skip'
372
+ @io.newline
373
+ next
374
+ end
375
+ else
376
+ proceed_with_line(action)
377
+ end
378
+ end
379
+ end
380
+
381
+ def execute_popen(content)
382
+ lines = content.split("\n")
383
+ lines.each do |line|
384
+ preview(line, :popen)
385
+
386
+ # TODO: stepwise
387
+ command = line.gsub(/"/, '\"').gsub(/\$/, "\\$")
388
+ command = (sources + [command]).join("\n")
389
+ command = %Q{bash -c "#{command} 2>&1"}
390
+
391
+ IO.popen(@env, command) do |pipe|
392
+ @io.indent do
393
+ pipe.each do |line|
394
+ @io.say(line)
395
+ end
396
+ end
397
+ end
398
+
399
+ exit 1 unless $?.success?
400
+ end
401
+ end
402
+
403
+ # def execute_ticks(content)
404
+ # content = content.gsub(/(\$[a-zA-Z0-9_]+)/) { |match| @env[match[1..-1]] }
405
+ # `#{content}`
406
+ # end
407
+
408
+ # ---
409
+
410
+ def proceed_with_task(task)
411
+ task.shells.each do |mode, content|
412
+ process_shell(content, mode)
413
+ end
414
+
415
+ task.steps.each do |step|
416
+ process_step(step)
417
+ end
418
+ end
419
+
420
+ def proceed_with_step(step)
421
+ step.shells.each do |mode, content|
422
+ process_shell(content, mode)
423
+ end
424
+ end
425
+
426
+ def proceed_with_script(script)
427
+ script = script.gsub(/"/, '\"').gsub(/\$/, "\\$")
428
+ script = (sources + [script]).join("\n")
429
+ script = %Q{bash -c "#{script}"}
430
+
431
+ Open3.popen2e(@env, script) do |stdin, stdoe, wait_thread|
432
+ status = wait_thread.value
433
+
434
+ @io.indent do
435
+ while line = stdoe.gets
436
+ @io.say("⫶ #{line}")
437
+ end
438
+ end
439
+
440
+ exit 1 unless status.success?
441
+ end
442
+ end
443
+
444
+ def proceed_with_line(line)
445
+ command = line.gsub(/"/, '\"').gsub(/\$/, "\\$")
446
+ command = (sources + [command]).join("\n")
447
+ command = %Q{bash -c "#{command}"}
448
+
449
+ Open3.popen2e(@env, command) do |stdin, stdoe, wait_thread|
450
+ status = wait_thread.value
451
+
452
+ @io.indent do
453
+ while line = stdoe.gets
454
+ @io.say("⫶ #{line}")
455
+ end
456
+ end
457
+
458
+ exit 1 unless status.success?
459
+ end
460
+ end
461
+
462
+ # ---
463
+
464
+ def sources
465
+ @context.map { |c| c[:sources] }.flatten
466
+ end
467
+
468
+ def clean(string)
469
+ return if string.nil?
470
+
471
+ indent = (string.scan(/^[ \t]*(?=\S)/).min || '').size
472
+ string.gsub(/^[ \t]{#{indent}}/, '').strip
473
+ end
474
+
475
+ def error(message)
476
+ @io.say stylize(message, :red)
477
+ end
478
+
479
+ def guard_error(description, command, check, actual)
480
+ case check
481
+ when nil
482
+ message = "Exit code was #{actual}"
483
+ when String
484
+ message = ['Output did not equal expected', check, actual]
485
+ when Regexp
486
+ message = ['Output did not match expected', check.inspect, actual]
487
+ end
488
+
489
+ @io.newline
490
+ error "Guard failed: '#{description}'"
491
+ @io.indent do
492
+ error "→ #{command}"
493
+ end
494
+
495
+ if message.is_a?(Array)
496
+ error message[0]
497
+
498
+ @io.newline
499
+ @io.say stylize('Expected:', :bold)
500
+ @io.indent do
501
+ @io.say message[1]
502
+ end
503
+
504
+ @io.newline
505
+ @io.say stylize('Actual:', :bold)
506
+ @io.indent do
507
+ @io.say message[2]
508
+ end
509
+ else
510
+ error message
511
+ end
512
+
513
+ raise GuardError, message[0]
514
+ end
515
+ end
516
+ end
517
+ end
518
+ end
@@ -0,0 +1,49 @@
1
+ require 'wip/runner/workflow/builder'
2
+ require 'wip/runner/workflow/builder/component'
3
+ require 'wip/runner/workflow/builder/workflow'
4
+ require 'wip/runner/workflow/builder/task'
5
+ require 'wip/runner/workflow/builder/step'
6
+ require 'wip/runner/workflow/runner'
7
+
8
+ module WIP
9
+ module Runner
10
+ module Workflow
11
+ class Error < WIP::Runner::Error; end
12
+ class GuardError < Error; end
13
+ class HaltSignal < Error; end
14
+
15
+ def self.define(&block)
16
+ command = (eval 'self', block.send(:binding))
17
+ command.send(:include, InstanceMethods)
18
+
19
+ command.class_exec do
20
+ options do |parser, config|
21
+ config.overview = false
22
+ config.preview = false
23
+
24
+ parser.on('--overview', 'Prints workflow overview') do
25
+ config.no_validate = true
26
+ config.overview = true
27
+ end
28
+
29
+ parser.on('--preview', 'Prints workflow preview') do
30
+ config.preview = true
31
+ end
32
+
33
+ define_method(:builder) do
34
+ @builder ||= Builder.new(self, &block)
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ module InstanceMethods
41
+ def execute(arguments, options)
42
+ workflow = builder.build(arguments, options)
43
+ runner = Runner.new(@io, workflow)
44
+ runner.run(options)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
data/lib/wip/runner.rb ADDED
@@ -0,0 +1,17 @@
1
+ require 'wip/runner/version'
2
+ require 'wip/runner/errors'
3
+ require 'wip/runner/command'
4
+ require 'wip/runner/options'
5
+ require 'wip/runner/parser'
6
+
7
+ require 'wip/runner/cli'
8
+ require 'wip/runner/cli/help'
9
+ require 'wip/runner/cli/version'
10
+
11
+ require 'wip/runner/shell'
12
+ require 'wip/runner/workflow'
13
+ require 'wip/runner/commands'
14
+
15
+ module WIP
16
+ module Runner ; end
17
+ end
@@ -0,0 +1,37 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'wip/runner/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "wip-runner"
8
+ spec.version = WIP::Runner::VERSION
9
+ spec.authors = ["Corey Innis"]
10
+ spec.email = ["corey@coolerator.net"]
11
+
12
+ spec.summary = %q{wip-runner is a generic CLI stand-alone and library.}
13
+ spec.description = %q{
14
+ wip-runner...
15
+ - A generic CLI (loads context-based commands)
16
+ - A library for building such commands
17
+ }
18
+ spec.homepage = "https://github.com/coreyti/wip-runner"
19
+ spec.license = "MIT"
20
+
21
+ if spec.respond_to?(:metadata)
22
+ spec.metadata['allowed_push_host'] = "https://rubygems.org"
23
+ else
24
+ raise "RubyGems 2.0 or newer is required to protect against public gem pushes."
25
+ end
26
+
27
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
28
+ spec.bindir = "exe"
29
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
30
+ spec.require_paths = ["lib"]
31
+
32
+ spec.add_dependency "highline"
33
+
34
+ spec.add_development_dependency "bundler", "~> 1.10"
35
+ spec.add_development_dependency "rake", "~> 10.0"
36
+ spec.add_development_dependency "rspec"
37
+ end