├── Gemfile ├── lib ├── shenzhen │ ├── version.rb │ ├── plistbuddy.rb │ ├── commands.rb │ ├── agvtool.rb │ ├── commands │ │ ├── distribute.rb │ │ ├── info.rb │ │ └── build.rb │ ├── xcodebuild.rb │ └── plugins │ │ ├── crashlytics.rb │ │ ├── deploygate.rb │ │ ├── pgyer.rb │ │ ├── s3.rb │ │ ├── fir.rb │ │ ├── hockeyapp.rb │ │ ├── ftp.rb │ │ └── itunesconnect.rb └── shenzhen.rb ├── Rakefile ├── .gitignore ├── bin └── ipa ├── LICENSE ├── shenzhen.gemspec └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /lib/shenzhen/version.rb: -------------------------------------------------------------------------------- 1 | module Shenzhen 2 | VERSION = '0.13.2' 3 | end 4 | -------------------------------------------------------------------------------- /lib/shenzhen.rb: -------------------------------------------------------------------------------- 1 | require 'shenzhen/version' 2 | require 'shenzhen/agvtool' 3 | require 'shenzhen/xcodebuild' 4 | require 'shenzhen/plistbuddy' 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | gemspec = eval(File.read("shenzhen.gemspec")) 4 | 5 | task :build => "#{gemspec.full_name}.gem" 6 | 7 | file "#{gemspec.full_name}.gem" => gemspec.files + ["shenzhen.gemspec"] do 8 | system "gem build shenzhen.gemspec" 9 | end 10 | -------------------------------------------------------------------------------- /lib/shenzhen/plistbuddy.rb: -------------------------------------------------------------------------------- 1 | module Shenzhen::PlistBuddy 2 | class << self 3 | def print(file, key) 4 | output = `/usr/libexec/PlistBuddy -c "Print :#{key}" "#{file}" 2> /dev/null` 5 | 6 | !output || output.empty? || /Does Not Exist/ === output ? nil : output.strip 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/shenzhen/commands.rb: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path('../', __FILE__) 2 | 3 | require 'plugins/hockeyapp' 4 | require 'plugins/deploygate' 5 | require 'plugins/itunesconnect' 6 | require 'plugins/ftp' 7 | require 'plugins/s3' 8 | require 'plugins/crashlytics' 9 | require 'plugins/fir' 10 | require 'plugins/pgyer' 11 | 12 | require 'commands/build' 13 | require 'commands/distribute' 14 | require 'commands/info' 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # General 2 | *.gem 3 | *.rbc 4 | /.config 5 | /coverage/ 6 | /InstalledFiles 7 | /pkg/ 8 | /spec/reports/ 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Documentation 14 | /.yardoc/ 15 | /_yardoc/ 16 | /doc/ 17 | /rdoc/ 18 | 19 | # Environment 20 | /.bundle/ 21 | /lib/bundler/man/ 22 | 23 | # Library 24 | Gemfile.lock 25 | .ruby-version 26 | .ruby-gemset 27 | 28 | # RVM 29 | .rvmrc 30 | -------------------------------------------------------------------------------- /lib/shenzhen/agvtool.rb: -------------------------------------------------------------------------------- 1 | module Shenzhen::Agvtool 2 | class << self 3 | def what_version 4 | output = `agvtool what-version -terse` 5 | output.length > 0 ? output : nil 6 | end 7 | 8 | alias :vers :what_version 9 | 10 | def what_marketing_version 11 | output = `agvtool what-marketing-version -terse` 12 | output.scan(/\=(.+)$/).flatten.first 13 | end 14 | 15 | alias :mvers :what_marketing_version 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /bin/ipa: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'dotenv' 4 | Dotenv.load 5 | 6 | require 'commander/import' 7 | require 'terminal-table' 8 | 9 | $:.push File.expand_path("../../lib", __FILE__) 10 | require 'shenzhen' 11 | 12 | HighLine.track_eof = false # Fix for built-in Ruby 13 | 14 | program :version, Shenzhen::VERSION 15 | program :description, 'Build and distribute iOS apps (.ipa files)' 16 | 17 | program :help, 'Author', 'Mattt Thompson ' 18 | program :help, 'Website', 'http://mattt.me' 19 | program :help_formatter, :compact 20 | 21 | global_option('--verbose') { $verbose = true } 22 | 23 | default_command :help 24 | 25 | require 'shenzhen/commands' 26 | -------------------------------------------------------------------------------- /lib/shenzhen/commands/distribute.rb: -------------------------------------------------------------------------------- 1 | private 2 | 3 | def determine_file! 4 | files = Dir['*.ipa'] 5 | @file ||= case files.length 6 | when 0 then nil 7 | when 1 then files.first 8 | else 9 | @file = choose "Select an .ipa File:", *files 10 | end 11 | end 12 | 13 | def determine_dsym! 14 | dsym_files = Dir['*.dSYM.zip'] 15 | @dsym ||= case dsym_files.length 16 | when 0 then nil 17 | when 1 then dsym_files.first 18 | else 19 | dsym_files.detect do |dsym| 20 | File.basename(dsym, ".app.dSYM.zip") == File.basename(@file, ".ipa") 21 | end or choose "Select a .dSYM.zip file:", *dsym_files 22 | end 23 | end 24 | 25 | def determine_notes! 26 | placeholder = %{What's new in this release: } 27 | 28 | @notes = ask_editor placeholder 29 | @notes = nil if @notes == placeholder 30 | end 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012–2015 Mattt Thompson (http://mattt.me/) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /shenzhen.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | 4 | require "shenzhen/version" 5 | 6 | Gem::Specification.new do |s| 7 | s.name = "shenzhen" 8 | s.authors = ["Mattt Thompson"] 9 | s.email = "m@mattt.me" 10 | s.license = "MIT" 11 | s.homepage = "http://nomad-cli.com" 12 | s.version = Shenzhen::VERSION 13 | s.platform = Gem::Platform::RUBY 14 | s.summary = "Shenzhen" 15 | s.description = "CLI for Building & Distributing iOS Apps (.ipa Files)" 16 | 17 | s.add_dependency "commander", "~> 4.3" 18 | s.add_dependency "highline", ">= 1.7.1" 19 | s.add_dependency "terminal-table", "~> 1.4.5" 20 | s.add_dependency "json", "~> 1.8" 21 | s.add_dependency "faraday", "~> 0.8.9" 22 | s.add_dependency "faraday_middleware", "~> 0.9" 23 | s.add_dependency "dotenv", "~> 0.7" 24 | s.add_dependency "aws-sdk", "~> 1.0" 25 | s.add_dependency "net-sftp", "~> 2.1.2" 26 | s.add_dependency "plist", "~> 3.1.0" 27 | s.add_dependency "rubyzip", "~> 1.1" 28 | s.add_dependency "security", "~> 0.1.3" 29 | 30 | s.add_development_dependency "rspec" 31 | s.add_development_dependency "rake" 32 | 33 | s.files = Dir["./**/*"].reject { |file| file =~ /\.\/(bin|log|pkg|script|spec|test|vendor)/ } 34 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 35 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 36 | s.require_paths = ["lib"] 37 | end 38 | -------------------------------------------------------------------------------- /lib/shenzhen/commands/info.rb: -------------------------------------------------------------------------------- 1 | require 'plist' 2 | require 'tempfile' 3 | require 'zip' 4 | require 'zip/filesystem' 5 | 6 | command :info do |c| 7 | c.syntax = 'ipa info [options]' 8 | c.summary = 'Show mobile provisioning information about an .ipa file' 9 | c.description = '' 10 | 11 | c.action do |args, options| 12 | say_error "`security` command not found in $PATH" and abort if `which security` == "" 13 | 14 | determine_file! unless @file = args.pop 15 | say_error "Missing or unspecified .ipa file" and abort unless @file and ::File.exist?(@file) 16 | 17 | Zip::File.open(@file) do |zipfile| 18 | entry = zipfile.find_entry("Payload/#{File.basename(@file, File.extname(@file))}.app/embedded.mobileprovision") 19 | 20 | if (!entry) 21 | zipfile.dir.entries("Payload").each do |dir_entry| 22 | if dir_entry =~ /.app$/ 23 | say "Using .app: #{dir_entry}" 24 | entry = zipfile.find_entry("Payload/#{dir_entry}/embedded.mobileprovision") 25 | break 26 | end 27 | end 28 | end 29 | 30 | say_error "Embedded mobile provisioning file not found in #{@file}" and abort unless entry 31 | 32 | tempfile = Tempfile.new(::File.basename(entry.name)) 33 | begin 34 | zipfile.extract(entry, tempfile.path){ override = true } 35 | plist = Plist::parse_xml(`security cms -D -i #{tempfile.path}`) 36 | 37 | table = Terminal::Table.new do |t| 38 | plist.each do |key, value| 39 | next if key == "DeveloperCertificates" 40 | 41 | columns = [] 42 | columns << key 43 | columns << case value 44 | when Hash 45 | value.collect{|k, v| "#{k}: #{v}"}.join("\n") 46 | when Array 47 | value.join("\n") 48 | else 49 | value.to_s 50 | end 51 | 52 | t << columns 53 | end 54 | end 55 | 56 | puts table 57 | 58 | rescue => e 59 | say_error e.message 60 | ensure 61 | tempfile.close and tempfile.unlink 62 | end 63 | end 64 | end 65 | 66 | private 67 | 68 | def determine_file! 69 | files = Dir['*.ipa'] 70 | @file ||= case files.length 71 | when 0 then nil 72 | when 1 then files.first 73 | else 74 | @file = choose "Select an .ipa File:", *files 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/shenzhen/xcodebuild.rb: -------------------------------------------------------------------------------- 1 | require 'ostruct' 2 | 3 | module Shenzhen::XcodeBuild 4 | class Info < OpenStruct; end 5 | class Settings < OpenStruct 6 | include Enumerable 7 | 8 | def initialize(hash = {}) 9 | super 10 | self.targets = hash.keys 11 | end 12 | 13 | def members 14 | self.targets 15 | end 16 | 17 | def each 18 | members.each do |target| 19 | yield target, send(target) 20 | end 21 | 22 | self 23 | end 24 | end 25 | 26 | class Error < StandardError; end 27 | class NilOutputError < Error; end 28 | 29 | class << self 30 | def info(*args) 31 | options = args.last.is_a?(Hash) ? args.pop : {} 32 | output = `xcodebuild -list #{(args + args_from_options(options)).join(" ")} 2>&1` 33 | 34 | raise Error.new $1 if /^xcodebuild\: error\: (.+)$/ === output 35 | 36 | return nil unless /\S/ === output 37 | 38 | lines = output.split(/\n/) 39 | info, group = {}, nil 40 | 41 | info[:project] = lines.shift.match(/\"(.+)\"\:/)[1] rescue nil 42 | 43 | lines.each do |line| 44 | if /\:$/ === line 45 | group = line.strip[0...-1].downcase.gsub(/\s+/, '_') 46 | info[group] = [] 47 | next 48 | end 49 | 50 | unless group.nil? or /\.$/ === line 51 | info[group] << line.strip 52 | end 53 | end 54 | 55 | info.each do |group, values| 56 | next unless Array === values 57 | values.delete("") and values.uniq! 58 | end 59 | 60 | Info.new(info) 61 | end 62 | 63 | def settings(*args) 64 | options = args.last.is_a?(Hash) ? args.pop : {} 65 | output = `xcodebuild #{(args + args_from_options(options)).join(" ")} -showBuildSettings 2> /dev/null` 66 | 67 | return nil unless /\S/ === output 68 | 69 | raise Error.new $1 if /^xcodebuild\: error\: (.+)$/ === output 70 | 71 | lines = output.split(/\n/) 72 | 73 | settings, target = {}, nil 74 | lines.each do |line| 75 | case line 76 | when /Build settings for action build and target \"?([^":]+)/ 77 | target = $1 78 | settings[target] = {} 79 | else 80 | key, value = line.split(/\=/).collect(&:strip) 81 | settings[target][key] = value if target 82 | end 83 | end 84 | 85 | Settings.new(settings) 86 | end 87 | 88 | def version 89 | output = `xcodebuild -version` 90 | output.scan(/([\d+\.?]+)/).flatten.first rescue nil 91 | end 92 | 93 | private 94 | 95 | def args_from_options(options = {}) 96 | options.reject{|key, value| value.nil?}.collect{|key, value| "-#{key} '#{value}'"} 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/shenzhen/plugins/crashlytics.rb: -------------------------------------------------------------------------------- 1 | require 'pathname' 2 | 3 | module Shenzhen::Plugins 4 | module Crashlytics 5 | class Client 6 | 7 | def initialize(crashlytics_path, api_token, build_secret) 8 | @api_token, @build_secret = api_token, build_secret 9 | 10 | @crashlytics_path = Pathname.new("#{crashlytics_path}/submit").cleanpath.to_s 11 | say_error "Path to Crashlytics.framework/submit is invalid" and abort unless File.exists?(@crashlytics_path) 12 | end 13 | 14 | def upload_build(ipa, options) 15 | command = "#{@crashlytics_path} #{@api_token} #{@build_secret} -ipaPath '#{options[:file]}'" 16 | command += " -notesPath '#{options[:notes]}'" if options[:notes] 17 | command += " -emails #{options[:emails]}" if options[:emails] 18 | command += " -groupAliases #{options[:groups]}" if options[:groups] 19 | command += " -notifications #{options[:notifications] ? 'YES' : 'NO'}" 20 | 21 | system command 22 | end 23 | end 24 | end 25 | end 26 | 27 | command :'distribute:crashlytics' do |c| 28 | c.syntax = "ipa distribute:crashlytics [options]" 29 | c.summary = "Distribute an .ipa file over Crashlytics" 30 | c.description = "" 31 | c.option '-c', '--crashlytics_path PATH', "/path/to/Crashlytics.framework/" 32 | c.option '-f', '--file FILE', ".ipa file for the build" 33 | c.option '-a', '--api_token TOKEN', "API Token. Available at https://www.crashlytics.com/settings/organizations" 34 | c.option '-s', '--build_secret SECRET', "Build Secret. Available at https://www.crashlytics.com/settings/organizations" 35 | c.option '-m', '--notes PATH', "Path to release notes file" 36 | c.option '-e', '--emails EMAIL1,EMAIL2', "Emails of users for access" 37 | c.option '-g', '--groups GROUPS', "Groups for users for access" 38 | c.option '-n', '--notifications [YES | NO]', "Should send notification email to testers?" 39 | 40 | c.action do |args, options| 41 | determine_file! unless @file = options.file 42 | say_error "Missing or unspecified .ipa file" and abort unless @file and File.exist?(@file) 43 | 44 | determine_crashlytics_path! unless @crashlytics_path = options.crashlytics_path || ENV['CRASHLYTICS_FRAMEWORK_PATH'] 45 | say_error "Missing path to Crashlytics.framework" and abort unless @crashlytics_path 46 | 47 | determine_crashlytics_api_token! unless @api_token = options.api_token || ENV['CRASHLYTICS_API_TOKEN'] 48 | say_error "Missing API Token" and abort unless @api_token 49 | 50 | determine_crashlytics_build_secret! unless @build_secret = options.build_secret || ENV['CRASHLYTICS_BUILD_SECRET'] 51 | say_error "Missing Build Secret" and abort unless @build_secret 52 | 53 | parameters = {} 54 | parameters[:file] = @file 55 | parameters[:notes] = options.notes if options.notes 56 | parameters[:emails] = options.emails if options.emails 57 | parameters[:groups] = options.groups if options.groups 58 | parameters[:notifications] = options.notifications == 'YES' if options.notifications 59 | 60 | client = Shenzhen::Plugins::Crashlytics::Client.new(@crashlytics_path, @api_token, @build_secret) 61 | 62 | if client.upload_build(@file, parameters) 63 | say_ok "Build successfully uploaded to Crashlytics" 64 | else 65 | say_error "Error uploading to Crashlytics" and abort 66 | end 67 | end 68 | 69 | private 70 | 71 | def determine_crashlytics_path! 72 | @crashlytics_path ||= ask "Path to Crashlytics.framework:" 73 | end 74 | 75 | def determine_crashlytics_api_token! 76 | @api_token ||= ask "API Token:" 77 | end 78 | 79 | def determine_crashlytics_build_secret! 80 | @build_secret ||= ask "Build Secret:" 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/shenzhen/plugins/deploygate.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'openssl' 3 | require 'faraday' 4 | require 'faraday_middleware' 5 | 6 | module Shenzhen::Plugins 7 | module DeployGate 8 | class Client 9 | HOSTNAME = 'deploygate.com' 10 | 11 | def initialize(api_token, user_name) 12 | @api_token, @user_name = api_token, user_name 13 | @connection = Faraday.new(:url => "https://#{HOSTNAME}", :request => { :timeout => 120 }) do |builder| 14 | builder.request :multipart 15 | builder.request :json 16 | builder.response :json, :content_type => /\bjson$/ 17 | builder.use FaradayMiddleware::FollowRedirects 18 | builder.adapter :net_http 19 | end 20 | end 21 | 22 | def upload_build(ipa, options) 23 | options.update({ 24 | :token => @api_token, 25 | :file => Faraday::UploadIO.new(ipa, 'application/octet-stream'), 26 | :message => options[:message] || '' 27 | }) 28 | 29 | @connection.post("/api/users/#{@user_name}/apps", options).on_complete do |env| 30 | yield env[:status], env[:body] if block_given? 31 | end 32 | 33 | rescue Faraday::Error::TimeoutError 34 | say_error "Timed out while uploading build. Check https://deploygate.com/ to see if the upload was completed." and abort 35 | end 36 | end 37 | end 38 | end 39 | 40 | command :'distribute:deploygate' do |c| 41 | c.syntax = "ipa distribute:deploygate [options]" 42 | c.summary = "Distribute an .ipa file over deploygate" 43 | c.description = "" 44 | c.option '-f', '--file FILE', ".ipa file for the build" 45 | c.option '-a', '--api_token TOKEN', "API Token. Available at https://deploygate.com/settings" 46 | c.option '-u', '--user_name USER_NAME', "User Name. Available at https://deploygate.com/settings" 47 | c.option '-m', '--message MESSAGE', "Release message for the build" 48 | c.option '-d', '--distribution_key DESTRIBUTION_KEY', "distribution key for distribution page" 49 | c.option '-n', '--disable_notify', "disable notification" 50 | c.option '-r', '--release_note RELEASE_NOTE', "release note for distribution page" 51 | c.option '-v', '--visibility (private|public)', "privacy setting ( require public for personal free account)" 52 | 53 | c.action do |args, options| 54 | determine_file! unless @file = options.file 55 | say_error "Missing or unspecified .ipa file" and abort unless @file and File.exist?(@file) 56 | 57 | determine_deploygate_api_token! unless @api_token = options.api_token || ENV['DEPLOYGATE_API_TOKEN'] 58 | say_error "Missing API Token" and abort unless @api_token 59 | 60 | determine_deploygate_user_name! unless @user_name = options.user_name || ENV['DEPLOYGATE_USER_NAME'] 61 | say_error "Missing User Name" and abort unless @api_token 62 | 63 | @message = options.message 64 | @distribution_key = options.distribution_key || ENV['DEPLOYGATE_DESTRIBUTION_KEY'] 65 | @release_note = options.release_note 66 | @disable_notify = ! options.disable_notify.nil? ? "yes" : nil 67 | @visibility = options.visibility 68 | @message = options.message 69 | 70 | parameters = {} 71 | parameters[:file] = @file 72 | parameters[:message] = @message 73 | parameters[:distribution_key] = @distribution_key if @distribution_key 74 | parameters[:release_note] = @release_note if @release_note 75 | parameters[:disable_notify] = @disable_notify if @disable_notify 76 | parameters[:visibility] = @visibility if @visibility 77 | parameters[:replace] = "true" if options.replace 78 | 79 | client = Shenzhen::Plugins::DeployGate::Client.new(@api_token, @user_name) 80 | response = client.upload_build(@file, parameters) 81 | case response.status 82 | when 200...300 83 | say_ok "Build successfully uploaded to DeployGate" 84 | else 85 | say_error "Error uploading to DeployGate: #{response.body}" and abort 86 | end 87 | end 88 | 89 | private 90 | 91 | def determine_deploygate_api_token! 92 | @api_token ||= ask "API Token:" 93 | end 94 | 95 | def determine_deploygate_user_name! 96 | @user_name ||= ask "User Name:" 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/shenzhen/plugins/pgyer.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'openssl' 3 | require 'faraday' 4 | require 'faraday_middleware' 5 | 6 | module Shenzhen::Plugins 7 | module Pgyer 8 | class Client 9 | HOSTNAME = 'www.pgyer.com' 10 | 11 | def initialize(user_key, api_key) 12 | @user_key, @api_key = user_key, api_key 13 | @connection = Faraday.new(:url => "http://#{HOSTNAME}", :request => { :timeout => 120 }) do |builder| 14 | builder.request :multipart 15 | builder.request :json 16 | builder.response :json, :content_type => /\bjson$/ 17 | builder.use FaradayMiddleware::FollowRedirects 18 | builder.adapter :net_http 19 | end 20 | end 21 | 22 | def upload_build(ipa, options) 23 | options.update({ 24 | :uKey => @user_key, 25 | :_api_key => @api_key, 26 | :file => Faraday::UploadIO.new(ipa, 'application/octet-stream') 27 | }) 28 | 29 | @connection.post("/apiv1/app/upload", options).on_complete do |env| 30 | yield env[:status], env[:body] if block_given? 31 | end 32 | 33 | rescue Faraday::Error::TimeoutError 34 | say_error "Timed out while uploading build. Check http://www.pgyer.com/my to see if the upload was completed." and abort 35 | end 36 | 37 | def update_app_info(options) 38 | connection = Faraday.new(:url => "http://#{HOSTNAME}", :request => { :timeout => 120 }) do |builder| 39 | builder.request :url_encoded 40 | builder.request :json 41 | builder.response :logger 42 | builder.response :json, :content_type => /\bjson$/ 43 | builder.use FaradayMiddleware::FollowRedirects 44 | builder.adapter :net_http 45 | end 46 | 47 | options.update({ 48 | :uKey => @user_key, 49 | :_api_key => @api_key, 50 | }) 51 | 52 | connection.post("/apiv1/app/update", options) do |env| 53 | yield env[:status], env[:body] if block_given? 54 | end 55 | 56 | rescue Faraday::Error::TimeoutError 57 | say_error "Timed out while uploading build. Check http://www.pgyer.com/my to see if the upload was completed." and abort 58 | end 59 | end 60 | end 61 | end 62 | 63 | command :'distribute:pgyer' do |c| 64 | c.syntax = "ipa distribute:pgyer [options]" 65 | c.summary = "Distribute an .ipa file over Pgyer" 66 | c.description = "" 67 | c.option '-f', '--file FILE', ".ipa file for the build" 68 | c.option '-a', '--api_key KEY', "API KEY. Available at http://www.pgyer.com/doc/api#uploadApp" 69 | c.option '-u', '--user_key KEY', "USER KEY. Available at http://www.pgyer.com/doc/api#uploadApp/" 70 | c.option '--range RANGE', "Publish range. e.g. 1 (default), 2, 3" 71 | c.option '--[no-]public', "Allow build app on public to download. it is not public default." 72 | c.option '--password PASSWORD', "Set password to allow visit app web page." 73 | 74 | c.action do |args, options| 75 | determine_file! unless @file = options.file 76 | say_error "Missing or unspecified .ipa file" and abort unless @file and File.exist?(@file) 77 | 78 | determine_pgyer_user_key! unless @user_key = options.user_key || ENV['PGYER_USER_KEY'] 79 | say_error "Missing User Key" and abort unless @user_key 80 | 81 | determine_pgyer_api_key! unless @api_key = options.api_key || ENV['PGYER_API_KEY'] 82 | say_error "Missing API Key" and abort unless @api_key 83 | 84 | determine_publish_range! unless @publish_range = options.range 85 | say_error "Missing Publish Range" and abort unless @publish_range 86 | 87 | determine_is_public! unless @is_public = !!options.public 88 | @is_public = @is_public ? 1 : 2 89 | 90 | parameters = {} 91 | parameters[:publishRange] = @publish_range 92 | parameters[:isPublishToPublic] = @is_public 93 | parameters[:password] = options.password.chomp if options.password 94 | 95 | client = Shenzhen::Plugins::Pgyer::Client.new(@user_key, @api_key) 96 | response = client.upload_build(@file, parameters) 97 | case response.status 98 | when 200...300 99 | app_id = response.body['appKey'] 100 | app_short_uri = response.body['appShortcutUrl'] 101 | 102 | app_response = client.update_app_info({ 103 | :aKey => app_id, 104 | :appUpdateDescription => @notes 105 | }) 106 | 107 | if app_response.status == 200 108 | say_ok "Build successfully uploaded to Pgyer, visit url: http://www.pgyer.com/#{app_short_uri}" 109 | else 110 | say_error "Error update build information: #{response.body}" and abort 111 | end 112 | else 113 | say_error "Error uploading to Pgyer: #{response.body}" and abort 114 | end 115 | end 116 | 117 | private 118 | 119 | def determine_pgyer_api_key! 120 | @api_key ||= ask "API Key:" 121 | end 122 | 123 | def determine_pgyer_user_key! 124 | @user_key ||= ask "User Key:" 125 | end 126 | 127 | def determine_publish_range! 128 | @publish_range ||= "1" 129 | end 130 | 131 | def determine_is_public! 132 | @is_public ||= false 133 | end 134 | 135 | end 136 | -------------------------------------------------------------------------------- /lib/shenzhen/plugins/s3.rb: -------------------------------------------------------------------------------- 1 | require 'aws-sdk' 2 | 3 | module Shenzhen::Plugins 4 | module S3 5 | class Client 6 | def initialize(access_key_id, secret_access_key, region) 7 | @s3 = AWS::S3.new(:access_key_id => access_key_id, 8 | :secret_access_key => secret_access_key, 9 | :region => region) 10 | end 11 | 12 | def upload_build(ipa, options) 13 | path = expand_path_with_substitutions_from_ipa_plist(ipa, options[:path]) if options[:path] 14 | 15 | @s3.buckets.create(options[:bucket]) if options[:create] 16 | 17 | bucket = @s3.buckets[options[:bucket]] 18 | 19 | uploaded_urls = [] 20 | 21 | files = [] 22 | files << ipa 23 | files << options[:dsym] if options[:dsym] 24 | files.each do |file| 25 | basename = File.basename(file) 26 | key = path ? File.join(path, basename) : basename 27 | File.open(file) do |descriptor| 28 | obj = bucket.objects.create(key, descriptor, :acl => options[:acl]) 29 | uploaded_urls << obj.public_url.to_s 30 | end 31 | end 32 | 33 | uploaded_urls 34 | end 35 | 36 | private 37 | 38 | def expand_path_with_substitutions_from_ipa_plist(ipa, path) 39 | substitutions = path.scan(/\{CFBundle[^}]+\}/) 40 | return path if substitutions.empty? 41 | 42 | Dir.mktmpdir do |dir| 43 | system "unzip -q #{ipa} -d #{dir} 2> /dev/null" 44 | 45 | plist = Dir["#{dir}/**/*.app/Info.plist"].last 46 | 47 | substitutions.uniq.each do |substitution| 48 | key = substitution[1...-1] 49 | value = Shenzhen::PlistBuddy.print(plist, key) 50 | 51 | path.gsub!(Regexp.new(substitution), value) if value 52 | end 53 | end 54 | 55 | return path 56 | end 57 | end 58 | end 59 | end 60 | 61 | command :'distribute:s3' do |c| 62 | c.syntax = "ipa distribute:s3 [options]" 63 | c.summary = "Distribute an .ipa file over Amazon S3" 64 | c.description = "" 65 | 66 | c.example '', '$ ipa distribute:s3 -f ./file.ipa -a accesskeyid --bucket bucket-name' 67 | 68 | c.option '-f', '--file FILE', ".ipa file for the build" 69 | c.option '-d', '--dsym FILE', "zipped .dsym package for the build" 70 | c.option '-a', '--access-key-id ACCESS_KEY_ID', "AWS Access Key ID" 71 | c.option '-s', '--secret-access-key SECRET_ACCESS_KEY', "AWS Secret Access Key" 72 | c.option '-b', '--bucket BUCKET', "S3 bucket" 73 | c.option '--[no-]create', "Create bucket if it doesn't already exist" 74 | c.option '-r', '--region REGION', "Optional AWS region (for bucket creation)" 75 | c.option '--acl ACL', "Uploaded object permissions e.g public_read (default), private, public_read_write, authenticated_read" 76 | c.option '--source-dir SOURCE', "Optional source directory e.g. ./build" 77 | c.option '-P', '--path PATH', "S3 'path'. Values from Info.plist will be substituded for keys wrapped in {} \n\t\t eg. \"/path/to/folder/{CFBundleVersion}/\" could be evaluated as \"/path/to/folder/1.0.0/\"" 78 | 79 | c.action do |args, options| 80 | Dir.chdir(options.source_dir) if options.source_dir 81 | 82 | determine_file! unless @file = options.file 83 | say_error "Missing or unspecified .ipa file" and abort unless @file and File.exist?(@file) 84 | 85 | determine_dsym! unless @dsym = options.dsym 86 | say_error "Specified dSYM.zip file doesn't exist" if @dsym and !File.exist?(@dsym) 87 | 88 | determine_access_key_id! unless @access_key_id = options.access_key_id 89 | say_error "Missing AWS Access Key ID" and abort unless @access_key_id 90 | 91 | determine_secret_access_key! unless @secret_access_key = options.secret_access_key 92 | say_error "Missing AWS Secret Access Key" and abort unless @secret_access_key 93 | 94 | determine_bucket! unless @bucket = options.bucket 95 | say_error "Missing bucket" and abort unless @bucket 96 | 97 | determine_region! unless @region = options.region 98 | 99 | determine_acl! unless @acl = options.acl 100 | say_error "Missing ACL" and abort unless @acl 101 | 102 | @path = options.path 103 | 104 | client = Shenzhen::Plugins::S3::Client.new(@access_key_id, @secret_access_key, @region) 105 | 106 | begin 107 | urls = client.upload_build @file, {:bucket => @bucket, :create => !!options.create, :acl => @acl, :dsym => @dsym, :path => @path} 108 | urls.each { |url| say_ok url} 109 | say_ok "Build successfully uploaded to S3" 110 | rescue => exception 111 | say_error "Error while uploading to S3: #{exception}" 112 | end 113 | end 114 | 115 | private 116 | 117 | def determine_access_key_id! 118 | @access_key_id ||= ENV['AWS_ACCESS_KEY_ID'] 119 | @access_key_id ||= ask "Access Key ID:" 120 | end 121 | 122 | def determine_secret_access_key! 123 | @secret_access_key ||= ENV['AWS_SECRET_ACCESS_KEY'] 124 | @secret_access_key ||= ask "Secret Access Key:" 125 | end 126 | 127 | def determine_bucket! 128 | @bucket ||= ENV['S3_BUCKET'] 129 | @bucket ||= ask "S3 Bucket:" 130 | end 131 | 132 | def determine_region! 133 | @region ||= ENV['AWS_REGION'] 134 | end 135 | 136 | def determine_acl! 137 | @acl ||= "public_read" 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/shenzhen/plugins/fir.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'openssl' 3 | require 'faraday' 4 | require 'faraday_middleware' 5 | 6 | module Shenzhen::Plugins 7 | module Fir 8 | class Client 9 | HOSTNAME = 'fir.im' 10 | VERSION = 'v2' 11 | 12 | def initialize(user_token) 13 | @user_token = user_token 14 | 15 | @connection = Faraday.new(:url => "http://#{HOSTNAME}") do |builder| 16 | builder.request :url_encoded 17 | builder.response :json 18 | builder.use FaradayMiddleware::FollowRedirects 19 | builder.adapter :net_http 20 | end 21 | end 22 | 23 | def get_app_info(app_id) 24 | options = { 25 | :type => 'ios', 26 | :token => @user_token, 27 | } 28 | 29 | @connection.get("/api/#{VERSION}/app/info/#{app_id}", options) do |env| 30 | yield env[:status], env[:body] if block_given? 31 | end 32 | rescue Faraday::Error::TimeoutError 33 | say_error "Timed out while geting app info." and abort 34 | end 35 | 36 | def update_app_info(app_id, options) 37 | @connection.put("/api/#{VERSION}/app/#{app_id}?token=#{@user_token}", options) do |env| 38 | yield env[:status], env[:body] if block_given? 39 | end 40 | rescue Faraday::Error::TimeoutError 41 | say_error "Timed out while geting app info." and abort 42 | end 43 | 44 | def upload_build(ipa, options) 45 | connection = Faraday.new(:url => options['url'], :request => { :timeout => 360 }) do |builder| 46 | builder.request :multipart 47 | builder.response :json 48 | builder.use FaradayMiddleware::FollowRedirects 49 | builder.adapter :net_http 50 | end 51 | 52 | options = { 53 | :key => options['key'], 54 | :token => options['token'], 55 | :file => Faraday::UploadIO.new(ipa, 'application/octet-stream') 56 | } 57 | 58 | connection.post('/', options).on_complete do |env| 59 | yield env[:status], env[:body] if block_given? 60 | end 61 | rescue Errno::EPIPE 62 | say_error "Upload failed. Check internet connection is ok." and abort 63 | rescue Faraday::Error::TimeoutError 64 | say_error "Timed out while uploading build. Check https://fir.im// to see if the upload was completed." and abort 65 | end 66 | end 67 | end 68 | end 69 | 70 | command :'distribute:fir' do |c| 71 | c.syntax = "ipa distribute:fir [options]" 72 | c.summary = "Distribute an .ipa file over fir.im" 73 | c.description = "" 74 | c.option '-f', '--file FILE', ".ipa file for the build" 75 | c.option '-u', '--user_token TOKEN', "User Token. Available at http://fir.im/user/info" 76 | c.option '-a', '--app_id APPID', "App Id (iOS Bundle identifier)" 77 | c.option '-n', '--notes NOTES', "Release notes for the build" 78 | c.option '-V', '--app_version VERSION', "App Version" 79 | c.option '-S', '--short_version SHORT', "App Short Version" 80 | 81 | c.action do |args, options| 82 | determine_file! unless @file = options.file 83 | say_error "Missing or unspecified .ipa file" and abort unless @file and File.exist?(@file) 84 | 85 | determine_fir_user_token! unless @user_token = options.user_token || ENV['FIR_USER_TOKEN'] 86 | say_error "Missing User Token" and abort unless @user_token 87 | 88 | determine_fir_app_id! unless @app_id = options.app_id || ENV['FIR_APP_ID'] 89 | say_error "Missing App Id" and abort unless @app_id 90 | 91 | determine_notes! unless @notes = options.notes 92 | say_error "Missing release notes" and abort unless @notes 93 | 94 | determine_app_version! unless @app_version = options.app_version 95 | 96 | determine_short_version! unless @short_version = options.short_version 97 | 98 | client = Shenzhen::Plugins::Fir::Client.new(@user_token) 99 | app_response = client.get_app_info(@app_id) 100 | if app_response.status == 200 101 | upload_response = client.upload_build(@file, app_response.body['bundle']['pkg']) 102 | 103 | if upload_response.status == 200 104 | oid = upload_response.body['appOid'] 105 | today = Time.now.strftime('%Y-%m-%d %H:%M:%S') 106 | @notes ||= "Upload on #{today}" 107 | 108 | app_response = client.update_app_info(oid, { 109 | :changelog => @notes, 110 | :version => @app_version, 111 | :versionShort => @short_version 112 | }) 113 | 114 | if app_response.status == 200 115 | app_short_uri = app_response.body['short'] 116 | say_ok "Build successfully uploaded to Fir, visit url: http://fir.im/#{app_short_uri}" 117 | else 118 | say_error "Error updating build information: #{app_response.body[:error]}" and abort 119 | end 120 | else 121 | say_error "Error uploading to Fir: #{upload_response.body[:error]}" and abort 122 | end 123 | else 124 | say_error "Error getting app information: #{response.body[:error]}" 125 | end 126 | end 127 | 128 | private 129 | 130 | def determine_fir_user_token! 131 | @user_token ||= ask "User Token:" 132 | end 133 | 134 | def determine_fir_app_id! 135 | @app_id ||= ask "App Id:" 136 | end 137 | 138 | def determine_app_version! 139 | @app_version ||= ask "App Version:" 140 | end 141 | 142 | def determine_short_version! 143 | @short_version ||= ask "Short Version:" 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /lib/shenzhen/plugins/hockeyapp.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'openssl' 3 | require 'faraday' 4 | require 'faraday_middleware' 5 | 6 | module Shenzhen::Plugins 7 | module HockeyApp 8 | class Client 9 | HOSTNAME = 'upload.hockeyapp.net' 10 | 11 | def initialize(api_token) 12 | @api_token = api_token 13 | @connection = Faraday.new(:url => "https://#{HOSTNAME}") do |builder| 14 | builder.request :multipart 15 | builder.request :url_encoded 16 | builder.response :json, :content_type => /\bjson$/ 17 | builder.use FaradayMiddleware::FollowRedirects 18 | builder.adapter :net_http 19 | end 20 | end 21 | 22 | def upload_build(ipa, options) 23 | options[:ipa] = Faraday::UploadIO.new(ipa, 'application/octet-stream') if ipa and File.exist?(ipa) 24 | 25 | if dsym_filename = options.delete(:dsym_filename) 26 | options[:dsym] = Faraday::UploadIO.new(dsym_filename, 'application/octet-stream') 27 | end 28 | 29 | @connection.post do |req| 30 | if options[:public_identifier].nil? 31 | req.url("/api/2/apps/upload") 32 | else 33 | req.url("/api/2/apps/#{options.delete(:public_identifier)}/app_versions/upload") 34 | end 35 | req.headers['X-HockeyAppToken'] = @api_token 36 | req.body = options 37 | end.on_complete do |env| 38 | yield env[:status], env[:body] if block_given? 39 | end 40 | end 41 | end 42 | end 43 | end 44 | 45 | command :'distribute:hockeyapp' do |c| 46 | c.syntax = "ipa distribute:hockeyapp [options]" 47 | c.summary = "Distribute an .ipa file over HockeyApp" 48 | c.description = "" 49 | c.option '-f', '--file FILE', ".ipa file for the build" 50 | c.option '-d', '--dsym FILE', "zipped .dsym package for the build" 51 | c.option '-a', '--token TOKEN', "API Token. Available at https://rink.hockeyapp.net/manage/auth_tokens" 52 | c.option '-i', '--identifier PUBLIC_IDENTIFIER', "Public identifier of the app you are targeting, if not specified HockeyApp will use the bundle identifier to choose the right" 53 | c.option '-m', '--notes NOTES', "Release notes for the build (Default: Textile)" 54 | c.option '-r', '--release RELEASE', [:beta, :store, :alpha, :enterprise], "Release type: 0 - Beta, 1 - Store, 2 - Alpha , 3 - Enterprise" 55 | c.option '--markdown', 'Notes are written with Markdown' 56 | c.option '--tags TAGS', "Comma separated list of tags which will receive access to the build" 57 | c.option '--teams TEAMS', "Comma separated list of team ID numbers to which this build will be restricted" 58 | c.option '--users USERS', "Comma separated list of user ID numbers to which this build will be restricted" 59 | c.option '--notify', "Notify permitted teammates to install the build" 60 | c.option '--downloadOff', "Upload but don't allow download of this version just yet" 61 | c.option '--mandatory', "Make this update mandatory" 62 | c.option '--commit-sha SHA', "The Git commit SHA for this build" 63 | c.option '--build-server-url URL', "The URL of the build job on your build server" 64 | c.option '--repository-url URL', "The URL of your source repository" 65 | 66 | c.action do |args, options| 67 | determine_file! unless @file = options.file 68 | say_warning "Missing or unspecified .ipa file" unless @file and File.exist?(@file) 69 | 70 | determine_dsym! unless @dsym = options.dsym 71 | say_warning "Specified dSYM.zip file doesn't exist" if @dsym and !File.exist?(@dsym) 72 | 73 | determine_hockeyapp_api_token! unless @api_token = options.token || ENV['HOCKEYAPP_API_TOKEN'] 74 | say_error "Missing API Token" and abort unless @api_token 75 | 76 | determine_notes! unless @notes = options.notes 77 | say_error "Missing release notes" and abort unless @notes 78 | 79 | parameters = {} 80 | parameters[:public_identifier] = options.identifier if options.identifier 81 | parameters[:notes] = @notes 82 | parameters[:notes_type] = options.markdown ? "1" : "0" 83 | parameters[:notify] = "1" if options.notify && !options.downloadOff 84 | parameters[:status] = options.downloadOff ? "1" : "2" 85 | parameters[:tags] = options.tags if options.tags 86 | parameters[:teams] = options.teams if options.teams 87 | parameters[:users] = options.users if options.users 88 | parameters[:dsym_filename] = @dsym if @dsym 89 | parameters[:mandatory] = "1" if options.mandatory 90 | parameters[:release_type] = case options.release 91 | when :beta 92 | "0" 93 | when :store 94 | "1" 95 | when :alpha 96 | "2" 97 | when :enterprise 98 | "3" 99 | end 100 | parameters[:commit_sha] = options.commit_sha if options.commit_sha 101 | parameters[:build_server_url] = options.build_server_url if options.build_server_url 102 | parameters[:repository_url] = options.repository_url if options.repository_url 103 | 104 | client = Shenzhen::Plugins::HockeyApp::Client.new(@api_token) 105 | response = client.upload_build(@file, parameters) 106 | case response.status 107 | when 200...300 108 | say_ok "Build successfully uploaded to HockeyApp" 109 | else 110 | say_error "Error uploading to HockeyApp: #{response.body}" 111 | end 112 | end 113 | 114 | private 115 | 116 | def determine_hockeyapp_api_token! 117 | @api_token ||= ask "API Token:" 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/shenzhen/plugins/ftp.rb: -------------------------------------------------------------------------------- 1 | require 'net/ftp' 2 | require 'net/sftp' 3 | 4 | module Shenzhen::Plugins 5 | module FTP 6 | class Client 7 | 8 | def initialize(host, port, user, password) 9 | @host, @port, @user, @password = host, port, user, password 10 | end 11 | 12 | def upload(ipa, options = {}) 13 | connection = Net::FTP.new 14 | connection.passive = true 15 | connection.connect(@host, @port) 16 | 17 | path = expand_path_with_substitutions_from_ipa_plist(ipa, options[:path]) 18 | 19 | begin 20 | connection.login(@user, @password) rescue raise "Login authentication failed" 21 | 22 | if options[:mkdir] 23 | components, pwd = path.split(/\//).reject(&:empty?), nil 24 | components.each do |component| 25 | pwd = File.join(*[pwd, component].compact) 26 | 27 | begin 28 | connection.mkdir pwd 29 | rescue => exception 30 | raise exception unless /File exists/ === exception.message 31 | end 32 | end 33 | end 34 | 35 | connection.chdir path unless path.empty? 36 | connection.putbinaryfile ipa, File.basename(ipa) 37 | connection.putbinaryfile(options[:dsym], File.basename(options[:dsym])) if options[:dsym] 38 | ensure 39 | connection.close 40 | end 41 | end 42 | 43 | private 44 | 45 | def expand_path_with_substitutions_from_ipa_plist(ipa, path) 46 | substitutions = path.scan(/\{CFBundle[^}]+\}/) 47 | return path if substitutions.empty? 48 | 49 | Dir.mktmpdir do |dir| 50 | system "unzip -q #{ipa} -d #{dir} 2> /dev/null" 51 | 52 | plist = Dir["#{dir}/**/*.app/Info.plist"].last 53 | 54 | substitutions.uniq.each do |substitution| 55 | key = substitution[1...-1] 56 | value = Shenzhen::PlistBuddy.print(plist, key) 57 | 58 | path.gsub!(Regexp.new(substitution), value) if value 59 | end 60 | end 61 | 62 | return path 63 | end 64 | end 65 | end 66 | 67 | module SFTP 68 | class Client < Shenzhen::Plugins::FTP::Client 69 | def upload(ipa, options = {}) 70 | session = Net::SSH.start(@host, @user, :password => @password, :port => @port) 71 | connection = Net::SFTP::Session.new(session).connect! 72 | 73 | path = expand_path_with_substitutions_from_ipa_plist(ipa, options[:path]) 74 | 75 | begin 76 | connection.stat!(path) do |response| 77 | connection.mkdir! path if options[:mkdir] and not response.ok? 78 | 79 | connection.upload! ipa, determine_file_path(File.basename(ipa), path) 80 | connection.upload! options[:dsym], determine_file_path(File.basename(options[:dsym]), path) if options[:dsym] 81 | end 82 | ensure 83 | connection.close_channel 84 | session.shutdown! 85 | end 86 | end 87 | 88 | def determine_file_path(file_name, path) 89 | if path.empty? 90 | file_name 91 | else 92 | "#{path}/#{file_name}" 93 | end 94 | end 95 | end 96 | end 97 | end 98 | 99 | command :'distribute:ftp' do |c| 100 | c.syntax = "ipa distribute:ftp [options]" 101 | c.summary = "Distribute an .ipa file over FTP" 102 | c.description = "" 103 | 104 | c.example '', '$ ipa distribute:ftp --host 127.0.0.1 -f ./file.ipa -u username --path "/path/to/folder/{CFBundleVersion}/" --mkdir' 105 | 106 | c.option '-f', '--file FILE', ".ipa file for the build" 107 | c.option '-d', '--dsym FILE', "zipped .dsym package for the build" 108 | c.option '-h', '--host HOST', "FTP host" 109 | c.option '-u', '--user USER', "FTP user" 110 | c.option '-p', '--password PASS', "FTP password" 111 | c.option '-P', '--path PATH', "FTP path. Values from Info.plist will be substituted for keys wrapped in {} \n\t\t e.g. \"/path/to/folder/{CFBundleVersion}/\" would be evaluated as \"/path/to/folder/1.0.0/\"" 112 | c.option '--port PORT', "FTP port" 113 | c.option '--protocol [PROTOCOL]', [:ftp, :sftp], "Protocol to use (ftp, sftp)" 114 | c.option '--[no-]mkdir', "Create directories on FTP if they don't already exist" 115 | 116 | c.action do |args, options| 117 | options.default :mkdir => true 118 | 119 | determine_file! unless @file = options.file 120 | say_error "Missing or unspecified .ipa file" and abort unless @file and File.exist?(@file) 121 | 122 | determine_dsym! unless @dsym = options.dsym 123 | say_warning "Specified dSYM.zip file doesn't exist" unless @dsym and File.exist?(@dsym) 124 | 125 | determine_host! unless @host = options.host 126 | say_error "Missing FTP host" and abort unless @host 127 | 128 | determine_port!(options.protocol) unless @port = options.port 129 | 130 | determine_user! unless @user = options.user 131 | say_error "Missing FTP user" and abort unless @user 132 | 133 | @password = options.password 134 | if !@password && options.protocol != :sftp 135 | determine_password! 136 | say_error "Missing FTP password" and abort unless @password 137 | end 138 | 139 | @path = options.path || "" 140 | 141 | client = case options.protocol 142 | when :sftp 143 | Shenzhen::Plugins::SFTP::Client.new(@host, @port, @user, @password) 144 | else 145 | Shenzhen::Plugins::FTP::Client.new(@host, @port, @user, @password) 146 | end 147 | 148 | begin 149 | client.upload @file, {:path => @path, :dsym => @dsym, :mkdir => !!options.mkdir} 150 | say_ok "Build successfully uploaded to FTP" 151 | rescue => exception 152 | say_error "Error while uploading to FTP: #{exception}" 153 | raise if options.trace 154 | end 155 | end 156 | 157 | private 158 | 159 | def determine_host! 160 | @host ||= ask "FTP Host:" 161 | end 162 | 163 | def determine_port!(protocol) 164 | @port = case protocol 165 | when :sftp 166 | Net::SSH::Transport::Session::DEFAULT_PORT 167 | else 168 | Net::FTP::FTP_PORT 169 | end 170 | end 171 | 172 | def determine_user! 173 | @user ||= ask "Username:" 174 | end 175 | 176 | def determine_password! 177 | @password ||= password "Password:" 178 | end 179 | end 180 | 181 | alias_command :'distribute:sftp', :'distribute:ftp', '--protocol', 'sftp' 182 | -------------------------------------------------------------------------------- /lib/shenzhen/plugins/itunesconnect.rb: -------------------------------------------------------------------------------- 1 | require 'security' 2 | require 'fileutils' 3 | require 'digest/md5' 4 | require 'shellwords' 5 | 6 | module Shenzhen::Plugins 7 | module ITunesConnect 8 | ITUNES_CONNECT_SERVER = 'Xcode:itunesconnect.apple.com' 9 | 10 | class Client 11 | attr_reader :ipa, :sdk, :params 12 | 13 | def initialize(ipa, apple_id, sdk, account, password, params = []) 14 | @ipa = ipa 15 | @apple_id = apple_id 16 | @sdk = sdk 17 | @account = account 18 | @password = password 19 | @params = params 20 | @filename = File.basename(@ipa).tr(" ", "_") 21 | end 22 | 23 | def upload_build! 24 | size = File.size(@ipa) 25 | checksum = Digest::MD5.file(@ipa) 26 | 27 | begin 28 | FileUtils.mkdir_p("Package.itmsp") 29 | FileUtils.copy_entry(@ipa, "Package.itmsp/#{@filename}") 30 | 31 | File.write("Package.itmsp/metadata.xml", metadata(@apple_id, checksum, size)) 32 | 33 | case transport 34 | when /(error)|(fail)/i 35 | say_error "An error occurred when trying to upload the build to iTunesConnect.\nRun with --verbose for more info." and abort 36 | end 37 | ensure 38 | FileUtils.rm_rf("Package.itmsp", :secure => true) 39 | end 40 | end 41 | 42 | private 43 | 44 | def transport 45 | xcode = `xcode-select --print-path`.strip 46 | tool = transporter_path 47 | 48 | args = [tool, "-m upload", "-f Package.itmsp", "-u #{Shellwords.escape(@account)}", "-p #{Shellwords.escape(@password)}"] 49 | command = args.join(' ') 50 | 51 | puts "#{command}" if $verbose 52 | 53 | output = `#{command} 2> /dev/null` 54 | puts output.chomp if $verbose 55 | 56 | raise "Failed to upload the package" unless $?.exitstatus == 0 57 | 58 | output 59 | end 60 | 61 | def transporter_path 62 | xcode = `xcode-select --print-path`.strip + "/" 63 | [ 64 | "../Applications/Application Loader.app/Contents/MacOS/itms/bin/iTMSTransporter", 65 | "../Applications/Application Loader.app/Contents/itms/bin/iTMSTransporter" 66 | ].each do |path| 67 | result = File.join(xcode, path) 68 | return result.gsub(/\s/, '\ ') if File.exists?(result) 69 | end 70 | 71 | raise "Could not find transporter. Make sure you set the correct path to your Xcode installation." 72 | end 73 | 74 | def metadata(apple_id, checksum, size) 75 | %{ 76 | 77 | 78 | 79 | 80 | #{@filename} 81 | #{checksum} 82 | #{size} 83 | 84 | 85 | 86 | 87 | } 88 | end 89 | end 90 | end 91 | end 92 | 93 | command :'distribute:itunesconnect' do |c| 94 | c.syntax = "ipa distribute:itunesconnect [options]" 95 | c.summary = "Upload an .ipa file to iTunes Connect" 96 | c.description = "Upload an .ipa file directly to iTunes Connect for review. Requires that the app is in the 'Waiting for upload' state and the --upload flag to be set." 97 | c.option '-f', '--file FILE', ".ipa file for the build" 98 | c.option '-a', '--account ACCOUNT', "Apple ID used to log into https://itunesconnect.apple.com" 99 | c.option '-p', '--password PASSWORD', "Password for the account unless already stored in the keychain" 100 | c.option '-u', '--upload', "Actually attempt to upload the build to iTunes Connect" 101 | c.option '-w', '--warnings', "Check for warnings when validating the ipa" 102 | c.option '-e', '--errors', "Check for errors when validating the ipa" 103 | c.option '-i', '--apple-id STRING', "Apple ID from iTunes Connect" 104 | c.option '--sdk SDK', "SDK to use when validating the ipa. Defaults to 'iphoneos'" 105 | c.option '--save-keychain', "Save the provided account in the keychain for future use" 106 | 107 | c.action do |args, options| 108 | options.default :upload => false, :sdk => 'iphoneos', :save_keychain => true 109 | 110 | determine_file! unless @file = options.file 111 | say_error "Missing or unspecified .ipa file" and abort unless @file and File.exist?(@file) 112 | 113 | determine_itunes_connect_account! unless @account = options.account || ENV['ITUNES_CONNECT_ACCOUNT'] 114 | say_error "Missing iTunes Connect account" and abort unless @account 115 | 116 | apple_id = options.apple_id 117 | say_error "Missing Apple ID" and abort unless apple_id 118 | 119 | @password = options.password || ENV['ITUNES_CONNECT_PASSWORD'] 120 | if @password.nil? && @password = Security::GenericPassword.find(:s => Shenzhen::Plugins::ITunesConnect::ITUNES_CONNECT_SERVER, :a => @account) 121 | @password = @password.password 122 | say_ok "Found password in keychain for account: #{@account}" if options.verbose 123 | else 124 | determine_itunes_connect_password! unless @password 125 | say_error "Missing iTunes Connect password" and abort unless @password 126 | 127 | Security::GenericPassword.add(Shenzhen::Plugins::ITunesConnect::ITUNES_CONNECT_SERVER, @account, @password, {:U => nil}) if options.save_keychain 128 | end 129 | 130 | unless /^[0-9a-zA-Z]*$/ === @password 131 | say_warning "Password contains special characters, which may not be handled properly by iTMSTransporter. If you experience problems uploading to iTunes Connect, please consider changing your password to something with only alphanumeric characters." 132 | end 133 | 134 | parameters = [] 135 | parameters << :warnings if options.warnings 136 | parameters << :errors if options.errors 137 | 138 | client = Shenzhen::Plugins::ITunesConnect::Client.new(@file, apple_id, options.sdk, @account, @password, parameters) 139 | 140 | client.upload_build! 141 | say_ok "Upload complete." 142 | say_warning "You may want to double check iTunes Connect to make sure it was received correctly." 143 | end 144 | 145 | private 146 | 147 | def determine_itunes_connect_account! 148 | @account ||= ask "iTunes Connect account:" 149 | end 150 | 151 | def determine_itunes_connect_password! 152 | @password ||= password "iTunes Connect password:" 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Shenzhen](https://raw.github.com/nomad/nomad.github.io/assets/shenzhen-banner.png) 2 | 3 | Create `.ipa` files and distribute them from the command line, using any of the following methods: 4 | 5 | - [iTunes Connect](https://itunesconnect.apple.com) 6 | - [HockeyApp](http://www.hockeyapp.net) 7 | - [Crashlytics Beta](http://try.crashlytics.com/beta/) 8 | - [DeployGate](https://deploygate.com) 9 | - [Fly It Remotely (FIR.im)](http://fir.im) 10 | - [蒲公英 (PGYER)](http://www.pgyer.com) 11 | - [Amazon S3](http://aws.amazon.com/s3/) 12 | - FTP / SFTP 13 | 14 | Less cumbersome than clicking around in Xcode, and less hassle than rolling your own build script, Shenzhen radically improves the process of getting new builds out to testers and enterprises. 15 | 16 | > `shenzhen` is named for [深圳](http://en.wikipedia.org/wiki/Shenzhen), the Chinese city famous for being the center of manufacturing for a majority of consumer electronics, including iPhones and iPads. 17 | > It's part of a series of world-class command-line utilities for iOS development, which includes [Cupertino](https://github.com/mattt/cupertino) (Apple Dev Center management), [Houston](https://github.com/mattt/houston) (Push Notifications), [Venice](https://github.com/mattt/venice) (In-App Purchase Receipt Verification), [Dubai](https://github.com/mattt/dubai) (Passbook pass generation), and [Nashville](https://github.com/nomad/nashville) (iTunes Store API). 18 | 19 | ## Installation 20 | 21 | ``` 22 | $ gem install shenzhen 23 | ``` 24 | 25 | ### JSON Build Error 26 | 27 | Users running Mac OS X Mavericks with Xcode 5.1 may encounter an error when attempting to install the `json` gem dependency. As per the [Xcode 5.1 Release Notes](https://developer.apple.com/library/ios/releasenotes/DeveloperTools/RN-Xcode/Introduction/Introduction.html): 28 | 29 | > The Apple LLVM compiler in Xcode 5.1 treats unrecognized command-line options as errors. This issue has been seen when building both Python native extensions and Ruby Gems, where some invalid compiler options are currently specified. 30 | 31 | To work around this, install the `json` gem first with the following command: 32 | 33 | ``` 34 | $ ARCHFLAGS=-Wno-error=unused-command-line-argument-hard-error-in-future gem install json 35 | ``` 36 | 37 | ## Usage 38 | 39 | > For best results, set your environment localization to UTF-8, with `$ export LC_ALL="en_US.UTF-8"`. Otherwise, Shenzhen may return unexpectedly with the error "invalid byte sequence in US-ASCII". 40 | 41 | Shenzhen adds the `ipa` command to your PATH: 42 | 43 | ``` 44 | $ ipa 45 | 46 | Build and distribute iOS apps (.ipa files) 47 | 48 | Commands: 49 | build Create a new .ipa file for your app 50 | distribute:hockeyapp Distribute an .ipa file over HockeyApp 51 | distribute:crashlytics Distribute an .ipa file over Crashlytics 52 | distribute:deploygate Distribute an .ipa file over deploygate 53 | distribute:firim Distribute an .ipa file over fir.im 54 | distribute:itunesconnect Upload an .ipa file to iTunes Connect for review 55 | distribute:pgyer Distribute an .ipa file over Pgyer 56 | distribute:ftp Distribute an .ipa file over FTP 57 | distribute:s3 Distribute an .ipa file over Amazon S3 58 | info Show mobile provisioning information about an .ipa file 59 | help Display global or [command] help documentation. 60 | 61 | Global Options: 62 | -h, --help Display help documentation 63 | -v, --version Display version information 64 | -t, --trace Display backtrace when an error occurs 65 | ``` 66 | 67 | ### Building & Distribution 68 | 69 | ``` 70 | $ cd /path/to/iOS Project/ 71 | $ ipa build 72 | $ ipa distribute 73 | ``` 74 | 75 | #### HockeyApp Distribution 76 | 77 | ``` 78 | $ ipa distribute:hockeyapp -a API_TOKEN 79 | ``` 80 | 81 | > Shenzhen will load credentials from the environment variable `HOCKEYAPP_API_TOKEN` unless otherwise specified. 82 | 83 | #### Crashlytics Beta Distribution 84 | 85 | ``` 86 | $ ipa distribute:crashlytics -c /path/to/Crashlytics.framework -a API_TOKEN -s BUILD_SECRET 87 | ``` 88 | 89 | > Shenzhen will load credentials from the environment variables `CRASHLYTICS_API_TOKEN` & `CRASHLYTICS_BUILD_SECRET`, and attempt to run the submit executable `submit` in the path to Crashlytics.framework specified by `CRASHLYTICS_FRAMEWORK_PATH` unless otherwise specified. 90 | 91 | 92 | #### DeployGate Distribution 93 | 94 | ``` 95 | $ ipa distribute:deploygate -a API_TOKEN -u USER_NAME 96 | ``` 97 | 98 | > Shenzhen will load credentials from the environment variable `DEPLOYGATE_API_TOKEN` and `DEPLOYGATE_USER_NAME` unless otherwise specified. 99 | 100 | #### FTP Distribution 101 | 102 | ``` 103 | $ ipa distribute:ftp --host HOST -u USER -p PASSWORD -P FTP_PATH 104 | ``` 105 | 106 | #### SFTP Distribution 107 | 108 | ``` 109 | $ ipa distribute:sftp --host HOST -u USER -p PASSWORD -P FTP_PATH 110 | ``` 111 | 112 | #### Amazon S3 Distribution 113 | 114 | ``` 115 | $ ipa distribute:s3 -a ACCESS_KEY_ID -s SECRET_ACCESS_KEY -b BUCKET 116 | ``` 117 | 118 | > Shenzhen will load credentials from the environment variables `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_REGION` unless otherwise specified. 119 | 120 | #### FIR (Fly it Remotely) 121 | 122 | ``` 123 | $ ipa distribute:fir -u USER_TOKEN -a APP_ID 124 | ``` 125 | 126 | > Shenzhen will load credentials from the environment variables `FIR_USER_TOKEN`, `FIR_APP_ID` unless otherwise specified. 127 | 128 | #### 蒲公英 (PGYER) 129 | 130 | ``` 131 | $ ipa distribute:pgyer -u USER_KEY -a APP_KEY 132 | ``` 133 | 134 | > Shenzhen will load credentials from the environment variables `PGYER_USER_KEY`, `PGYER_API_KEY` unless otherwise specified. 135 | 136 | 137 | #### iTunes Connect Distribution 138 | 139 | ``` 140 | $ ipa distribute:itunesconnect -a me@email.com -p myitunesconnectpassword -i appleid --upload 141 | ``` 142 | 143 | > Shenzhen will load credentials from the environment variables `ITUNES_CONNECT_ACCOUNT` and `ITUNES_CONNECT_PASSWORD` unless otherwise specified. If only an account is provided, the keychain will be searched for a matching entry. 144 | > 145 | > The `-i` (or `--apple-id`) flag is "An automatically generated ID assigned to your app". It can be found via iTunes Connect by navigating to: 146 | > * My Apps -> [App Name] -> More -> About This App -> Apple ID 147 | > 148 | > For a fully hands-free upload, in a CI environment for example, ensure your iTunes Connect credentials are stored in your keychain, and that the keychain item has the Validation app in its 'Always allow access' list. Running Shenzhen once with the `--save-keychain` flag, and clicking `Always Allow` on the prompt will set this up for you. 149 | 150 | ### Displaying Embedded .mobileprovision Information 151 | 152 | ``` 153 | $ ipa info /path/to/app.ipa 154 | 155 | +-----------------------------+----------------------------------------------------------+ 156 | | ApplicationIdentifierPrefix | DJ73OPSO53 | 157 | | CreationDate | 2014-03-26T02:53:00+00:00 | 158 | | Entitlements | application-identifier: DJ73OPSO53.com.nomad.shenzhen | 159 | | | aps-environment: production | 160 | | | get-task-allow: false | 161 | | | keychain-access-groups: ["DJ73OPSO53.*"] | 162 | | CreationDate | 2017-03-26T02:53:00+00:00 | 163 | | Name | Shenzhen | 164 | | TeamIdentifier | S6ZYP4L6TY | 165 | | TimeToLive | 172 | 166 | | UUID | P7602NR3-4D34-441N-B6C9-R79395PN1OO3 | 167 | | Version | 1 | 168 | +-----------------------------+----------------------------------------------------------+ 169 | ``` 170 | 171 | ## Creator 172 | 173 | Mattt Thompson ([@mattt](https://twitter.com/mattt)) 174 | 175 | ## License 176 | 177 | Shenzhen is available under the MIT license. See the LICENSE file for more info. 178 | -------------------------------------------------------------------------------- /lib/shenzhen/commands/build.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | 3 | command :build do |c| 4 | c.syntax = 'ipa build [options]' 5 | c.summary = 'Create a new .ipa file for your app' 6 | c.description = '' 7 | 8 | c.option '-w', '--workspace WORKSPACE', 'Workspace (.xcworkspace) file to use to build app (automatically detected in current directory)' 9 | c.option '-p', '--project PROJECT', 'Project (.xcodeproj) file to use to build app (automatically detected in current directory, overridden by --workspace option, if passed)' 10 | c.option '-c', '--configuration CONFIGURATION', 'Configuration used to build' 11 | c.option '-s', '--scheme SCHEME', 'Scheme used to build app' 12 | c.option '--xcconfig XCCONFIG', 'use an extra XCCONFIG file to build the app' 13 | c.option '--xcargs XCARGS', 'pass additional arguments to xcodebuild when building the app. Be sure to quote multiple args.' 14 | c.option '--[no-]clean', 'Clean project before building' 15 | c.option '--[no-]archive', 'Archive project after building' 16 | c.option '-d', '--destination DESTINATION', 'Destination. Defaults to current directory' 17 | c.option '-m', '--embed PROVISION', 'Sign .ipa file with .mobileprovision' 18 | c.option '-i', '--identity IDENTITY', 'Identity to be used along with --embed' 19 | c.option '--sdk SDK', 'use SDK as the name or path of the base SDK when building the project' 20 | c.option '--ipa IPA', 'specify the name of the .ipa file to generate (including file extension)' 21 | 22 | c.action do |args, options| 23 | validate_xcode_version! 24 | 25 | @workspace = options.workspace 26 | @project = options.project unless @workspace 27 | 28 | @xcodebuild_info = Shenzhen::XcodeBuild.info(:workspace => @workspace, :project => @project) 29 | 30 | @scheme = options.scheme 31 | @sdk = options.sdk || 'iphoneos' 32 | @configuration = options.configuration 33 | @xcconfig = options.xcconfig 34 | @xcargs = options.xcargs 35 | @destination = options.destination || Dir.pwd 36 | @ipa_name_override = options.ipa 37 | FileUtils.mkdir_p(@destination) unless File.directory?(@destination) 38 | 39 | determine_workspace_or_project! unless @workspace || @project 40 | 41 | if @project 42 | determine_configuration! unless @configuration 43 | say_error "Configuration #{@configuration} not found" and abort unless (@xcodebuild_info.build_configurations.include?(@configuration) rescue false) 44 | end 45 | 46 | determine_scheme! unless @scheme 47 | say_error "Scheme #{@scheme} not found" and abort unless (@xcodebuild_info.schemes.include?(@scheme) rescue false) 48 | 49 | @configuration = options.configuration 50 | 51 | flags = [] 52 | flags << %{-sdk #{@sdk}} 53 | flags << %{-workspace "#{@workspace}"} if @workspace 54 | flags << %{-project "#{@project}"} if @project 55 | flags << %{-scheme "#{@scheme}"} if @scheme 56 | flags << %{-configuration "#{@configuration}"} if @configuration 57 | flags << %{-xcconfig "#{@xcconfig}"} if @xcconfig 58 | flags << @xcargs if @xcargs 59 | 60 | @target, @xcodebuild_settings = Shenzhen::XcodeBuild.settings(*flags).detect{|target, settings| settings['WRAPPER_EXTENSION'] == "app"} 61 | say_error "App settings could not be found." and abort unless @xcodebuild_settings 62 | 63 | if !@configuration 64 | @configuration = @xcodebuild_settings['CONFIGURATION'] 65 | flags << "-configuration '#{@configuration}'" 66 | end 67 | 68 | say_warning "Building \"#{@workspace || @project}\" with Scheme \"#{@scheme}\" and Configuration \"#{@configuration}\"\n" if $verbose 69 | 70 | log "xcodebuild", (@workspace || @project) 71 | 72 | actions = [] 73 | actions << :clean unless options.clean == false 74 | actions << :build 75 | actions << :archive unless options.archive == false 76 | 77 | ENV['CC'] = nil # Fix for RVM 78 | command = %{xcodebuild #{flags.join(' ')} #{actions.join(' ')} #{'1> /dev/null' unless $verbose}} 79 | puts command if $verbose 80 | abort unless system command 81 | 82 | @target, @xcodebuild_settings = Shenzhen::XcodeBuild.settings(*flags).detect{|target, settings| settings['WRAPPER_EXTENSION'] == "app"} 83 | say_error "App settings could not be found." and abort unless @xcodebuild_settings 84 | 85 | @app_path = File.join(@xcodebuild_settings['BUILT_PRODUCTS_DIR'], @xcodebuild_settings['WRAPPER_NAME']) 86 | @dsym_path = @app_path + ".dSYM" 87 | @dsym_filename = File.expand_path("#{@xcodebuild_settings['WRAPPER_NAME']}.dSYM", @destination) 88 | @ipa_name = @ipa_name_override || @xcodebuild_settings['WRAPPER_NAME'].gsub(@xcodebuild_settings['WRAPPER_SUFFIX'], "") + ".ipa" 89 | @ipa_path = File.expand_path(@ipa_name, @destination) 90 | 91 | log "xcrun", "PackageApplication" 92 | command = %{xcrun -sdk #{@sdk} PackageApplication -v "#{@app_path}" -o "#{@ipa_path}" --embed "#{options.embed || @dsym_path}" #{"-s \"#{options.identity}\"" if options.identity} #{'--verbose' if $verbose} #{'1> /dev/null' unless $verbose}} 93 | puts command if $verbose 94 | abort unless system command 95 | 96 | # Determine whether this is a Swift project and, eventually, the list of libraries to copy from 97 | # Xcode's toolchain directory since there's no "xcodebuild" target to do just that (it is done 98 | # post-build when exporting an archived build from the "Organizer"). 99 | @ipa_swift_frameworks = Dir["#{@app_path}/Frameworks/libswift*"] 100 | 101 | if not @ipa_swift_frameworks.empty? then 102 | Dir.mktmpdir do |tmpdir| 103 | # Copy all necessary Swift libraries to a temporary "SwiftSupport" directory so that we can 104 | # easily add it to the .ipa later. 105 | swift_support = File.join(tmpdir, "SwiftSupport") 106 | 107 | Dir.mkdir(swift_support) 108 | 109 | xcode = `xcode-select --print-path`.strip 110 | 111 | @ipa_swift_frameworks.each do |path| 112 | framework = File.basename(path) 113 | 114 | FileUtils.copy_file("#{xcode}/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/#{@sdk}/#{framework}", File.join(swift_support, framework)) 115 | end 116 | 117 | # Add "SwiftSupport" to the .ipa archive 118 | Dir.chdir(tmpdir) do 119 | abort unless system %{zip --recurse-paths "#{@ipa_path}" "SwiftSupport" #{'> /dev/null' unless $verbose}} 120 | end 121 | end 122 | end 123 | 124 | log "zip", @dsym_filename 125 | abort unless system %{cp -r "#{@dsym_path}" "#{@destination}" && zip -r "#{@dsym_filename}.zip" "#{@dsym_filename}" #{'> /dev/null' unless $verbose} && rm -rf "#{@dsym_filename}"} 126 | 127 | say_ok "Successfully built:" 128 | say_ok @ipa_path 129 | end 130 | 131 | private 132 | 133 | def validate_xcode_version! 134 | version = Shenzhen::XcodeBuild.version 135 | say_error "Shenzhen requires Xcode 4 (found #{version}). Please install or switch to the latest Xcode." and abort if version < "4.0.0" 136 | end 137 | 138 | def determine_workspace_or_project! 139 | workspaces, projects = Dir["*.xcworkspace"], Dir["*.xcodeproj"] 140 | 141 | if workspaces.empty? 142 | if projects.empty? 143 | say_error "No Xcode projects or workspaces found in current directory" and abort 144 | else 145 | if projects.length == 1 146 | @project = projects.first 147 | else 148 | @project = choose "Select a project:", *projects 149 | end 150 | end 151 | else 152 | if workspaces.length == 1 153 | @workspace = workspaces.first 154 | else 155 | @workspace = choose "Select a workspace:", *workspaces 156 | end 157 | end 158 | end 159 | 160 | def determine_scheme! 161 | say_error "No schemes found in Xcode project or workspace" and abort unless @xcodebuild_info.schemes 162 | 163 | if @xcodebuild_info.schemes.length == 1 164 | @scheme = @xcodebuild_info.schemes.first 165 | else 166 | @scheme = choose "Select a scheme:", *@xcodebuild_info.schemes 167 | end 168 | end 169 | 170 | def determine_configuration! 171 | configurations = @xcodebuild_info.build_configurations rescue [] 172 | if configurations.nil? or configurations.empty? or configurations.include?("Debug") 173 | @configuration = "Debug" 174 | elsif configurations.length == 1 175 | @configuration = configurations.first 176 | end 177 | 178 | if @configuration 179 | say_warning "Configuration was not passed, defaulting to #{@configuration}" 180 | else 181 | @configuration = choose "Select a configuration:", *configurations 182 | end 183 | end 184 | end 185 | --------------------------------------------------------------------------------