#!/usr/bin/env ruby
#
# Copyright (c) 2000-2001 Akinori MUSHA
#
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
# SUCH DAMAGE.
#

RCS_ID = %q$Idaemons: /home/cvs/pkgtools/portupgrade,v 1.62 2001/06/26 13:00:52 knu Exp $
RCS_REVISION = RCS_ID.split[2]
MYNAME = File.basename($0)

require "optparse"
require "pkgtools"

REQUIRED_BY = PkgDB::PKGDB_FILES[:required_by]

$all = false
$force = false
$clean = false
$cleanup = false
$fetch_only = false
$uninstall = false
$recursive = false
$upward_recursive = false
$interactive = false
$sort = true	# now sorted by default
$afterinstall_command = nil
$beforebuild_command = nil
$noexecute = false
$new = MYNAME == 'portinstall'
$install_target = 'reinstall'
$packages_dir = File.join(ENV['PKGREPOSITORY'] || File.join($ports_dir, 'packages'),
			  ENV['PKGREPOSITORYSUBDIR'] || 'All')
$pkg_hash = {}
$use_packages = false
$packages_only = false
$exclude_packages = []
$origin = nil
$make_args = ""

COLUMNSIZE = 24
NEXTLINE = "\n%*s" % [5 + COLUMNSIZE, '']

def main(argv)
  usage = <<-"EOF"
usage: #{MYNAME} [-hacCfFinpPPrRsuv] [-A command] [-B command]
  [-S command] [-x pkgname_glob] [[-o origin] [-m make_args] pkgname_glob]
  [[-o origin] [-m make_args] pkgname_glob ...]
  EOF

  banner = <<-"EOF"
#{MYNAME} rev.#{RCS_REVISION}

