command_mapper 0.1.2 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6b0bd1d63ca9619bc3d0c0bd3729e1d5a3e0de9d7291f1e92a24f0c4d7fc6e26
4
- data.tar.gz: 3f5c08cc90eb514a75ab6333b2e76f7e9a6dd5f3ec33502e5b8c23e8d60c03ed
3
+ metadata.gz: 7f40de593c4e0124a5c3075b9e89ec08e38fe7451af1ede8fc62030b89b694ec
4
+ data.tar.gz: '0392b215a5dff4c4b2bffd34ec7df72fece82adbe7d260a975bd909edb9f962d'
5
5
  SHA512:
6
- metadata.gz: b42311e2b1453ce2c2295ad1cf3e893a205239287dff21566240a721ac45fd9dc16332c34eb8e9beca88053655d88f0d265e8c4a767a58f803f38e318d263e98
7
- data.tar.gz: 7981d9e48540297c7282cef7800015933d1fa5820ac0378bc71faa36adec6a4f321160beb8a10d5e7b9a5b860235a6d8407001f9bfc6935ac31d03802bf1f7d1
6
+ metadata.gz: f5c09b88caea6446ac20238bbba137accd62552c413e5fa67d9dce9a62f2e3f027624c6a951ce59fc6c720f2683e80dcbe5d3e8942d85f472e5d25e12f5e5a85
7
+ data.tar.gz: a8e1b267f3628fd23d2924f9d6f25534744af6c2af8a80d5cc3b3fa63bbdc1f35853dcda99871db67cbeea6f6824f3ed77a19b33bd8bd1bb1f5ca7750abf8a7b
data/.document ADDED
@@ -0,0 +1,3 @@
1
+ -
2
+ ChangeLog.*
3
+ LICENSE.txt
@@ -11,7 +11,8 @@ jobs:
11
11
  ruby:
12
12
  - 2.6
13
13
  - 2.7
14
- - 3.0
14
+ - '3.0'
15
+ - 3.1
15
16
  - jruby
16
17
  - truffleruby
17
18
  name: Ruby ${{ matrix.ruby }}
