#!/usr/bin/env zsh ## caretaker - /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 PKG_ROOTS CL_OPTIONS # required by pkg_debug, since functions have their name as $0 self=$0 c_info=$'\e[0;36m' c_error=$'\e[0;31m' c_reset=$'\e[0m' ## Basic output functions 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 clear_line { echo -ne "\r\e[2K" } # Read local configuration : ${XDG_CONFIG_HOME=$HOME/.config} if [[ -r $XDG_CONFIG_HOME/caretaker/caretaker.conf ]] { source $XDG_CONFIG_HOME/caretaker/caretaker.conf } elif [[ -r $HOME/.caretaker.conf ]] { source $HOME/.caretaker.conf } # Parse commandline options while [[ $1 == --* ]] { case $1 in --coluors) COLOURS=1 ;; --no-colours) COLOURS=0 ;; --quiet) SILENT=1 ;; --no-quiiet) SILENT=0 ;; --auto-update) AUTOUPDATE=1 ;; --no-auto-update) AUTOUPDATE=0 ;; --checklinks-options) CL_OPTIONS+=$2; shift ;; --packagedir) PKG_DIR=$2; shift ;; --packageroot) PKG_ROOT=$2; shift ;; --progress) PROGRESS=1 ;; --no-progress) PROGRESS=0 ;; --) shift; break ;; *) die "Unknown argument: '$1'\n" ;; esac shift } action=$1 ((#)) && shift if [[ -n $PKG_ROOT ]] { PKG_ROOTS+=($PKG_ROOT) unset PKG_ROOT } if (( $#PKG_ROOTS == 0 )) { die "No package root(s) specified.\n" \ "Please edit the PKG_ROOTS value in your config file\n" } : ${PKG_DIR:="$HOME/packages"} : ${PKGLIST_LOCAL=0} : ${CL_OPTIONS:=--quiet} : ${SILENT=0} : ${AUTOUPDATE=1} : ${GIT_USE_ORIGIN=1} : ${COLOURS=1} : ${PROGRESS=1} export PKG_DIR export PKG_ROOT 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 {} PROGRESS=0 } if (( ! COLOURS )) { c_info='' c_reset='' c_error='' } if [[ ! -d $PKG_DIR ]] { die "Error: Package directory '$PKG_DIR' does not exist\n" } ## ## Setup some additional variables related to PKG_ROOT ## function pkgroot_parse { PKG_ROOT=$1 # Protocol case $PKG_ROOT in ssh://*) PKG_PROTO='ssh' ;; git://*) PKG_PROTO='git' ;; /*) PKG_PROTO='file' ;; *) die "Error: Unknown protocol in PKG_ROOT '$PKG_ROOT'\n" ;; esac # user, host, path if [[ $PKG_PROTO == (git|ssh) ]] { PKG_HOST=${${PKG_ROOT#"${PKG_PROTO}://"}%%/*} PKG_PATH=${PKG_ROOT#"${PKG_PROTO}://$PKG_HOST"} if [[ $PKG_HOST == *@* ]] { PKG_USER=${PKG_HOST%%@*} PKG_HOST=${PKG_HOST#*@} PKG_UAH=${PKG_USER}@${PKG_HOST} } else { PKG_USER=$USERNAME PKG_UAH=${PKG_HOST} } } elif [[ $PKG_PROTO == 'file' ]] { PKG_PATH=$PKG_ROOT } : ${PKGLIST_PATH:=$PKG_PATH/pkglist} } function pkgroot_clean { unset PKG_ROOT PKG_PROTO PKG_HOST PKG_PATH PKG_USER PKG_UAH ((PKGLIST_LOCAL)) || unset PKGLIST_PATH } function check_installed { [[ -n $1 && -d $PKG_DIR/$1 ]] || die "Package is not installed: '$1'\n" } function check_valid { list_exists $1 || die "Unknown package name: '$1' (not in package list)\n" } # Default reply: Yes function confirm_yes { echo -n - "$* [Y/n] " read -k 1 [[ $REPLY != $'\n' ]] && echo [[ $REPLY == (y|Y|$'\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 { ((PROGRESS)) || 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 { repeat $1; { output+=$2 } } c=$(( currentper/5 )) a=$(( 20-c )) output+="${c_info}$desc${c_reset} [" item $c '=' item $a ' ' output+="] $currentper% $desc2" clear_line echo -ne $output } ## VCS Wrappers function vcs_add ( pkgroot_parse $(list_get_root $1) cd $PKG_DIR if [[ $(list_get_type $1) == git ]] { git clone "$PKG_ROOT/$1" vcs_setup $1 git config push.default matching } else { die "$1: Cannot handle repository format '$(list_get_type $1)'\n" } pkgroot_clean ) function vcs_branch_is_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, caretaker can only operate on the branch master\n" \ " -> skipping repo, please fix manually\n" return 1 } ) function vcs_log ( vcs_setup $1 git log ) function vcs_pull ( vcs_setup $1 if ((GIT_USE_ORIGIN)) { git pull } else { pkgroot_parse $(list_get_root $1) vcs_branch_is_master $1 && git pull $PKG_ROOT/${PWD:t} master pkgroot_clean } ) function vcs_push ( vcs_setup $1 if ((GIT_USE_ORIGIN)) { git push } else { pkgroot_parse $(list_get_root $1) vcs_branch_is_master $1 && git push $PKG_ROOT/${PWD:t} master } ) function vcs_setup { cd $PKG_DIR/$1 || die "vcs_setup: Cannot cd $PKG_DIR/$1" } function vcs_status ( typeset gitstatus vcs_setup $1 gitstatus=$(git status) [[ $gitstatus == *'nothing to commit (working directory clean)' ]] && gitstatus='' echo $gitstatus ) function vcs_to_list ( vcs_setup $1 if [[ -d $PKG_DIR/$1/.git ]] { echo -n - "$1 git " echo ${$(git log -n 1 master)[2]} } else { warn "No git repository found: '$1'\n" } ) ## List stuff function list_exists { grep -q "^$1 " $PKG_DIR/.list-remote } function list_get_root { echo - ${$(grep "^$1 " $PKG_DIR/.list-remote)[4]} } function list_get_type { echo - ${$(grep "^$1 " $PKG_DIR/.list-remote)[2]} } function list_get_type_local { echo - ${$(grep "^$1 " $PKG_DIR/.list)[2]} } function list_get_version_local { echo ${$(grep "^$1 " $PKG_DIR/.list)[3]} } function list_get_version_remote { echo ${$(grep "^$1 " $PKG_DIR/.list-remote)[3]} } function list_incoming { [[ $(list_get_version_local $1) != $(list_get_version_remote $1) ]] } function list_is_installed { grep -q "^$1 " $PKG_DIR/.list } function list_package_update { typeset list list=$(grep -v "^$1 " $PKG_DIR/.list) echo - $list > $PKG_DIR/.list vcs_to_list $1 >> $PKG_DIR/.list } function list_package_remove { typeset list list=$(grep -v "^$1 " $PKG_DIR/.list) echo - $list > $PKG_DIR/.list } function list_packages_local { cut -d ' ' -f 1 $PKG_DIR/.list } function list_packages_remote { cut -d ' ' -f 1 $PKG_DIR/.list-remote } function list_update_local { typeset -i all=${#$(echo $PKG_DIR/*(/))} typeset -i current=0 typeset package rm -f $PKG_DIR/.list for package in *(-/); { (( current++ )) progress $current $all 'Updating package list' $package vcs_to_list $package >> $PKG_DIR/.list } } function list_update_remote { typeset tmpfile=$(mktemp -t pkglist.XXXXXX) PKG_ROOT typeset -i ret=0 for PKG_ROOT in $PKG_ROOTS; { pkgroot_parse $PKG_ROOT if [[ $PKGLIST_LOCAL == 1 || $PKG_PROTO == 'file' ]] { $PKGLIST_PATH $PKG_PATH $PKG_ROOT >> $tmpfile } elif [[ $PKG_PROTO == 'ssh' ]] { ssh $PKG_UAH "$PKGLIST_PATH $PKG_PATH $PKG_ROOT" >> $tmpfile } pkgroot_clean } if [[ -n $(cat $tmpfile) ]] { cp $tmpfile .list-remote rm $tmpfile } else { rm $tmpfile die "remote list update failed\n" } } function priority_name { case $1 in 6) echo 'essential' ;; 5) echo 'important' ;; 4) echo 'required' ;; 3) echo 'standard' ;; 2) echo 'optional' ;; 1) echo 'extra' ;; *) warn "invalid priority: '$1'\n" ;; esac } function exec_hook { typeset package=$1 typeset hook=$2 if [[ -r $PKG_DIR/$package/hooks/$hook ]] { info "$package: executing hook $hook\n" cd $PKG_DIR/$package (source hooks/$hook) } } function global_hook { cd $PKG_DIR/$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 update_collected $1 update_provides $1 list_package_update $1 ;; pre-remove) exec_hook $1 pre-remove genocide_collected $1 checklinks $CL_OPTIONS --remove list_package_remove $1 update_provides $1 ;; esac (( $+functions[pkg_hook_$2] )) && pkg_hook_$2 $1 } function check_prereqs { typeset -a -U install maybe_install typeset warn info i typeset package=$1 [[ -r $PKG_DIR/$package/prereqs ]] || return 0 cd $PKG_DIR/$package wrap_info $1 info "checking prerequisites\n" # function scope → typeset and localoptions are possible function source_prereqs { { source prereqs } always { if (( TRY_BLOCK_ERROR )) { warn "Error in prereqs script\n" TRY_BLOCK_ERROR=0 } } } function is_installed { [[ -d $PKG_DIR/$1 ]] } function perlmodule { perl -M$1 < /dev/null 2> /dev/null } function executable { which $1 > /dev/null } function offer_install { install+=$1 } function depend { if [[ $1 == 'package' ]] { is_installed $2 || offer_install $2 } else { $* || warn+="Requirement failed: $*\n" } } 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 if [[ -n $warn || -n $info ]] { [[ -n $warn ]] && warn $warn [[ -n $info ]] && echo -n $info read -q '?continue [] ' } 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 update_collected { cd $PKG_DIR/$1 || return typeset -i bins=0 bino=0 mans=0 mano=0 typeset man section manpage file target wrap_info $1 if [[ ! -d bin && ! -d man ]] { return } info "Processing documentation and binaries\n" for man in man/*/*(N); { section=${man:h:t} manpage=${man:t} target=$PKG_DIR/.collected/man/man$section/${manpage%.pod}.$section if podchecker man/$section/$manpage &> /dev/null; then pod2man -u -s $section -c "$1 package" -r $HOME man/$section/$manpage > $target elif [[ $manpage != *.pod && ! -e $target ]]; then ln -s ../../../$1/man/$section/$manpage $target fi } for file in bin/*(N); { if podchecker $file &> /dev/null; then pod2man -u $file > $PKG_DIR/.collected/man/man1/${file:t}.1 fi } for file in ~/bin/*(@N); { if [[ $(readlink $file) == (../${PKG_DIR//$HOME\/}|$PKG_DIR)/$1/bin/${file:t} ]] { if [[ ! -e $PKG_DIR/$1/bin/${file:t} ]] { rm $file } } } for file in bin/*(-*N); { if [[ -L $HOME/$file || ! -e $HOME/$file ]] { if [[ $(readlink $HOME/$file) != (../${PKG_DIR//$HOME\/}|$PKG_DIR)/$1/$file ]] { rm -f $HOME/$file if [[ $PKG_DIR == ${HOME}* ]] { ln -s ../${PKG_DIR//$HOME\/}/$1/$file $HOME/$file } else { ln -s $PKG_DIR/$1/$file $HOME/$file } } } else { warn "update_collected: ~/$file is not a symlink - skipping $1/$file\n" \ "Please fix manually and then call '$0 refresh $1'\n" } } } # Remove a package's files from .collected # Assuming there are no packages with colliding files function genocide_collected { typeset i file man manual section cd $PKG_DIR/$1 || return if [[ ! -d bin && ! -d man ]] { return } wrap_info $1 info "Removing documentation and binaries\n" for man in man/*/*(N); { section=${man:h:t} manual=${man:t} if [[ -e $PKG_DIR/.collected/man/man$section/${manual%.pod}.$section ]] { rm $PKG_DIR/.collected/man/man$section/${manual%.pod}.$section } } for file in bin/*(N); { rm -f $PKG_DIR/.collected/man/man1/${file:t}.1 } for file in bin/*(-*N); { if [[ $(readlink $HOME/$file) == (../${PKG_DIR//$HOME\/}|$PKG_DIR)/$1/$file ]] { rm -f $HOME/$file } } } function update_provides { typeset package for package in $PKG_DIR/$1/provides/*(N:t); { if [[ -d $PKG_DIR/$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 all current if [[ -n $2 ]] { $function $2 } else { function wrap_info { clear_line info "$1: " } [[ -n $progress ]] && all=$(list_packages_local | wc -l) [[ -n $progress ]] && current=0 for package in *(-/); { cd $PKG_DIR (( current++ )) [[ -n $progress ]] && progress $current $all $progress $package $function $package } [[ -n $progress ]] && clear_line } } ## ## The "frontend" functions ## function pkg_add { check_valid $1 if [[ -d $PKG_DIR/$1 ]] { info "Package '$1' is already installed!\n" exit 1 } info "Retrieving package $1...\n" vcs_add $1 || return 255 global_hook $1 post-add } function pkg_debug { echo "--- running ---" echo " zsh $ZSH_VERSION" echo " git "${$(git --version)[3]} echo " caretaker "${$(git --git-dir=$PKG_DIR/${${(s:/:)$(readlink $self)}[-3]}/.git/ log -n 1)[2]} echo "--- settings ---" echo " PKGLIST_LOCAL $PKGLIST_LOCAL" for PKG_ROOT in $PKG_ROOTS; { pkgroot_parse $PKG_ROOT echo " PKG_ROOT $PKG_ROOT" echo " PKG_PROTO $PKG_PROTO" echo " PKG_USER $PKG_USER" echo " PKG_HOST $PKG_HOST" echo " PKG_UAH $PKG_UAH" echo " PKGLIST_PATH $PKGLIST_PATH" pkgroot_clean } echo " PKG_DIR $PKG_DIR" echo " CL_OPTIONS $CL_OPTIONS" echo " SILENT $SILENT" echo " COLOURS $COLOURS" echo " PROGRESS $PROGRESS" echo " AUTOUPDATE $AUTOUPDATE" echo " GIT_USE_ORIGIN $GIT_USE_ORIGIN" } function pkg_info { list_is_installed $1 || list_exists $1 || die "No such package: $1\n" list_exists $1 && pkgroot_parse $(list_get_root $1) typeset name=$1 package_root=$PKG_ROOT typeset repo_type=$(list_get_type $1) typeset priority priority_name typeset hooks makefile discription state pkgroot_clean if [[ -d $1 ]] { cd $1 if [[ -r priority ]] { priority=$(cat priority) priority_name=$(priority_name $priority) } if [[ -d hooks ]] { hooks=$(ls -m hooks) } if [[ -r Makefile ]] { makefile=1 } size=$(du -sh .$(list_get_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 'Source' $package_root show_info 'State' $state [[ -n $priority ]] && show_info 'Priority' "$priority ($priority_name)" show_info 'Local Version' $(list_get_version_local $1) show_info 'Remote Version' $(list_get_version_remote $1) show_info 'Repository Type' $repo_type show_info 'Repository Size' $size show_info 'Hooks' $hooks show_info 'Description' $description } 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_log { check_installed $1 vcs_log $1 } function pkg_push { check_installed $1 if list_incoming $1 || ! list_exists $1; then clear_line info "Pushing $1\n" global_hook $1 pre-update vcs_push $1 global_hook $1 post-update fi } function pkg_refresh { check_installed $1 global_hook $1 pre-update global_hook $1 post-update } function pkg_remove { check_installed $1 if [[ -r $PKG_DIR/$1/priority ]] { if (( $(cat $PKG_DIR/$1/priority) > 3 )) { confirm_no "Package '$1' is $(priority_name $(cat $PKG_DIR/$1/priority)). Really remove?" || return } } global_hook $1 pre-remove rm -rf $PKG_DIR/$1 info "Package removed.\n" } function pkg_status { typeset vcs_status check_installed $1 vcs_status=$(PAGER='' vcs_status $1) if [[ -n $vcs_status ]] { if ((SILENT)) { echo $1 } else { clear_line info "$1:\n" echo $vcs_status } } } function pkg_update { if [[ -z $1 || $1 == local ]] { info "Updating local package list\n" list_update_local clear_line } if [[ -z $1 || $1 == remote ]] { info "Updating remote package list\n" list_update_remote } } function pkg_upgrade { check_installed $1 if list_exists $1 && [[ $(list_get_type $1) != $(list_get_type_local $1) ]]; then clear_line warn "Incompatible systems. Please reinstall: $1\n" warn " remote '$(list_get_type $1)' <-> local '$(list_get_type_local $1)'\n" return 9 fi if list_incoming $1 || ! list_exists $1; then clear_line info "Updating $1 to $(list_get_version_remote $1)\n" global_hook $1 pre-update vcs_pull $1 global_hook $1 post-update fi } cd $PKG_DIR || die "Cannot cd $PKG_DIR" # Note: # wrap foobar "$1" <- the "" are neccessary here, since $1 is optional (and therefore may be empty) case $action in a|add) pkg_add $* ;; debug) pkg_debug $* ;; e|eval) eval $* ;; i|info) pkg_info $* ;; ls|list) pkg_list $* ;; l|log) pkg_log $* ;; f|pull) (( AUTOUPDATE )) && pkg_update remote wrap pkg_upgrade "$1" 'Looking for updates' ;; p|push) (( AUTOUPDATE )) && pkg_update wrap pkg_push "$1" 'Pushing' ;; r|refresh) wrap pkg_refresh "$1" 'Refreshing' ;; rm|remove) pkg_remove $* ;; s|status) wrap pkg_status "$1" 'Checking package status' ;; u|update) pkg_update $* ;; *) die "wait, what?\nct: unknown action: '$action'\n" ;; esac apply_triggers