#{usage}
  EOF

  OptionParser.new(banner, COLUMNSIZE) do |opts|
    opts.on("-h", "--help", "Show this message") {
      print opts
      return 0
    }

    opts.on("-a", "--all", "Do with all the installed packages") {
      |$all|
    }

    opts.on("-A", "--afterinstall=CMD", "Run the command after each installation") {
      |$afterinstall_command|
    }

    opts.on("-B", "--beforebuild=CMD", "Run the command before each build; If the command#{NEXTLINE}exits in failure, then the port will be skipped") {
      |$beforebuild_command|
    }

    opts.on("-c", "--clean", "Do \"make clean\" before each build, or do#{NEXTLINE}\"make distclean\" when specified with -F") {
      |$clean|
    }

    opts.on("-C", "--cleanup", "Do \"make clean\" after each installation") {
      |$cleanup|
    }

    opts.on("-f", "--force", "Force upgrade (even if equal or older)") {
      |$force|
    }

    opts.on("-F", "--fetch", "Only fetch distfiles, do not build anything") {
      |$fetch_only|
    }

    opts.on("-i", "--interactive", "Turn on interactive mode") {
      |$interactive|
      if $interactive
	$verbose = true
      end
    }

    opts.on("-m", "--make-args=ARGS", "Specify arguments to pass to make(1)") {
      |$make_args|
    }

    opts.on("-n", "--noexecute", "Do not upgrade any ports, but just show what would#{NEXTLINE}be done") {
      |$noexecute|
      if $noexecute
	$verbose = true
	$interactive = false
      end
    }

    opts.on("-N", "--new", "Install a new one when a specified package is#{NEXTLINE}not installed (default: #{MYNAME == 'portinstall' ? 'on' : 'off'})") {
      |$new|
    }

    opts.on("-o", "--origin=ORIGIN", "Specify a port to upgrade the following pkg with") {
      |$origin|
      $origin.sub!(%r`^#{Regexp.quote($ports_dir)}/`, '')
    }

    opts.on("-p", "--package", "Build package when each port is installed") {
      |flag|
      if flag
	$install_target = 'reinstall package'
      end
    }

    opts.on("-P", "--use-packages", "Use packages instead of ports whenever available#{NEXTLINE}Specified twice, use no#{NEXTLINE}ports but packages only") {
      |arg|
      if $use_packages
	$packages_only = true
      else
	unless File.directory?(File.join($packages_dir, '.'))
	  warning_message "No such directory '#{$packages_dir}' is found."
	  exit 1
	end

	progress_message "Scanning the directory: " + $packages_dir

	IO.popen("cd #{$packages_dir} && pkg_info -o *.tgz") do |r|
	  pkgname = nil

	  r.each do |line|
	    case line
	    when /^Information for +(\S+-\S+)\.tgz:/
	      pkgname = $1
	    when /^(\S+\/\S+)$/		# /
	      origin = $1

	      if pkgname
		printf "%-26s  %s\n", pkgname, origin if $verbose

		pkg = PkgInfo.new(pkgname)

		pkg0 = $pkg_hash[origin]

		if pkg0.nil? || pkg0 < pkg
		  $pkg_hash[origin] = pkg
		end

		pkgname = nil
	      end
	    end
	  end
	end

        $use_packages = true
      end
    }

    opts.on("-r", "--recursive", "Do with all those depending on the given packages#{NEXTLINE}as well") {
      |$recursive|
      $sort = true
    }

    opts.on("-R", "--upward-recursive", "Do with all those required by the given packages#{NEXTLINE}as well") {
      |$upward_recursive|
      $sort = true
    }

    opts.on("-s", "--sudo", "Run commands under sudo(8) where needed") {
      |$sudo|
    }

    opts.on("-S", "--sudo-command=CMD", "Specify an alternative to sudo(8)#{NEXTLINE}e.g. 'su root -c \"%s\"' (default: sudo)") {
      |sudo_command|
      $sudo_args = shellwords(sudo_command)
    }

    opts.on("-u", "--uninstall", "Uninstall before each port installation") {
      |$uninstall|
    }

    opts.on("-v", "--verbose", "Be verbose") {
      |$verbose|
    }

    opts.on("-x", "--exclude=GLOB", "Exclude packages matching the specified glob#{NEXTLINE}pattern") {
      |arg|
      $exclude_packages |= $pkgdb.glob(arg, false)
    }

    opts.on_tail <<-"EOF".chomp

pkgname_glob is one of these: a full pkgname, a pkgname w/o version,
or a shell glob pattern in which you can use the wildcards *, ?, and [..].
The package list is automatically sorted in dependency order.

