Using gems to package polyglot CLI tools

Just in time for Halloween! A true horror, but with a sort of tortured beauty. How (and why, WHYYYY?!) you can annex one of the ruby community’s most pervasive technologies to distribute your filthy, heathen, non-ruby code.

I’m not going to lie to you – this is not for the faint of heart. There’s not a lot of ruby involved, but you will need to edit some. If you’re a veteran ruby developer and packager – I am so sorry.

Rubygems

Rubygems, for the uninitiated, are how the ruby community does ad-hoc package management. Despite the potential security nightmare involved in allowing anyone at all to add to the primary rubygems repository, it’s pretty much the only way packages get distributed for ruby.

Whatever qualms one may have about that particular aspect of the gem architecture, from a developer and consumer’s perspective (if not a sysadmin’s), it’s a fantastic system. It’s also very straight forward to actually build gems themselves.

If you want to get some software in front of a bunch of developers, rubygems are a delicious low hanging fruit. The only problem, really, is that it’s pretty much expected that you’re writing the bulk if your stuff in ruby. Maybe you’ll be patching in some native extensions, etc, but otherwise why would you be using gems?

Well, for starters, rubygems comes preinstalled on every OSX machine for the last few years, putting it one step ahead of the likes of brew, macports, etc in terms of penetration. It also does not have the centralized (if benevolent) authority of these resources (a mixed blessing).

Another reason is that there are other languages you can expect to be on a Unix system if Ruby is on it – some sort of shell for starters, but probably a smattering of others depending on your audience.

You’re a savvy, globetrotting developer; you know there’s more to programming than picking a language and running with it like someone who has just recently discovered scissors.

In any case, here’s what you need to get your abomination into the world.

Before we continue

You’re gonna want to have ruby and rubygems installed. This is sort of a pre-req. There are many truly horrifying ways to get ruby on to your system; you’re a coder (if you’re not, bail here), you’ll figure it out.

You will need to have your code, at least, all sitting in a directory on your hard drive.

Dependencies, etc, will be a bit of a show stopper. If you’re going to be dropping executables on machines, you’re probably going to expect whatever runs them to already be there. For the purposes of shlint, which uses POSIX shell and Perl, we can expect most Unix based systems to either have these preinstalled, or for them to land on a system before you’ll want something like shlint.

Yes, haphazard.

First step – the gem specification

So the major thing that makes a gem a gem is the gemspec. The gemspec is a file, written in ruby, which describes your code. It’s a really straight forward bit of code, and you can read the gemspec for shlint here:

# -*- encoding: utf-8 -*-
lib = File.expand_path('../lib/', __FILE__)
$:.unshift lib unless $:.include?(lib)
 
Gem::Specification.new do |s|
  s.name        = "shlint"
  s.version     = "0.1.1"
  s.platform    = Gem::Platform::RUBY
  s.authors     = ["Ross Duggan"]
  s.email       = ["[email protected]"]
  s.homepage    = "http://github.com/duggan/shlint"
  s.summary     = "A linting tool for shell."
  s.description = "Checks the syntax of your shellscript against known and available shells."
 
  s.required_rubygems_version = ">= 1.3.6"
 
  s.files        = Dir.glob("{bin,lib}/**/*") + %w(LICENSE README.md)
  s.executables  = ['shlint', 'checkbashisms']
  s.require_path = 'lib'
end

Ok, so the biggest chunk of this is pretty straight forward, I basically have no idea why the chunk at the top is there, other than that it’s in the example I used as a reference. Ruby voodoo?

The interesting things are the s.files, s.executables and s.require_path directives.

We’re going to use this sort of directory structure (again, see shlint for a working example):

/README.md
/LICENSE
mypackage.gemspec
/lib/evil.sh
/bin/evil

For the sake of simplicity, you’re going to throw the executables you wish to run (shell, perl, etc) into a directory named lib. You are going to do something horrible in the bin directory (nobody will forgive you).

LICENSE and README.md are going to be in the root of your gem, naturally.

Second step – the executable

Ok, so you’ve got your alien code in lib, but what are we putting in bin? Well, unfortunately when you install a gem, it gets interpreted as ruby, meaning your unruby will cause it to throw a total fit.

(Un)fortunately, there’s a solution! For each tool you want available on the command line, you’re going to want something that looks like this:

#!/usr/bin/env ruby
 
spec = Gem::Specification.find_by_name("shlint")
gem_root = spec.gem_dir
gem_lib = gem_root + "/lib"
 
shell_output = ""
IO.popen("#{gem_lib}/shlint #{ARGV.join(" ")}", 'r+') do |pipe|
  pipe.close_write
  shell_output = pipe.read
end
 
puts shell_output

Haha, it is so evil, but it works!

The gist of what’s happening:

spec = Gem::Specification.find_by_name("shlint")
gem_root = spec.gem_dir
gem_lib = gem_root + "/lib"

Here, we’re interrogating the gem system to find out where the hell we’re executing from, using that to direct to what we have sitting in the lib directory of our gem.

Next:

shell_output = ""
IO.popen("#{gem_lib}/shlint #{ARGV.join(" ")}", 'r+') do |pipe|
  pipe.close_write
  shell_output = pipe.read
end
 
puts shell_output

We’re opening a pipe and basically shunting all arguments into our tool to deal with. You can get clever here if you like, but I felt just passing it all through was preferable. The result gets printed to screen.

Import side note here: if you’re executing the tool directly like I am here you’ll need the shebang set correctly. Otherwise, you’ll want to invoke the code prefixed by whatever executable you hope is going to be running it (like perl #{gem_lib}/checkbashisms.pl #{ARGV.join(" ")}, etc.)

Third step – packaging

Once all the bits and pieces are in place, you can try packaging your code with:

gem build mypackage.gemspec

If you’re lucky, you’ll have gotten everything right first time and you’ll now have a .gem file sitting in the directory you ran the command in. If not, the error output is pretty good, you’ll muddle through.

Next thing you’ll want to do is install your freshly forged gem with gem install mypackage-0.1.gem and see if it actually works. I went through several iterations to get the path stuff worked out, but you should find it easier.

Once you’re satisfied the gem is working, it’s time to magic it out to the world.

Fourth step – rubygems

So to get your gem out to the rest of the world, you’ll need to sign up for an account at rubygems.org. This process is simple, and once you’ve got an account, you can then proceed to run this command:

gem push mypackage-0.1.gem

The first time this is run, it’ll ask for your rubygems.org account details, fill these in and boom, your gem will be published to the world!

Now try booting a VM or something to see if you can install it from anywhere by just running gem install mypackage.

Final thoughts

This is a pretty horrible hack, but it has its benefits, frankly the largest of which is the sheer glee of building such a Frankenstein. Maybe though, if you’ve got a system where you’re pretty confident that it’s both got ruby on it and a particular subset of other languages, it’s a fun way to subvert the intended usage.

This entry was posted in Code. Bookmark the permalink. Both comments and trackbacks are currently closed.