Ing

Vanilla ruby command-line scripting.

or gratuitous backronym: I Need a Generator!

(Note this is a work-in-progress. Expect some quirkiness.)

The command-line syntax is similar to Thor's, and it incorporates Thor's (Rails') generator methods and shell conventions like

if yes? 'process foo files?', :yellow
  inside('foo') { create_file '%foo_file%.rb' }
end

but unlike Thor or Rake, it does not define its own DSL. Your tasks correspond to plain ruby classes and methods. Ing just handles routing from the command line to them, and setting options. Your classes (or even Procs) do the rest.

Option parsing courtesy of the venerable and excellent Trollop, under the hood.

Installation

gem install ing

To generate a default ing.rb file that loads from a tasks directory:

ing setup

A quick tour

The command line

The ing command line is generally parsed as

[ing command] [ing command options] [subcommand] [args] [subcommand options]

But in cases where the first argument isn't a built-in ing command or options, it's simplified to

[subcommand] [args] [subcommand options]

The "subcommand" is your task. To take some examples.

ing -r ./path/to/some/task.rb some:task run something --verbose
  1. ing -r loads specified ruby files or libraries/gems; then
  2. it dispatches to Some::Task.new(:verbose => true).run("something").

(Assuming you define a task Some::Task#run, in /path/to/some/task.rb.)

You can -r as many libaries/files as you like. Of course, that gets pretty long-winded.

By default, it requires a file ./ing.rb if it exists (the equivalent of Rakefile or Thorfile). In which case, assuming your task class is defined or loaded from there, the command can be simply

ing some:task run something --verbose

Built-in commands

Ing has some built-in commands. These are still being implemented, but you can see what they are so far with ing list -n ing:commands.

The most significant subcommand is generate or g, which simplifies a common and familiar use-case (at the expense of some file- system conventions):

ing generate some:task --force

Unlike Thor/Rails generators, these don't need to be packaged up as gems and preloaded into ruby. They can be either parsed as:

  1. A file relative to a root dir: e.g. some/task, or
  2. A subdirectory of the root dir, in which case it attempts to preload ing.rb within that subdirectory: e.g. some/task/ing.rb

The command above is then dispatched as normal to Some::Task.new(:force => true).call (#call is used if no method is specified). So you should put the task code within that namespace in the preloaded file.

(By default, the generator root directory is specified by ENV['ING_GENERATORS_ROOT'] or failing that, ~/.ing/generators.)

TODO: more examples needed

A simple example of a plain old ruby task

Let's say you want to run your project's tests with a command like ing test. The default is to run the whole suite; but if you just want unit tests you can say ing test unit. This is what it would look like (in ./ing.rb):

class Test

  # no options passed, but you need the constructor
  def initialize(options); end

  # `ing test`
  def call(*args)
    suite
  end

  # `ing test suite`
  def suite
    unit; functional; acceptance
  end

  # `ing test unit`
  def unit
    type 'unit'
  end

  # `ing test functional`
  def functional
    type 'functional'
  end

  # `ing test acceptance`
  def acceptance
    type 'acceptance'
  end

  def type(dir)
    Dir["./test/#{dir}/*.rb"].each { |f| require_relative f }
  end

end

As you can see, the second arg corresponds to the method name. call is what gets called when there is no second arg. Organizing the methods like this means you can also do ing test type custom: extra non-option arguments are passed into the method as parameters.

For more worked examples of ing tasks, see the examples directory.

MORE

Option arguments

Your tasks (ing subcommands) can specify what options they take by defining a class method specify_options. For example:

class Cleanup

  def self.specify_options(spec)
    spec.text "Clean up your path"
    spec.text "\nUsage:"
    spec.text "ing cleanup [OPTIONS]"
    spec.text "\nOptions:"
    spec.opt :quiet, "Run silently"
    spec.opt :path,  "Path to clean up", :type => :string, :default => '.'
  end

  attr_accessor :options

  def initialize(options)
    self.options = options
  end

  # ...
end

The syntax used in self.specify_options is Trollop - in fact what you are doing is building a Trollop::Parser which then emits the parsed options into your constructor.

In general your constructor should just save the options to an instance variable like this, but in some cases you might want to do further processing of the passed options.

MORE

Using the Task base class

To save some boilerplate, and to allow more flexible options specification, as well as a few more conveniences, you can inherit from Ing::Task and rewrite this example as:

class Cleanup < Ing::Task
  desc "Clean up your path"
  usage "ing cleanup [OPTIONS]"
  opt :quiet, "Run silently"
  opt :path,  "Path to clean up", :type => :string, :default => '.'

  # ...
end

This gives you a slightly more automated help message, with the description lines followed by usage followed by options, and with headers for each section.

Ing::Task also lets you inherit options. Say you have another task:

class BigCleanup < Cleanup
  opt :servers, "On servers", :type => :string, :multi => true
end

This task will have the two options from its superclass as well as its own. (Note the description and usage lines are not inherited this way, only the options).

Generator tasks

If you want to use Thor-ish generator methods, your task classes need a few more things added to their interface. Basically, it should look something like this.

class MyGenerator

  def self.specify_options(spec)
    # ...
  end

  include Ing::Files

  attr_accessor :destination_root, :source_root, :options, :shell

  # default == execution from within your project directory
  def destination_root
    @destination_root ||= Dir.pwd
  end

  # default == current file is within root directory of generator files
  def source_root
    @source_root ||= File.expand_path(File.dirname(__FILE__))
  end

  def initialize(options)
    self.options = options
  end

  # ...
end

The generator methods need :destination_root, :source_root, and :shell. Also, include Ing::Files after you specify any options (this is because Ing::Files adds several options automatically).

If you prefer, you can inherit from Ing::Generator, which gives you all of the above defaults, plus the functionality of Ing::Task.

Like Ing::Task, Ing::Generator is simply a convenience for common scenarios.

MORE

Motivation

I wanted to use Thor's generator methods and shell conventions to write my own generators. But I didn't want to fight against Thor's hijacking of ruby classes.

Brief note about the design

One of the design principles is to limit inheritance (classical and mixin), and most importantly to avoid introducing new state via inheritance. An important corollary of this is that the application objects, ie. your task classes, must themselves take responsibility for their interface with the underlying resources they mix in or compose, instead of those resources providing the interface (via so-called macro-style class methods, for instance).

Q & A

But what about task dependency resolution?

That's what require and ||= are for ;)

Seriously, you do have Ing.invoke Some::Task, :some_method and Ing.execute ... for this kind of thing. Personally I think it's a code smell to put reusable code in things that are also run from the command line. Is it application or library code? Controller or model? But invoke is there if you must, hopefully with a suitably ugly syntax to dissuade you. :P

But what about security?

Yes, this means any ruby library and even built-in classes can be exercised from the command line... but so what?

  1. You can't run module methods, and the objects you invoke need to have a hash constructor. So Kernel, Process, IO, File, etc. are pretty much ruled out. Most of the ruby built-in classes are ruled out in fact.

  2. More to the point, you're already in a shell with much more dangerous knives lying around. You had better trust the scripts you're working with!