Environment Variables [default]:
    TMPDIR                   temporary directory (for backup etc.) [/var/tmp]
    PORTSDIR                 ports directory [/usr/ports]
    PKGREPOSITORY            packages directory [$PORTSDIR/packages]
    PKGREPOSITORYSUBDIR      packages subdirectory [All]
    PKG_DBDIR                packages DB directory [/var/db/pkg]
    PORTUPGRADE              default options (e.g. -v) [none]
    EOF

    tasks = []
    task_options = {}

    begin
      opts.parse!(argv)

      envopt = ENV[MYNAME.upcase]
      if envopt
	progress_message "Reading default options: " + envopt if $verbose

	opts.parse(*shellwords(envopt))
      end

      if argv.empty? && !$all
	print opts
	return 0
      end

      all = '*'
      argv << all

      opts.order(*argv) do |arg|
	found = false

	if arg.equal? all
	  next unless $all
	end

	size = tasks.size

	$pkgdb.glob(arg, false).each do |pkgname|
	  found = true

	  if $upward_recursive
	    deps = $pkgdb.pkgdep(pkgname)

	    if deps && !deps.empty?
	      deps.each do |i|
		next if $exclude_packages.include? i

		tasks << i
		task_options[i] = {
		  :make_args => $make_args
		}
	      end
	    end
	  end

	  unless $exclude_packages.include? pkgname
	    tasks << pkgname
	    task_options[pkgname] = {
	      :origin => $origin,
	      :make_args => $make_args
	    }
	  end

	  if $recursive
	    req_by = $pkgdb.required_by(pkgname)

	    if req_by
	      req_by.each do |i|
		next if $exclude_packages.include? i

		tasks << i
		task_options[i] = {
		  :make_args => $make_args
		}
	      end
	    end
	  end
	end

	$origin = nil

	if found
	  if size == tasks.size
	    warning_message "All the packages matching '#{arg}' were excluded."
	  end
	elsif $new
	  ports = $portsdb.glob(arg)

	  case ports.size
	  when 0
	    warning_message "No such installed package nor such port called '#{arg}' is found."
	    next
	  when 1
	    install_new_port ports[0]
	  else
	    progress_message "#{ports.size} ports match the given pattern '#{arg}'."

	    ports.each do |portinfo|
	      install_new_port portinfo, true
	    end
	  end
	else
	  warning_message "No such package '#{arg}' is installed."
	  next
	end
      end
 
      if $sort
	$pkgdb.sort!(tasks)
      end
    rescue OptionParser::ParseError => e
      STDERR.puts "#{MYNAME}: #{e}", usage
      exit 64
    end

    upgraded_pkgs = []
    failed_pkgs = []

    $interrupt_proc = proc { 
      unless upgraded_pkgs.empty?
	warning_message "The following packages have been successfully upgraded:"
	failed_pkgs.each do |pkg|
	  STDERR.puts "\t" + pkg.fullname
	end
      end

      unless failed_pkgs.empty?
	warning_message "Some ports could not be upgraded due to errors:"
	failed_pkgs.each do |pkg|
	  STDERR.puts "\t" + pkg.fullname
	end
      end
    }      

    tasks.each do |pkgname|
      pkg = PkgInfo.new(pkgname)

      $origin, $make_args = task_options[pkgname].indices(:origin, :make_args)

      if upgrade_pkg(pkg, $origin)
	upgraded_pkgs << pkg
      else
	failed_pkgs << pkg
      end
    end

    unless failed_pkgs.empty?
      warning_message "Some ports could not be upgraded due to errors:"
      failed_pkgs.each do |pkg|
	STDERR.puts "\t" + pkg.fullname
      end
    end

    return failed_pkgs.size
  end
end

