wip-runner 0.1.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,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