#!/usr/bin/env zsh ## pkg - /home package manager and zsh playground ## Copyright © 2008-2009 by Daniel Friesel ## ## Permission to use, copy, modify, and/or distribute this software for any ## purpose with or without fee is hereby granted, provided that the above ## copyright notice and this permission notice appear in all copies. ## ## THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES ## WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF ## MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ## ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES ## WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ## ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF ## OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. setopt extended_glob typeset -a -U triggers typeset -a CL_OPTIONS ## ## Internal functions for displaying stuff ## c_info=$'\e[0;36m' c_error=$'\e[0;31m' c_reset=$'\e[0m' function debug { typeset func line if (( ${#*} >= 3 )) { func=$1 line=$2 shift 2 } echo "(debug) $func:$line: $*" >&2 } # I need function name and line number of the function _calling_ debug, # so I can't get them from inside the debug function. alias debug='debug ${(%):-%N} ${(%):-%i}' function info { echo -ne "${c_info}$*${c_reset}" } function warn { echo -ne "${c_error}$*${c_reset}" > /dev/stderr } function die { echo -ne "${c_error}$*${c_reset}" > /dev/stderr exit 100 } function say { echo $* } function check_installed { [[ -n $1 && -d $PDIR/$1 ]] || die "Package not installed: '$1'\n" } function check_valid { list_exists $1 || die "Package does not exist: '$1'\n" } function clear_line { echo -ne "\r\e[2K" } # Read local configuration if [[ -f $HOME/.pkg.conf ]] { source $HOME/.pkg.conf } # Parse commandline options while [[ $1 == --* ]] { case $1 in --quiet) SILENT=1 ;; --no-quiiet) SILENT=0 ;; --debug) DEBUG=1 ;; --no-debug) DEBUG=0 ;; --auto-update) AUTOUPDATE=1 ;; --no-auto-update) AUTOUPDATE=0 ;; --checklinks-options) CL_OPTIONS+=$2; shift ;; --packagedir) PDIR=$2; shift ;; --packageroot) PKG_ROOT=$2; shift ;; *) die "Unknown argument: '$1'\n" ;; esac shift } action=$1 shift # Default values... or not [[ -n $PKG_ROOT ]] || die "No PKG_ROOT specified. Please edit ~/.pkg.conf\n" : ${PDIR:="$HOME/packages"} : ${CL_OPTIONS:=-q} : ${SILENT=0} : ${DEBUG=0} : ${AUTOUPDATE=1} : ${GIT_USE_ORIGIN=1} export PDIR export PKG_ROOT # Avoid calling debug without debug mode... just in case one needs more speed if (( !DEBUG )) { unalias debug function debug {} } if (( SILENT )) { # The goal is not to override anything set by the user... # So, an alias should be safer than fiddling with $MAKEFLAGS alias make='make -s' function info {} function say {} function clear_line {} } ## ## Make sure everything's sane ## Warn otherwise ## if [[ ! -d $PDIR ]] { die "Error: Package directory '$PDIR' not found\n" } ## ## Setup some additional variables related to PKG_ROOT ## # Protocol if [[ $PKG_ROOT == ssh://* ]] { PKG_PROTO='ssh' } elif [[ $PKG_ROOT == /* ]] { PKG_PROTO='file' } else { die "Error: Unknown protocol in PKG_ROOT '$PKG_ROOT'\n" } # user, host, path if [[ $PKG_PROTO == 'ssh' ]] { PKG_HOST=${${PKG_ROOT#'ssh://'}%%/*} PKG_PATH=${PKG_ROOT#"ssh://$PKG_HOST"} if [[ $PKG_HOST == *@* ]] { PKG_USER=${PKG_HOST%%@*} PKG_HOST=${PKG_HOST#*@} } else { PKG_USER=$USERNAME } } elif [[ $PKG_PROTO == 'file' ]] { PKG_PATH=$PKG_ROOT } : ${PKGLIST_PATH:=$PKG_PATH/pkglist} debug "Running zsh v$ZSH_VERSION" debug "--- settings ---" debug " PKG_ROOT '$PKG_ROOT'" debug " PKG_PROTO: '$PKG_PROTO'" debug " PKG_USER: '$PKG_USER'" debug " PKG_HOST: '$PKG_HOST'" debug " PKGLIST_PATH: '$PKGLIST_PATH'" debug " PDIR: '$PDIR'" debug " CL_OPTIONS: '$CL_OPTIONS'" debug " SILENT: '$SILENT'" debug " AUTOUPDATE: '$AUTOUPDATE'" debug " GIT_USE_ORIGIN: '$GIT_USE_ORIGIN'" ## ## Ask the user for confirmation ## # Default reply: Yes function confirm_yes { echo -n "$* [Y/n] " read -k 1 [[ $REPLY != $'\n' ]] && echo [[ $REPLY == 'y' || $REPLY == 'Y' || $REPLY == $'\n' ]] } # Default reply: No function confirm_no { echo -n "$* [y/N] " read -q } ## ## Major internal functions ## # this function only has content when wrap is used. # but since it's always called, it will be empty by default function wrap_info {} function progress { (( SILENT )) && return typeset -i current=$1 typeset -i max=$2 typeset desc=$3 typeset desc2=$4 typeset output='' typeset -i currentper=$(( (current*100)/max )) typeset item j c a function item { for j in {0..$1}; { (( j > 0 )) && output+=$2 } } clear_line c=$(( currentper/5 )) a=$(( 20-c )) output+="${c_info}$desc${c_reset} [" item $c '=' item $a ' ' output+="] $currentper% $desc2" echo -ne $output } ## VCS Wrappers function vcs_setup { export GIT_DIR=$PDIR/$1/.git export GIT_WORK_TREE=$PDIR/$1 } function vcs_to_list { vcs_setup $1 if [[ -d $PDIR/$1/.git ]] { echo -n "$1 git " echo ${$(git log -n 1 master)[2]} } else { warn "No git repository found: $1\n" } } function vcs_add { case $(list_type $1) in git) git clone "$PKG_ROOT/$1" ;; *) die "$1: Cannot handle repository format '$(list_type $1)'\n" ;; esac } function vcs_log { vcs_setup $1 git log } function vcs_branch_master { vcs_setup $1 typeset IFS=$'\n' branch line for line in $(git branch); { [[ $line == \*\ * ]] && branch=${line#* } } if [[ $branch != master ]] { warn "$1: The currently checked out branch is not master, but '$branch'\n" \ "Currently, with GIT_USE_ORIGIN=0, pkg can only operate on the branch master\n" \ " -> skipping repo, please fix manually\n" return 1 } } function vcs_pull { vcs_setup $1 if ((GIT_USE_ORIGIN)) { # the package might be newly created and not have an origin yet vcs_fix_origin $1 git pull } else { vcs_branch_master $1 && git pull $PKG_ROOT/${PWD:t} master } } function vcs_push { vcs_setup $1 if ((GIT_USE_ORIGIN)) { # see above vcs_fix_origin $1 git push } else { vcs_branch_master $1 && git push $PKG_ROOT/${PWD:t} master } } function vcs_status { vcs_setup $1 git status } # Set the correct origin function vcs_fix_origin { vcs_setup $1 if [[ ! -r $GIT_DIR/remotes/origin && ! -r $GIT_DIR/refs/remotes/origin/HEAD ]] { fgrep '[remote "origin"]' $GIT_DIR/config &> /dev/null || git remote add origin $PKG_ROOT/${PWD:t} } } ## List stuff function list_is_installed { grep "^$1 " $PDIR/.list &> /dev/null } function list_exists { grep "^$1 " $PDIR/.list-remote &> /dev/null } function list_packages_local { cut -d ' ' -f 1 $PDIR/.list } function list_packages_remote { cut -d ' ' -f 1 $PDIR/.list-remote } function list_incoming { [[ $(list_version_local $1) != $(list_version_remote $1) ]] } function list_type { echo ${$(grep "^$1 " $PDIR/.list-remote)[2]} } function list_type_local { echo ${$(grep "^$1 " $PDIR/.list)[2]} } function list_update_remote { typeset tmpfile=$(mktemp -t pkglist.XXXXXX) typeset -i ret=0 export PDIR if [[ $PKG_PROTO == 'ssh' ]] { ssh $PKG_USER@$PKG_HOST "PDIR='$PDIR' $PKGLIST_PATH $PKG_PATH" > $tmpfile } elif [[ $PKG_PROTO == 'file' ]] { $PKGLIST_PATH $PKG_PATH > $tmpfile } if [[ -n $(cat $tmpfile) ]] { cp $tmpfile .list-remote } else { die "remote list update failed\n" \ "-- note --\n" \ "There were recent changes in pkg involving the package root\n\n" \ "You might want to copy the pkglist script ($PDIR/core/include/pkglist)\n" \ "into the package root, so that it's available as\n" \ "$PKG_HOST:$PKG_PATH/pkglist\n" "alternatively, set PKGLIST_PATH to the correct value (see pkg.conf(5))\n" } rm $tmpfile } function list_update_local { typeset all=${#$(echo $PDIR/*(/))} typeset -i current=0 typeset i rm -f $PDIR/.list for package in *(-/); { (( current++ )) progress $current $all 'Updating package list' $package vcs_to_list $package >> $PDIR/.list } } function list_package_update { typeset list list=$(grep -v "^$1 " $PDIR/.list) echo $list > $PDIR/.list vcs_to_list $1 >> $PDIR/.list } function list_package_remove { typeset list list=$(grep -v "^$1 " $PDIR/.list) echo $list > $PDIR/.list } function list_version_local { echo ${$(grep "^$1 " $PDIR/.list)[3]} } function list_version_remote { echo ${$(grep "^$1 " $PDIR/.list-remote)[3]} } # Return an understandable priority from the numeric one function real_priority { case $1 in 6) echo 'essential' ;; 5) echo 'important' ;; 4) echo 'required' ;; 3) echo 'standard' ;; 2) echo 'optional' ;; 1) echo 'extra' ;; *) warn "invalid priority: $!" ;; esac } # Execute a hook function exec_hook { typeset package=$1 typeset hook=$2 if [[ -r $PDIR/$package/hooks/$hook ]] { info "$package: executing hook $hook\n" cd $PDIR/$package (source hooks/$hook) } } function global_hook { cd $PDIR/$1 case $2 in post-add) exec_hook $1 post-add global_hook $1 post-update ;; pre-update) ;; post-update) triggers+=$1 check_prereqs $1 if [[ -r Makefile ]] { wrap_info $1 info "Running make\n" make } checklinks $CL_OPTIONS \ --parameter package=${${PWD#$HOME}#/##} \ --parameter etc=${${PWD#$HOME}#/##}/etc populate_collected $1 update_provides $1 list_package_update $1 ;; pre-remove) exec_hook $1 pre-remove genocide_collected $1 checklinks -r list_package_remove $1 update_provides $1 'remove' ;; esac (( $+functions[pkg_hook_$2] )) && pkg_hook_$2 $1 } # Check dependencies, conflicts etc. function check_prereqs { typeset -a -U install maybe_install typeset warn info i typeset package=$1 [[ -r $PDIR/$package/prereqs ]] || return 0 cd $PDIR/$package wrap_info $1 info "checking prerequisites\n" function is_installed { [[ -d $PDIR/$1 ]] } function perlmodule { perl -M$1 < /dev/null 2> /dev/null } function file_in_path { which $1 > /dev/null } function offer_install { install+=$1 } function require { if [[ $1 == 'package' ]] { is_installed $2 || offer_install $2 } else { $* || warn+="Requirement failed: $*\n" } } function depend { require $* } function suggest { if [[ $1 == 'package' ]] { is_installed $2 || info "$package suggests package $2\n" } else { $* || echo "Suggest failed: $*\n" } } function recommend { if [[ $1 == 'package' ]] { is_installed $2 || maybe_install+=$2 } else { $* || info+="Recommend failed: $*\n" } } { source prereqs } always { if (( TRY_BLOCK_ERROR )) { warn "Error in prereqs script\n" TRY_BLOCK_ERROR=0 } } if [[ -n $warn || -n $info ]] { [[ -n $warn ]] && warn $warn [[ -n $info ]] && echo -n $info read -q } if [[ -n $install ]] { info "$1 requires the following packages: ${(j:, :)install}\n" if confirm_yes "Install them?"; then for package in $install; { pkg_add $package } fi } if [[ -n $maybe_install ]] { info "$1 recommends the following packages: ${(j:, :)maybe_install}\n" if confirm_no "Install them?"; then for package in $maybe_install; { pkg_add $package } fi } } # Write a package's documentation to .collected # and symlink its binaries from ~/bin function populate_collected { cd $PDIR/$1 || return typeset -i bins=0 bino=0 mans=0 mano=0 typeset i wrap_info $1 info "Enabling documentation " for man in man/*/*(N); { section=${man:h:t} manpage=${man:t} if podchecker man/$section/$manpage &> /dev/null; then pod2man -u -s $section -c "$1 package" -r $HOME man/$section/$manpage > $PDIR/.collected/man/man$section/$manpage.$section say -n "+" (( mans++ )) else say -n "." (( mano++ )) fi } for file in bin/*(N); { if podchecker $file &> /dev/null; then pod2man -u $file > $PDIR/.collected/man/man1/${file:t}.1 say -n "+" (( bins++ )) else say -n "." (( bino++ )) fi } clear_line if (( bins + mans > 0 )) { wrap_info $1 info "Compiled documentation " say -n '(' if (( bins + bino > 0 )) { say -n "$(( bins*100/(bins+bino) ))% bin" (( mans + mano > 0 )) && say -n ', ' } if (( mans + mano > 0 )) { say -n "$(( mans*100/(mans+mano) ))% man" } say ')' } for file in bin/*(-*N); { if [[ -L $HOME/$file || ! -e $HOME/$file ]] { if [[ $(readlink $HOME/$file) != (../${PDIR//$HOME\/}|$PDIR)/$1/$file ]] { rm -f $HOME/$file if [[ $PDIR == ${HOME}* ]] { ln -s ../${PDIR//$HOME\/}/$1/$file $HOME/$file } else { ln -s $PDIR/$1/$file $HOME/$file } } } else { warn "populate_collected: Not updating ~/$file since it's not a symlink\n" } } } # Remove a package's files from .collected # Assuming there are no packages with colliding files function genocide_collected { typeset i cd $PDIR/$1 || return wrap_info $1 info "Removing documentation" for man in man/*/*(N); { section=${man:h:t} manual=${man:t} if [[ -e $PDIR/.collected/man/man$section/$manual.$section ]] { rm $PDIR/.collected/man/man$section/$manual.$section } } for file in bin/*(N); { rm -f $PDIR/.collected/man/man1/${file:t}.1 } clear_line for file in bin/*(-*N); { if [[ $(readlink $HOME/$file) == (../${PDIR//$HOME\/}|$PDIR)/$1/$file ]] { rm -f $HOME/$file } } } function update_provides { typeset package [[ -d $PDIR/$1/provides ]] || return cd $PDIR/$1/provides for package in *; { if [[ -d $PDIR/$package ]] { [[ $2 = 'remove' ]] && rm -r $package triggers+=$package } } } function apply_triggers { typeset package for package in $triggers; { exec_hook $package 'post-update' } } # Iterate a function over every installed package function wrap { typeset function=$1 typeset arg=$2 typeset progress=$3 typeset i typeset -i wrapped all current if [[ -n $2 ]] { wrapped=0 $function $2 } else { wrapped=1 function wrap_info { clear_line info "$1: " } [[ -n $progress ]] && all=$(list_packages_local | wc -l) [[ -n $progress ]] && current=0 for package in *(-/); { cd $PDIR (( current++ )) [[ -n $progress ]] && progress $current $all $progress $package $function $package } [[ -n $progress ]] && clear_line } } ## ## Finally - the functions actually doing something ## function pkg_add { if [[ -d $PDIR/$1 ]] { info "Package '$1' is already installed!\n" exit 1 } check_valid $1 info "Retrieving package $1...\n" vcs_add $1 || return 255 cd $1 || return 255 global_hook $1 post-add } function pkg_push { check_installed $1 check_valid $1 cd $PDIR/$1 if list_incoming $1; then clear_line info "Pushing $1\n" global_hook $1 pre-update vcs_push $1 global_hook $1 post-update fi } function pkg_remove { check_installed $1 cd $PDIR/$1 if [[ -r priority ]] { if (( $(cat priority) > 3 )) { confirm_no "Package '$1' is $(real_priority $(cat priority)). Really remove?" || return } } global_hook $1 pre-remove rm -rf $PDIR/$1 info "Package removed.\n" } function pkg_upgrade { check_installed $1 check_valid $1 cd $PDIR/$1 if [[ $(list_type $1) != $(list_type_local $1) ]] { clear_line warn "Incompatible systems. Please reinstall: $1\n" warn " remote '$(list_type $1)' <-> local '$(list_type_local)'\n" return 9 } if list_incoming $1; then clear_line info "Updating $1 to $(list_version_remote $1)\n" global_hook $1 pre-update vcs_pull $1 global_hook $1 post-update fi } function pkg_list { typeset package crap case $1 in ''|local) list_packages_local ;; all|remote) list_packages_remote ;; not-installed) for package in $(list_packages_remote); { list_is_installed $package || echo $package } ;; esac } function pkg_status { typeset vcs_status check_installed $1 cd $PDIR/$1 vcs_status=$(PAGER='' vcs_status $1) if [[ -n $vcs_status && $(echo $vcs_status | tail -n 1) != 'nothing to commit (working directory clean)' ]] { clear_line info "$1:\n" echo $vcs_status } } function pkg_refresh { check_installed $1 cd $PDIR/$1 global_hook $1 pre-update if [[ -r Makefile ]] { wrap_info $1 info "Cleaning build diroctery\n" make clean } global_hook $1 post-update } function pkg_update { pkg_update_remote pkg_update_local } function pkg_update_remote { info "Updating remote package list..." list_update_remote clear_line } function pkg_update_local { info "Updating local package list..." list_update_local clear_line } # Various information related to a package function pkg_info { check_valid $1 # Fetch the infos typeset name=$1 typeset local_version=$(list_version_local $1) typeset remote_version=$(list_version_remote $1) typeset repo_type=$(list_type $1) typeset priority priority_name typeset hooks makefile discription state if [[ -d $1 ]] { cd $1 if [[ -r priority ]] { priority=$(cat priority) priority_name=$(real_priority $priority) } if [[ -d hooks ]] { hooks=$(ls -m hooks) } if [[ -r Makefile ]] { makefile=1 } size=$(du -sh .$(list_type $1) | grep -o '.*[KMG]') if [[ -r description ]] { description=$(cat description) } state='installed' if list_incoming $1; then state+=', needs update' else state+=', up-to-date' fi } else { state='not installed' } function show_info { [[ -z $2 ]] && return info "$1: " echo $2 } show_info 'Package' $name show_info 'State' $state [[ -n $priority ]] && show_info 'Priority' "$priority ($priority_name)" show_info 'Local Version' $local_version show_info 'Remote Version' $remote_version show_info 'Repository Type' $repo_type show_info 'Repository Size' $size show_info 'Hooks' $hooks show_info 'Description' $description } function pkg_log { check_installed $1 cd $PDIR/$1 vcs_log $1 } ## ## Now what shall we do... ## cd $PDIR || die "Cannot cd $PDIR" # Note: # wrap foobar "$1" <- the "" are neccessary here, since $1 is optional (and therefore may be empty) case $action in add) pkg_add $* ;; delete) pkg_remove $* ;; info) pkg_info $* ;; install) pkg_add $* ;; list) pkg_list $* ;; local-update) pkg_update_local $* ;; log) pkg_log $* ;; push) (( AUTOUPDATE )) && pkg_update wrap pkg_push "$1" 'Pushing' ;; refresh) wrap pkg_refresh "$1" 'Refreshing' ;; remote-update) pkg_update_remote $* ;; remove) pkg_remove $* ;; status) wrap pkg_status "$1" 'Checking package status' ;; update) pkg_update $* ;; upgrade|pull) (( AUTOUPDATE )) && pkg_update_remote wrap pkg_upgrade "$1" 'Looking for updates' ;; eval) eval $* ;; *) die "wait, what?\npkg: unknown action: '$action'\n" ;; esac apply_triggers