def upgrade_pkg(oldpkg, origin = nil)
  oldpkgname = oldpkg.fullname

  origin ||= oldpkg.origin

  if origin.nil?
    warning_message "The origin of '#{oldpkgname}' is unknown. (Specify it with -o option)"
    return false
  end

  if $use_packages && $pkg_hash.include?(origin)
    have_package = true

    newpkg = $pkg_hash[origin]
    newpkgname = newpkg.fullname
  else
    if $packages_only
      warning_message "The package for '#{origin}' does not exist."
      return false
    end

    have_package = false

    newpkgname = check_pkgname(origin)

    case newpkgname
    when :skip
      warning_message "Command failed: " + $beforebuild_command
      progress_message "Skipping '#{origin}'"
      return false
    when nil, :broken
      return false
    end

    newpkg = nil

    begin
      newpkg = PkgInfo.new(newpkgname)
    rescue ArgumentError => e
      warning_message "Invalid package name: #{origin}: #{e}"
      return false
    end
  end

  cmp = newpkg.version <=> oldpkg.version

  if cmp > 0
    service = :upgrade
  elsif cmp == 0
    service = :reinstall
  else
    service = :downgrade
  end

  if newpkg.name != oldpkg.name
    warning_message "Package name changed from '#{oldpkg.name}' (#{oldpkg.origin || 'unknown'}) to '#{newpkg.name}' (#{origin})."
  end

  if service != :upgrade && !$force
    warning_message "No need to upgrade '#{oldpkgname}' (>= #{newpkgname})."
    return true
  end

  if $verbose || $interactive
    case service
    when :upgrade
      progress_message "Upgrading '#{oldpkgname}' to '#{newpkgname}' (#{origin})"
    when :downgrade
      progress_message "Downgrading '#{oldpkgname}' to '#{newpkgname}' (#{origin})"
    when :reinstall
      progress_message "Reinstalling '#{oldpkgname}' (#{origin})"
    end
  end

  if $interactive && !prompt_yesno('OK?', true)
    return false
  end

  return true if $noexecute

  unless have_package
    build_port(origin) or return false

    return true if $fetch_only
  end

  update_pkgdep(oldpkgname, newpkgname)

  req_by = oldpkg.required_by

  if $uninstall
    teardown_proc = uninstall_pkg(oldpkgname)

    if teardown_proc.nil?
      warning_message "Backup failed."
      return false
    end
  else
    teardown_proc = nil
  end

  if have_package
    install_pkg(newpkgname, teardown_proc) or return false
  else
    install_port(origin, teardown_proc) or return false
  end

  unless $uninstall || oldpkgname == newpkgname
    progress_message "Removing the old package info" if $verbose
    
    sudo '/bin/rm', '-rf', oldpkg.pkgdir
  end

  if req_by
    progress_message "Restoring the dependency info" if $verbose

    t = Tempfile.new(REQUIRED_BY)
    t.puts req_by
    t.close
    File.chmod 0644, t.path
    sudo '/bin/cp', t.path, newpkg.pkg_required_by

    if $? != 0
      saved_file = File.join($tmpdir, REQUIRED_BY + '.' + newpkgname)

      warning_message "Copy failed.  Saving as '#{saved_file}'."
      system '/bin/cp', t.path, saved_file
    end
  end

  true
end

def check_pkgname(origin)
  portdir = File.join($ports_dir, origin)

  unless File.directory? portdir
    warning_message "The port directory for '#{origin}' does not exist."
    return nil
  end

  Dir.chdir portdir

  if $beforebuild_command
    system $beforebuild_command

    if $? != 0
      return :skip
    end
  end

  output = `make -V PKGNAME #{$make_args} 2>&1`.to_a

  if output.size != 1
    if $verbose
      warning_message "Makefile of #{origin} is possibly broken:"
      output.each { |line| STDERR.print "\t" + line }
    end
    return :broken
  end

  output[0].chomp
end

def install_new_port(portinfo, interactive = $interactive)
  origin = portinfo.origin

  if $use_packages && $pkg_hash.include?(origin)
    have_package = true

    newpkg = $pkg_hash[origin]
    newpkgname = newpkg.fullname
  else
    if $packages_only
      warning_message "The package for '#{origin}' does not exist."
      return false
    end

    have_package = false

    newpkgname = check_pkgname(origin)

    case newpkgname
    when :skip
      warning_message "Command failed: " + $beforebuild_command
      progress_message "Skipping '#{origin}'"
      return false
    when nil, :broken
      return false
    end

    newpkg = nil

    begin
      newpkg = PkgInfo.new(newpkgname)
    rescue ArgumentError => e
      warning_message "Invalid package name: #{origin}: #{e}"
      return false
    end
  end

  if $verbose || interactive
    if have_package
      progress_message "Installing '#{newpkgname}' from a package"
    else
      progress_message "Installing '#{newpkgname}' from a port (#{origin})"
    end
  end

  if interactive
    if $noexecute
      puts 'OK? [no]'
      return false
    end

    prompt_yesno or return false
  end

  return true if $noexecute

  if have_package
    return true if $fetch_only

    install_pkg(newpkgname) or return false
  else
    build_port(origin) or return false

    return true if $fetch_only

    install_port(origin) or return false
  end
end

