├── .gitignore ├── LICENSE ├── README.markdown ├── Rakefile ├── TODO ├── bin └── textmate ├── script ├── destroy └── generate ├── spec ├── spec_helper.rb └── tm_spec.rb └── textmate.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | pkg/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008 Yehuda Katz 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # textmate 2 | 3 | A binary that provides package management for TextMate. 4 | 5 | # Usage 6 | 7 | `textmate [COMMAND] [*PARAMS]` 8 | 9 | Textmate bundles are automatically reloaded after install or uninstall operations. 10 | 11 | ## List available remote bundles 12 | 13 | `textmate remote [SEARCH]` 14 | 15 | List all of the available bundles in the remote repository, optionally filtering by `search`. 16 | 17 | ## List installed bundles 18 | 19 | `textmate list [SEARCH]` 20 | 21 | List all of the bundles that are installed on the local system, optionally filtering by `search`. 22 | 23 | ## Installing new bundles 24 | 25 | `textmate install NAME [SOURCE]` 26 | 27 | Installs a bundle from the remote repository. SOURCE filters known remote bundle locations. 28 | For example, if you want to install the "Ruby on Rails" bundle off GitHub, you'd type the following: 29 | 30 | `textmate install "Ruby on Rails" GitHub` 31 | 32 | Available remote bundle locations are: 33 | * Macromates Trunk 34 | * Macromates Review 35 | * GitHub 36 | 37 | ## Updating bundles 38 | 39 | `textmate update` 40 | 41 | Tries to update all installed bundles. 42 | 43 | ## Uninstalling bundles 44 | 45 | `textmate uninstall NAME` 46 | 47 | Uninstalls a bundle from the local repository. 48 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'rake/gempackagetask' 3 | require 'date' 4 | 5 | GEM = "textmate" 6 | GEM_VERSION = "0.9.6" 7 | AUTHOR = "Yehuda Katz" 8 | EMAIL = "wycats@gmail.com" 9 | HOMEPAGE = "http://yehudakatz.com" 10 | SUMMARY = "Command-line textmate package manager" 11 | 12 | spec = Gem::Specification.new do |s| 13 | s.name = GEM 14 | s.version = GEM_VERSION 15 | s.platform = Gem::Platform::RUBY 16 | s.has_rdoc = true 17 | s.extra_rdoc_files = ["README.markdown", "LICENSE"] 18 | s.summary = SUMMARY 19 | s.description = s.summary 20 | s.author = AUTHOR 21 | s.email = EMAIL 22 | s.homepage = HOMEPAGE 23 | 24 | s.add_dependency "thor", ">= 0.9.2" 25 | 26 | s.require_path = 'bin' # Yes, it's a hack, but otherwise gem complains on install 27 | s.autorequire = GEM 28 | s.files = %w(LICENSE README.markdown Rakefile) + Dir.glob("{bin,specs}/**/*") 29 | s.bindir = "bin" 30 | s.executables = %w( textmate ) 31 | end 32 | 33 | Rake::GemPackageTask.new(spec) do |pkg| 34 | pkg.gem_spec = spec 35 | end 36 | 37 | desc "make a gemspec file" 38 | task :make_spec do 39 | File.open("#{GEM}.gemspec", "w") do |file| 40 | file.puts spec.to_ruby 41 | end 42 | end 43 | 44 | task :install => [:package] do 45 | sh %{sudo gem install pkg/#{GEM}-#{GEM_VERSION} --no-rdoc --no-ri} 46 | end -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | * Add specs for, well, everything 2 | * Only show author names for GitHub bundles when there are more than one with the same name 3 | * Show the Authors and/or Project URLs for GitHub bundles 4 | * Make the SOURCE param on install an option 5 | * Add an option to remove all other versions of the same bundle on install 6 | * Add a DESTINATION option to decide where to install. Maybe make some sort of ~/.textmate-cli config file? 7 | * Implement a verbose mode to toggle showing all the gory details 8 | * Offer suggestions for alternate bundles with similar names if the installation fails. Maybe let them choose? 9 | * Add Git support to remote_bundle_locations. Define some sort of standard way of listing git repos, checkout how rubygems does it 10 | * Add some way for the user to configure where they'd prefer to install bundles 11 | * Add some way to add more custom remotes 12 | * Clean up installing from GitHub 13 | -------------------------------------------------------------------------------- /bin/textmate: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "fileutils" 4 | require "rubygems" 5 | require "thor" 6 | require "open-uri" 7 | require "yaml" 8 | require "cgi" 9 | 10 | class TextmateInstaller < Thor 11 | 12 | # CHANGED: renamed list to remote. Could there be a better name? 13 | desc "search [SEARCH]", "Lists all the matching remote bundles" 14 | def search(search_term = "") 15 | search_term = Regexp.new(".*#{search_term}.*", "i") 16 | 17 | REMOTE_LOCATIONS.each do |name,location| 18 | puts "\n" << name.to_s << " Remote Bundles\n" << name.to_s.gsub(/./,'-') << '---------------' 19 | 20 | results = case location[:scm] 21 | when :svn 22 | %x[svn list #{e_sh location[:url]}].map {|x| x.split(".")[0]}.select {|x| x =~ search_term}.join("\n") 23 | when :git 24 | 'git remotes not implemented yet' 25 | when :github 26 | find_github_bundles(search_term).map {|result| 27 | "%s (by %s)" % 28 | [ 29 | normalize_github_repo_name(result['name']).split('.').first, 30 | result['username'] 31 | ] 32 | } 33 | end 34 | 35 | puts results 36 | end 37 | end 38 | 39 | desc "list [SEARCH]", "lists all the bundles installed locally" 40 | def list(search_term = "") 41 | search_term = Regexp.new(".*#{search_term}.*", "i") 42 | 43 | local_bundle_paths.each do |name,bundles_path| 44 | puts "\n" << name.to_s << " Bundles\n" << name.to_s.gsub(/./,'-') << '--------' 45 | puts Dir["#{e_sh bundles_path}/*.tmbundle"].map {|x| x.split("/").last.split(".").first}. 46 | select {|x| x =~ search_term}.join("\n") 47 | end 48 | end 49 | 50 | desc "install NAME", "Install a bundle. Source must be one of trunk, review, or github. \n" \ 51 | "If multiple gems with the same name exist, you will be prompted to \n" \ 52 | "choose from the available list." 53 | method_option :source 54 | def install(bundle_name) 55 | FileUtils.mkdir_p install_bundles_path 56 | puts "Checking out #{bundle_name}..." 57 | 58 | # CHANGED: It's faster to just try and fail for each repo than to search them all first 59 | installed=false 60 | REMOTE_LOCATIONS.each do |remote_name,location| 61 | next unless remote_bundle(options["source"]) == location if options.key?("source") 62 | 63 | cmd = case location[:scm] 64 | when :git 65 | 'echo "git remotes not implemented yet"' 66 | when :svn 67 | %[svn co "#{location[:url]}/#{url_escape bundle_name}.tmbundle" #{e_sh install_bundles_path}/#{e_sh bundle_name}.tmbundle 2>&1] 68 | when :github 69 | repos = find_github_bundles(denormalize_github_repo_name(bundle_name)) 70 | 71 | # Handle possible multiple Repos with the same name 72 | case repos.size 73 | when 0 74 | 'echo "Sorry, no such bundle found"' 75 | when 1 76 | git_clone(repos.first["username"], repos.first["name"]) 77 | else 78 | puts "Multiple bundles with that name found. Please choose which one you want to install:" 79 | repos.each_with_index {|repo, idx| 80 | puts "%d: %s by %s" % 81 | [ 82 | idx + 1, 83 | normalize_github_repo_name(repo['name']), 84 | repo['username'] 85 | ] 86 | } 87 | print "Your choice: " 88 | 89 | # Since to_i defaults to 0, we have to use Integer 90 | choice = Integer(STDIN.gets.chomp) rescue nil 91 | until choice && (0...repos.size).include?( choice - 1 ) do 92 | print "Sorry, invalid choice. Please enter a valid number or Ctrl+C to stop: " 93 | choice = Integer(STDIN.gets.chomp) rescue nil 94 | end 95 | 96 | git_clone(repos[choice - 1]["username"], repos[choice - 1]["name"]) 97 | end 98 | end 99 | 100 | res = %x{#{cmd}} 101 | 102 | puts cmd, res.gsub(/^/,' ') 103 | 104 | installed=true and break if res =~ /Checked out revision|Initialized empty Git repository/ 105 | end 106 | abort 'Not Installed' unless installed 107 | 108 | reload :verbose => true 109 | end 110 | 111 | desc "uninstall NAME", "uninstall a bundle" 112 | def uninstall(bundle_name) 113 | removed = false 114 | 115 | puts "Removing bundle..." 116 | # When moving to the trash, maybe move the bundle into a trash/disabled_bundles subfolder 117 | # named as the bundles_path key. Just in case there are multiple versions of 118 | # the same bundle in multiple bundle paths 119 | local_bundle_paths.each do |name,bundles_path| 120 | bundle_path = "#{bundles_path}/#{bundle_name}.tmbundle" 121 | if File.exist? bundle_path 122 | removed = true 123 | %x[osascript -e 'tell application "Finder" to move the POSIX file "#{bundle_path}" to trash'] 124 | end 125 | end 126 | 127 | unless removed 128 | say "There is no bundle by that name in the system", :red 129 | exit 130 | else 131 | reload :verbose => true 132 | end 133 | end 134 | 135 | desc "update", "updates all installed bundles" 136 | def update 137 | local_bundle_paths.each do |name,bundles_path| 138 | 139 | if (File.exist?(bundles_path)) 140 | Dir.new(bundles_path).each{|dir| 141 | path = File.join(bundles_path, dir) 142 | if (![".", ".."].include?(dir) && File.directory?(path)) 143 | 144 | svn_path = File.join(path, ".svn") 145 | git_path = File.join(path, ".git") 146 | 147 | if File.exist?(git_path) 148 | puts "Updateing #{path}" 149 | %x[git --git-dir=#{e_sh git_path} --work-tree=#{e_sh path}pull] 150 | elsif File.exist?(svn_path) 151 | puts "Updateing #{path}" 152 | %x[svn up #{e_sh path}] 153 | end 154 | 155 | end 156 | } 157 | end 158 | 159 | end 160 | end 161 | 162 | desc "update", "updates all installed bundles" 163 | def update 164 | local_bundle_paths.each do |name,bundles_path| 165 | 166 | if (File.exist?(bundles_path)) 167 | Dir.new(bundles_path).each{|dir| 168 | path = File.join(bundles_path, dir) 169 | if (![".", ".."].include?(dir) && File.directory?(path)) 170 | 171 | svn_path = File.join(path, ".svn") 172 | git_path = File.join(path, ".git") 173 | 174 | if File.exist?(git_path) 175 | puts "Updateing #{path}" 176 | %x[git --git-dir=#{e_sh git_path} --work-tree=#{e_sh path}pull] 177 | elsif File.exist?(svn_path) 178 | puts "Updateing #{path}" 179 | %x[svn up #{e_sh path}] 180 | end 181 | 182 | end 183 | } 184 | end 185 | 186 | end 187 | end 188 | 189 | desc "reload", "Reloads TextMate Bundles" 190 | method_options :verbose => :boolean 191 | def reload(opts = {}) 192 | puts "Reloading bundles..." if opts[:verbose] 193 | %x[osascript -e 'tell app "TextMate" to reload bundles'] 194 | puts "Done." if opts[:verbose] 195 | end 196 | 197 | private 198 | REMOTE_LOCATIONS = 199 | { :'Macromates Trunk' => {:scm => :svn, :url => 'http://svn.textmate.org/trunk/Bundles'}, 200 | :'Macromates Review' => {:scm => :svn, :url => 'http://svn.textmate.org/trunk/Review/Bundles'}, 201 | 202 | # :'Bunch of Git Bundles' => {:scm => :git, :url => 'git://NotImplemented'}, 203 | 204 | :'GitHub' => {:scm => :github, :url => 'http://github.com/search?q=tmbundle'}, 205 | } 206 | 207 | SHORT_LOCATIONS = {:trunk => :"Macromates Trunk", :review => :"Macromates Review", :github => :"GitHub"} 208 | 209 | def remote_bundle(name) 210 | name = name.to_sym 211 | REMOTE_LOCATIONS[name] || REMOTE_LOCATIONS[SHORT_LOCATIONS[name]] 212 | end 213 | 214 | def local_bundle_paths 215 | { :Application => '/Applications/TextMate.app/Contents/SharedSupport/Bundles', 216 | :User => "#{ENV["HOME"]}/Library/Application Support/TextMate/Bundles", 217 | :System => '/Library/Application Support/TextMate/Bundles', 218 | :'User Pristine' => "#{ENV["HOME"]}/Library/Application Support/TextMate/Pristine Copy/Bundles", 219 | :'System Pristine' => '/Library/Application Support/TextMate/Pristine Copy/Bundles', 220 | } 221 | end 222 | 223 | def install_bundles_path 224 | local_bundle_paths[:'User Pristine'] 225 | end 226 | 227 | # Copied from http://macromates.com/svn/Bundles/trunk/Support/lib/escape.rb 228 | # escape text to make it useable in a shell script as one “word” (string) 229 | def e_sh(str) 230 | str.to_s.gsub(/(?=[^a-zA-Z0-9_.\/\-\x7F-\xFF\n])/, '\\').gsub(/\n/, "'\n'").sub(/^$/, "''") 231 | end 232 | 233 | def url_escape(str) 234 | chars = ((33...47).to_a + (94...96).to_a + (123...126).to_a).map {|c| c.chr }.join + "\\[\\]\\\\" 235 | str = str.to_s.gsub(%r{[#{chars}]}) {|m| CGI.escape(m) } 236 | end 237 | 238 | CAPITALIZATION_EXCEPTIONS = %w[tmbundle on] 239 | # Convert a GitHub repo name into a "normal" TM bundle name 240 | # e.g. ruby-on-rails-tmbundle => Ruby on Rails.tmbundle 241 | def normalize_github_repo_name(name) 242 | name = name.gsub(/[\-\.]/, " ").split.each{|part| part.capitalize! unless CAPITALIZATION_EXCEPTIONS.include? part}.join(" ") 243 | name[-9] = ?. if name =~ / tmbundle$/ 244 | name 245 | end 246 | 247 | # Does the opposite of normalize_github_repo_name 248 | def denormalize_github_repo_name(name) 249 | name = name.split(' ').each{|part| part.downcase!}.join(' ').gsub(' ', '-') 250 | name += ".tmbundle" unless name =~ /.tmbundle$/ 251 | name 252 | end 253 | 254 | def find_github_bundles(search_term) 255 | # Until GitHub fixes http://support.github.com/discussions/feature-requests/11-api-search-results, 256 | # we need to account for multiple pages of results: 257 | page = 1 258 | repositories = YAML.load(open("http://github.com/api/v1/yaml/search/tmbundle?start_value=#{page}"))['repositories'] 259 | results = [] 260 | until repositories.empty? 261 | results += repositories.find_all{|result| result['name'].match(search_term)} 262 | page += 1 263 | repositories = YAML.load(open("http://github.com/api/v1/yaml/search/tmbundle?start_value=#{page}"))['repositories'] 264 | end 265 | results.sort{|a,b| a['name'] <=> b['name']} 266 | end 267 | 268 | def git_clone(repo, name) 269 | bundle_name = normalize_github_repo_name(name) 270 | 271 | path = "#{install_bundles_path}/#{bundle_name}" 272 | escaped_path = "#{e_sh(install_bundles_path)}/#{e_sh(bundle_name)}" 273 | 274 | if File.directory?(path) 275 | say "Sorry, that bundle is already installed. Please uninstall it first.", :red 276 | exit 277 | end 278 | 279 | %[git clone "git://github.com/#{url_escape(repo)}/#{url_escape(name)}.git" #{escaped_path} 2>&1] 280 | end 281 | 282 | end 283 | 284 | # TODO: create a "monument to personal cleverness" by class-izing everything? 285 | # class TextMateBundle 286 | # def self.find_local(bundle_name) 287 | # 288 | # end 289 | # 290 | # def self.find_remote(bundle_name) 291 | # 292 | # end 293 | # attr_reader :name 294 | # attr_reader :location 295 | # attr_reader :scm 296 | # def initialize(name, location, scm) 297 | # @name = name 298 | # @location = location 299 | # @scm = scm 300 | # end 301 | # 302 | # def install! 303 | # 304 | # end 305 | # 306 | # def uninstall! 307 | # 308 | # end 309 | # 310 | # 311 | # def installed? 312 | # # List all the installed versions, and where they're at 313 | # end 314 | # 315 | # # TODO: dirty? method to show if there are any deltas 316 | # end 317 | 318 | TextmateInstaller.start 319 | -------------------------------------------------------------------------------- /script/destroy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..')) 3 | 4 | begin 5 | require 'rubigen' 6 | rescue LoadError 7 | require 'rubygems' 8 | require 'rubigen' 9 | end 10 | require 'rubigen/scripts/destroy' 11 | 12 | ARGV.shift if ['--help', '-h'].include?(ARGV[0]) 13 | RubiGen::Base.use_component_sources! [:newgem_simple, :test_unit] 14 | RubiGen::Scripts::Destroy.new.run(ARGV) 15 | -------------------------------------------------------------------------------- /script/generate: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '..')) 3 | 4 | begin 5 | require 'rubigen' 6 | rescue LoadError 7 | require 'rubygems' 8 | require 'rubigen' 9 | end 10 | require 'rubigen/scripts/generate' 11 | 12 | ARGV.shift if ['--help', '-h'].include?(ARGV[0]) 13 | RubiGen::Base.use_component_sources! [:newgem_simple, :test_unit] 14 | RubiGen::Scripts::Generate.new.run(ARGV) 15 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $TESTING=true 2 | $:.push File.join(File.dirname(__FILE__), '..', 'lib') 3 | -------------------------------------------------------------------------------- /spec/tm_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/spec_helper' 2 | 3 | describe "tm" do 4 | it "should do nothing" do 5 | true.should == true 6 | end 7 | end -------------------------------------------------------------------------------- /textmate.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | Gem::Specification.new do |s| 4 | s.name = %q{textmate} 5 | s.version = "0.9.6" 6 | 7 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 8 | s.authors = ["Yehuda Katz"] 9 | s.autorequire = %q{textmate} 10 | s.date = %q{2009-08-03} 11 | s.default_executable = %q{textmate} 12 | s.description = %q{Command-line textmate package manager} 13 | s.email = %q{wycats@gmail.com} 14 | s.executables = ["textmate"] 15 | s.extra_rdoc_files = ["README.markdown", "LICENSE"] 16 | s.files = ["LICENSE", "README.markdown", "Rakefile", "bin/textmate"] 17 | s.homepage = %q{http://yehudakatz.com} 18 | s.require_paths = ["bin"] 19 | s.rubygems_version = %q{1.3.5} 20 | s.summary = %q{Command-line textmate package manager} 21 | 22 | if s.respond_to? :specification_version then 23 | current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION 24 | s.specification_version = 3 25 | 26 | if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then 27 | s.add_runtime_dependency(%q, [">= 0.9.2"]) 28 | else 29 | s.add_dependency(%q, [">= 0.9.2"]) 30 | end 31 | else 32 | s.add_dependency(%q, [">= 0.9.2"]) 33 | end 34 | end 35 | --------------------------------------------------------------------------------