sshaw/itunes_store_transporter

View on GitHub
bin/itms

Summary

Maintainability
Test Coverage
#!/usr/bin/env ruby

require "erb"
require "uri"
require "yaml"
require "net/smtp"
require "net/http"
require "rexml/document"
require "itunes/store/transporter"

# Command line interface to the ITunes::Store::Transporter library.
# Using this is sorta like using iTMSTransporter except it can send email notifications and allows
# one to set global/per-command defaults via $HOME/.itms

module Command

  class << self
    def execute(name, options, argv)
      name = name.capitalize
      # Avoid Ruby 1.8/1.9 String/Symbol/const_defined? differences
      unless constants.include?(name) || constants.include?(name.to_sym)
        raise ArgumentError, "unknown command '#{name}'"
      end

      command = const_get(name).new(options)
      command.execute(argv)
    end
  end

  class Base
    def initialize(options)
      @itms = ITunes::Store::Transporter.new(options)
      @options = options
    end
  end

  class Providers < Base
    def initialize(options)
      # Let iTMSTransporter print the providers
      options[:print_stdout] = true
      super
    end

    def execute(args = [])
      @itms.providers
    end
  end

  class Lookup < Base
    def initialize(options)
      @assets  = options.delete(:assets)
      # Must call *after* we delete the assets option
      super
      @assets  = @assets.split(",").map { |x| x.split(":").grep(/\w/) } if String === @assets
      @package = "#{options[:apple_id] || options[:vendor_id]}.itmsp"
      Dir.mkdir(@package)
    end

    def execute(args = [])
      metadata = @itms.lookup
      File.open("#@package/metadata.xml", "w") { |f| f.write(metadata) }
      puts "Metadata saved in #@package"
      retrieve_assets(metadata) if @assets
    end

    def retrieve_assets(metadata)
      doc = REXML::Document.new(metadata)
      assets = build_asset_list(doc)

      if assets.none?
        puts "No assets found"
      else
        assets.each do |asset|
          puts "Downloading #{asset[:type]} asset #{asset[:filename]}"
          uri = URI.parse(asset[:url])

          Net::HTTP.start(uri.host, uri.port) do |http|
            File.open("#@package/#{asset[:filename]}", "wb") do |io|
              http.request_get(uri.to_s) do |res|
                raise "HTTP response #{res.message} (#{res.code})" unless Net::HTTPSuccess === res
                res.read_body do |data|
                  io.write data
                end
              end
            end
          end
        end
      end
    rescue => e
      raise "Asset retrieval failed: #{e}"
    end

    def build_asset_list(doc)
      asset_info = lambda do |xpath|
        doc.get_elements(xpath).map do |e|
          file = {}
          file[:url] = e.get_text(".//*[ @key = 'proxy-encode-url' ]").to_s
          type = e.attribute("type")
          file[:type] = type.value if type
          file[:filename] = e.get_text(".//data_file[ @role = 'source' ]/file_name").to_s
          file[:territories] = e.get_elements(".//territory").map { |t| t.get_text.to_s }
          file
        end
      end

      files = []
      warned = false
      if @assets == true
        files = asset_info["//asset[ .//*[ @key = 'proxy-encode-url' ]]"]
      else
        @assets.each do |asset|
          type, regions = asset.shift, asset
          regions.clear if type == "full" # There's only one full asset per package

          xpath = "//asset[ @type = '#{type}'"
          if regions.any?
            if !warned && RUBY_VERSION.start_with?("1.8")
              warn "WARNING: you're using Ruby 1.8, which has known problems looking up assets by region, expect problems!"
              warned = true
            end
            xpath << " and (%s)" % regions.map { |code| ".//territory = '#{code}'" }.join(" or ")
          end
          xpath << " and .//*[ @key = 'proxy-encode-url' ]]"

          info = asset_info[xpath]
          unknown = regions - info.map { |file| file[:territories] }.flatten
          raise "No #{type} asset for #{unknown.join ", "}" if unknown.any?

          files.concat info
        end
      end

      files
    end
  end

  class Schema < Base
    def execute(args = [])
      filename = "#{@options[:version]}-#{@options[:type]}.rng"
      schema = @itms.schema
      File.open(filename, "w") { |f| f.write(schema) }
    end
  end

  class Status < Base
    def initialize(options)
      # It's preferable to let iTMSTransporter output the status but unlike other commands
      # it summarizes errors on stdout. This results in redundant error messages since we summarize
      # errors too. To avoid this, and keep our error messages consistant, we reprint the status below.
      options[:print_stdout] = false unless options.include?(:print_stdout)
      super
    end

    def execute(args = [])
      info = @itms.status
      info.each do |k, v|
        next if k == :status
        say(k, v, 21)
      end

      info[:status].each_with_index do |status, i|
        pos = info[:status].size - i
        puts "\n#{'-' * 15} Upload ##{pos} #{'-' * 15}"
        status.each do |k, v|
          say(k, v, 18)
        end
      end
    end

    def say(k, v, width)
      k = k.to_s.capitalize.gsub("_", " ")
      printf "%-#{width}s %s\n", k, v
    end
  end

  class Upload < Base
    def initialize(options)
      # These can take a while so we let the user know what's going on
      options[:print_stderr] = true unless options.include?(:print_stderr)
      super
    end

    def execute(args = [])
      @itms.upload(args.shift)
      puts "Upload complete"
    end
  end

  class Verify < Base
    def execute(args = [])
      @itms.verify(args.shift)
      puts "Package is valid"
    end
  end

  class Version < Base
    def execute(args = [])
      puts @itms.version
    end
  end