data/ChangeLog.md CHANGED
@@ -1,3 +1,18 @@
1
+ ### 0.2.0 / 2022-04-18
2
+
3
+ * Added {CommandMapper::Command.spawn} and
4
+ {CommandMapper::Command#spawn_command}.
5
+ * Added checks to {CommandMapper::Command.option},
6
+ {CommandMapper::Command.argument}, and {CommandMapper::Command.subcommand} to
7
+ avoid overwriting an existing option/argument/subcommand with the same name.
8
+ * Added the `value_in_flag:` keyword argument to
9
+ {CommandMapper::Command.option} which indicates an option's value
10
+ should be appended to the flag (ex: `-Fvalue`).
11
+ * Added the `range:` keyword argument to {CommandMapper::Types::Num#initialize}
12
+ for specifying the acceptable range of numbers.
13
+ * Allow options with `equals: true` (aka `--opt=...`) or `value_in_flag: true`
14
+ (aka `-Fvalue`) to accept values that start with a `-` character.
15
+
1
16
  ### 0.1.2 / 2021-11-29
2
17
 
3
18
  * Fixed a bug where {CommandMapper::Command.command_name} was not checking the
data/Gemfile CHANGED
@@ -7,7 +7,9 @@ group :development do
7
7
  gem 'rubygems-tasks', '~> 0.2'
8
8
  gem 'rspec', '~> 3.0'
9
9
  gem 'simplecov', '~> 0.20', require: false
10
+
10
11
  gem 'kramdown'
12
+ gem 'redcarpet', platform: :mri
11
13
  gem 'yard', '~> 0.9'
12
14
  gem 'yard-spellcheck'
13
15
 
data/LICENSE.txt CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2021 Hal Brodigan
1
+ Copyright (c) 2021-2022 Hal Brodigan
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
data/README.md CHANGED
@@ -43,7 +43,10 @@ allow safely and securely executing commands.
43
43
  * Allows running commands with additional environment variables.
44
44
  * Allows overriding the command name or path to the command.
45
45
  * Allows running commands via `sudo`.
46
- * Prevents command injection and option injection.
46
+ * Prevents [command injection] and [option injection].
47
+
48
+ [command injection]: https://owasp.org/www-community/attacks/Command_Injection
49
+ [option injection]: https://staaldraad.github.io/post/2019-11-24-argument-injection/
47
50
 
48
51
  [CommandMapper::Types::Str]: https://rubydoc.info/gems/command_mapper/CommandMapper/Types/Str
49
52
  [CommandMapper::Types::Num]: https://rubydoc.info/gems/command_mapper/CommandMapper/Types/Num
@@ -73,7 +76,7 @@ class Grep < CommandMapper::Command
73
76
  option "--basic-regexp"
74
77
  option "--perl-regexp"
75
78
  option "--regexp", equals: true, value: true
76
- option "--file", equals: true, value: true
79
+ option "--file", name: :patterns_file, equals: true, value: true
77
80
  option "--ignore-case"
78
81
  option "--no-ignore-case"
79
82
  option "--word-regexp"
@@ -142,6 +145,18 @@ Defines an option with a required value:
142
145
  option "--output", value: {required: true}
143
146
  ```
144
147
 
148
+ Defines an option that uses an equals sign (ex: `--output=value`):
149
+
150
+ ```ruby
151
+ option "--output", equals: true, value: {required: true}
152
+ ```
153
+
154
+ Defines an option where the value is embedded into the flag (ex: `-Ivalue`):
155
+
156
+ ```ruby
157
+ option "-I", value: {required: true}, value_in_flag: true
158
+ ```
159
+
145
160
  Defines an option that can be specified multiple times:
146
161
 
147
162
  ```ruby
@@ -154,6 +169,12 @@ Defines an option that accepts a numeric value:
154
169
  option "--count", value: {type: Num.new}
155
170
  ```
156
171
 
172
+ Define an option that only accepts a range of acceptable values:
173
+
174
+ ```ruby
175
+ option "--count", value: {type: Num.new(range: 1..100)}
176
+ ```
177
+
157
178
  Defines an option that accepts a comma-separated list:
158
179
 
159
180
  ```ruby
@@ -368,18 +389,18 @@ $ gem install command_mapper
368
389
  ### Gemfile
369
390
 
370
391
  ```ruby
371
- gem 'command_mapper', '~> 0.1'
392
+ gem 'command_mapper', '~> 0.2'
372
393
  ```
373
394
 
374
395
  ### gemspec
375
396
 
376
397
  ```ruby
377
- gemspec.add_dependency 'command_mapper', '~> 0.1'
398
+ gemspec.add_dependency 'command_mapper', '~> 0.2'
378
399
  ```
379
400
 
380
401
  ## License
381
402
 
382
- Copyright (c) 2021 Hal Brodigan
403
+ Copyright (c) 2021-2022 Hal Brodigan
383
404
 
384
405
  See {file:LICENSE.txt} for license information.
385
406
 
@@ -6,6 +6,7 @@ module CommandMapper
6
6
  # The base class for both {Option options} and {Argument arguments}.
7
7
  #
8
8
  class Arg
9
+
9
10
  # The argument's arg's type.
10
11
  #
11
12
  # @return [Types::Type, nil]
@@ -109,7 +109,33 @@ module CommandMapper
109
109
  end
110
110
 
111
111
  #
112
- # Runs the command in a shell and captures all stdout output.
112
+ # Initializes and spawns the command as a separate process, returning the
113
+ # PID of the process.
114
+ #
115
+ # @param [Hash{Symbol => Object}] params
116
+ # The option values.
117
+ #
118
+ # @yield [self]
119
+ # The newly initialized command.
120
+ #
121
+ # @yieldparam [Command] self
122
+ #
123
+ # @return [Integer]
124
+ # The PID of the new command process.
125
+ #
126
+ # @raise [Errno::ENOENT]
127
+ # The command could not be found.
128
+ #
129
+ # @since 0.2.0
130
+ #
131
+ def self.spawn(params={},**kwargs,&block)
132
+ command = new(params,**kwargs,&block)
133
+ command.spawn_command
134
+ end
135
+
136
+ #
137
+ # Initializes and runs the command in a shell and captures all stdout
138
+ # output.
113
139
  #
114
140
  # @param [Hash{Symbol => Object}] params
115
141
  # The option values.
@@ -128,7 +154,7 @@ module CommandMapper
128
154
  end
129
155
 
130
156
  #
131
- # Executes the command and returns an IO object to it.
157
+ # Initializes and executes the command and returns an IO object to it.
132
158
  #
133
159
  # @param [Hash{Symbol => Object}] params
134
160
  # The option values.
@@ -146,7 +172,7 @@ module CommandMapper
146
172
  end
147
173
 
148
174
  #
149
- # Initializes and runs the command through sudo.
175
+ # Initializes and runs the command through `sudo`.
150
176
  #
151
177
  # @param [Hash{Symbol => Object}] params
152
178
  # The option values.
@@ -225,6 +251,23 @@ module CommandMapper
225
251
  end
226
252
  end
227
253
 
254
+ #
255
+ # Determines if an option with the given name has been defined.
256
+ #
257
+ # @param [Symbol] name
258
+ # The given name.
259
+ #
260
+ # @return [Boolean]
261
+ # Specifies whether an option with the given name has been defined.
262
+ #
263
+ # @api semipublic
264
+ #
265
+ # @since 0.2.0
266
+ #
267
+ def self.has_option?(name)
268
+ options.has_key?(name)
269
+ end
270
+
228
271
  #
229
272
  # Defines an option for the command.
230
273
  #
@@ -234,10 +277,6 @@ module CommandMapper
234
277
  # @param [Symbol, nil] name
235
278
  # The option's name.
236
279
  #
237
- # @param [Boolean] equals
238
- # Specifies whether the option's flag and value should be separated with a
239
- # `=` character.
240
- #
241
280
  # @param [Hash, nil] value
242
281
  # The option's value.
243
282
  #
@@ -250,6 +289,14 @@ module CommandMapper
250
289
  # @param [Boolean] repeats
251
290
  # Specifies whether the option can be given multiple times.
252
291
  #
292
+ # @param [Boolean] equals
293
+ # Specifies whether the option's flag and value should be separated with a
294
+ # `=` character.
295
+ #
296
+ # @param [Boolean] value_in_flag
297
+ # Specifies that the value should be appended to the option's flag
298
+ # (ex: `-Fvalue`).
299
+ #
253
300
  # @api public
254
301
  #
255
302
  # @example Defining an option:
@@ -264,6 +311,9 @@ module CommandMapper
264
311
  # @example Defining an option who's value is optional:
265
312
  # option '--file', value: {required: false}
266
313
  #
314
+ # @example Defining an `-Fvalue` option:
315
+ # option '--foo', value: true, value_in_flag: true
316
+ #
267
317
  # @example Defining an `--opt=value` option:
268
318
  # option '--foo', equals: true, value: true
269
319
  #
@@ -274,25 +324,35 @@ module CommandMapper
274
324
  # option '--list', value: List.new
275
325
  #
276
326
  # @raise [ArgumentError]
277
- # The option flag conflicts with a pre-existing internal method.
278
- #
279
- def self.option(flag, name: nil, equals: nil, value: nil, repeats: false, &block)
327
+ # The option flag conflicts with a pre-existing internal method, or
328
+ # another argument or subcommand.
329
+ #
330
+ def self.option(flag, name: nil, value: nil, repeats: false,
331
+ # formatting options
332
+ equals: nil,
333
+ value_in_flag: nil,
334
+ &block)
280
335
  option = Option.new(flag, name: name,
281
- equals: equals,
282
336
  value: value,
283
337
  repeats: repeats,
338
+ # formatting options
339
+ equals: equals,
340
+ value_in_flag: value_in_flag,
284
341
  &block)
285
342
 
286
- self.options[option.name] = option
287
-
288
343
  if is_internal_method?(option.name)
289
344
  if name
290
345
  raise(ArgumentError,"option #{flag.inspect} with name #{name.inspect} cannot override the internal method with same name: ##{option.name}")
291
346
  else
292
347
  raise(ArgumentError,"option #{flag.inspect} maps to method name ##{option.name} and cannot override the internal method with same name: ##{option.name}")
293
348
  end
349
+ elsif has_argument?(option.name)
350
+ raise(ArgumentError,"option #{flag.inspect} with name #{option.name.inspect} conflicts with another argument with the same name")
351
+ elsif has_subcommand?(option.name)
352
+ raise(ArgumentError,"option #{flag.inspect} with name #{option.name.inspect} conflicts with another subcommand with the same name")
294
353
  end
295
354
 
355
+ self.options[option.name] = option
296
356
  attr_accessor option.name
297
357
  end
298
358
 
@@ -311,6 +371,23 @@ module CommandMapper
311
371
  end
312
372
  end
313
373
 
374
+ #
375
+ # Determines if an argument with the given name has been defined.
376
+ #
377
+ # @param [Symbol] name
378
+ # The given name.
379
+ #
380
+ # @return [Boolean]
381
+ # Specifies whether an argument with the given name has been defined.
382
+ #
383
+ # @api semipublic
384
+ #
385
+ # @since 0.2.0
386
+ #
387
+ def self.has_argument?(name)
388
+ arguments.has_key?(name)
389
+ end
390
+
314
391
  #
315
392
  # Defines an option for the command.
316
393
  #
@@ -337,7 +414,8 @@ module CommandMapper
337
414
  # argument :file, required: false
338
415
  #
339
416
  # @raise [ArgumentError]
340
- # The argument name conflicts with a pre-existing internal method.
417
+ # The argument name conflicts with a pre-existing internal method, or
418
+ # another option or subcommand.
341
419
  #
342
420
  def self.argument(name, required: true, type: Str.new, repeats: false)
343
421
  name = name.to_sym
@@ -345,12 +423,15 @@ module CommandMapper
345
423
  type: type,
346
424
  repeats: repeats)
347
425
 
348
- self.arguments[argument.name] = argument
349
-
350
426
  if is_internal_method?(argument.name)
351
427
  raise(ArgumentError,"argument #{name.inspect} cannot override internal method with same name: ##{argument.name}")
428
+ elsif has_option?(argument.name)
429
+ raise(ArgumentError,"argument #{name.inspect} conflicts with another option with the same name")
430
+ elsif has_subcommand?(argument.name)
431
+ raise(ArgumentError,"argument #{name.inspect} conflicts with another subcommand with the same name")
352
432
  end
353
433
 
434
+ self.arguments[argument.name] = argument
354
435
  attr_accessor name
355
436
  end
356
437
 
@@ -369,6 +450,23 @@ module CommandMapper
369
450
  end
370
451
  end
371
452
 
453
+ #
454
+ # Determines if a subcommand with the given name has been defined.
455
+ #
456
+ # @param [Symbol] name
457
+ # The given name.
458
+ #
459
+ # @return [Boolean]
460
+ # Specifies whether a subcommand with the given name has been defined.
461
+ #
462
+ # @api semipublic
463
+ #
464
+ # @since 0.2.0
465
+ #
466
+ def self.has_subcommand?(name)
467
+ subcommands.has_key?(name)
468
+ end
469
+
372
470
  #
373
471
  # Defines a subcommand.
374
472
  #
@@ -396,25 +494,30 @@ module CommandMapper
396
494
  # end
397
495
  #
398
496
  # @raise [ArgumentError]
399
- # The subcommand name conflicts with a pre-existing internal method.
497
+ # The subcommand name conflicts with a pre-existing internal method, or
498
+ # another option or argument.
400
499
  #
401
500
  def self.subcommand(name,&block)
402
- name = name.to_s
501
+ name = name.to_s
502
+ method_name = name.tr('-','_')
503
+ class_name = name.split(/[_-]+/).map(&:capitalize).join
504
+ subcommand_name = method_name.to_sym
505
+
506
+ if is_internal_method?(method_name)
507
+ raise(ArgumentError,"subcommand #{name.inspect} maps to method name ##{method_name} and cannot override the internal method with same name: ##{method_name}")
508
+ elsif has_option?(subcommand_name)
509
+ raise(ArgumentError,"subcommand #{name.inspect} conflicts with another option with the same name")
510
+ elsif has_argument?(subcommand_name)
511
+ raise(ArgumentError,"subcommand #{name.inspect} conflicts with another argument with the same name")
512
+ end
403
513
 
404
514
  subcommand_class = Class.new(Command)
405
515
  subcommand_class.command(name)
406
516
  subcommand_class.class_eval(&block)
407
517
 
408
- method_name = name.tr('-','_')
409
- class_name = name.split(/[_-]+/).map(&:capitalize).join
410
-
411
- self.subcommands[method_name.to_sym] = subcommand_class
518
+ self.subcommands[subcommand_name] = subcommand_class
412
519
  const_set(class_name,subcommand_class)
413
520
 
414
- if is_internal_method?(method_name)
415
- raise(ArgumentError,"subcommand #{name.inspect} maps to method name ##{method_name} and cannot override the internal method with same name: ##{method_name}")
416
- end
417
-
418
521
  define_method(method_name) do |&block|
419
522
  if block then @command_subcommand = subcommand_class.new(&block)
420
523
  else @command_subcommand
@@ -533,12 +636,28 @@ module CommandMapper
533
636
  end
534
637
 
535
638
  #
536
- # Initializes and runs the command.
639
+ # Runs the command.
537
640
  #
538
641
  # @return [Boolean, nil]
539
642
  #
540
643
  def run_command
541
- system(@command_env,*command_argv)
644
+ Kernel.system(@command_env,*command_argv)
645
+ end
646
+
647
+ #
648
+ # Spawns the command as a separate process, returning the PID of the
649
+ # process.
650
+ #
651
+ # @return [Integer]
652
+ # The PID of the new command process.
653
+ #
654
+ # @raise [Errno::ENOENT]
655
+ # The command could not be found.
656
+ #
657
+ # @since 0.2.0
658
+ #
659
+ def spawn_command
660
+ Process.spawn(@command_env,*command_argv)
542
661
  end
543
662
 
544
663
  #
@@ -563,7 +682,7 @@ module CommandMapper
563
682
  end
564
683
 
565
684
  #
566
- # Initializes and runs the command through sudo.
685
+ # Runs the command through `sudo`.
567
686
  #
568
687
  # @param [Hash{Symbol => Object}] sudo_params
569
688
  # Additional keyword arguments for {Sudo#initialize}.
@@ -7,12 +7,18 @@ module CommandMapper
7
7
  #
8
8
  class Option
9
9
 
10
+ # The option's flag (ex: `-o` or `--output`).
11
+ #
10
12
  # @return [String]
11
13
  attr_reader :flag
12
14
 
15
+ # The option's name.
16
+ #
13
17
  # @return [Symbol]
14
18
  attr_reader :name
15
19
 
20
+ # Describes the option's value.
21
+ #
16
22
  # @return [OptionValue, nil]
17
23
  attr_reader :value
18
24
 
@@ -25,10 +31,6 @@ module CommandMapper
25
31
  # @param [Symbol, nil] name
26
32
  # The option's name.
27
33
  #
28
- # @param [Boolean] equals
29
- # Specifies whether the option's flag and value should be separated with a
30
- # `=` character.
31
- #
32
34
  # @param [Hash, nil] value
33
35
  # The option's value.
34
36
  #
@@ -41,15 +43,29 @@ module CommandMapper
41
43
  # @param [Boolean] repeats
42
44
  # Specifies whether the option can be given multiple times.
43
45
  #
44
- def initialize(flag, name: nil, equals: nil, value: nil, repeats: false)
46
+ # @param [Boolean] equals
47
+ # Specifies whether the option's flag and value should be separated with a
48
+ # `=` character.
49
+ #
50
+ # @param [Boolean] value_in_flag
51
+ # Specifies that the value should be appended to the option's flag
52
+ # (ex: `-Fvalue`).
53
+ #
54
+ def initialize(flag, name: nil, value: nil, repeats: false,
55
+ # formatting options
56
+ equals: nil,
57
+ value_in_flag: nil)
45
58
  @flag = flag
46
59
  @name = name || self.class.infer_name_from_flag(flag)
47
- @equals = equals
48
60
  @value = case value
49
61
  when Hash then OptionValue.new(**value)
50
62
  when true then OptionValue.new
51
63
  end
52
64
  @repeats = repeats
65
+
66
+ # formatting options
67
+ @equals = equals
68
+ @value_in_flag = value_in_flag
53
69
  end
54
70
 
55
71
  #
@@ -90,6 +106,15 @@ module CommandMapper
90
106
  !@value.nil?
91
107
  end
92
108
 
109
+ #
110
+ # Determines whether the option can be given multiple times.
111
+ #
112
+ # @return [Boolean]
113
+ #
114
+ def repeats?
115
+ @repeats
116
+ end
117
+
93
118
  #
94
119
  # Indicates whether the option flag and value should be separated with a
95
120
  # `=` character.
@@ -101,12 +126,14 @@ module CommandMapper
101
126
  end
102
127
 
103
128
  #
104
- # Determines whether the option can be given multiple times.
129
+ # Indicates whether the value will be appended to the option's flag.
105
130
  #
106
131
  # @return [Boolean]
107
132
  #
108
- def repeats?
109
- @repeats
133
+ # @since 0.2.0
134
+ #
135
+ def value_in_flag?
136
+ @value_in_flag
110
137
  end
111
138
 
112
139
  #
@@ -269,13 +296,15 @@ module CommandMapper
269
296
  else
270
297
  string = @value.format(value)
271
298
 
272
- if string.start_with?('-')
273
- raise(ValidationError,"option #{@name} formatted value (#{string.inspect}) cannot start with a '-'")
274
- end
275
-
276
299
  if equals?
277
300
  argv << "#{@flag}=#{string}"
301
+ elsif value_in_flag?
302
+ argv << "#{@flag}#{string}"
278
303
  else
304
+ if string.start_with?('-')
305
+ raise(ValidationError,"option #{@name} formatted value (#{string.inspect}) cannot start with a '-'")
306
+ end
307
+
279
308
  argv << @flag << string
280
309
  end
281
310
  end
@@ -14,9 +14,11 @@ module CommandMapper
14
14
  # Specifies whether the hex value will start with `0x` or not.
15
15
  #
16
16
  # @param [Hash{Symbol => Object}] kwargs
17
- # Additional keyword arguments for {Type#initialize}.
17
+ # Additional keyword arguments for {Num#initialize}.
18
18
  #
19
- def initialize(leading_zero: false)
19
+ def initialize(leading_zero: false, **kwargs)
20
+ super(**kwargs)
21
+
20
22
  @leading_zero = leading_zero
21
23
  end
22
24
 
@@ -46,6 +48,12 @@ module CommandMapper
46
48
  return [false, "not in hex format (#{value.inspect})"]
47
49
  end
48
50
 
51
+ if @range
52
+ unless @range.include?(value.to_i(16))
53
+ return [false, "unacceptable value (#{value.inspect})"]
54
+ end
55
+ end
56
+
49
57
  return true
50
58
  else
51
59
  super(value)
@@ -4,6 +4,8 @@ module CommandMapper
4
4
  module Types
5
5
  class Map < Type
6
6
 
7
+ # The map of values to Strings.
8
+ #
7
9
  # @return [Hash{Object => String}]
8
10
  attr_reader :map
9
11
 
@@ -7,6 +7,21 @@ module CommandMapper
7
7
  #
8
8
  class Num < Type
9
9
 
10
+ # The optional range of acceptable numbers.
11
+ #
12
+ # @return [Range, nil]
13
+ attr_reader :range
14
+
15
+ #
16
+ # Initializes the numeric value.
17
+ #
18
+ # @param [Range] range
19
+ # Specifies the range of acceptable numbers.
20
+ #
21
+ def initialize(range: nil)
22
+ @range = range
23
+ end
24
+
10
25
  #
11
26
  # Validates a value.
12
27
  #
@@ -20,7 +35,7 @@ module CommandMapper
20
35
  def validate(value)
21
36
  case value
22
37
  when Integer
23
- return true
38
+ # no-op
24
39
  when String
25
40
  unless value =~ /\A\d+\z/
26
41
  return [false, "contains non-numeric characters (#{value.inspect})"]
@@ -31,6 +46,12 @@ module CommandMapper
31
46
  end
32
47
  end
33
48
 
49
+ if @range
50
+ unless @range.include?(value.to_i)
51
+ return [false, "unacceptable value (#{value.inspect})"]
52
+ end
53
+ end
54
+
34
55
  return true
35
56
  end
36
57
 
@@ -1,4 +1,4 @@
1
1
  module CommandMapper
2
2
  # Version of command_mapper
3
- VERSION = '0.1.2'
3
+ VERSION = '0.2.0'
4
4
  end