Files
Amaro/tiramisu
2016-07-11 22:59:31 -04:00

725 lines
24 KiB
Ruby
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/ruby
# -*- coding: utf-8 -*-
require 'rake'
require 'io/console'
require 'net/http'
require 'uri'
require 'json'
require 'pathname'
# If the environmental variable AMARO_EXPECT is set, color output is disabled,
# and a standard getc is used instead of io/console's getch. This mode is
# for running the script under expect(1), which is used for testing purposes,
# and gets confused by all our l33t TTY hackery.
# If an argument is passed to the script, it is expected to be a path
# to a local clone of the Amaro repository. In this case, instead of cloning the
# github repo, the local clone is copied into place and cleaned up.
# This is intended to test modifications to the repo without needing to push
# to the remote.
trap('INT') do
puts
exit
end
class String
def self.color_output?
STDOUT.isatty && !ENV['AMARO_EXPECT']
end
{ :red => 1,
:green => 2,
:yellow => 3,
:blue => 4,
:gray => 7
}.each do |name, code|
bg_name = ('bg_' + name.to_s).to_sym
if color_output?
define_method(name) { "\e[#{code + 30}m#{self}\e[39m" }
define_method(bg_name) { "\e[#{code + 40}m#{self}\e[49m" }
else
define_method(name) { self }
define_method(bg_name) { self }
end
end
{ :bold => 1, :faint => 2 }.each do |name, code|
if color_output?
define_method(name) { "\e[#{code}m#{self}\e[22m" }
else
define_method(name) { self }
end
end
end
class AmaroError < RuntimeError
def initialize(message = nil, details = nil)
super(message)
@details = details
end
attr_accessor :details
end
module AmaroUtils
def commit(message, add_all: true, error_if_nothing_to_commit: true)
sh('git add --all') if add_all
has_changes = anything_to_commit?
if error_if_nothing_to_commit && !has_changes
raise AmaroError.new('Repository Error', 'There should be something to commit at this point, but there\'s not!')
elsif has_changes
sh("git commit -q -m '[Amaro] #{message}'")
end
end
def anything_to_commit?
# If this outputs anything, there are changed files.
sh('git status --porcelain').chomp.length > 0
end
def task(message, failure_is_fatal: true)
print "#{message}... ".blue.bold
system('tput sc')
recall_cursor = `tput rc`
clear_to_end_of_line = `tput el`
chars = %w(⣻ ⢿ ⡿ ⣟ ⣯ ⣷ ⣾ ⣽)
bg_thread = Thread.new do
system('tput civis')
begin
print chars[0]
i = 0
loop do
sleep 0.1
i = (i + 1) % chars.length
print "#{recall_cursor}#{chars[i]}#{clear_to_end_of_line}"
end
ensure
system('tput cnorm')
end
end
begin
bg_thread.run if STDIN.isatty && !ENV['AMARO_EXPECT']
yield
rescue AmaroError => e
puts "#{recall_cursor}💀#{clear_to_end_of_line}"
die(e.message, e.details, :fatal => failure_is_fatal)
else
puts "#{recall_cursor}👍#{clear_to_end_of_line}"
ensure
bg_thread.kill
end
end
def replace_in_files(file_list, pattern, replacement)
file_list.each do |fn|
next unless File.file? fn # Skip directories
file_contents = IO.binread(fn)
# Since we read the file as binary, Ruby will freak out if the replacement
# string isn't also binary/ASCII (which it won't be if the user input some
# Unicode). So, we force the replacement string to binary.
updated_contents = file_contents.gsub(pattern, replacement.force_encoding('BINARY'))
IO.binwrite(fn, updated_contents) unless file_contents == updated_contents
end
end
def rename_filelist(file_list, pattern, replacement)
# Iterating in reverse to ensure that files/folders in subdirectories are
# renamed before their parent directories (which would invalidate their paths).
# TODO: FileList's behavior doesn't guarantee that that will work. Really
# should roll our own in-order directory traversal.
file_list.reverse_each do |fn|
dirname, basename = File.split(fn)
new_basename = basename.sub(pattern, replacement)
next if new_basename == basename
new_fn = File.join(dirname, new_basename)
sh("git mv '#{fn}' '#{new_fn}'")
end
end
def edit(filename)
editor = ENV['VISUAL'] || ENV['EDITOR'] || 'nano -w'
system("#{editor} '#{filename}'")
end
def sh(cmd, error_message = 'Error', show_output_on_error: true)
output = `#{cmd} 2>&1`
unless $? == 0
details = show_output_on_error ? "Command: #{cmd.bold}\nOutput:\n#{output}" : nil
raise AmaroError.new(error_message, details)
end
output
end
def die(message, details = nil, fatal: true)
puts message.red
puts "#{details}\n" unless details.nil?
exit if fatal
end
end
class InputReader
def initialize(length_limit: nil, output_on_empty: '', secure_echo: false, auto_return_when_length_reached: false, source: STDIN)
@source = source
@length_limit = length_limit
@secure_echo = secure_echo
@output_on_empty = output_on_empty
@auto_return_when_length_reached = auto_return_when_length_reached
end
def read
s = read_loop
print @output_on_empty.faint if s.empty?
puts
s
end
private
def read_loop
line = ''
loop do
c = (@source.isatty && !ENV['AMARO_EXPECT']) ? @source.getch : @source.getc
# newline or carriage return
break if c == "\r" || c == "\n"
# control-c
if c.ord == 3
puts
exit
end
if c == "\b" || c.ord == 127
backspace! line
next
end
# Ignore all escape codes, which come in as multiple characters.
# The nonblock version is snatched from here: https://gist.github.com/acook/4190379,
# but it seems to be a Bad Idea to mix standard read methods with io/console's...
if c == "\e"
if @source.isatty
@source.getch
@source.getch
else
@source.read_nonblock(3) rescue nil
@source.read_nonblock(2) rescue nil
end
next
end
next unless c =~ /[[:print:]]/
if @length_limit.nil? || line.length < @length_limit
line << c
print @secure_echo ? '·' : c
break if line.length == @length_limit && @auto_return_when_length_reached
end
end
line
end
def backspace!(line)
return if line.empty?
line.chop!
print "\b \b"
end
end
class Prompt
def initialize(prompt, key: nil, default: nil)
@key = key # Hash key used for this prompt if executed via run_prompts
@prompt = prompt # The text to use for the prompt.
@default = default # The default value, or nil if the user must input something
@strip = true # True to remove whitespace on either side of the input
@length_limit = nil # Maximum allowed characters; nil for no limit
@secure_echo = false # Password-style obscured input
@value = nil # The final result of the prompt's execution (e.g., processed input or default)
@show_correction = true # If true, show the result of the validation_proc if it differs from the input
@show_default = true # If true, show the default value after the prompt
@raw_input = nil # The pre-corrected input from the user
@pre_run_hook = nil # A proc to be executed at the beginning of run!
@auto_return_when_length_reached = false # If true, and length_limit is not nil, the prompt finishes when input reaches the length limit
# This proc is run after input is complete. It should return the desired resulting value
# of the prompt, or throw a RuntimeError if the input is invalid. In that case, the exception's
# message will be output and the prompt will be retried.
# Note that this proc is not called if input is blank and there is a default.
@validation_proc = ->input { input }
yield self if block_given?
end
attr_accessor :prompt, :default, :validation_proc, :length_limit, :show_correction, :strip, :secure_echo, :show_default, :pre_run_hook, :auto_return_when_length_reached
attr_reader :key, :value, :raw_input
def run!
pre_run_hook[] if pre_run_hook
print composed_prompt.bold
@raw_input = input_reader.read
pre_stripped_input = @raw_input.dup
if @raw_input.empty?
if !default.nil?
@value = default
else
# No default and no input; retry
puts ' ❕ Please enter a value.'.yellow
return run!
end
else
@raw_input.strip! if strip
if @raw_input.empty?
# If we're empty now, they must have entered all whitespace. For shame.
puts ' ❕ Please enter a value or hit enter for the default.'.yellow
return run!
end
begin
@value = validation_proc[@raw_input.dup]
rescue RuntimeError => exc
puts " ❗️ #{exc.message}".red
return run! # Try again
end
if show_correction && (@value != @raw_input || @raw_input != pre_stripped_input)
puts " ✨ Fixed that for ya. Using '#{@value.bold}'".gray
end
end
@value
end
# Oneshot new and run
def self.run!(*args, &block)
new(*args, &block).run!
end
# Runs the given prompts, and returns a hash where the keys are the prompts' keys,
# and the values are the prompts' values. If a prompt has subprompts and those
# should be executed, the prompt's value becomes the hash result of running the
# subprompts (i.e., the result of run_sequence on the subprompts).
def self.run_sequence(*prompts)
results = {}
prompts.each do |prompt|
prompt.run!
results[prompt.key] = prompt.value unless prompt.key.nil?
next unless prompt.is_a?(YesNoPrompt) && prompt.run_subprompts?
subprompts_value = run_sequence(*prompt.subprompts)
results[prompt.key] = subprompts_value unless prompt.key.nil?
end
results
end
protected
def input_reader
InputReader.new(
:length_limit => length_limit,
:output_on_empty => default_autofill,
:secure_echo => secure_echo,
:auto_return_when_length_reached => auto_return_when_length_reached
)
end
def composed_prompt
p = prompt.rstrip
punct = p[-1]
if punct == ':' || punct == '?'
p.chop!
else
punct = ':'
end
p << " #{default_prompt}" unless default.nil? || !show_default
p << "#{punct} "
end
# The text output before punctuation at the end of the prompt, if
# a default is set.
def default_prompt
"(blank for #{default})"
end
# The text to output if the user accepts the default
def default_autofill
default ? default.to_s : ''
end
end
class YesNoPrompt < Prompt
def initialize(prompt, key: nil, default: nil)
super
@length_limit = 1
@auto_return_when_length_reached = true
@show_correction = false
@subprompts = nil
@validation_proc = lambda do |input|
return true if input.downcase == 'y'
return false if input.downcase == 'n'
raise "Please enter 'y' or 'n'."
end
end
attr_accessor :subprompts
protected
def default_prompt
default ? '(Y/n)' : '(y/N)'
end
def default_autofill
default ? 'y' : 'n'
end
def run_subprompts?
value && subprompts && subprompts.length > 0
end
end
class Bootstrapper
REPLACEMENT_PREFIX = 'CRBS'
REPLACEMENT_PROJECT_NAME = 'CrushBootstrap'
REPLACEMENT_ORGANIZATION = 'Crush & Lovely'
REPLACEMENT_DOMAIN = 'com.crushlovely'
BOOTSTRAP_REPO = 'https://github.com/tappollo/Amaro.git'
DEFAULT_BOOTSTRAP_BRANCH = 'master'
BOOTSTRAP_WEBSITE = 'https://github.com/tappollo/Amaro'
attr_accessor :options
def run!
init_fs
init_git
rename_files
update_contents
update_xcconfigs
install_pods
remove_bootstrap_detritus
end
def check_sanity
task 'Checking environment' do
osx_version = sh('sw_vers -productVersion', 'You don\'t seem to be on OS X, silly.', :show_output_on_error => false)
raise AmaroError, 'Amaro requires OS 10.9.0 or later.' unless Gem::Dependency.new('osx', '~>10.9').match?('osx', osx_version)
raise AmaroError, 'This does not seem to be an interactive terminal. That\'s not gonna work.' unless STDIN.isatty and STDOUT.isatty
raise AmaroError.new('Amaro requires UTF-8 encoding on your terminal.', 'See http://stackoverflow.com/a/8161863 for details on how to fix this.') if Encoding.default_external != Encoding::UTF_8
sh('which -s pod', 'CocoaPods is not installed. See http://cocoapods.org/#install', :show_output_on_error => false)
pod_version = sh('pod --version', 'Error getting CocoaPods version. Make sure the pod binary is functional.').strip
pod_version_is_good = Gem::Dependency.new('pod', '>0.34.0').match?('pod', pod_version)
raise AmaroError.new("Amaro requires version 0.34.1 or later of CocoaPods. You have version #{pod_version}", 'Running "gem update cocoapods" should fix this.') unless pod_version_is_good
dev_tools_path = sh('xcode-select -p', 'The Xcode command line tools don\'t seem to be installed. Running "xcode-select --install" should fix that.', :show_output_on_error => false).strip
xcode_version = sh("/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' '#{dev_tools_path}/../Info.plist'").strip
xcode_version_is_good = Gem::Dependency.new('xcode', '~>7.0').match?('xcode', xcode_version)
raise AmaroError.new("Amaro requires the default Xcode command line tools to point to Xcode 7.0 or later. Yours point to #{xcode_version}.", 'Install Xcode 7.0 or later, and run "xcode-select" to point to that.') unless xcode_version_is_good
if ARGV.empty?
@source_path = nil
else
path = Pathname.new(ARGV[0])
raise AmaroError, "Provided local clone path (#{ARGV[0]}) is invalid." unless path.directory?
@source_path = path.realpath.to_s
end
# If we're building in Travis, target the branch we came from. Otherwise, master.
@bootstrap_branch = ENV['TRAVIS_BRANCH'] || DEFAULT_BOOTSTRAP_BRANCH
end
end
def puts_source_description
if @source_path.nil?
puts 'Repository: '.gray.bold + BOOTSTRAP_REPO.gray
puts 'Branch: '.gray.bold + @bootstrap_branch.gray
else
puts 'Using local clone: '.gray.bold + @source_path.gray
end
puts
end
private
include AmaroUtils
def init_fs
Dir.mkdir(options[:project_name])
Dir.chdir(options[:project_name])
end
def init_git
task 'Initializing local repository' do
sh('git init -q')
# It's a shame we have to do this, really, but you can't do a squashed merge into an empty repo
IO.write('README.md', "# %{project_name}\n\n*An iOS project begun with [Amaro](#{BOOTSTRAP_WEBSITE})*\n" % options)
commit 'Initial commit'
end
if @source_path.nil?
fetch_from_remote
else
fetch_from_local
end
end
def fetch_from_remote
task 'Fetching repository' do
sh("git remote add bootstrap '#{BOOTSTRAP_REPO}'")
sh("git fetch --depth=1 -q bootstrap '#{@bootstrap_branch}'")
end
task 'Merging' do
# We're using 'ours' merge option so that our README.md wins
sh("git merge -q --squash -X ours 'remotes/bootstrap/#{@bootstrap_branch}'")
commit 'Bootstrapping', :add_all => false
end
task 'Cleaning up' do
# See issue #21
sh('git remote rm bootstrap')
end
end
def fetch_from_local
task 'Copying and preparing local clone' do
# Note the trailing / appended to source_path. Otherwise rsync will put
# a copy of the directory inside the destination, rather than a copy of its contents.
sh("rsync -a '#{@source_path}/' . --exclude=/.git --exclude=/README.md")
commit 'Bootstrapping'
sh('git clean -xdf') # Remove .gitignored files and directories
end
end
def rename_files
task 'Renaming project files' do
files_with_proj_name = FileList["**/*#{REPLACEMENT_PROJECT_NAME}*"]
rename_filelist(files_with_proj_name, REPLACEMENT_PROJECT_NAME, options[:project_name])
end
task 'Renaming prefixed files' do
files_with_prefix = FileList["**/#{REPLACEMENT_PREFIX}*"]
rename_filelist(files_with_prefix, /^#{Regexp.quote(REPLACEMENT_PREFIX)}/, options[:class_prefix])
end
end
def update_contents
task 'Updating file contents' do
# Any reference to the project name in all files.
filelist = FileList['**/*', '**/.*'].exclude('.git', '.', '..')
replace_in_files(filelist, REPLACEMENT_PROJECT_NAME, options[:project_name]) unless options[:project_name] == REPLACEMENT_PROJECT_NAME
# Replace default bundle domain and organization
replace_in_files(filelist, REPLACEMENT_ORGANIZATION, options[:org_name]) unless options[:org_name] == REPLACEMENT_ORGANIZATION
replace_in_files(filelist, REPLACEMENT_DOMAIN, options[:bundle_domain]) unless options[:bundle_domain] == REPLACEMENT_DOMAIN
# Replace the class prefix in all files.
replace_in_files(filelist, REPLACEMENT_PREFIX, options[:class_prefix])
# If the new class prefix is empty, we need to delete the whole CLASSPREFIX = ... line in the project file.
# In this case, the replacement above leaves this line reading "CLASSPREFIX = ;", which is invalid syntax.
if options[:class_prefix].empty?
project_file = FileList['**/project.pbxproj']
replace_in_files(project_file, /^\s*CLASSPREFIX\s*=.*$/, '')
end
# The 'Created by' line in the headers of code files
filelist = FileList['**/*.{h,m}']
today = Time.now.strftime('%-m/%-d/%y')
replace_in_files(filelist, /Created by .+ on [0-9].*/, "Created by %{full_name} on #{today}" % options)
# Remove ignores and build commands that are only relevant in the bootstrap repo itself
# These are demarcated with ">>>bootstrap-only ... <<<bootstrap-only". The lines containing the
# delimiters will themselves be deleted.
# It's important that we do this now so the gitignore is correct for future commits.
filelist = FileList['.gitignore', '.travis.yml']
replace_in_files(filelist, /^.+>>>bootstrap-only(?:.|\n)*<<<bootstrap-only.*/, '')
end
task 'Committing' do
commit 'Bootstrapped'
end
end
def update_xcconfigs
task 'Pulling latest xcconfigs' do
sh("'%{project_name}/Other-Sources/Configuration/jspahrsummers-xcconfigs/UpdateXCConfigs.sh' -q" % options)
end
if anything_to_commit?
task 'Committing' do
commit 'Updated xcconfigs'
end
end
end
def install_pods
edit 'Podfile' if YesNoPrompt.run!('Would you like to edit your Podfile?', :default => false)
if anything_to_commit?
task 'Committing' do
commit 'Modified Podfile'
end
end
task 'Installing CocoaPods' do
sh('pod install --silent')
commit 'Install pods and update subtrees'
end
end
def remove_bootstrap_detritus
task 'Cleaning up after ourselves' do
sh('git rm -q tiramisu')
sh('git rm -rq bootstrap-scripts')
commit 'Remove init and bootstrap-specific scripts'
# Squash all of our commits together into one, for prettiness
# See: http://stackoverflow.com/questions/1657017/git-squash-all-commits-into-a-single-commit
squashed_commit_hash = sh('git commit-tree HEAD^{tree} -m "[Amaro] Bootstrapped."')
sh("git reset #{squashed_commit_hash}")
end
end
end
# Prompts
project_name = Prompt.new('New project name', :key => :project_name) do |p|
p.validation_proc = lambda do |input|
input.gsub!(/\s+/, '-')
raise 'Your project name should consist of basic alphanumeric characters.' if input =~ /[^\w_-]/
raise 'A file or directory with that name already exists.' if File.exist? input
input
end
end
class_prefix = Prompt.new('Class prefix (optional; 2 or preferably 3 characters)', :key => :class_prefix) do |p|
p.default = ''
p.show_default = false
p.length_limit = 3
p.validation_proc = lambda do |input|
return '' if input.empty?
raise 'Prefix is too short.' if input.length == 1
input.upcase!
bad_prefixes = %w(AB AC AD AF AL AU AV CA CB CF CG CI CL CM CV CT EA EK GC JS MA MC MF MK NK NS PK QL SC SK SL SS TW UI UT SCN WK)
raise 'That prefix is already in use by a well-known library.' if bad_prefixes.include? input
raise 'Class prefixes must be valid identifiers.' unless input =~ /^[A-Z][A-Z0-9]+$/
input
end
end
full_name = Prompt.new('Your name', :key => :full_name) do |p|
last_line = `dscl . read "/Users/$USER" RealName 2>/dev/null`.lines.last || ''
p.default = last_line.strip.empty? ? 'Pfr. Developer' : last_line.strip
end
org_name = Prompt.new('Organization name', :key => :org_name) do |p|
p.default = `/usr/libexec/PlistBuddy -c 'Print :IDETemplateOptions:organizationName' "${HOME}/Library/Preferences/com.apple.dt.Xcode.plist" 2>/dev/null`.strip
p.default = 'The Company' if p.default.empty?
end
bundle_domain = Prompt.new('Bundle ID domain', :key => :bundle_domain) do |p|
p.pre_run_hook = -> { p.default = 'com.' + org_name.value.gsub(/\W/, '').downcase }
p.validation_proc = lambda do |input|
raise 'Domain should be a reverse domain name, like "com.apple".' unless input =~ /\w+\.\w+/
input.gsub(/\s+/, '-').downcase
end
end
# Away we go!
print <<'EOS'.chomp.faint
___ ___
/\ \ /\__\
/ \ \ / / /
/ /\ \__\ / /__/
\ \ \/__/ & \ \ \
\ \__\ \ \__\
\/__/ \/__/
EOS
puts " Amaro v1.0.0\n".blue.bold
bootstrapper = Bootstrapper.new
bootstrapper.check_sanity
bootstrapper.puts_source_description
bootstrapper.options = Prompt.run_sequence(project_name, class_prefix, full_name, org_name, bundle_domain)
puts <<-EOS.gray
🎉 #{'It\'s time to build your project!'.green}
Pausing for 3 seconds in case you change your mind. Press any key to abort.
EOS
exit if STDIN.getch(:min => 0, :time => 3)
bootstrapper.run!
puts <<-EOS.gray
👌 #{'You\'re all set!'.green}
Don't forget to open the .xcworkspace, not the .xcodeproj,
and add some prose to README.md!
XOXO -C&L 💋
EOS
open_prompt = YesNoPrompt.run!('Would you like to open your project?', :default => true)
system("open '%{project_name}.xcworkspace'" % bootstrapper.options) if open_prompt