rails_preflight 0.1.0 → 0.1.2

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: 02fcbcfa3f7d98bbc550334fcf9bd65919fbb2eb5266088823b87c0fd7465ae3
4
- data.tar.gz: 42c66fa2d33b67e47fed5a36a7127eba476c34b57705d0f3c2591dea9fa1f5eb
3
+ metadata.gz: 18c100a721d1de1301b776d720f280062ca59598fdd5c54e07dde7719669e9d4
4
+ data.tar.gz: ad762983e63966629008b296c177b004ae2fea2782da1640046f4ae3d97a8f10
5
5
  SHA512:
6
- metadata.gz: ab95dfb109b6fee6cd50d1014792f85d4e941ba7871734766b834844bea2a2bfb15e526e426f7a19a97be7abd58339141bfea037365d9e0b34a3e6f5063b8226
7
- data.tar.gz: 1606dfaa001672ed19d45469592f212979228c2efb6500b9e7f8b8017d9977817e1814b25045f79b3ada23a9d3d9fe32b09fbe71e74c3b606e5e69e761ea7731
6
+ metadata.gz: 2ad46f48a8c223d95e8e130ad05a85d8f00ccb5601633f347c41e0e5fb09a4ebb6f1644faa19a9bb3be11d21bf8cc17fe4946c9dd5b37797d8e2c0e294cbb880
7
+ data.tar.gz: 9ff337a9512221c2720d637b6444f880a2dc4250ad08344383eec45ac7d4148e13a0723aed05d6cf133b796b22e1c1a235cdc76c6d6067b73ddc9d89d610101f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2 (2026-10-13)
4
+
5
+ - Code inside Packwerk packs (`packs/<name>/app`, `lib`, `spec`, ...) is scanned like the app root, and rules scoped to `app/helpers` or `spec` apply inside packs too (#22, thanks @JuanVqz)
6
+ - `BUNDLE_GEMFILE=Gemfile.next rails-preflight` reads that Gemfile and its lockfile, so a dual-boot app can audit its next boot; one outside the app, as `bundle exec` sets it, is ignored (#23, #24, thanks @JuanVqz)
7
+ - A compiled binary under `bin/` no longer aborts the run, and scanned files are read as UTF-8 whatever the locale, so a file with non-ASCII characters no longer crashes the scan under `LANG=C` (#21, thanks @JuanVqz)
8
+
9
+ ## 0.1.1 (2026-10-09)
10
+
11
+ - rubygems.org lookups also trust the certificates RubyGems ships for rubygems.org, so they work wherever `gem install` does, even when Ruby's OpenSSL can't find the system certificates
12
+ - Failed lookups are reported once per distinct error, under their own "rubygems.org lookup" entry with the gem names listed once, instead of the same error repeated for every gem under a second "Private Gems" card
13
+ - A certificate failure says what to do: behind a VPN or proxy that inspects HTTPS, point `SSL_CERT_FILE` at a bundle that includes its root certificate, or pass `--offline`
14
+ - The terminal warns when lookups fail
15
+
3
16
  ## 0.1.0 (2026-10-09)
4
17
 
5
18
  First release. Point it at a Rails 5.0 – 8.1 app and a target version: it writes a report into the app as HTML, Markdown or JSON, plus a short terminal summary. Read-only and static.
data/README.md CHANGED
@@ -97,12 +97,12 @@ It knows Rails 5.0 to 8.1 (`database/compatibility.yml`); the default target is
97
97
 
98
98
  The tool will analyze:
99
99
 
100
- - `Gemfile` and `Gemfile.lock`
100
+ - `Gemfile` and `Gemfile.lock`, or the Gemfile `BUNDLE_GEMFILE` names and its lockfile (`BUNDLE_GEMFILE=Gemfile.next` for a dual-boot app)
101
101
  - `.ruby-version`
102
102
  - `Dockerfile` (if present)
103
103
  - `db/schema.rb` (if present; an app on `db/structure.sql` gets a note that the schema checks were skipped)
104
104
  - `config/application.rb` and `config/**/*.yml`
105
- - `.rb`, `.erb`, `.haml`, `.slim` and `.rake` files under `app/`, `config/`, `db/`, `lib/`, `test/` and `spec/`
105
+ - `.rb`, `.erb`, `.haml`, `.slim` and `.rake` files under `app/`, `config/`, `db/`, `lib/`, `test/` and `spec/`, at the root and in each Packwerk pack (`packs/<name>/app/`, ...)
106
106
  - Scripts and CI config (`bin/`, `script/`, Procfiles, Makefiles, CI workflows) for removed rake tasks
107
107
 
108
108
  And generate:
data/bin/rails-preflight CHANGED
@@ -16,6 +16,7 @@ if %w[-h --help].include?(ARGV[0])
16
16
  puts "--format markdown|json: write rails_preflight_report.md or .json into the app instead of the HTML report."
17
17
  puts "--stdout: print the report instead of writing it (progress goes to stderr), for pipes and coding agents."
18
18
  puts "--fail-on blockers: exit 1 when anything blocks the upgrade or is already broken. --fail-on broken: only when something is already broken."
19
+ puts "BUNDLE_GEMFILE=Gemfile.next: read that Gemfile and its lockfile instead, for a dual-boot app. Ignored when it's outside APP_PATH."
19
20
  exit
20
21
  end
21
22
 
@@ -38,7 +39,8 @@ abort "Target must be a Rails version such as 7.2 (got #{target.inspect})." if t
38
39
  project_path = ARGV[0] || Dir.pwd
39
40
 
40
41
  begin
41
- summary = RailsPreflight::UpgradeAnalyzer.new(target, project_path, offline: offline, format: format, stdout: stdout).run
42
+ summary = RailsPreflight::UpgradeAnalyzer.new(target, project_path, offline: offline, format: format, stdout: stdout,
43
+ gemfile: ENV["BUNDLE_GEMFILE"]).run
42
44
  rescue RailsPreflight::Error => e
43
45
  abort e.message
44
46
  end
@@ -4,6 +4,8 @@ module RailsPreflight
4
4
  DATA_PATH = File.expand_path('../../database/deprecations.yml', __dir__)
5
5
  SCAN_DIRS = %w[app config db lib test spec].freeze
6
6
  SCAN_EXTS = %w[.rb .erb .rake .haml .slim].freeze
7
+ # Packwerk packs (`packs/<name>/app`, `packs/<name>/spec`, ...) hold app code outside the root's SCAN_DIRS.
8
+ PACKS_DIR = 'packs'.freeze
7
9
  # Lines that never run, by file type: [line comment, silent comment]. A silent comment hides the lines indented
8
10
  # under it too (HAML `-#`, Slim `/`); an HTML comment (HAML `/`, Slim `/!` or `/[if IE]`) is text, but the lines
9
11
  # under it still render and run. In templates `#main` is an element id, not a comment.
@@ -22,8 +24,9 @@ module RailsPreflight
22
24
  # Snippets go into a report people print and share: on a line that names a secret, values are hidden.
23
25
  SECRET_NAME = /secret|token|passw(?:or)?d|api_?key|access_?key|private_?key|credential/i
24
26
 
25
- def initialize(root_path = Dir.pwd, database_path = DATA_PATH, target_rails: nil, current_rails: nil)
27
+ def initialize(root_path = Dir.pwd, database_path = DATA_PATH, target_rails: nil, current_rails: nil, lockfile_path: nil)
26
28
  @root_path = root_path
29
+ @lockfile_path = lockfile_path || File.join(root_path, "Gemfile.lock")
27
30
  @rules = YAML.load_file(database_path)
28
31
  @target_rails = target_rails
29
32
  @current_rails = current_rails
@@ -87,7 +90,8 @@ module RailsPreflight
87
90
  found = []
88
91
  compiled = rules.map { |rule| [Regexp.new(rule['pattern']), rule] }
89
92
 
90
- files = Dir.glob(File.join(@root_path, "{#{SCAN_DIRS.join(',')}}/**/*")).select { |file| SCAN_EXTS.include?(File.extname(file)) }.to_h { |file| [file, 'code'] }
93
+ code_globs = ["", "#{PACKS_DIR}/*/"].map { |prefix| File.join(@root_path, "#{prefix}{#{SCAN_DIRS.join(',')}}/**/*") }
94
+ files = Dir.glob(code_globs).select { |file| SCAN_EXTS.include?(File.extname(file)) }.to_h { |file| [file, 'code'] }
91
95
  FILE_GLOBS.each do |kind, globs|
92
96
  Dir.glob(globs.map { |glob| File.join(@root_path, glob) }).each { |file| files[file] ||= kind }
93
97
  end
@@ -96,15 +100,19 @@ module RailsPreflight
96
100
  next if File.directory?(file)
97
101
 
98
102
  relative_path = Pathname.new(file).relative_path_from(Pathname.new(@root_path)).to_s
99
- test = relative_path.start_with?('test/', 'spec/')
103
+ # A pack is laid out like the app root, so rules see `packs/billing/app/models/x.rb` as `app/models/x.rb`.
104
+ app_path = relative_path.sub(%r{\A#{PACKS_DIR}/[^/]+/}, '')
105
+ test = app_path.start_with?('test/', 'spec/')
100
106
 
101
- under = ->(paths) { paths.any? { |path| relative_path == path || relative_path.start_with?("#{path}/") } }
107
+ under = ->(paths) { paths.any? { |path| app_path == path || app_path.start_with?("#{path}/") } }
102
108
  # A rule with `paths:` only applies under those directories or to those files.
103
109
  applicable = compiled.select do |_regex, rule|
104
110
  rule.fetch('files', ['code']).include?(kind) && (rule['paths'].nil? || under.(rule['paths']))
105
111
  end
106
112
  next if applicable.empty?
107
- content = File.read(file)
113
+ # UTF-8 whatever the locale (LANG=C reads US-ASCII); a compiled binary under bin/ (e.g. a Rust CLI) isn't text to scan.
114
+ content = File.read(file, encoding: "UTF-8")
115
+ next unless content.valid_encoding?
108
116
  # Only `x.errors` counts under `needs_receiver_in:` (a bare `errors` in a helper or view is a local), and in a
109
117
  # file that assigns `errors` or takes it as a parameter (`skip_bare_if_local:`), where the bare name is that local.
110
118
  # ponytail: file-wide, so a model with an `errors` local in one method loses bare hits in the others.
@@ -207,7 +215,7 @@ module RailsPreflight
207
215
  end
208
216
 
209
217
  def lockfile
210
- @lockfile ||= File.exist?(File.join(@root_path, "Gemfile.lock")) ? File.read(File.join(@root_path, "Gemfile.lock")) : ""
218
+ @lockfile ||= File.exist?(@lockfile_path) ? File.read(@lockfile_path) : ""
211
219
  end
212
220
 
213
221
  end
@@ -280,7 +280,7 @@ module RailsPreflight
280
280
  <div class="limits">
281
281
  <% @summary.fetch(:cant_see, []).each do |entry| %>
282
282
  <div class="limit">
283
- <h3><%= h(entry[:section]) %></h3>
283
+ <h3><%= h(entry[:title] || entry[:section]) %></h3>
284
284
  <p><%= code_html(entry[:message]) %></p>
285
285
  <% names = Array(entry[:names]) %>
286
286
  <% if names.any? %><p class="names"><%= h(names.join(" · ")) %></p><% end %>
@@ -477,7 +477,10 @@ module RailsPreflight
477
477
  h(text).gsub(/(?<![\w&;])&#39;(.+?)&#39;(?![\w&])/) { "<code>#{$1}</code>" }
478
478
  end
479
479
 
480
+ # A titled entry (the rubygems.org lookup) says what to do in its own message.
480
481
  def cant_see_next(entry)
482
+ return if entry[:title]
483
+
481
484
  case entry[:section]
482
485
  when "Private Gems" then "Check each gemspec's Rails dependency, and bump them in the same step."
483
486
  when "Gem Compatibility"
@@ -16,14 +16,19 @@ module RailsPreflight
16
16
  # format: :html, :markdown or :json, written into the app as rails_preflight_report.<ext>.
17
17
  # stdout: print the report instead of writing it, with progress on stderr, for pipes and coding agents.
18
18
  # run returns the summary (nil when there's nothing to upgrade to), so the caller can set the exit code.
19
- def initialize(target_rails = nil, project_path = Dir.pwd, offline: false, format: :html, stdout: false)
19
+ def initialize(target_rails = nil, project_path = Dir.pwd, offline: false, format: :html, stdout: false, gemfile: nil)
20
20
  @target_rails = target_rails
21
21
  @offline = offline
22
22
  @format = format
23
23
  @stdout = stdout
24
24
  @out = stdout ? $stderr : $stdout
25
25
  @project_path = project_path
26
- @lockfile_path = File.join(project_path, "Gemfile.lock")
26
+ # gemfile: another Gemfile, as BUNDLE_GEMFILE names it (Gemfile.next for dual boot); its lockfile sits next to it.
27
+ # Only one inside the app counts: `bundle exec` sets BUNDLE_GEMFILE to the Gemfile of wherever it was run.
28
+ gemfile = File.expand_path(gemfile, project_path) if gemfile
29
+ inside = gemfile&.start_with?(File.join(File.expand_path(project_path), ""))
30
+ @gemfile_path = inside ? gemfile : File.expand_path("Gemfile", project_path)
31
+ @lockfile_path = "#{@gemfile_path}.lock"
27
32
  @rules = YAML.load_file(DATA_PATH)
28
33
  end
29
34
 
@@ -52,7 +57,8 @@ module RailsPreflight
52
57
  results << DockerAnalyzer.new(@project_path, checked_ruby: @eol_checked_ruby).run
53
58
  results << GemAnalyzer.new(@project_path, lockfile_specs, hops: upgrade_hops.map { |hop| hop[:version] }, direct: lockfile&.dependencies&.keys || [],
54
59
  last_releases: (@last_releases || {} unless @offline), target_released: target_rails_rules&.dig('released')).run
55
- results << DeprecationAnalyzer.new(@project_path, target_rails: @target_rails, current_rails: current_rails&.to_s).run
60
+ results << DeprecationAnalyzer.new(@project_path, target_rails: @target_rails, current_rails: current_rails&.to_s,
61
+ lockfile_path: @lockfile_path).run
56
62
  results << ConfigAnalyzer.new(@project_path, current_rails).run
57
63
  results << DatabaseAnalyzer.new(@project_path, database_rules).run
58
64
 
@@ -100,12 +106,12 @@ module RailsPreflight
100
106
  if current_rails.nil?
101
107
  result[:status] = :warning
102
108
  result[:confidence] = :low
103
- result[:checks] << { message: "Could not read current Rails version from Gemfile.lock.", status: :warning, kind: :unknown, fix_effort: :low }
109
+ result[:checks] << { message: "Could not read current Rails version from #{lockfile_name}.", status: :warning, kind: :unknown, fix_effort: :low }
104
110
  elsif Gem::Version.new(current_rails.segments.first(2).join(".")) >= Gem::Version.new(@target_rails)
105
111
  result[:status] = :warning
106
112
  result[:checks] << { message: "Already on Rails #{current_rails}: #{@target_rails} is not an upgrade.", status: :passed }
107
113
  else
108
- result[:checks] << { message: "Rails #{current_rails} (Gemfile.lock) → #{@target_rails}", status: :passed, fix_effort: :low }
114
+ result[:checks] << { message: "Rails #{current_rails} (#{lockfile_name}) → #{@target_rails}", status: :passed, fix_effort: :low }
109
115
  end
110
116
 
111
117
  result
@@ -169,7 +175,7 @@ module RailsPreflight
169
175
  return [File.read(ruby_version_file), ".ruby-version"] if File.exist?(ruby_version_file)
170
176
 
171
177
  lock_ruby = File.exist?(@lockfile_path) && File.read(@lockfile_path)[/^RUBY VERSION\s+ruby (\S+)/, 1]
172
- return [lock_ruby, "Gemfile.lock"] if lock_ruby
178
+ return [lock_ruby, lockfile_name] if lock_ruby
173
179
 
174
180
  docker_ruby = DockerAnalyzer.new(@project_path).detect_ruby_version
175
181
  [docker_ruby, "Dockerfile"] if docker_ruby
@@ -204,7 +210,7 @@ module RailsPreflight
204
210
  result[:status] = :warning
205
211
  result[:confidence] = :medium
206
212
  result[:checks] << {
207
- message: "No Gemfile.lock found. Skipping gem compatibility checks.",
213
+ message: "No #{lockfile_name} found. Skipping gem compatibility checks.",
208
214
  status: :warning,
209
215
  kind: :unknown,
210
216
  fix_effort: :low
@@ -231,7 +237,7 @@ module RailsPreflight
231
237
  end
232
238
 
233
239
  if mixed_gems.any? && @offline
234
- message = "#{mixed_gems.size} gems come from one Gemfile.lock section that lists rubygems.org and #{mixed_remotes.join(', ')}; can't tell which are private with --offline"
240
+ message = "#{mixed_gems.size} gems come from one #{lockfile_name} section that lists rubygems.org and #{mixed_remotes.join(', ')}; can't tell which are private with --offline"
235
241
  result[:checks] << { message: message, status: :warning, kind: :unknown, names: mixed_gems, fix_effort: :unknown }
236
242
  result[:status] = :warning
237
243
  end
@@ -244,11 +250,20 @@ module RailsPreflight
244
250
  private_gems.concat(mixed_gems.select { |name| found[name] == false })
245
251
  @last_releases = dated_gems.filter_map { |name| [name, found[name]['version_created_at']] if found[name].is_a?(Hash) && found[name]['version_created_at'] }.to_h
246
252
  if mixed_gems.any?
247
- result[:checks] << { message: "Looked up #{mixed_gems.size} gems on rubygems.org, since Gemfile.lock lists them under both rubygems.org and #{mixed_remotes.join(', ')}. Pass --offline to skip.", status: :passed, fix_effort: :low }
253
+ result[:checks] << { message: "Looked up #{mixed_gems.size} gems on rubygems.org, since #{lockfile_name} lists them under both rubygems.org and #{mixed_remotes.join(', ')}. Pass --offline to skip.", status: :passed, fix_effort: :low }
248
254
  end
249
- if inconclusive.any?
250
- names = inconclusive.map { |name, error| "#{name} (#{error})" }
251
- result[:checks] << { message: "Could not verify #{inconclusive.size} gems against rubygems.org", status: :warning, kind: :unknown, names: names, fix_effort: :unknown }
255
+ # One entry per distinct error: a missing certificate fails every lookup the same way.
256
+ inconclusive.group_by { |_, error| error }.each do |error, failed|
257
+ gems = failed.map(&:first).sort
258
+ @out.puts "Couldn't reach rubygems.org for #{gems.size} gems (#{error}). See \"Couldn't check\" in the report."
259
+ # A VPN or proxy that inspects HTTPS re-signs certificates with a root the OS trusts but Ruby's OpenSSL doesn't read.
260
+ advice = if error.include?("certificate verify failed")
261
+ "Behind a VPN or proxy that inspects HTTPS, point SSL_CERT_FILE at a certificate bundle that includes its root certificate, or pass --offline."
262
+ else
263
+ "Run it again once rubygems.org is reachable, or pass --offline."
264
+ end
265
+ result[:checks] << { message: "Couldn't reach rubygems.org for #{gems.size} #{gems.size == 1 ? 'gem' : 'gems'} (#{error}), so whether #{gems.size == 1 ? "it's private and when it was" : "they're private and when they were"} last released is unknown. #{advice}",
266
+ title: "rubygems.org lookup", status: :warning, kind: :unknown, names: gems, fix_effort: :unknown }
252
267
  result[:status] = :warning
253
268
  end
254
269
  end
@@ -272,6 +287,7 @@ module RailsPreflight
272
287
  found = {}
273
288
  inconclusive = {}
274
289
  lock = Mutex.new
290
+ store = rubygems_cert_store
275
291
 
276
292
  Array.new([names.size, 8].min) do
277
293
  Thread.new do
@@ -279,14 +295,16 @@ module RailsPreflight
279
295
  begin
280
296
  url = URI("https://rubygems.org/api/v1/gems/#{name}.json")
281
297
  # Certificate errors are reported as inconclusive, never silently trusted.
282
- response = Net::HTTP.start(url.host, url.port, use_ssl: true, open_timeout: 5, read_timeout: 5) do |http|
298
+ response = Net::HTTP.start(url.host, url.port, use_ssl: true, cert_store: store, open_timeout: 5, read_timeout: 5) do |http|
283
299
  http.request(Net::HTTP::Get.new(url))
284
300
  end
285
301
  raise "HTTP #{response.code}" unless %w[200 404].include?(response.code) # e.g. 429 when rate limited
286
302
  info = response.code == "200" && (JSON.parse(response.body.to_s) rescue {})
287
303
  lock.synchronize { found[name] = info }
288
304
  rescue StandardError => e
289
- lock.synchronize { inconclusive[name] = "#{e.class.name}: #{e.message}" }
305
+ # The peer address changes per connection; without it, the same failure reads the same for every gem.
306
+ # The peer address changes per connection; without it, the same failure reads the same for every gem.
307
+ lock.synchronize { inconclusive[name] = "#{e.class.name}: #{e.message.sub(/ peeraddr=\S+/, '')}" }
290
308
  end
291
309
  end
292
310
  end
@@ -295,6 +313,20 @@ module RailsPreflight
295
313
  [found, inconclusive]
296
314
  end
297
315
 
316
+ # The system's certificates plus the ones RubyGems ships for rubygems.org, which is why `gem install` works on a
317
+ # machine whose OpenSSL can't find the system store. Verification stays on.
318
+ def rubygems_cert_store
319
+ require 'rubygems/request'
320
+ OpenSSL::X509::Store.new.tap do |store|
321
+ store.set_default_paths
322
+ Gem::Request.get_cert_files.each do |file|
323
+ store.add_file(file)
324
+ rescue OpenSSL::X509::StoreError
325
+ # already in the system store
326
+ end
327
+ end
328
+ end
329
+
298
330
  # From the lockfile: :public, :private (git, path, or only non-rubygems.org remotes), or, when one GEM
299
331
  # section lists rubygems.org next to another remote (Bundler before 2.2), whatever the Gemfile says; :mixed if it doesn't.
300
332
  def gem_origin(spec)
@@ -333,7 +365,7 @@ module RailsPreflight
333
365
  global = []
334
366
  scoped = {}
335
367
  blocks = [] # source for a `source … do` block, nil for any other block
336
- gemfile_lines(File.join(@project_path, "Gemfile")).each do |line|
368
+ gemfile_lines(@gemfile_path).each do |line|
337
369
  source = line[/^\s*source\b\s*\(?\s*(.+?)\s*\)?\s*(?:do\b.*)?$/, 1]&.delete_prefix("\"")&.delete_suffix("\"")&.delete("'")
338
370
  name = line[/^\s*gem\s*\(?\s*["']([^"']+)["']/, 1]
339
371
  if name
@@ -380,6 +412,10 @@ module RailsPreflight
380
412
  @lockfile = File.exist?(@lockfile_path) ? Bundler::LockfileParser.new(File.read(@lockfile_path)) : nil
381
413
  end
382
414
 
415
+ def lockfile_name
416
+ File.basename(@lockfile_path)
417
+ end
418
+
383
419
  def known_rails_versions
384
420
  @rules.fetch('rails_versions', {}).keys.sort_by { |v| Gem::Version.new(v) }
385
421
  end
@@ -391,7 +427,7 @@ module RailsPreflight
391
427
  # Next known minor after the current Rails (7.1.3 → 7.2), or nil when already on the newest.
392
428
  def next_rails
393
429
  unless current_rails
394
- reason = File.exist?(@lockfile_path) ? "No Rails in #{@lockfile_path}" : "No Gemfile.lock in #{@project_path}"
430
+ reason = File.exist?(@lockfile_path) ? "No Rails in #{@lockfile_path}" : "No #{lockfile_name} in #{@project_path}"
395
431
  raise Error, "#{reason}, so the current Rails version is unknown. Pass a target, e.g. `rails-preflight #{latest_known_rails}`."
396
432
  end
397
433
  current_minor = Gem::Version.new(current_rails.segments.first(2).join("."))
@@ -1,3 +1,3 @@
1
1
  module RailsPreflight
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.2"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_preflight
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Syed Aslam