#!/usr/bin/env 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 say "Pow has been started." 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} 2>/dev/null} end end if File.exists? POW_DAEMON_PLIST_PATH %x{launchctl unload #{POW_DAEMON_PLIST_PATH} 2>/dev/null} end if MAC_OS_X_MINOR_VERSION >= 10 stop_on_yosemite else stop_on_mavericks end say "Pow has been stopped." end desc "stop", "An alias to down" alias :stop :down desc "link", "Link a pow" method_option :force, :type => :boolean, :default => false, :aliases => '-f', :desc => "remove the old configuration, overwrite .powder" method_option :"no-config", :type => :boolean, :default => false, :aliases => '-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 => nil, :aliases => '-b', :desc => 'browser to open with' method_option :xip, :type => :boolean, :default => false, :aliases => '-x', :desc => "open xip.io instead of .domain" method_option :path, :type => :string, :default => '', :aliases => '-p', :desc => 'path to open' def open(name=nil) browser = options.browser? ? "-a \'#{options.browser}\'" : nil path = options.path.start_with?('/') ? options.path : '/' + options.path 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#{path}} else %x{open #{browser} http://#{name || get_pow_name}.#{domain}#{path}} end end desc "unlink", "Unlink a pow app" method_option :delete, :type => :boolean, :default => false, :aliases => '-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 begin system "tail -f #{path_to_log_file}" rescue Interrupt say "\nExiting log..." exit 130 end 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") begin system "tail -f log/#{env}.log" if is_powable? rescue Interrupt say "\nExiting log..." exit 130 end 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, :aliases => '-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 = 'test' if returned_domain.nil? || returned_domain.empty? returned_domain else 'test' 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} 2>/dev/null} %x{launchctl enable gui/"$UID"/cx.pow.powd 2>/dev/null} %x{launchctl kickstart -k gui/"$UID"/cx.pow.powd 2>/dev/null} end def stop_on_yosemite %{sudo pfctl -a "com.apple/250.PowFirewall" -F all 2>/dev/null} 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} 2>/dev/null} return say "Pow daemon configuration missing." unless File.exists?(POW_DAEMON_PLIST_PATH) %x{launchctl load #{POW_DAEMON_PLIST_PATH} 2>/dev/null} end def stop_on_mavericks 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 end end Powder::CLI.start