#!/usr/bin/ruby require 'rubygems' require 'thor' require 'fileutils' require 'net/https' require 'powder' require 'powder/version' module Powder class CLI < Thor include Thor::Actions include Powder default_task :help map '-r' => 'restart' map '-a' => 'always_restart' map '-l' => 'list' map '-L' => 'link' map '-d' => 'default' map '-o' => 'open' map '-v' => 'version' MAC_OS_X_VERSION_REGEXP = /^(?\d+)\.(?\d+)/ MAC_OS_X_MINOR_VERSION = begin version_string = %x{sw_vers -productVersion}.strip version_string.match(MAC_OS_X_VERSION_REGEXP)[:minor_version].to_i end POWDER_CONFIG = ".powder" POW_ENV = ".powenv" POW_PATH = "#{ENV['HOME']}/.pow" POW_CONFIG = "#{ENV['HOME']}/.powconfig" POW_DAEMON_PLIST_PATH="#{ENV['HOME']}/Library/LaunchAgents/cx.pow.powd.plist" POW_FIREWALL_PLIST_PATH = "/Library/LaunchDaemons/cx.pow.firewall.plist" desc "env", "Pass an arbitrary environment variable to pow" def env(key = nil, value = nil) if key.nil? show_env else value.nil? ? remove_env(key) : create_or_update_env(key, value) end end desc "env_reset", "Remove all custom environment variables" def env_reset %x{rm .powenv && touch .powenv} if powenv_exists? end desc "test", "Run pow with RAILS_ENV=test" def test env("RAILS_ENV", "test") end desc "development", "Run pow with RAILS_ENV=development" def development env("RAILS_ENV", "development") end desc "dev", "An alias to development" alias :dev :development desc "production", "Run pow with RAILS_ENV=production" def production env("RAILS_ENV", "production") end desc "prod", "An alias to production" alias :prod :production desc "up", "Enable pow" def up if MAC_OS_X_MINOR_VERSION >= 10 start_on_yosemite else start_on_mavericks end end desc "start", "An alias to up" alias :start :up desc "down", "Disable pow" def down if File.exists? POW_FIREWALL_PLIST_PATH if not %x{sudo launchctl list | grep cx.pow.firewall}.empty? %x{sudo launchctl unload #{POW_FIREWALL_PLIST_PATH}} end end if File.exists? POW_DAEMON_PLIST_PATH %x{launchctl unload #{POW_DAEMON_PLIST_PATH}} end if MAC_OS_X_MINOR_VERSION < 10 if File.exists? POW_FIREWALL_PLIST_PATH if ports = File.open(POW_FIREWALL_PLIST_PATH).read.match(/fwd .*?,([\d]+).*?dst-port ([\d]+)/) http_port, dst_port = ports[1..2] end end http_port ||= 20559 dst_port ||= 80 if rule = %x{sudo ipfw show | grep ",#{http_port} .* dst-port #{dst_port} in"}.split.first %x{sudo ipfw delete #{rule} && sudo sysctl -w net.inet.ip.forwarding=0} end end if MAC_OS_X_MINOR_VERSION >= 10 %{sudo pfctl -a "com.apple/250.PowFirewall" -F all} end end desc "stop", "An alias to down" alias :stop :down desc "link", "Link a pow" method_option :force, :type => :boolean, :default => false, :alias => '-f', :desc => "remove the old configuration, overwrite .powder" method_option :"no-config", :type => :boolean, :default => false, :alias => '-n', :desc => "do not write a .powder file" def link(name=nil) return unless is_powable? if File.symlink?(POW_PATH) current_path = %x{pwd}.chomp if name write_pow_config(name) else name = get_pow_name end symlink_path = "#{POW_PATH}/#{name}" FileUtils.rm_f(symlink_path) if options[:force] FileUtils.ln_s(current_path, symlink_path) unless File.exists?(symlink_path) say "Your application is now available at http://#{name}.#{domain}/" else say "Pow is not installed. That is, the ${HOME}/.pow symlink does not exist." end end desc "default", "Set this app as default" def default(name=nil) return unless is_powable? current_path = %x{pwd}.chomp name ||= get_pow_name symlink_path = "#{POW_PATH}/default" FileUtils.rm_f symlink_path if File.exists?(symlink_path) FileUtils.ln_sf(current_path, symlink_path) say "Your application(#{name}) is now default at http://localhost/" end desc "un_default", "remove current default app" def un_default(name=nil) name ||= get_pow_name symlink_path = "#{POW_PATH}/default" FileUtils.rm_f symlink_path if File.exists?(symlink_path) end desc "restart", "Restart current pow" def restart return unless is_powable? FileUtils.mkdir_p('tmp') %x{touch tmp/restart.txt} end desc "always_restart", "Always restart current pow" def always_restart return unless is_powable? FileUtils.mkdir_p('tmp') %x{touch tmp/always_restart.txt} end desc "no_restarts", "Reset this app's restart settings" def no_restarts return unless is_powable? FileUtils.rm_f Dir.glob('tmp/*restart.txt') end desc "respawn", "Restart the pow process" def respawn %x{launchctl stop cx.pow.powd} end desc "list", "List current pows" def list pows = Dir[POW_PATH + "/*"].map do |link_or_port| realpath_or_port = get_app_origin(link_or_port) app_is_current = (realpath_or_port == Dir.pwd) ? '*' : ' ' [app_is_current, File.basename(link_or_port), realpath_or_port.gsub(ENV['HOME'], '~')] end print_table(pows) end desc "open", "Open a pow in the browser" method_option :browser, :type => :string, :default => false, :aliases => '-b', :desc => 'browser to open with' method_option :xip, :type => :boolean, :default => false, :alias => '-x', :desc => "open xip.io instead of .domain" def open(name=nil) browser = options.browser? ? "-a \'#{options.browser}\'" : nil if options.xip? local_ip = '0.0.0.0' begin orig, Socket.do_not_reverse_lookup = Socket.do_not_reverse_lookup, true # turn off reverse DNS resolution temporarily UDPSocket.open do |s| s.connect '64.233.187.99', 1 local_ip = s.addr.last.to_s end ensure Socket.do_not_reverse_lookup = orig end %x{open #{browser} http://#{name || get_pow_name}.#{local_ip}.xip.io} else %x{open #{browser} http://#{name || get_pow_name}.#{domain}} end end desc "unlink", "Unlink a pow app" method_option :delete, :type => :boolean, :default => false, :alias => '-e', :desc => "delete .powder" def unlink(name=nil) return unless is_powable? FileUtils.rm_f POW_PATH + '/' + (name || get_pow_name) say "Successfully removed #{(name || get_pow_name)}" if options[:delete] FileUtils.rm_f POWDER_CONFIG say "Successfully removed #{POWDER_CONFIG}" end end desc "remove", "An alias to Unlink (deprecated)" alias :remove :unlink desc "cleanup", "Clean up invalid symbolic link" def cleanup Dir[POW_PATH + "/*"].map do |symlink| begin FileUtils.rm(symlink) unless File.exists?(File.readlink(symlink)) rescue # Not all platforms support File.readlink, thus throwing exceptions. # Unfortunately we can't decide then if a symlink is invalid. end end end desc "install", "Installs or updates pow" def install %x{curl get.pow.cx | sh} end desc "update", "An alias to install" alias :update :install desc "log", "Tails the Pow log" def log path_to_log_file = "#{ENV['HOME']}/Library/Logs/Pow/apps/#{current_dir_name}.log" if File.exist? path_to_log_file trap('INT') { say "\nExiting log..." } system "tail -f #{path_to_log_file}" else say "There is no Pow log file, have you set this application up yet?" end end desc "applog", "Tails in current app" def applog(env="development") trap('INT') { say "\nExiting applog..." } system "tail -f log/#{env}.log" if is_powable? end desc "uninstall", "Uninstalls pow" def uninstall %x{curl get.pow.cx/uninstall.sh | sh} end desc "debug", "Open a debug session" def debug check_rdebug_initializer # Connect to remote rdebug session require 'ruby-debug' Debugger.settings[:autoeval] = true Debugger.settings[:autolist] = 1 Debugger.settings[:reload_source_on_change] = true connected = false start = Time.now.to_i while !connected && (Time.now.to_i - start) < 15 begin Debugger.start_client && connected = true rescue next end end say "Tried to connect for 15 seconds. Is the server ready for a debugger connection?" unless connected end desc "version", "Shows the version" def version say "powder #{Powder::VERSION}" end desc "host", "Updates hosts file to map pow domains to 127.0.0.1" def host hosts_file_path = "/etc/hosts" pow_domain_records = Dir[POW_PATH + "/*"].map { |a| "127.0.0.1\t#{File.basename(a)}.#{domain}\t#powder" } hosts_file = File.read("/etc/hosts").split("\n").delete_if {|row| row =~ /.+(#powder)/} first_loopback_index = hosts_file.index {|i| i =~ /^(127.0.0.1).+/} hosts_file = hosts_file.insert(first_loopback_index + 1, pow_domain_records) File.open("#{ENV['HOME']}/hosts-powder", "w") do |file| file.puts hosts_file.join("\n") end %x{cp #{hosts_file_path} #{ENV['HOME']}/hosts-powder.bak} %x{sudo mv #{ENV['HOME']}/hosts-powder #{hosts_file_path}} %x{dscacheutil -flushcache} say "Domains added to hosts file, old host file is saved at #{ENV['HOME']}/hosts-powder.bak" end desc "unhost", "Removes pow domains from hostfile" def unhost hosts_file_path = "/etc/hosts" hosts_file = File.read("/etc/hosts").split("\n").delete_if {|row| row =~ /.+(#powder)/} File.open("#{ENV['HOME']}/hosts-powder", "w") do |file| file.puts hosts_file.join("\n") end %x{cp #{hosts_file_path} #{ENV['HOME']}/hosts-powder.bak} %x{sudo mv #{ENV['HOME']}/hosts-powder #{hosts_file_path}} %x{dscacheutil -flushcache} say "Domains removed from hosts file, old host file is saved at #{ENV['HOME']}/hosts-powder.bak" end desc "config", "Shows current pow configuration" def config http_port = ":" + configured_pow_http_port.to_s results = %x{curl --silent -H host:pow 127.0.0.1#{http_port}/config.json}.gsub(':','=>') return say("Error: Cannot get Pow config. Pow may be down. Try 'powder up' first.") if results.empty? || !(results =~ /^\{/) json = eval results json.each do |k,v| case v when String, Numeric say "#{k.ljust(12,' ')} #{v}" when Array say "#{k.ljust(12,' ')} #{v.join ', '}\n" end end end desc "status", "Shows current pow status" def status http_port = ":" + configured_pow_http_port.to_s results = %x{curl --silent -H host:pow 127.0.0.1#{http_port}/status.json}.gsub(':','=>') return say("Error: Cannot get Pow status. Pow may be down. Try 'powder up' first.") if results.empty? || !(results =~ /^\{/) json = eval results json.each {|k,v| say "#{k.ljust(12, ' ')} #{v}"} end desc "portmap PORT", "Map a port to an app" method_option :name, :type => :string, :aliases => '-n', :desc => 'name of the port map' method_option :force, :type => :boolean, :default => false, :alias => '-f', :desc => "remove the old configuration, overwrite .powder" def portmap(port) if File.symlink?(POW_PATH) name = options.name ? options.name : get_pow_name app_path = "#{POW_PATH}/#{name}" FileUtils.rm_f(app_path) if options[:force] File.open(app_path, 'w') do |file| file.write(port) end say "Your application is now available at http://#{name}.#{domain}/" else say "Pow is not installed." end end desc "env_rvm", "Create or add rvm env to .powenv" def env_rvm if File.exists?(%x{pwd}.chomp + "/.rvmrc") say %x{rvm env . -- --env >> #{POW_ENV}} show_env else say ".rvmrc does not exist." end end private def powenv_exists? File.exists?(POW_ENV) end def show_env if powenv_exists? && File.readlines(POW_ENV).any? say %x{echo && cat #{POW_ENV} && echo} else say "\nYou haven't yet configured any custom environment variables for pow." say "Try `powder env BACON chunky` to set ENV[\"BACON\"]=chunky" say " or `powder env BACON` to remove ENV[\"BACON\"]" say " or `powder prod` to set RAILS_ENV=production.\n\n" end end def remove_env(key) if powenv_exists? && File.readlines(POW_ENV).grep(/export #{key}=/).any? %x{sed -i '' '/^export #{key}=.*$/d' #{POW_ENV}} end end def create_or_update_env(key, value) if exists = powenv_exists? && File.readlines(POW_ENV).grep(/export #{key}=/).any? %x{sed -i '' 's/^export #{key}=.*$/export #{key}=#{value}/' #{POW_ENV}} say "Modified #{POW_ENV}" else %x{echo "export #{key}=#{value}" >> #{POW_ENV} && touch tmp/restart.txt} say "Appended to #{POW_ENV}" gitignore POW_ENV unless exists end end def gitignore(filename) if File.exists?(".gitignore") && File.readlines(".gitignore").grep(Regexp.new(filename)).none? %x{echo #{filename} >> .gitignore} say "\nYou'll probably be wanting to .gitignore that new .powenv file..." say "Here, let me take care of that for you.\n\n" end end def current_dir_name File.basename(%x{pwd}.chomp) end def configured_pow_name return nil unless File.exists?(POWDER_CONFIG) File.foreach(POWDER_CONFIG) do |line| next if line =~ /^($|#)/ return line.chomp end return nil end def configured_pow_http_port return 20559 unless File.exists?(POW_CONFIG) return File.open(POW_CONFIG).read.match(/export[\s]+POW_HTTP_PORT[\s]*=[\s]*(\d+)/) || 20559 end def current_dir_pow_name current_dir_name.tr('_', '-') end def get_pow_name configured_pow_name || current_dir_pow_name end def write_pow_config(name=nil) return if ! name || options[:"no-config"] powder_exists = File.exists?(POWDER_CONFIG) configured_name = get_pow_name unlink if powder_exists && configured_name != name if options[:force] || ! powder_exists File.open(POWDER_CONFIG, "w") do |f| f.puts(name) end say "Created powder config #{POWDER_CONFIG}" elsif !options[:force] && powder_exists && configured_name != name say "Cowardly refusing to overwrite #{POWDER_CONFIG}" exit 1 end end def is_powable? if File.exists?('config.ru') || File.exists?('public/index.html') true elsif legacy = (is_rails2_app? || is_radiant_app?) say "This appears to be a #{legacy} application. You need a config.ru file." if yes? "Do you want to autogenerate a basic config.ru for #{legacy}?" create_file "config.ru", get_gist("https://gist.github.com/sstephenson/909308/raw") return true else say "Did not create config.ru" return false end else say "This does not appear to be a rack app as there is no config.ru." say "Pow can also host static apps if there is an index.html in public/" return false end end def is_rails2_app? File.exists?('config/environment.rb') && !`grep RAILS_GEM_VERSION config/environment.rb`.empty? ? 'Rails 2' : nil end def is_radiant_app? File.exists?('config/environment.rb') && !`grep Radiant::Initializer config/environment.rb`.empty? ? 'Radiant' : nil end def domain if File.exists? File.expand_path('~/.powconfig') returned_domain = %x{source ~/.powconfig; echo $POW_DOMAINS}.gsub("\n", "").split(",").first returned_domain = %x{source ~/.powconfig; echo $POW_DOMAIN}.gsub("\n", "") if returned_domain.nil? || returned_domain.empty? returned_domain = 'dev' if returned_domain.nil? || returned_domain.empty? returned_domain else 'dev' end end def check_rdebug_initializer rails_initializer = %x{pwd}.chomp + "/config/initializers/rdebug.rb" rack_initializer = %x{pwd}.chomp + "/rdebug.rb" unless File.exists?(rack_initializer) || File.exists?(rails_initializer) say "Its appears that the required initializer for rdebug doesn't exists in your application." if yes? "Do you want to create it(y/n)?" if yes? "This is a Rails/Radiant app(y/n)?" create_file rails_initializer, get_gist("https://gist.github.com/csiszarattila/1135055/raw") else create_file rack_initializer, get_gist("https://gist.github.com/csiszarattila/1262647/raw") append_to_file 'config.ru', "\nrequire 'rdebug.rb'" end restart else return false end end end def get_gist(url) uri = URI.parse(url) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true http.verify_mode = OpenSSL::SSL::VERIFY_NONE request = Net::HTTP::Get.new(uri.request_uri) http.request(request).body end def start_on_yosemite return say "Pow daemon configuration missing." unless File.exists?(POW_DAEMON_PLIST_PATH) %x{launchctl bootstrap gui/"$UID" #{POW_DAEMON_PLIST_PATH}} %x{launchctl enable gui/"$UID"/cx.pow.powd} %x{launchctl kickstart -k gui/"$UID"/cx.pow.powd} end def start_on_mavericks return say "Pow firewall configuration missing." unless File.exists?(POW_FIREWALL_PLIST_PATH) %x{launchctl load #{POW_FIREWALL_PLIST_PATH}} return say "Pow daemon configuration missing." unless File.exists?(POW_DAEMON_PLIST_PATH) %x{launchctl load #{POW_DAEMON_PLIST_PATH}} end end end Powder::CLI.start