bin/itms
#!/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