1 # Rails Plugin Manager.
3 # Listing available plugins:
5 # $ ./script/plugin list
6 # continuous_builder http://dev.rubyonrails.com/svn/rails/plugins/continuous_builder
7 # asset_timestamping http://svn.aviditybytes.com/rails/plugins/asset_timestamping
8 # enumerations_mixin http://svn.protocool.com/rails/plugins/enumerations_mixin/trunk
9 # calculations http://techno-weenie.net/svn/projects/calculations/
14 # $ ./script/plugin install continuous_builder asset_timestamping
16 # Finding Repositories:
18 # $ ./script/plugin discover
20 # Adding Repositories:
22 # $ ./script/plugin source http://svn.protocool.com/rails/plugins/
26 # * Maintains a list of subversion repositories that are assumed to have
27 # a plugin directory structure. Manage them with the (source, unsource,
28 # and sources commands)
30 # * The discover command scrapes the following page for things that
31 # look like subversion repositories with plugins:
32 # http://wiki.rubyonrails.org/rails/pages/Plugins
34 # * Unless you specify that you want to use svn, script/plugin uses plain old
35 # HTTP for downloads. The following bullets are true if you specify
36 # that you want to use svn.
38 # * If `vendor/plugins` is under subversion control, the script will
39 # modify the svn:externals property and perform an update. You can
40 # use normal subversion commands to keep the plugins up to date.
42 # * Or, if `vendor/plugins` is not under subversion control, the
43 # plugin is pulled via `svn checkout` or `svn export` but looks
46 # This is Free Software, copyright 2005 by Ryan Tomayko (rtomayko@gmail.com)
47 # and is licensed MIT: (http://www.opensource.org/licenses/mit-license.php)
58 class RailsEnvironment
65 def self.find(dir=nil)
68 return new(dir) if File.exist?(File.join(dir, 'config', 'environment.rb'))
69 dir = File.dirname(dir)
77 def self.default=(rails_env)
81 def install(name_uri_or_plugin)
82 if name_uri_or_plugin.is_a? String
83 if name_uri_or_plugin =~ /:\/\//
84 plugin = Plugin.new(name_uri_or_plugin)
86 plugin = Plugins[name_uri_or_plugin]
89 plugin = name_uri_or_plugin
94 puts "Plugin not found: #{name_uri_or_plugin}"
99 require 'active_support/core_ext/kernel'
100 silence_stderr {`svn --version` rescue nil}
101 !$?.nil? && $?.success?
105 use_svn? && File.directory?("#{root}/vendor/plugins/.svn")
109 # this is a bit of a guess. we assume that if the rails environment
110 # is under subversion then they probably want the plugin checked out
111 # instead of exported. This can be overridden on the command line
112 File.directory?("#{root}/.svn")
115 def best_install_method
116 return :http unless use_svn?
118 when use_externals? then :externals
119 when use_checkout? then :checkout
125 return [] unless use_externals?
126 ext = `svn propget svn:externals "#{root}/vendor/plugins"`
127 ext.reject{ |line| line.strip == '' }.map do |line|
128 line.strip.split(/\s+/, 2)
132 def externals=(items)
133 unless items.is_a? String
134 items = items.map{|name,uri| "#{name.ljust(29)} #{uri.chomp('/')}"}.join("\n")
136 Tempfile.open("svn-set-prop") do |file|
139 system("svn propset -q svn:externals -F \"#{file.path}\" \"#{root}/vendor/plugins\"")
146 attr_reader :name, :uri
148 def initialize(uri, name=nil)
154 name =~ /\// ? new(name) : Repositories.instance.find_plugin(name)
158 "#{@name.ljust(30)}#{@uri}"
162 @uri =~ /svn(?:\+ssh)?:\/\/*/
166 File.directory?("#{rails_env.root}/vendor/plugins/#{name}") \
167 or rails_env.externals.detect{ |name, repo| self.uri == repo }
170 def install(method=nil, options = {})
171 method ||= rails_env.best_install_method?
172 method = :export if method == :http and svn_url?
174 uninstall if installed? and options[:force]
177 send("install_using_#{method}", options)
180 puts "already installed: #{name} (#{uri}). pass --force to reinstall"
185 path = "#{rails_env.root}/vendor/plugins/#{name}"
186 if File.directory?(path)
187 puts "Removing 'vendor/plugins/#{name}'" if $verbose
191 puts "Plugin doesn't exist: #{path}"
193 # clean up svn:externals
194 externals = rails_env.externals
195 externals.reject!{|n,u| name == n or name == u}
196 rails_env.externals = externals
200 tmp = "#{rails_env.root}/_tmp_about.yml"
202 cmd = "svn export #{@uri} \"#{rails_env.root}/#{tmp}\""
206 open(svn_url? ? tmp : File.join(@uri, 'about.yml')) do |stream|
208 end rescue "No about.yml found in #{uri}"
210 FileUtils.rm_rf tmp if svn_url?
216 install_hook_file = "#{rails_env.root}/vendor/plugins/#{name}/install.rb"
217 load install_hook_file if File.exists? install_hook_file
220 def run_uninstall_hook
221 uninstall_hook_file = "#{rails_env.root}/vendor/plugins/#{name}/uninstall.rb"
222 load uninstall_hook_file if File.exists? uninstall_hook_file
225 def install_using_export(options = {})
226 svn_command :export, options
229 def install_using_checkout(options = {})
230 svn_command :checkout, options
233 def install_using_externals(options = {})
234 externals = rails_env.externals
235 externals.push([@name, uri])
236 rails_env.externals = externals
237 install_using_checkout(options)
240 def install_using_http(options = {})
241 root = rails_env.root
242 mkdir_p "#{root}/vendor/plugins/#{@name}"
243 Dir.chdir "#{root}/vendor/plugins/#{@name}" do
244 puts "fetching from '#{uri}'" if $verbose
245 fetcher = RecursiveHTTPFetcher.new(uri, -1)
246 fetcher.quiet = true if options[:quiet]
251 def svn_command(cmd, options = {})
252 root = rails_env.root
253 mkdir_p "#{root}/vendor/plugins"
254 base_cmd = "svn #{cmd} #{uri} \"#{root}/vendor/plugins/#{name}\""
255 base_cmd += ' -q' if options[:quiet] and not $verbose
256 base_cmd += " -r #{options[:revision]}" if options[:revision]
257 puts base_cmd if $verbose
262 @name = File.basename(url)
263 if @name == 'trunk' || @name.empty?
264 @name = File.basename(File.dirname(url))
269 @rails_env || RailsEnvironment.default
276 def initialize(cache_file = File.join(find_home, ".rails-plugin-sources"))
277 @cache_file = File.expand_path(cache_file)
282 @repositories.each(&block)
286 unless find{|repo| repo.uri == uri }
287 @repositories.push(Repository.new(uri)).last
292 @repositories.reject!{|repo| repo.uri == uri}
296 @repositories.detect{|repo| repo.uri == uri }
303 def find_plugin(name)
304 @repositories.each do |repo|
305 repo.each do |plugin|
306 return plugin if plugin.name == name
313 contents = File.exist?(@cache_file) ? File.read(@cache_file) : defaults
314 contents = defaults if contents.empty?
315 @repositories = contents.split(/\n/).reject do |line|
316 line =~ /^\s*#/ or line =~ /^\s*$/
317 end.map { |source| Repository.new(source.strip) }
321 File.open(@cache_file, 'w') do |f|
331 http://dev.rubyonrails.com/svn/rails/plugins/
336 ['HOME', 'USERPROFILE'].each do |homekey|
337 return ENV[homekey] if ENV[homekey]
339 if ENV['HOMEDRIVE'] && ENV['HOMEPATH']
340 return "#{ENV['HOMEDRIVE']}:#{ENV['HOMEPATH']}"
343 File.expand_path("~")
344 rescue StandardError => ex
345 if File::ALT_SEPARATOR
354 @instance ||= Repositories.new
357 def self.each(&block)
358 self.instance.each(&block)
364 attr_reader :uri, :plugins
367 @uri = uri.chomp('/') << "/"
374 puts "Discovering plugins in #{@uri}"
378 @plugins = index.reject{ |line| line !~ /\/$/ }
379 @plugins.map! { |name| Plugin.new(File.join(@uri, name), name) }
391 @index ||= RecursiveHTTPFetcher.new(@uri).ls
396 # load default environment and parse arguments
401 attr_reader :environment, :script_name, :sources
403 @environment = RailsEnvironment.default
404 @rails_root = RailsEnvironment.default.root
405 @script_name = File.basename($0)
409 def environment=(value)
411 RailsEnvironment.default = value
415 OptionParser.new do |o|
416 o.set_summary_indent(' ')
417 o.banner = "Usage: #{@script_name} [OPTIONS] command"
418 o.define_head "Rails plugin manager."
421 o.separator "GENERAL OPTIONS"
423 o.on("-r", "--root=DIR", String,
424 "Set an explicit rails app directory.",
425 "Default: #{@rails_root}") { |@rails_root| self.environment = RailsEnvironment.new(@rails_root) }
426 o.on("-s", "--source=URL1,URL2", Array,
427 "Use the specified plugin repositories instead of the defaults.") { |@sources|}
429 o.on("-v", "--verbose", "Turn on verbose output.") { |$verbose| }
430 o.on("-h", "--help", "Show this help message.") { puts o; exit }
433 o.separator "COMMANDS"
435 o.separator " discover Discover plugin repositories."
436 o.separator " list List available plugins."
437 o.separator " install Install plugin(s) from known repositories or URLs."
438 o.separator " update Update installed plugins."
439 o.separator " remove Uninstall plugins."
440 o.separator " source Add a plugin source repository."
441 o.separator " unsource Remove a plugin repository."
442 o.separator " sources List currently configured plugin repositories."
445 o.separator "EXAMPLES"
446 o.separator " Install a plugin:"
447 o.separator " #{@script_name} install continuous_builder\n"
448 o.separator " Install a plugin from a subversion URL:"
449 o.separator " #{@script_name} install http://dev.rubyonrails.com/svn/rails/plugins/continuous_builder\n"
450 o.separator " Install a plugin and add a svn:externals entry to vendor/plugins"
451 o.separator " #{@script_name} install -x continuous_builder\n"
452 o.separator " List all available plugins:"
453 o.separator " #{@script_name} list\n"
454 o.separator " List plugins in the specified repository:"
455 o.separator " #{@script_name} list --source=http://dev.rubyonrails.com/svn/rails/plugins/\n"
456 o.separator " Discover and prompt to add new repositories:"
457 o.separator " #{@script_name} discover\n"
458 o.separator " Discover new repositories but just list them, don't add anything:"
459 o.separator " #{@script_name} discover -l\n"
460 o.separator " Add a new repository to the source list:"
461 o.separator " #{@script_name} source http://dev.rubyonrails.com/svn/rails/plugins/\n"
462 o.separator " Remove a repository from the source list:"
463 o.separator " #{@script_name} unsource http://dev.rubyonrails.com/svn/rails/plugins/\n"
464 o.separator " Show currently configured repositories:"
465 o.separator " #{@script_name} sources\n"
469 def parse!(args=ARGV)
470 general, sub = split_args(args)
471 options.parse!(general)
473 command = general.shift
474 if command =~ /^(list|discover|install|source|unsource|sources|remove|update|info)$/
475 command = Commands.const_get(command.capitalize).new(self)
478 puts "Unknown command: #{command}"
486 left << args.shift while args[0] and args[0] =~ /^-/
487 left << args.shift if args[0]
491 def self.parse!(args=ARGV)
492 Plugin.new.parse!(args)
498 def initialize(base_command)
499 @base_command = base_command
506 OptionParser.new do |o|
507 o.set_summary_indent(' ')
508 o.banner = "Usage: #{@base_command.script_name} list [OPTIONS] [PATTERN]"
509 o.define_head "List available plugins."
511 o.separator "Options:"
513 o.on( "-s", "--source=URL1,URL2", Array,
514 "Use the specified plugin repositories.") {|@sources|}
516 "List locally installed plugins.") {|@local| @remote = false}
518 "List remotely available plugins. This is the default behavior",
519 "unless --local is provided.") {|@remote|}
525 unless @sources.empty?
526 @sources.map!{ |uri| Repository.new(uri) }
528 @sources = Repositories.instance.all
531 @sources.map{|r| r.plugins}.flatten.each do |plugin|
532 if @local or !plugin.installed?
537 cd "#{@base_command.environment.root}/vendor/plugins"
538 Dir["*"].select{|p| File.directory?(p)}.each do |name|
547 def initialize(base_command)
548 @base_command = base_command
552 OptionParser.new do |o|
553 o.set_summary_indent(' ')
554 o.banner = "Usage: #{@base_command.script_name} sources [OPTIONS] [PATTERN]"
555 o.define_head "List configured plugin repositories."
557 o.separator "Options:"
559 o.on( "-c", "--check",
560 "Report status of repository.") { |@sources|}
566 Repositories.each do |repo|
574 def initialize(base_command)
575 @base_command = base_command
579 OptionParser.new do |o|
580 o.set_summary_indent(' ')
581 o.banner = "Usage: #{@base_command.script_name} source REPOSITORY [REPOSITORY [REPOSITORY]...]"
582 o.define_head "Add new repositories to the default search list."
590 if Repositories.instance.add(uri)
591 puts "added: #{uri.ljust(50)}" if $verbose
594 puts "failed: #{uri.ljust(50)}"
597 Repositories.instance.save
598 puts "Added #{count} repositories."
604 def initialize(base_command)
605 @base_command = base_command
609 OptionParser.new do |o|
610 o.set_summary_indent(' ')
611 o.banner = "Usage: #{@base_command.script_name} source URI [URI [URI]...]"
612 o.define_head "Remove repositories from the default search list."
614 o.on_tail("-h", "--help", "Show this help message.") { puts o; exit }
622 if Repositories.instance.remove(uri)
624 puts "removed: #{uri.ljust(50)}"
626 puts "failed: #{uri.ljust(50)}"
629 Repositories.instance.save
630 puts "Removed #{count} repositories."
636 def initialize(base_command)
637 @base_command = base_command
643 OptionParser.new do |o|
644 o.set_summary_indent(' ')
645 o.banner = "Usage: #{@base_command.script_name} discover URI [URI [URI]...]"
646 o.define_head "Discover repositories referenced on a page."
648 o.separator "Options:"
650 o.on( "-l", "--list",
651 "List but don't prompt or add discovered repositories.") { |@list| @prompt = !@list }
652 o.on( "-n", "--no-prompt",
653 "Add all new repositories without prompting.") { |v| @prompt = !v }
659 args = ['http://wiki.rubyonrails.org/rails/pages/Plugins'] if args.empty?
661 scrape(uri) do |repo_uri|
665 $stdout.print "Add #{repo_uri}? [Y/n] "
666 throw :next_uri if $stdin.gets !~ /^y?$/i
675 Repositories.instance.add(repo_uri)
676 puts "discovered: #{repo_uri}" if $verbose or !@prompt
680 Repositories.instance.save
685 puts "Scraping #{uri}" if $verbose
687 content = open(uri).each do |line|
689 if line =~ /<a[^>]*href=['"]([^'"]*)['"]/ || line =~ /(svn:\/\/[^<|\n]*)/
691 if uri =~ /^\w+:\/\// && uri =~ /\/plugins\// && uri !~ /\/browser\// && uri !~ /^http:\/\/wiki\.rubyonrails/ && uri !~ /http:\/\/instiki/
692 uri = extract_repository_uri(uri)
693 yield uri unless dupes.include?(uri) || Repositories.instance.exist?(uri)
698 puts "Problems scraping '#{uri}': #{$!.to_s}"
703 def extract_repository_uri(uri)
704 uri.match(/(svn|https?):.*\/plugins\//i)[0]
709 def initialize(base_command)
710 @base_command = base_command
712 @options = { :quiet => false, :revision => nil, :force => false }
716 OptionParser.new do |o|
717 o.set_summary_indent(' ')
718 o.banner = "Usage: #{@base_command.script_name} install PLUGIN [PLUGIN [PLUGIN] ...]"
719 o.define_head "Install one or more plugins."
721 o.separator "Options:"
722 o.on( "-x", "--externals",
723 "Use svn:externals to grab the plugin.",
724 "Enables plugin updates and plugin versioning.") { |v| @method = :externals }
725 o.on( "-o", "--checkout",
726 "Use svn checkout to grab the plugin.",
727 "Enables updating but does not add a svn:externals entry.") { |v| @method = :checkout }
728 o.on( "-q", "--quiet",
729 "Suppresses the output from installation.",
730 "Ignored if -v is passed (./script/plugin -v install ...)") { |v| @options[:quiet] = true }
731 o.on( "-r REVISION", "--revision REVISION",
732 "Checks out the given revision from subversion.",
733 "Ignored if subversion is not used.") { |v| @options[:revision] = v }
734 o.on( "-f", "--force",
735 "Reinstalls a plugin if it's already installed.") { |v| @options[:force] = true }
737 o.separator "You can specify plugin names as given in 'plugin list' output or absolute URLs to "
738 o.separator "a plugin repository."
742 def determine_install_method
743 best = @base_command.environment.best_install_method
744 @method = :http if best == :http and @method == :export
746 when (best == :http and @method != :http)
747 msg = "Cannot install using subversion because `svn' cannot be found in your PATH"
748 when (best == :export and (@method != :export and @method != :http))
749 msg = "Cannot install using #{@method} because this project is not under subversion."
750 when (best != :externals and @method == :externals)
751 msg = "Cannot install using externals because vendor/plugins is not under subversion."
762 environment = @base_command.environment
763 install_method = determine_install_method
764 puts "Plugins will be installed using #{install_method}" if $verbose
766 ::Plugin.find(name).install(install_method, @options)
768 rescue StandardError => e
769 puts "Plugin not found: #{args.inspect}"
770 puts e.inspect if $verbose
776 def initialize(base_command)
777 @base_command = base_command
781 OptionParser.new do |o|
782 o.set_summary_indent(' ')
783 o.banner = "Usage: #{@base_command.script_name} update [name [name]...]"
784 o.on( "-r REVISION", "--revision REVISION",
785 "Checks out the given revision from subversion.",
786 "Ignored if subversion is not used.") { |v| @revision = v }
787 o.define_head "Update plugins."
793 root = @base_command.environment.root
795 args = Dir["vendor/plugins/*"].map do |f|
796 File.directory?("#{f}/.svn") ? File.basename(f) : nil
797 end.compact if args.empty?
800 if File.directory?(name)
801 puts "Updating plugin: #{name}"
802 system("svn #{$verbose ? '' : '-q'} up \"#{name}\" #{@revision ? "-r #{@revision}" : ''}")
804 puts "Plugin doesn't exist: #{name}"
811 def initialize(base_command)
812 @base_command = base_command
816 OptionParser.new do |o|
817 o.set_summary_indent(' ')
818 o.banner = "Usage: #{@base_command.script_name} remove name [name]..."
819 o.define_head "Remove plugins."
825 root = @base_command.environment.root
827 ::Plugin.new(name).uninstall
833 def initialize(base_command)
834 @base_command = base_command
838 OptionParser.new do |o|
839 o.set_summary_indent(' ')
840 o.banner = "Usage: #{@base_command.script_name} info name [name]..."
841 o.define_head "Shows plugin info at {url}/about.yml."
848 puts ::Plugin.find(name).info
855 class RecursiveHTTPFetcher
857 def initialize(urls_to_fetch, level = 1, cwd = ".")
860 @urls_to_fetch = urls_to_fetch.to_a
865 @urls_to_fetch.collect do |url|
866 if url =~ /^svn:\/\/.*/
867 `svn ls #{url}`.split("\n").map {|entry| "/#{entry}"} rescue nil
869 open(url) do |stream|
870 links("", stream.read)
877 @cwd = File.join(@cwd, dir)
878 FileUtils.mkdir_p(@cwd)
882 @cwd = File.dirname(@cwd)
885 def links(base_url, contents)
887 contents.scan(/href\s*=\s*\"*[^\">]*/i) do |link|
888 link = link.sub(/href="/i, "")
889 next if link =~ /svnindex.xsl$/
890 next if link =~ /^(\w*:|)\/\// || link =~ /^\./
891 links << File.join(base_url, link)
897 puts "+ #{File.join(@cwd, File.basename(link))}" unless @quiet
898 open(link) do |stream|
899 File.open(File.join(@cwd, File.basename(link)), "wb") do |file|
900 file.write(stream.read)
905 def fetch(links = @urls_to_fetch)
907 (l =~ /\/$/ || links == @urls_to_fetch) ? fetch_dir(l) : download(l)
913 push_d(File.basename(url)) if @level > 0
914 open(url) do |stream|
915 contents = stream.read
916 fetch(links(url, contents))
923 Commands::Plugin.parse!