#!/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 ## ## 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 { (( SILENT )) || 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 { (( SILENT )) || echo $* } function check_installed { [[ -n $1 && -d $PDIR/$1 ]] || die "Not installed: '$1'\n" } function check_valid { if ! list_exists $1; then die "No such package: '$1'\n" fi } function clear_line { (( SILENT )) || echo -ne "\r\e[2K" } # Read local configuration if [[ -f $HOME/.pkg.conf ]] { . $HOME/.pkg.conf } # 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 ## ## commandline parsing ## # commandline options override everything else while [[ $1 == [-+]* ]] { case $1 in -q|--quiet) SILENT=1 ;; +q|--no-quiiet) SILENT=0 ;; -d|--debug) DEBUG=1 ;; +d|--no-debug) DEBUG=0 ;; -au|--auto-update) AUTOUPDATE=1 ;; +au|--no-auto-update) AUTOUPDATE=0 ;; -co|--checklinks-options) CL_OPTIONS+=$2; shift ;; -p|--packagedir) PDIR=$2; shift ;; *) die "Unknown argument: '$1'\n" ;; esac shift } action=$1 shift # 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' } ## ## 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'" } # 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 "PKG_ROOT: '$PKG_ROOT'" debug "PKG_PROTO: '$PKG_PROTO'" debug "PKG_USER: '$PKG_USER'" debug "PKG_HOST: '$PKG_HOST'" debug "PKGLIST_PATH: '$PKGLIST_PATH'" ## ## Ask the user for confirmation ## # Default reply: Yes function confirm_yes { echo -n "$* [Y/n] " read -k 1 [[ $REPLY != $'\n' ]] && echo if [[ $REPLY == 'y' || $REPLY == 'Y' || $REPLY == $'\n' ]] { true } else { false } } # 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_to_list { if [[ -d $1/.git ]] { echo -n "$1 git " echo ${$(git --git-dir=$1/.git log -n 1 master)[2]} } else { warn "No git repository found: $1\n" } } function vcs_add { cd $PDIR 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 { git log } function vcs_pull { typeset IFS=$'\n' line typeset branch for line in $(git branch); { [[ $line == \*\ * ]] && branch=${line#* } } if ((GIT_USE_ORIGIN)) { # the package might be newly created and not have an origin yet vcs_fix_origin git pull } else { if [[ $branch != master ]] { warn "$1: The currently checked out branch is not master, but '$branch'\n" \ "Currently, pkg can only operate on the branch master\n" \ " -> skipping repo, please fix manually or report a bug\n" return 1 } git pull $PKG_ROOT/${PWD:t} master } } function vcs_push { if ((GIT_USE_ORIGIN)) { # see above vcs_fix_origin git push } else { git push $PKG_ROOT/${PWD:t} master } } function vcs_status { git status } # Set the correct origin function vcs_fix_origin { if [[ ! -r .git/remotes/origin && ! -r .git/refs/remotes/origin/HEAD ]] { fgrep '[remote "origin"]' .git/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_incoming { [[ $(list_local_version $1) != $(list_remote_version $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 pkglist.XXXXXX) typeset -i ret=0 export PDIR if [[ $PKG_PROTO == 'ssh' ]] { ssh $PKG_USER@$PKG_HOST "PDIR='$PDIR' $PKGLIST_PATH $PKG_PATH" > $tmpfile || ret=$? } elif [[ $PKG_PROTO == 'file' ]] { $PKGLIST_PATH $PKG_PATH > $tmpfile || ret=$? } if (( ret == 0 )) { 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 cd $PDIR rm -f .list for i in *(-/); { (( current++ )) progress $current $all 'Updating package list' $i vcs_to_list $i >> .list } } function list_update_package { typeset list cd $PDIR list=$(grep -v "^$1 " .list) echo $list > .list vcs_to_list $1 >> .list } function list_remove_package { typeset list cd $PDIR list=$(grep -v "^$1 " .list) echo $list > .list } function list_local_version { echo ${$(grep "^$1 " $PDIR/.list)[3]} } function list_remote_version { 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' ;; *) echo ;; 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_update_package $1 ;; pre-remove) exec_hook $1 pre-remove genocide_collected $1 checklinks -r list_remove_package $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 i in $install; { pkg_add $i } fi } if [[ -n $maybe_install ]] { info "$1 recommends the following packages: ${(j:, :)maybe_install}\n" if confirm_no "Install them?"; then for i in $maybe_install; { pkg_add $i } 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 " if [[ -d man ]] { for i in man/*/*; { section=${i:h:t} manpage=${i:t} if (podchecker man/$section/$manpage &> /dev/null) { 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++ )) } } } if [[ -d bin ]] { for i in bin/*; { if (podchecker $i &> /dev/null) { pod2man -u $i > $PDIR/.collected/man/man1/${i:t}.1 say -n "+" (( bins++ )) } else { say -n "." (( bino++ )) } } } 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 ')' } if [[ -d bin ]] { for i in bin/*(-*); { if [[ -L $HOME/$i || ! -e $HOME/$i ]] { if [[ $(readlink $HOME/$i) != "../$1/$i" ]] { rm -f $HOME/$i ln -s ../${PDIR//$HOME\/}/$1/$i $HOME/$i } } else { warn "populate_collected: Not updating ~/$i 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" if [[ -d man ]] { for i in man/*/*; { section=${i:h:t} manual=${i:t} if [[ -e $PDIR/.collected/man/man$section/$manual ]] { rm $PDIR/.collected/man/man$section/$manual } } } if [[ -d bin ]] { for i in bin/*; { rm -f $PDIR/.collected/man/man1/${i:t}.1 } } clear_line if [[ -d bin ]] { for i in bin/*(-*); { if [[ $(readlink $HOME/$i) = "../${PDIR//$HOME\/}/$1/$i" ]] { rm -f $HOME/$i } } } } 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: " } cd $PDIR [[ -n $progress ]] && all=$(wc -l < $PDIR/.list) [[ -n $progress ]] && current=0 for i in *(-/); { (( current++ )) [[ -n $progress ]] && progress $current $all $progress $i $function $i } [[ -n $progress ]] && clear_line } } ## ## Finally - the functions actually doing something ## function pkg_add { if [[ -d $PDIR/$1 ]] { info "Package '$1' is already installed!\n" return 100 } list_exists $1 || die "No such package: $1\n" info "Retrieving package $1...\n" cd $PDIR || return 255 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 list_incoming $1 if [[ $? = 0 ]] { clear_line info "Pushing $1\n" global_hook $1 pre-update vcs_push global_hook $1 post-update } } 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_remote_version $1)\n" global_hook $1 pre-update vcs_pull global_hook $1 post-update fi } function pkg_list { typeset package crap case $1 in ''|local) cut -d ' ' -f 1 $PDIR/.list ;; all|remote) cut -d ' ' -f 1 $PDIR/.list-remote ;; not-installed) while read package crap; do grep "^$package" $PDIR/.list &> /dev/null || echo $package done < $PDIR/.list-remote ;; 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 { cd $PDIR 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 { cd $PDIR check_valid $1 # Fetch the infos typeset name=$1 typeset local_version=$(list_local_version $1) typeset remote_version=$(list_remote_version $1) typeset repo_type=$(list_type $1) typeset priority priority_name typeset -a tags typeset hooks makefile discription state if [[ -d $1 ]] { cd $1 if [[ -r priority ]] { priority=$(cat priority) priority_name=$(real_priority $priority) } if [[ -r tags ]] { TAGS=($(cat tags)) tags=$tags tags=${tags//[[:space:]]/, } } 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 'Tags' $tags show_info 'Hooks' $hooks show_info 'Description' $description } function pkg_log { check_installed $1 cd $PDIR/$1 vcs_log } ## ## Now what shall we do... ## # 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