end

class Email
  Binding = Class.new do
    def initialize(options = {})
      options.each do |k, v|
        name = k.to_s.gsub(/[^\w]+/, "_")
        instance_variable_set("@#{name}", v)
      end
    end
  end

  def initialize(config = {})
    unless config["to"]
      raise "No email recipients provided, you must specify at least one"
    end

    @config = config
  end

  def send(params = {})
    to      = @config["to"].to_s.split /,/
    host    = @config["host"] || "localhost"
    from    = @config["from"] || "#{user}@#{host}"
    params  = params.merge(@config)
    message = ::ERB.new(build_template).def_class(Binding).new(params).result

    # TODO: auth
    smtp = ::Net::SMTP.start(host, @config["port"]) do |mail|
      mail.send_message message, from, to
    end
  end

  protected
  def user
    ENV["USER"] || ENV["USERNAME"]
  end

  def build_template
    %w[to from subject cc bcc].inject("") do |t, key|
      t << "#{key}: #{@config[key]}\n" if @config[key]
      t
    end + "\n#{@config["message"]}"
  end
end

COMMANDS = ITunes::Store::Transporter::ITMSTransporter.instance_methods(false).map(&:to_s)
CONFIG_FILE_NAME = ".itms"

def home
  ENV["HOME"] || ENV["USERPROFILE"]
end

# Should probably create a class for the options
def load_config(command)
  return {} unless home

  path = File.join(home, CONFIG_FILE_NAME)
  return {} unless File.file?(path)

  config = YAML.load_file(path)
  return {} unless config

  # Get the global defaults. select() returns an aray on Ruby < 1.9
  defaults = config.select { |k,v| !v.is_a?(Hash) }
  defaults = Hash[defaults] unless defaults.is_a?(Hash)

  config[command] = defaults.merge(config[command] || {})

  # Normalize the email config
  email = Hash.new { |h, k| h[k] = {} }

  %w[success failure].each do |type|
    email[type] = (config[command]["email"] ||= {})[type]
    next unless email[type]

    # Merge the global email options & the command's "global" options with the success/failure options
    settings = (config["email"].to_a + config[command]["email"].to_a).reject { |k, v| k == "success" or k == "failure" }
    settings.each do |k, v|
      email[type][k] = email[type][k] ? "#{email[type][k]}, #{v}" : v
    end
  end

  # ITunes::Store::Transporter uses Symbols for options
  config[command] = config[command].inject({}) do |cfg, (k,v)|
    cfg[k.to_sym] = v unless k.empty? # Avoid intern empty string errors in 1.8
    cfg
  end

  config[command][:email] = email
  config[command]
end

def print_errors(e)
  $stderr.puts "#{e.errors.length} error(s)"
  $stderr.puts "-" * 25
  # TODO: group by type
  e.errors.each_with_index do |msg, i|
    $stderr.puts "#{i+1}. #{msg}"
  end
end

def send_email(config, options)
  return unless config
  mail = Email.new(config)
  mail.send(options)
end

command = ARGV.shift
abort("usage: itms command [options]") unless command
abort("invalid command '#{command}', valid commands are: #{COMMANDS.sort.join(', ')}") unless COMMANDS.include?(command)

options = ARGV.delete("--no-config") ? {} : load_config(command)

while ARGV.any?
  opt = ARGV.first.dup
  break unless opt.sub!(/\A--(?=\w)/, "")

  key, val = opt.split(/=/, 2)
  key.gsub!(/-/, "_")

  if val
    val = val.to_i if val =~ /\A\d+\z/
  else
    # Boolean option
    val = key.sub!(/\Ano_(?=\w)/, "") ? false : true
  end

  options[key.to_sym] = val
  ARGV.shift
end

# Keys for this are strings
email_options = options.delete(:email) || {}
command_options = options.dup
options[:argv] = ARGV.dup
options[:command] = command

begin
  puts "Running command '#{command}'\n\n"
  Command.execute(command, command_options, ARGV)
  send_email(email_options["success"], options)
rescue ITunes::Store::Transporter::ExecutionError => e
  print_errors(e)
  options[:error] = e
  send_email(email_options["failure"], options)
  exit 1
rescue => e
  $stderr.puts e
  exit 2
end