#!/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.54 2001/06/15 08:24:02 knu Exp $
RCS_REVISION = RCS_ID.split[2]
MYNAME = File.basename($0)

require "optparse"
require "pkgtools"
require "tsort"

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
$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") {
      |$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("-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, '.'))
	  STDERR.puts "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
	skipped = false

	if arg.equal? all
	  next unless $all
	end

	$pkgdb.glob(arg, false).each do |pkgname|
	  if $exclude_packages.include? pkgname
	    skipped = true
	    next
	  end

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

	    if deps && !deps.empty?
	      deps.each do |i|
		tasks << i
		task_options[i] = {
		  :make_args => $make_args
		}
	      end
	    end
	  end

	  tasks << pkgname
	  task_options[pkgname] = {
	    :origin => $origin,
	    :make_args => $make_args
	  }
	  $origin = nil

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

	    if req_by
	      req_by.each do |i|
		tasks << i
		task_options[i] = {
		  :make_args => $make_args
		}
	      end
	    end
	  end

	  found = true
	end

	unless found
	  if skipped
	    STDERR.puts "All the packages matching '#{arg}' were excluded."
	  else
	    STDERR.puts "No such package '#{arg}' is found."
	  end
	end
      end

      if $sort
	tsort = TSort.new
	indep_tasks = []

	tasks.each do |pkgname|
	  deps = $pkgdb.pkgdep(pkgname)

	  if deps && !deps.empty?
	    deps.each { |dep|
	      tsort.add(dep, pkgname)
	    }
	  else
	    indep_tasks << pkgname
	  end
	end

	dep_tasks = tsort.tsort!.select { |pkgname| tasks.include?(pkgname) }

	tasks = dep_tasks | indep_tasks
      end
    rescue OptionParser::ParseError => e
      STDERR.puts "#{MYNAME}: #{e}", usage
      exit 64
    end

    failed_pkgs = []

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

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

      unless upgrade_pkg(pkg, $origin)
	failed_pkgs << pkg
      end
    end

    unless failed_pkgs.empty?
      STDERR.puts "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.to_s

  origin ||= oldpkg.origin

  if origin.nil?
    STDERR.puts "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.to_s
  else
    if $packages_only
      STDERR.puts "The package for '#{origin}' does not exist."
      return false
    end

    have_package = false

    portdir = File.join($ports_dir, origin)

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

    Dir.chdir portdir

    if $beforebuild_command
      system $beforebuild_command
    end

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

    if output.size != 1
      if $verbose
	STDERR.puts "#{origin}: port Makefile possibly broken:"
	output.each { |line| STDERR.print "\t", line }
      end
      return [5, nil]
    end

    newpkgname = output[0].chomp
    newpkg = nil

    begin
      newpkg = PkgInfo.new(newpkgname)
    rescue ArgumentError => e
      STDERR.puts "#{origin}: bad PKGNAME: #{e}"
      return false
    end
  end

  if newpkg.version <= oldpkg.version && !$force
    STDERR.puts "No need to upgrade #{newpkgname}. (#{newpkgname} is already installed)"
    return true
  end

  if $verbose
    progress_message "Upgrading '#{oldpkgname}' with '#{origin}' (version #{newpkg.version})"

    if $interactive
      return false unless prompt_yesno()
    end
  end

  return true if $noexecute

  unless have_package
    build_port(portdir) 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?
      STDERR.puts "Backup failed."
      return false
    end
  else
    teardown_proc = nil
  end

  if have_package
    install_pkg(newpkgname, teardown_proc) or return false
  else
    install_port(portdir, 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)

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

  true
end

def build_port(portdir)
  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

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

  true
end

def install_port(portdir, teardown_proc = nil)
  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 the port" if $verbose

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

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

    teardown_proc.call(:restore) if teardown_proc

    STDERR.puts "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 the package" if $verbose

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

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

    teardown_proc.call(:restore) if teardown_proc

    STDERR.puts "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."
  exit
end

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

  exit main(ARGV)
end