def build_port(origin)
  portdir = File.join($ports_dir, origin)

  cmdargs = ['make']

  cmdargs.push(*shellwords($make_args.dup)) unless $make_args.empty?

  if $fetch_only
    if $clean
      cmdargs << 'distclean'
    end

    cmdargs << 'fetch' << '-DBATCH' << '-DPACKAGE_BUILDING'
  else
    if $clean
      cmdargs << 'clean'
    end

    cmdargs << 'build'
  end
  
  progress_message "#{$fetch_only ? 'Fetching' : 'Building'} '#{portdir}'" if $verbose

  Dir.chdir(portdir)

  unless system(*cmdargs)
    warning_message "Command failed: " + shelljoin(*cmdargs)
    warning_message "Fix the problem and try again."
    return false
  end

  true
end

def install_port(origin, teardown_proc = nil)
  portdir = File.join($ports_dir, origin)

  install_target_a = shellwords($install_target.dup)

  cmdargs = ['make', *install_target_a]

  cmdargs.push(*shellwords($make_args.dup)) unless $make_args.empty?

  if $recursive && install_target_a.include?('package')
    cmdargs << 'DEPENDS_TARGET=package'
  end

  if $force
    cmdargs << '-DFORCE_PKG_REGISTER'
  end

  if $cleanup
    cmdargs << 'clean'
  end

  progress_message "Installing the new version via ports" if $verbose

  Dir.chdir(portdir)

  if sudo(*cmdargs)
    teardown_proc.call(:cleanup) if teardown_proc

    if $afterinstall_command
      system $afterinstall_command
    end
  else
    warning_message "Command failed: " + shelljoin(*cmdargs)

    teardown_proc.call(:restore) if teardown_proc

    warning_message "Fix the installation problem and try again."
    return false
  end

  true
end

def install_pkg(pkgname, teardown_proc = nil)
  cmdargs = ['pkg_add', '-f', File.join($packages_dir, pkgname + '.tgz')]

  progress_message "Installing the new version via packages" if $verbose

  if sudo(*cmdargs)
    teardown_proc.call(:cleanup) if teardown_proc

    if $afterinstall_command
      system $afterinstall_command
    end
  else
    warning_message "Command failed: " + shelljoin(*cmdargs)

    teardown_proc.call(:restore) if teardown_proc

    warning_message "Fix the package's problem and try again."
    return false
  end

  true
end

def uninstall_pkg(pkgname)
  progress_message "Backing up the old version" if $verbose

  backup_tgz_name = nil
  backup_tmp_tgz = nil

  `pkg_tarup #{pkgname}`.each { |line|
    if /^Creating gzip\'d tar ball in \'(.*)\'/ =~ line
      backup_tmp_tgz = $1
      backup_tgz_name = File.basename(backup_tmp_tgz)
      break
    end
  }

  if backup_tmp_tgz.nil? || !File.file?(backup_tmp_tgz)
    return nil
  end

  pkgdir = $pkgdb.pkgdir(pkgname)
  backup_dir = pkgdir + '.bak'

  sudo '/bin/cp', '-RPp', pkgdir, backup_dir
  sudo '/bin/mv', '-f', backup_tmp_tgz, backup_dir

  progress_message "Uninstalling the old version" if $verbose

  sudo 'pkg_delete', '-f', pkgname

  proc { |behavior|
    case behavior
    when :restore
      progress_message "Restoring the old version" if $verbose

      backup_tgz = File.join(backup_dir, backup_tgz_name)
      sudo 'pkg_add', '-f', backup_tgz
      sudo '/bin/rm', '-rf', backup_tgz, pkgdir
      sudo '/bin/mv', '-f', backup_dir, pkgdir
    when :cleanup
      progress_message "Cleaning up the backup" if $verbose

      sudo '/bin/rm', '-rf', backup_dir
    end
  }
end

def signal_handler(sig)
  puts "\nInterrupted."
  $interrupt_proc.call if $interrupt_proc
  exit
end

if $0 == __FILE__
  for sig in [2, 3, 15]
    trap(sig) do
      signal_handler(sig)
    end
  end

  exit main(ARGV)
end
