├── .bin ├── bootstrap ├── install ├── install-links ├── shell-hooks │ ├── .zshlogin │ ├── .zshlogout │ ├── .zshrc │ ├── README.md │ ├── bash_profile │ ├── bashrc │ ├── fish │ ├── install │ ├── shell-hooks.sh │ ├── zsh-login.zsh │ ├── zsh-logout.zsh │ └── zshenv ├── uninstall ├── xg.fish ├── xpkg-bash └── xpkg-dev ├── .gitignore ├── .swiftpm └── xcode │ ├── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcschemes │ ├── Configure.xcscheme │ ├── XPkg-Package.xcscheme │ └── xpkg.xcscheme ├── .travis.yml ├── Extras └── Scripts ├── LICENSE.md ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── XPkgCommand │ └── main.swift └── XPkgCore │ ├── Commands │ ├── CheckCommand.swift │ ├── InitCommand.swift │ ├── InstallCommand.swift │ ├── LinkCommand.swift │ ├── ListCommand.swift │ ├── PathCommand.swift │ ├── RebuildCommand.swift │ ├── ReinstallCommand.swift │ ├── RemoveCommand.swift │ ├── RenameCommand.swift │ ├── RepairCommand.swift │ ├── RevealCommand.swift │ └── UpdateCommand.swift │ ├── Engine.swift │ ├── Failure.swift │ ├── Package.swift │ └── URL+Extensions.swift └── Tests ├── LinuxMain.swift └── XPkgTests ├── XCTestManifests.swift └── XPkgTests.swift /.bin/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ROOT="$HOME/.local/share/xpkg" 4 | 5 | if [[ -e "$ROOT/code" ]] 6 | then 7 | echo "Updating xpkg." 8 | cd "$ROOT/code" 9 | git pull 10 | XPKG_INSTALL_MODE="--no-backup" 11 | else 12 | echo "Downloading xpkg." 13 | mkdir -p "$ROOT" 14 | cd "$ROOT" 15 | git clone https://github.com/elegantchaos/XPkg.git code 16 | cd code 17 | fi 18 | 19 | if [[ $? == 0 ]] 20 | then 21 | source "$ROOT/code/.bin/install" 22 | fi 23 | -------------------------------------------------------------------------------- /.bin/install: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | ROOT="$HOME/.local/share/xpkg" 5 | 6 | echo "Building." 7 | swift run builder build 8 | if [[ $? != 0 ]] 9 | then 10 | echo "Build failed." 11 | exit 1 12 | fi 13 | 14 | FISH="$HOME/.config/fish/functions" 15 | 16 | LOCAL="$HOME/.local" 17 | echo "Installing links." 18 | if [[ -e "$LOCAL" ]] 19 | then 20 | echo "Installing to ~/.local." 21 | export STARTUP="$LOCAL/share/shell-hooks/startup" 22 | export BIN="$LOCAL/bin" 23 | export SUDO= 24 | else 25 | echo "Installing to /usr/local (requires sudo)." 26 | export STARTUP="/usr/local/share/shell-hooks/startup" 27 | export BIN="/usr/local/bin" 28 | export SUDO=sudo 29 | fi 30 | 31 | $SUDO mkdir -p "$STARTUP" 32 | $SUDO ln -sf "$ROOT/code/.bin/xpkg-bash" "$STARTUP/xpkg" 33 | 34 | $SUDO mkdir -p "$BIN" 35 | $SUDO ln -sf "$ROOT/code/.build/debug/xpkg" "$BIN/xpkg" 36 | $SUDO ln -sf "$ROOT/code/.bin/xpkg-dev" "$BIN/xpkg-dev" 37 | $SUDO ln -sf "$ROOT/code/.bin/uninstall" "$BIN/xpkg-uninstall" 38 | 39 | mkdir -p "$FISH" 40 | ln -sf "$ROOT/code/.bin/xg.fish" "$FISH/xg.fish" 41 | 42 | echo "Installing shell startup hooks." 43 | $SUDO "$ROOT/code/.bin/shell-hooks/install" "$ROOT" "$XPKG_INSTALL_MODE" 44 | 45 | printf "Done.\n\nOpen a new shell / terminal window and type xpkg to get started.\n\n" 46 | -------------------------------------------------------------------------------- /.bin/install-links: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | ROOT="$HOME/.local/share/xpkg" 5 | 6 | FISH="$HOME/.config/fish/functions" 7 | 8 | LOCAL="$HOME/.local" 9 | echo "Installing links." 10 | if [[ -e "$LOCAL" ]] 11 | then 12 | echo "Installing to ~/.local." 13 | export STARTUP="$LOCAL/share/shell-hooks/startup" 14 | export BIN="$LOCAL/bin" 15 | export SUDO= 16 | else 17 | echo "Installing to /usr/local (requires sudo)." 18 | export STARTUP="/usr/local/share/shell-hooks/startup" 19 | export BIN="/usr/local/bin" 20 | export SUDO=sudo 21 | fi 22 | 23 | $SUDO mkdir -p "$STARTUP" 24 | $SUDO ln -sf "$ROOT/code/.bin/xpkg-bash" "$STARTUP/xpkg" 25 | 26 | $SUDO mkdir -p "$BIN" 27 | $SUDO ln -sf "$ROOT/code/.build/debug/xpkg" "$BIN/xpkg" 28 | $SUDO ln -sf "$ROOT/code/.bin/xpkg-dev" "$BIN/xpkg-dev" 29 | $SUDO ln -sf "$ROOT/code/.bin/uninstall" "$BIN/xpkg-uninstall" 30 | 31 | mkdir -p "$FISH" 32 | ln -sf "$ROOT/code/.bin/xg.fish" "$FISH/xg.fish" -------------------------------------------------------------------------------- /.bin/shell-hooks/.zshlogin: -------------------------------------------------------------------------------- 1 | # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | # Created by Sam Deane, 05/06/2019. 3 | # All code (c) 2019 - present day, Elegant Chaos Limited. 4 | # For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | echo "login" 8 | 9 | if [[ "$SHELL_HOOKS_LOGIN" == "" ]] 10 | then 11 | 12 | export SHELL_HOOKS_LOGIN=1 13 | export BASH_HOOKS_LOGIN=1 # legacy 14 | 15 | source_hooks "$SHELL_HOOKS_ROOT/login" 16 | source_hooks "$SHELL_HOOKS_ROOT/login-$SHELL_HOOKS_PLATFORM" 17 | 18 | if [[ -e "$HOME/.zshlogin" ]] 19 | then 20 | source "$HOME/.zshlogin" 21 | fi 22 | 23 | fi 24 | -------------------------------------------------------------------------------- /.bin/shell-hooks/.zshlogout: -------------------------------------------------------------------------------- 1 | # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | # Created by Sam Deane, 05/06/2019. 3 | # All code (c) 2019 - present day, Elegant Chaos Limited. 4 | # For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | -------------------------------------------------------------------------------- /.bin/shell-hooks/.zshrc: -------------------------------------------------------------------------------- 1 | # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | # Created by Sam Deane, 05/06/2019. 3 | # All code (c) 2019 - present day, Elegant Chaos Limited. 4 | # For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | if [[ "$SHELL_HOOKS_RC" == "" ]] 8 | then 9 | 10 | export SHELL_HOOKS_RC=1 11 | export BASH_HOOKS_INTERACTIVE=1 #legacy 12 | source_hooks "$SHELL_HOOKS_ROOT/interactive" 13 | source_hooks "$SHELL_HOOKS_ROOT/interactive-$BASH_HOOKS_PLATFORM" 14 | 15 | 16 | if [[ -e "$HOME/.zshrc" ]] 17 | then 18 | source "$HOME/.zshrc" 19 | fi 20 | fi 21 | -------------------------------------------------------------------------------- /.bin/shell-hooks/README.md: -------------------------------------------------------------------------------- 1 | These scripts hook into the bash/zsh/fish startup process in a way that's extensible. 2 | 3 | ### BASH 4 | 5 | It replaces the existing `.bashrc` and `.bash_profile` with versions that scan the `~/.config/shell-hooks/startup/` and `~/.config/shell-hooks/login/` folders, and source anything that they find in there. 6 | 7 | They then execute the original `bashrc` and/or `bash_profile` files, which will have been backed up as `~/.bashrc.backup` and `~/.bash_profile.backup` respectively. 8 | 9 | This allows other packages to transparently hook into the bash startup process without having to modify `.bashrc` or `.bash_profile`. 10 | 11 | ### ZSH 12 | 13 | Similar to bash, but replacing the equivalent .zsh* files. 14 | 15 | ### FISH 16 | 17 | Similar to bash, but adds a fish config file. 18 | -------------------------------------------------------------------------------- /.bin/shell-hooks/bash_profile: -------------------------------------------------------------------------------- 1 | if [[ -e "$HOME/.bashrc" ]] 2 | then 3 | source "$HOME/.bashrc" 4 | fi 5 | 6 | if [[ "$SHELL_HOOKS_LOGIN" == "" ]] 7 | then 8 | export SHELL_HOOKS_LOGIN=1 9 | export BASH_HOOKS_LOGIN=1 # legacy 10 | 11 | source_hooks "$SHELL_HOOKS_ROOT/login" 12 | source_hooks "$SHELL_HOOKS_ROOT/login-$SHELL_HOOKS_PLATFORM" 13 | 14 | if [[ -e "$HOME/.bash_profile.backup" ]] 15 | then 16 | source "$HOME/.bash_profile.backup" 17 | fi 18 | fi 19 | -------------------------------------------------------------------------------- /.bin/shell-hooks/bashrc: -------------------------------------------------------------------------------- 1 | 2 | if [[ "$SHELL_HOOKS_RC" == "" ]] 3 | then 4 | 5 | export SHELL_HOOKS_SHELL="bash" 6 | export SHELL_HOOKS_RC=1 7 | export SHELL_HOOKS_PLATFORM=`uname` 8 | export SHELL_HOOKS_ROOT="$HOME/.local/share/shell-hooks" 9 | 10 | # legacy 11 | export BASH_HOOKS_RC=1 12 | export BASH_HOOKS_PLATFORM=`uname` 13 | export BASH_HOOKS_ROOT="$HOME/.local/share/shell-hooks" 14 | 15 | # Source a hook 16 | # Before sourcing, we change the working directory to the true 17 | # location of the hook file. This allows hooks to reference other 18 | # local resources just using ./my-resource 19 | function source_hook() { 20 | absolute=$(readlink "$1") 21 | container=$(dirname "$absolute") 22 | pushd "$container" > /dev/null 23 | source "$absolute" 24 | popd > /dev/null 25 | } 26 | 27 | # Source each hook in a folder. 28 | function source_hooks() { 29 | FOLDER=$1 30 | if [[ -e "$FOLDER" ]] 31 | then 32 | for f in "$FOLDER"/* 33 | do 34 | source_hook $f 35 | done 36 | fi 37 | } 38 | 39 | export -f source_hook 40 | export -f source_hooks 41 | 42 | source_hooks "$SHELL_HOOKS_ROOT/startup" 43 | source_hooks "$SHELL_HOOKS_ROOT/startup-$SHELL_HOOKS_PLATFORM" 44 | 45 | if [[ -e "$HOME/.bashrc.backup" ]] 46 | then 47 | source "$HOME/.bashrc.backup" 48 | fi 49 | 50 | # If not running interactively exit now 51 | case $- in 52 | *i*) ;; 53 | *) return;; 54 | esac 55 | 56 | export BASH_HOOKS_INTERACTIVE=1 57 | source_hooks "$SHELL_HOOKS_ROOT/interactive" 58 | source_hooks "$SHELL_HOOKS_ROOT/interactive-$SHELL_HOOKS_PLATFORM" 59 | 60 | fi 61 | -------------------------------------------------------------------------------- /.bin/shell-hooks/fish: -------------------------------------------------------------------------------- 1 | #!/usr/local/bin/fish 2 | 3 | # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 4 | # Created by Sam Deane on 26/03/2020. 5 | # All code (c) 2020 - present day, Elegant Chaos Limited. 6 | # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 7 | 8 | set -x SHELL_HOOKS_SHELL fish 9 | set -x SHELL_HOOKS_RC 1 10 | set -x SHELL_HOOKS_PLATFORM (uname) 11 | set -x SHELL_HOOKS_ROOT "$HOME/.local/share/shell-hooks" 12 | set -x SHELL_HOOKS_INTERACTIVE 1 13 | 14 | if status --is-login 15 | set -x PATH "$HOME/.local/bin" $PATH 16 | end 17 | -------------------------------------------------------------------------------- /.bin/shell-hooks/install: -------------------------------------------------------------------------------- 1 | 2 | function install_link() { 3 | if [[ "$ROOT" == "uninstall" ]] 4 | then 5 | echo "Removing $2." 6 | rm "$2" 7 | else 8 | if [[ "$3" != "--no-backup" ]] 9 | then 10 | if [[ -e "$2" ]] 11 | then 12 | if [[ ! -e "$2.backup" ]] 13 | then 14 | echo "Backing up $2." 15 | mv "$2" "$2.backup" 16 | fi 17 | rm "$2" 18 | fi 19 | fi 20 | 21 | absolute="$ROOT/code/.bin/shell-hooks/$1" 22 | echo "Installed hook for $(basename $2)." 23 | mkdir -p "$(dirname $2)" 24 | ln -sf "$absolute" "$2" 25 | fi 26 | 27 | } 28 | 29 | #set -e 30 | ROOT=$1 31 | BACKUPSWITCH=$2 32 | 33 | if [[ (! -e "$ROOT") && ("$ROOT" != "uninstall") ]] 34 | then 35 | echo "Root directory is missing. $ROOT" 36 | exit 1 37 | fi 38 | 39 | install_link "bashrc" ~/.bashrc $BACKUPSWITCH 40 | install_link "bash_profile" ~/.bash_profile $BACKUPSWITCH 41 | install_link "zshenv" ~/.zshenv $BACKUPSWITCH 42 | 43 | install_link "shell-hooks.sh" ~/.local/bin/hooks --no-backup 44 | install_link "fish" ~/.config/fish/conf.d/com.elegantchaos.xpkg.fish --no-backup 45 | -------------------------------------------------------------------------------- /.bin/shell-hooks/shell-hooks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | list_hooks () { 4 | hooks="$SHELL_HOOKS_ROOT/$1" 5 | if [[ -e "$hooks" ]] 6 | then 7 | files=$(find "$hooks/"* -exec basename {} \;) 8 | fi 9 | 10 | if [[ -e "$hooks-$SHELL_HOOKS_PLATFORM" ]] 11 | then 12 | platform=$(find "$hooks-$SHELL_HOOKS_PLATFORM/"* -exec basename {} \;) 13 | fi 14 | 15 | if [[ "$files$platform" != "" ]] 16 | then 17 | echo "$1:" $files $platform 18 | fi 19 | } 20 | 21 | if [[ "$1" == "list" ]] 22 | then 23 | list_hooks "login" 24 | list_hooks "startup" 25 | list_hooks "interactive" 26 | list_hooks "fish" 27 | else 28 | cmd=$(basename "$0") 29 | echo "Usage: $cmd list" 30 | fi 31 | -------------------------------------------------------------------------------- /.bin/shell-hooks/zsh-login.zsh: -------------------------------------------------------------------------------- 1 | .zshlogin -------------------------------------------------------------------------------- /.bin/shell-hooks/zsh-logout.zsh: -------------------------------------------------------------------------------- 1 | .zshlogout -------------------------------------------------------------------------------- /.bin/shell-hooks/zshenv: -------------------------------------------------------------------------------- 1 | # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | # Created by Sam Deane, 05/06/2019. 3 | # All code (c) 2019 - present day, Elegant Chaos Limited. 4 | # For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | 8 | if [[ "$SHELL_HOOKS_ZSH" == "" ]] 9 | then 10 | 11 | export PATH="$HOME/.local/bin":"$PATH" 12 | 13 | export SHELL_HOOKS_ZSH=1 14 | export SHELL_HOOKS_PLATFORM=`uname` 15 | export SHELL_HOOKS_ROOT="$HOME/.local/share/shell-hooks" 16 | export SHELL_HOOKS_SHELL="zsh" 17 | 18 | # legacy 19 | export BASH_HOOKS_RC=1 20 | export BASH_HOOKS_PLATFORM=`uname` 21 | export BASH_HOOKS_ROOT="$HOME/.local/share/shell-hooks" 22 | 23 | # read the other configuration files from here 24 | export ZDOTDIR="$HOME/.local/share/xpkg/code/.bin/shell-hooks" 25 | 26 | # Source a hook 27 | # Before sourcing, we change the working directory to the true 28 | # location of the hook file. This allows hooks to reference other 29 | # local resources just using ./my-resource 30 | export function source_hook() { 31 | absolute=$(readlink "$1") 32 | container=$(dirname "$absolute") 33 | pushd "$container" > /dev/null 34 | source "$absolute" 35 | popd > /dev/null 36 | } 37 | 38 | # Source each hook in a folder. 39 | export function source_hooks() { 40 | FOLDER=$1 41 | if [[ -e "$FOLDER" ]] 42 | then 43 | for f in "$FOLDER"/* 44 | do 45 | source_hook $f 46 | done 47 | fi 48 | } 49 | 50 | source_hooks "$SHELL_HOOKS_ROOT/startup" 51 | source_hooks "$SHELL_HOOKS_ROOT/startup-$SHELL_HOOKS_PLATFORM" 52 | 53 | if [[ -e "$HOME/.zshenv.backup" ]] 54 | then 55 | source "$HOME/.zshenv.backup" 56 | fi 57 | 58 | fi 59 | -------------------------------------------------------------------------------- /.bin/uninstall: -------------------------------------------------------------------------------- 1 | 2 | ROOT="$HOME/.local/share/xpkg" 3 | 4 | echo "Removing shell startup scripts." 5 | $SUDO "$ROOT/code/.bin/shell-hooks/install" "uninstall" 6 | 7 | echo "Removing shell hooks." 8 | rm -rf ~/.local/share/shell-hooks 9 | 10 | echo "Removing xpkg." 11 | rm -rf ~/.local/share/xpkg 12 | 13 | echo "Done." 14 | -------------------------------------------------------------------------------- /.bin/xg.fish: -------------------------------------------------------------------------------- 1 | complete --no-files --command xg -n "not __fish_seen_subcommand_from (__fish_xg_complete_packages)" --arguments "(__fish_xg_complete_packages)" 2 | 3 | function __fish_xg_complete_packages 4 | xpkg list --compact 5 | end 6 | 7 | function xg 8 | set -l directory (xpkg path $argv) 9 | pushd $directory 10 | end 11 | -------------------------------------------------------------------------------- /.bin/xpkg-bash: -------------------------------------------------------------------------------- 1 | xg() { 2 | builtin pushd "`xpkg path $@`" 3 | } 4 | 5 | _comp_xg() { 6 | cur="${COMP_WORDS[COMP_CWORD]}" 7 | COMPREPLY=( $(compgen -W "$(xpkg list --compact)" -- "$cur") ) 8 | } 9 | 10 | _comp_xg_zsh() { 11 | # _arguments "1: :($(xpkg list --compact))" 12 | # args = ( "blah: test thing" ) 13 | _arguments $args 14 | } 15 | 16 | if [[ "SHELL_HOOKS_SHELL" == "bash" ]] 17 | then 18 | complete -o nospace -o bashdefault -F _comp_xg xg 19 | 20 | elif [[ "SHELL_HOOKS_SHELL" == "zsh" ]] 21 | then 22 | compdef _comp_xg_zsh xg 23 | fi 24 | -------------------------------------------------------------------------------- /.bin/xpkg-dev: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ROOT="$HOME/.local/share/xpkg" 4 | if [[ ! -e "$ROOT" ]] 5 | then 6 | ROOT="/usr/local/share/xpkg" 7 | fi 8 | 9 | swift run --package-path "$ROOT/code" xpkg "$@" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata 5 | .bin/shell-hooks/.zcompdump 6 | .bin/shell-hooks/.zsh_history 7 | .bin/shell-hooks/.zsh_sessions 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/Configure.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 57 | 58 | 61 | 62 | 63 | 64 | 70 | 72 | 78 | 79 | 80 | 81 | 83 | 84 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/XPkg-Package.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 57 | 63 | 64 | 65 | 71 | 77 | 78 | 79 | 80 | 81 | 86 | 87 | 89 | 95 | 96 | 97 | 98 | 99 | 109 | 110 | 116 | 117 | 118 | 119 | 125 | 126 | 132 | 133 | 134 | 135 | 137 | 138 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/xpkg.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 47 | 53 | 54 | 55 | 56 | 57 | 68 | 70 | 76 | 77 | 78 | 79 | 82 | 83 | 86 | 87 | 90 | 91 | 94 | 95 | 98 | 99 | 102 | 103 | 106 | 107 | 110 | 111 | 112 | 113 | 119 | 121 | 127 | 128 | 129 | 130 | 132 | 133 | 136 | 137 | 138 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | 2 | matrix: 3 | include: 4 | - os: linux 5 | dist: bionic 6 | language: generic 7 | sudo: required 8 | install: 9 | - SWIFT_BRANCH=swift-5.1-release 10 | - SWIFT_VERSION=swift-5.1-RELEASE 11 | - sudo apt-get install clang libicu-dev 12 | - mkdir swift 13 | - curl https://swift.org/builds/$SWIFT_BRANCH/ubuntu1804/$SWIFT_VERSION/$SWIFT_VERSION-ubuntu18.04.tar.gz -s | tar xz -C swift &> /dev/null 14 | - export PATH="$(pwd)/swift/$SWIFT_VERSION-ubuntu18.04/usr/bin:$PATH" 15 | script: 16 | - swift build 17 | - .build/debug/xpkg --version 18 | - swift test 19 | - os: osx 20 | osx_image: xcode11 21 | language: swift 22 | install: 23 | - sudo gem install xcpretty-travis-formatter 24 | script: 25 | - swift build --product builder 26 | - .build/debug/builder build 27 | - .build/debug/xpkg --version 28 | - .build/debug/builder test 29 | 30 | 31 | notifications: 32 | email: false 33 | slack: 34 | rooms: 35 | secure: soh6OcjOfQmamDQBFSET9z95ROkk8mhC9DANX0WYMucAeFu+bvm3DJsilU4MauHwmXVr33IeyoF48QNuOUY8o92KfVO787y9lvS06SFcb27Mw9TE36ws7ZYLlODJ90G9rkfeExVuiguiDY+7g3z5DonVyJ07CROYijrv3x0RB0pVV8ShK6OHYUNPnB5Ce9nDdglIRNYNb9zytT4qzj2X0TySQspEMQosdwbWktbtECE7CK80uLiaKHAa2h3YBaoNvmR9Z2G4VGD/tR8frkV6JhWIMnwm2yA1yWwBi2lT3wtFZ79VRSwkUgRgvb8XhnGUgc0/3+/X7YWK4+fSRMDIIjg+0kTtTuC3XQDrFzBwj7xW2OSEqOMKna5GmwvA/Sspit55BeH4rC49MGWbnEvtksar2E172MCyfH4X2KqjkVytWCkbzY2PdvNqwgFH8lnJfEFEB1Yp+5zeXCepEYUb8yD0Lx97ggrIWEOFfQOIGtpX8FBj/q+oPEeYymvl8FY1oceCt6wAj0YiePFb0XqWqSPFHUwhaNYJ9wb9dgIf1rX51XrGSandpYfjmtJjKb0G5kgP3cMx9OutdqL1wqGWSMdRPd9c+NwoTaZGNfdcao7DjtKYahSzA6f6ZBnRs+CaVLfpBhy1YCRjhCpJyCSJf9EbGkVaJDMBoiuwI2UHQcY= 36 | -------------------------------------------------------------------------------- /Extras/Scripts: -------------------------------------------------------------------------------- 1 | ../.bin -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Elegant Chaos Limited 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "expressions", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/elegantchaos/Expressions.git", 7 | "state" : { 8 | "revision" : "3f1a4caccb9e33d95d0650e9b78350acd3576300", 9 | "version" : "1.1.1" 10 | } 11 | }, 12 | { 13 | "identity" : "files", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/elegantchaos/Files.git", 16 | "state" : { 17 | "revision" : "ccb417b5b4f2d473964594a7c906a50a62c8530c", 18 | "version" : "1.2.2" 19 | } 20 | }, 21 | { 22 | "identity" : "logger", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/elegantchaos/Logger.git", 25 | "state" : { 26 | "revision" : "6dc36bcaae47498a79d01a9b41ac5ee6a26e4809", 27 | "version" : "1.8.0" 28 | } 29 | }, 30 | { 31 | "identity" : "matchable", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/elegantchaos/Matchable.git", 34 | "state" : { 35 | "revision" : "d53e807009960aafbd54f6229c2542f906628b38", 36 | "version" : "1.0.7" 37 | } 38 | }, 39 | { 40 | "identity" : "runner", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/elegantchaos/Runner.git", 43 | "state" : { 44 | "revision" : "65420eb4e534a58c8fb1bf18e344bc86edb42c65", 45 | "version" : "1.3.2" 46 | } 47 | }, 48 | { 49 | "identity" : "semanticversion", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/elegantchaos/SemanticVersion.git", 52 | "state" : { 53 | "revision" : "f6851038d07c1d8dc585754131995654fdcbec43", 54 | "version" : "1.1.1" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-argument-parser", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/apple/swift-argument-parser", 61 | "state" : { 62 | "revision" : "6b2aa2748a7881eebb9f84fb10c01293e15b52ca", 63 | "version" : "0.5.0" 64 | } 65 | }, 66 | { 67 | "identity" : "versionator", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/elegantchaos/Versionator.git", 70 | "state" : { 71 | "revision" : "d7bf7747ae277f6a5cd3193806262160d579f0cc", 72 | "version" : "1.0.3" 73 | } 74 | }, 75 | { 76 | "identity" : "xctestextensions", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/elegantchaos/XCTestExtensions.git", 79 | "state" : { 80 | "revision" : "c1cec43d4ca0b657323080b90a840b675e8af638", 81 | "version" : "1.4.9" 82 | } 83 | }, 84 | { 85 | "identity" : "xpkgpackage", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/elegantchaos/XPkgPackage.git", 88 | "state" : { 89 | "revision" : "cdcc0cff87cfe9f117c0cd26b700137f25a968de", 90 | "version" : "1.1.1" 91 | } 92 | } 93 | ], 94 | "version" : 2 95 | } 96 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.6 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "XPkg", 7 | 8 | platforms: [ 9 | .macOS(.v10_13) 10 | ], 11 | 12 | products: [ 13 | .executable(name: "xpkg", targets: ["XPkgCommand"]), 14 | ], 15 | 16 | dependencies: [ 17 | .package(url: "https://github.com/elegantchaos/CommandShell.git", from: "2.1.5"), 18 | .package(url: "https://github.com/elegantchaos/Expressions.git", from: "1.1.1"), 19 | .package(url: "https://github.com/elegantchaos/Files.git", from: "1.2.2"), 20 | .package(url: "https://github.com/elegantchaos/Logger.git", from: "1.8.0"), 21 | .package(url: "https://github.com/elegantchaos/Runner.git", from: "1.3.2"), 22 | .package(url: "https://github.com/elegantchaos/SemanticVersion.git", from: "1.1.1"), 23 | .package(url: "https://github.com/elegantchaos/Versionator.git", from: "1.0.3"), 24 | .package(url: "https://github.com/elegantchaos/XPkgPackage.git", from: "1.0.9") 25 | ], 26 | 27 | targets: [ 28 | .executableTarget( 29 | name: "XPkgCommand", 30 | 31 | dependencies: [ 32 | "XPkgCore" 33 | ], 34 | plugins: [ 35 | .plugin(name: "VersionatorPlugin", package: "Versionator") 36 | ] 37 | ), 38 | 39 | .target( 40 | name: "XPkgCore", 41 | 42 | dependencies: [ 43 | "CommandShell", 44 | "Expressions", 45 | "Files", 46 | "Logger", 47 | "Runner", 48 | "SemanticVersion", 49 | "XPkgPackage", 50 | ] 51 | ), 52 | 53 | .testTarget( 54 | name: "XPkgTests", 55 | dependencies: [ 56 | "XPkgCore" 57 | ] 58 | ) 59 | ] 60 | ) 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xpkg 2 | 3 | A simple cross-platform "package" manager. 4 | 5 | ## Overview 6 | 7 | I tend to set up many aspects of my environment in a similar way, whether I'm on Linux, macOS, or some other unix-like. 8 | 9 | As part of this, I have a whole grab-bag of scripts, configuration files, and the like, which I need to somehow download, install, and then quite often sym-link into various locations. 10 | 11 | Previously I've done this by having a single git repo that I clone, and a bunch of scripts within it that I hook up to things like `.bashrc` in order to get everything working. 12 | 13 | This isn't a very scalable solution, and I wanted something better. 14 | 15 | Specifically I wanted to be able to: 16 | 17 | - split the monolithic dump of tools and scripts into individual packages 18 | - have each package live in a git repo 19 | - easily install or remove a package 20 | - have hooks automatically run when a package is installed, to set up symlinks etc 21 | - have similar hooks run automatically to clean up when a package is removed 22 | 23 | In a nutshell, that's what XPkg does. 24 | 25 | ## Installation 26 | 27 | **Disclaimer: XPkg was developed purely for my own use. It is under active (albeit sporadic) development and probably has bugs in it. It will modify your `.bashrc`, `.zshrc` etc scripts, and although it backs them up, it may break things. Please do tell me if it does something wrong, but I can't guarantee to support you, and I definitely won't be held responsible for any damage it does. Use XPkg at your own risk!** 28 | 29 | ### Dependencies 30 | 31 | When bringing up a new machine, Xpkg is one of the first things I install - and I then use it to install lots of other things. 32 | 33 | There one or two things that it does require first, however: 34 | 35 | - git 36 | - swift (or Xcode) 37 | - github access (ideally via ssh, which means you need to set up your ssh keys for the machine and register them with github) 38 | 39 | 40 | ### Bootstrap 41 | 42 | Running `curl https://raw.githubusercontent.com/elegantchaos/XPkg/master/.bin/bootstrap | bash` should get you up and running. 43 | 44 | What this does is: 45 | - clone the project into `.local/share/xpkg` 46 | - build it 47 | - install some hooks to link it in to your path 48 | - prompt you to open a new shell or terminal 49 | 50 | During installation, an alias to xpkg is installed into `~/.local/bin`, and some startup hooks are installed for `bash`, `zsh` and `fish` which include this location in `$PATH`. 51 | 52 | *You need to start a new shell / open a new terminal window before this path change is picked up.* 53 | 54 | 55 | 56 | ## Usage 57 | 58 | To install a package from Github: `xpkg install `. 59 | 60 | This will clone the package into a hidden location, then run its installer. 61 | 62 | You can also specify a full repo URL if it's not on github. 63 | 64 | To remove a package, `xpkg remove `. 65 | 66 | To list the installed packages `xpkg list`. 67 | 68 | To navigate to a package directory (using `pushd`), type `xg `. 69 | 70 | For other commands, see `xpkg help`. 71 | 72 | ## Writing Packages 73 | 74 | At their most basic, packages are just git repositories, which contain a payload (whatever files you want to install or hook into your system), plus an installer (that tells XPkg how to install/uninstall the payload). 75 | 76 | XPkg was designed to be cross-platform (this is what the "X" stands for), and is written in Swift. It should work on any platform that has a working Swift compiler and standard libraries. Since Swift is a requirement for XPkg itself, I decided to also make it a requirement for the installer.[^1] 77 | 78 | In fact, XPkg packages are also Swift Package Manager (SPM) packages. 79 | 80 | When you install a package with XPkg, it clones the corresponding repository, then uses SPM to build and run the installer (a product with a special name, currently `-xpkg-hooks`) in your package, passing it a known set of arguments and environment variables. 81 | 82 | There are two nice aspects of this design choice: 83 | 84 | - Your installer can do whatever it wants when it is run; it's just code! 85 | - We get dependency management for free 86 | - the installer can list other SPM packages as dependencies, and use them when its run 87 | - it can also list other _XPkg_ packages as dependencies; SPM will pull them in, and XPkg will notice that they've been pulled in and install them! 88 | 89 | Of course, there are some things that you commonly want to do when installing a package, such as creating symbolic links to places like `/usr/local/bin`, running other scripts, etc. 90 | 91 | To avoid every installer having to write that code every time, we just put them in another Swift package (currently called `XpkgPackage`) which all installers can import and use. 92 | 93 | [^1]: The first iteration of XPkg didn't have installers, it had manifests. The manifest was a hidden json file called `.xpkg.json` which sat at the root of package and described how to install/uninstall the package. This was lightweight, but a little inflexible, since it was up to XPkg to interpret the manifest. You could run code by invoking shell scripts, but you couldn't specify a requirement for other packages as dependencies. 94 | 95 | [^2]: Note that the package can be a real swift package, designed for use in building Swift products, and with a other products and targets. It doesn't have to be, but it can be. 96 | 97 | ### Example 98 | 99 | A simple package might consist of the following files: 100 | 101 | my-package/ 102 | Package.swift 103 | Sources/xpkg-my-package/ 104 | main.swift 105 | Payload/ 106 | my-command.sh 107 | 108 | The `Package.swift` file might look like this: 109 | 110 | ```Swift 111 | 112 | // swift-tools-version:5.0 113 | 114 | import PackageDescription 115 | 116 | let package = Package( 117 | name: "my-package", 118 | platforms: [ 119 | .macOS(.v10_13) 120 | ], 121 | products: [ 122 | .executable(name: "my-package-xpkg-hooks", targets: ["my-package-xpkg-hooks"]), 123 | ], 124 | dependencies: [ 125 | .package(url: "https://github.com/elegantchaos/XPkgPackage", from:"1.0.5"), 126 | ], 127 | targets: [ 128 | .target( 129 | name: "my-package-xpkg-hooks", 130 | dependencies: ["XPkgPackage"]), 131 | ] 132 | ) 133 | ``` 134 | 135 | The `main.swift` file might look like this: 136 | 137 | ```Swift 138 | import XPkgPackage 139 | 140 | let links = [ 141 | ["Payload/my-command.sh"] 142 | ] 143 | 144 | let arguments = CommandLine.arguments 145 | let package = InstalledPackage(fromCommandLine: arguments) 146 | try! package.performAction(fromCommandLine: CommandLine.arguments, links: links, commands: []) 147 | ``` 148 | 149 | This uses the functionality provided by XPkgPackage to install a link `my-command` into `/usr/local/bin`, which points to the file `Payload/my-command.sh` in the cached version of the package on the disk. 150 | 151 | ### A Note About Terminology 152 | 153 | XPkg has undergone a major redesign, and the terminology hasn't caught up yet. 154 | 155 | Previously we had packages and manifests. Now we have packages and installers. 156 | 157 | I plan to rename a lot of things to reflect the new reality: 158 | 159 | - `xpkg-hooks` will probably become `xpkg-installer` 160 | - `XPkgPackage` will probably become `XPkgInstaller` 161 | 162 | ### Hooking Into Shell Startup 163 | 164 | Something that many packages need to do is to hook into the shell startup process (be it .bashrc, .zshrc, or whatever), in order to set environment variables, aliases, and so on. 165 | 166 | Rather than have each package modify these init files, which could get messy, I decided to have one package install itself into this startup process, and have this package provide a flexible way to install other hooks. 167 | 168 | This package is called `shell-hooks` (https://github.com/elegantchaos/shell-hooks), and is installed by default when you install XPkg itself. 169 | 170 | It hooks itself into the startup process for Bash, Zsh, and Fish. At startup, it scans `~/.config/shell-hooks/` and runs any files that it finds there. It actually uses subdirectories and pattern matching to choose exactly which files to run, depending on what platform you're on, and whether this is an interative or non-interactive session. 171 | 172 | The standard installer support package `XpkgPackage` knows about shell-hooks, and provides support for installing symbolic links into its directories. This makes it really simple for Xpkg packages to insert themselves into the shell startup process. 173 | 174 | 175 | ## Existing Packages 176 | 177 | I'm slowly converting over my tangle of old scripts and links to packages, and have a bunch that I've made, supporting things like: 178 | 179 | - Atom setup 180 | - Travis helpers 181 | - Git configs and helpers 182 | - Terminal setup 183 | - Xcode templates 184 | - Coding fonts 185 | - Homebrew installation 186 | - Swift helpers 187 | - Mouse helpers (for Linux) 188 | - Tabtab support (for Linux) 189 | - Keyboard support (for Linux) 190 | - VIM settings 191 | - Appledoc helpers 192 | - Conky settings 193 | 194 | Many of these are in private repos, because they're basically _my_ settings, but you'll find a few public on github. I will try to open up more over time, I just need to make sure that they don't accidentally contain private tokens or other stuff not-for-general-consumption. 195 | 196 | 197 | ## How It Works 198 | 199 | XPkg basically creates a local swift package in a hidden directory, and maintains a `Package.swift` file in there. 200 | 201 | When you install a package, XPkg adds it to the `Package.swift` file, and uses `swift update` to resolve it and fetch the dependencies. 202 | 203 | It then spots any packages that got added as a result of this, and tries to build and run the installer for them. 204 | 205 | Package removal works in a similar way, in reverse. 206 | 207 | There's a bit more to it than that, but that's the basic idea. 208 | 209 | Using SPM to do all the dependency resolution and fetching seemed like a good way to get a lot of fuctionality for not a lot of work! 210 | 211 | The idea is potentially pretty solid I think, and would be purely an implementation detail if it weren't for the fact that the use of SPM is exposed as a way to provide the installers. In theory other installer mechanisms could be provided instead / as well. 212 | 213 | During development, I've found that occasionally bugs in XPkg itself can cause the auto-generated `Package.swift` to become corrupted and need hand editing. This is obviously not ideal for something that other people would use, but it should be possible to prevent this corruption from happening when XPkg itself settles down. 214 | 215 | 216 | ## Future Plans 217 | 218 | As well as supporting installation, XPkg was originally intended to help with some other things: 219 | 220 | - installing and navigating to work projects on my machine (eg being able to type `xg MyProject` and cd to my working directory for MyProject) 221 | - automatically fetching / pull a list of tracked git repos (both packages and projects) 222 | - automatically backing up / pushing a list of tracked git repos (both packages and projects) 223 | 224 | This is all intended to help support an existence where you are working on multiple machines at the same time / moving regularly between machines. 225 | 226 | Some of these features are in the pipeline, or may be added at a later date. 227 | 228 | XPkg also used to install itself into `/usr/local/share`, and install links etc into `/usr/local/bin`. At some point I moved over into using `~/.local/` instead. At some point I intend to make it support either, depending on a configuration flag. At some point. Maybe... 229 | -------------------------------------------------------------------------------- /Sources/XPkgCommand/main.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane, 08/06/2018. 3 | // All code (c) 2018 - present day, Elegant Chaos Limited. 4 | // For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | import CommandShell 8 | import Foundation 9 | import XPkgCore 10 | import SemanticVersion 11 | 12 | var info: [String:Any] = [:] 13 | 14 | info[.versionInfoKey] = CurrentVersion.string 15 | info[.buildInfoKey] = CurrentVersion.build 16 | 17 | let components = Calendar.current.dateComponents([.year], from: Date()) 18 | info[.copyrightInfoKey] = "Copyright © \(components.year!) Elegant Chaos. All rights reserved." 19 | 20 | CommandShell.main(info: info) 21 | 22 | -------------------------------------------------------------------------------- /Sources/XPkgCore/Commands/CheckCommand.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane, 11/07/2018. 3 | // All code (c) 2018 - present day, Elegant Chaos Limited. 4 | // For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | import ArgumentParser 8 | import CommandShell 9 | import Foundation 10 | 11 | public struct CheckCommand: ParsableCommand { 12 | static public var configuration: CommandConfiguration = CommandConfiguration( 13 | commandName: "check", 14 | abstract: "Check that an installed package is ok." 15 | ) 16 | 17 | @Argument(help: "The package to check.") var packageName: String 18 | @OptionGroup() var common: CommandShellOptions 19 | 20 | public init() { 21 | } 22 | 23 | public func run() throws { 24 | let engine: Engine = common.loadEngine() 25 | if packageName == "" { 26 | let _ = try engine.forEachPackage { (package) in 27 | check(package: package, engine: engine) 28 | } 29 | } else { 30 | let manifest = try engine.loadManifest() 31 | let package = try engine.existingPackage(from: packageName, manifest: manifest) 32 | check(package: package, engine: engine) 33 | } 34 | } 35 | 36 | func check(package: Package, engine: Engine) { 37 | if package.check(engine: engine) { 38 | engine.output.log("\(package.name) ok.") 39 | } else { 40 | engine.output.log("\(package.name) missing.") 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/XPkgCore/Commands/InitCommand.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane on 18/07/2019. 3 | // All code (c) 2019 - present day, Elegant Chaos Limited. 4 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 5 | 6 | import ArgumentParser 7 | import CommandShell 8 | import Foundation 9 | import Runner 10 | 11 | 12 | public struct InitCommand: ParsableCommand { 13 | static public var configuration: CommandConfiguration = CommandConfiguration( 14 | commandName: "init", 15 | abstract: "Create a new package." 16 | ) 17 | 18 | @OptionGroup() var common: CommandShellOptions 19 | 20 | public init() { 21 | } 22 | 23 | public func run() throws { 24 | let fm = FileManager.default 25 | let engine: Engine = common.loadEngine() 26 | var name = engine.remoteOriginForCwd() 27 | var path = engine.localRepoForCwd() 28 | if path.isEmpty { 29 | path = fm.currentDirectoryPath 30 | } 31 | let root = URL(fileURLWithPath: path) 32 | 33 | if name.isEmpty { 34 | name = root.lastPathComponent 35 | } 36 | 37 | do { 38 | try writeFiles(for: name, to: root, engine: engine) 39 | engine.output.log("Inited package \(name).") 40 | } catch { 41 | engine.output.log("Failed to init package \(name).") 42 | engine.verbose.log(error) 43 | } 44 | } 45 | 46 | fileprivate func writeFiles(for name: String, to root: URL, engine: Engine) throws { 47 | let user = engine.gitUserName() ?? ProcessInfo.processInfo.environment["USER"] ?? "Unknown" 48 | let now = DateFormatter.localizedString(from: Date(), dateStyle: .short, timeStyle: .none) 49 | let manifest = manifestSource(for: name, user: user, date: now) 50 | let main = mainSource(for: name, user: user, date: now) 51 | let runner = Runner(for: engine.gitURL) 52 | let fm = FileManager.default 53 | 54 | // create manifest 55 | try manifest.write(to: root.appendingPathComponent("Package.swift"), atomically: true, encoding: .utf8) 56 | 57 | // create source 58 | let source = root.appendingPathComponent("Sources/\(name)-xpkg-hooks") 59 | try? fm.createDirectory(at: source, withIntermediateDirectories: true, attributes: nil) 60 | try main.write(to: source.appendingPathComponent("main.swift"), atomically: true, encoding: .utf8) 61 | 62 | // create gitignore if we've a global one to copy 63 | if let result = try? runner.sync(arguments: ["config", "--global", "core.excludesfile"]) { 64 | if result.status == 0 { 65 | let globalIgnoreURL = URL(fileURLWithPath: result.stdout.trimmingCharacters(in: .newlines)) 66 | try? fm.copyItem(at: globalIgnoreURL, to: root.appendingPathComponent(".gitignore")) 67 | } 68 | } 69 | 70 | // git init if necessary 71 | if !fm.fileExists(at: root.appendingPathComponent(".git")) { 72 | let _ = try? runner.sync(arguments: ["init"]) 73 | } 74 | } 75 | 76 | func manifestSource(for name: String, user: String, date: String) -> String { 77 | return """ 78 | // swift-tools-version:5.0 79 | 80 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 81 | // \(name) - An XPkg package. 82 | // Created by \(user), \(date). 83 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 84 | 85 | import PackageDescription 86 | 87 | let package = Package( 88 | name: "\(name)", 89 | platforms: [ 90 | .macOS(.v10_13) 91 | ], 92 | products: [ 93 | .executable(name: "\(name)-xpkg-hooks", targets: ["\(name)-xpkg-hooks"]), 94 | ], 95 | dependencies: [ 96 | .package(url: "https://github.com/elegantchaos/XPkgPackage", from:"1.0.0"), 97 | ], 98 | targets: [ 99 | .target( 100 | name: "\(name)-xpkg-hooks", 101 | dependencies: ["XPkgPackage"]), 102 | ] 103 | ) 104 | """ 105 | } 106 | 107 | func mainSource(for name: String, user: String, date: String) -> String { 108 | return """ 109 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 110 | // \(name) - An XPkg package. 111 | // Created by \(user), \(date). 112 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 113 | 114 | import XPkgPackage 115 | 116 | let links: [InstalledPackage.ManifestLink] = [] 117 | 118 | let package = InstalledPackage(fromCommandLine: CommandLine.arguments) 119 | try! package.performAction(fromCommandLine: CommandLine.arguments, links: links) 120 | """ 121 | 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Sources/XPkgCore/Commands/InstallCommand.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane, 08/06/2018. 3 | // All code (c) 2018 - present day, Elegant Chaos Limited. 4 | // For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | import ArgumentParser 8 | import CommandShell 9 | import Foundation 10 | 11 | public struct InstallCommand: ParsableCommand { 12 | @Argument(help: "The package to install.") var packageSpec: String 13 | @Flag(name: .customLong("project"), help: "Install in the projects folder, and not as a package.") var asProject = false 14 | @Option(name: .customLong("as"), help: "The name to use for the package.") var asName: String? 15 | @OptionGroup() var common: CommandShellOptions 16 | 17 | static public var configuration: CommandConfiguration = CommandConfiguration( 18 | commandName: "install", 19 | abstract: "Install a package." 20 | ) 21 | 22 | public init() { 23 | } 24 | 25 | public func run() throws { 26 | let engine: Engine = common.loadEngine() 27 | try engine.install(packageSpec: packageSpec, asProject: asProject, asName: asName) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/XPkgCore/Commands/LinkCommand.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane, 08/06/2018. 3 | // All code (c) 2018 - present day, Elegant Chaos Limited. 4 | // For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | import ArgumentParser 8 | import CommandShell 9 | import Foundation 10 | import Runner 11 | 12 | public struct LinkCommand: ParsableCommand { 13 | @Argument(help: "The name of the package to link to.") var packageName: String 14 | @Argument(help: "The path to the package.") var packagePath: String 15 | @OptionGroup() var common: CommandShellOptions 16 | 17 | static public var configuration: CommandConfiguration = CommandConfiguration( 18 | commandName: "link", 19 | abstract: "Link an existing folder as a package." 20 | ) 21 | 22 | public init() { 23 | } 24 | 25 | public func run() throws { 26 | let engine: Engine = common.loadEngine() 27 | let output = engine.output 28 | var package = self.packageName 29 | var path = self.packagePath 30 | 31 | if package == "" { 32 | package = engine.remoteOriginForCwd() 33 | } 34 | 35 | if path == "" { 36 | path = engine.localRepoForCwd() 37 | } 38 | 39 | guard package != "", let linkedURL = URL(string: path) else { 40 | output.log("Couldn't infer package name/path.") 41 | return 42 | } 43 | 44 | let packageURL = linkedURL.appendingPathComponent("Package.swift") 45 | if FileManager.default.fileExists(at: packageURL) { 46 | try engine.install(packageSpec: package, linkTo: linkedURL) 47 | } else { 48 | engine.output.log("Linked \(package) as an alias.") 49 | let defaults = UserDefaults.standard 50 | var aliases: [String:String] 51 | if let current = defaults.dictionary(forKey: "aliases") as? [String:String] { 52 | aliases = current 53 | } else { 54 | aliases = [:] 55 | } 56 | 57 | let key = linkedURL.lastPathComponent 58 | aliases[key] = linkedURL.path 59 | defaults.set(aliases, forKey: "aliases") 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/XPkgCore/Commands/ListCommand.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane, 13/06/2018. 3 | // All code (c) 2018 - present day, Elegant Chaos Limited. 4 | // For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | import ArgumentParser 8 | import CommandShell 9 | import Foundation 10 | 11 | 12 | public struct ListCommand: ParsableCommand { 13 | @Flag(help: "Produces output on a single line.") var oneline = false 14 | @Flag(help: "Produces minimal output.") var compact = false 15 | @Flag(help: "Produces output with extra details.") var full = false 16 | @OptionGroup() var common: CommandShellOptions 17 | 18 | static public var configuration: CommandConfiguration = CommandConfiguration( 19 | commandName: "list", 20 | abstract: "List the installed packages." 21 | ) 22 | 23 | public init() { 24 | } 25 | 26 | public func run() throws { 27 | let engine: Engine = common.loadEngine() 28 | if oneline { 29 | try listOneline(engine: engine) 30 | } else if compact { 31 | try listCompact(engine: engine) 32 | } else if full { 33 | try listFull(engine: engine) 34 | } else { 35 | try listNormal(engine: engine) 36 | } 37 | } 38 | 39 | func listOneline(engine: Engine) throws { 40 | var output: [String] = [] 41 | let _ = try engine.forEachPackage { (package) in 42 | output.append(package.name) 43 | } 44 | engine.output.log(output.joined(separator: " ")) 45 | } 46 | 47 | func listCompact(engine: Engine) throws { 48 | let gotPackages = try engine.forEachPackage { (package) in 49 | engine.output.log("\(package.name)") 50 | } 51 | if !gotPackages { 52 | engine.output.log("No packages installed.") 53 | } 54 | } 55 | 56 | func listNormal(engine: Engine) throws { 57 | var gotLinked = false 58 | let gotPackages = try engine.forEachPackage { (package) in 59 | let linked = !package.local.absoluteString.contains(engine.vaultURL.absoluteString) 60 | let flags = linked ? "*" : " " 61 | gotLinked = gotLinked || linked 62 | let status = package.status(engine: engine) 63 | let statusString = status == .pristine ? "" : " (\(status))" 64 | engine.output.log("\(flags) \(package.name)\(statusString)") 65 | } 66 | 67 | 68 | if !gotPackages { 69 | engine.output.log("No packages installed.") 70 | } else if gotLinked { 71 | engine.output.log("\n(items marked with * are linked to external folders)") 72 | } 73 | } 74 | 75 | func listFull(engine: Engine) throws { 76 | let gotPackages = try engine.forEachPackage { (package) in 77 | let linked = !package.local.absoluteString.contains(engine.vaultURL.absoluteString) 78 | let location = linked ? "\(package.local.path) (linked)" : package.local.path 79 | let status = package.status(engine: engine) 80 | let version = engine.currentVersion(forLocalURL: package.local) ?? "" 81 | let statusString = status == .pristine ? "" : " (\(status))\n" 82 | engine.output.log("\n\(package.name) (\(version))\n---------------------\n\(package.url)\n\(location)\n\(statusString)") 83 | } 84 | 85 | if !gotPackages { 86 | engine.output.log("No packages installed.") 87 | } 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /Sources/XPkgCore/Commands/PathCommand.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane, 13/06/2018. 3 | // All code (c) 2018 - present day, Elegant Chaos Limited. 4 | // For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | import ArgumentParser 8 | import CommandShell 9 | import Foundation 10 | 11 | public struct PathCommand: ParsableCommand { 12 | @Argument(help: "The package to show.") var packageName: String? 13 | @Flag(name: .customLong("self"), help: "Perform the action on xpkg itself, rather than an installed package.") var asSelf = false 14 | @Flag(help: "Show the path to the vault.") var vault = false 15 | @Flag(help: "Show the path to the projects folder.") var projects = false 16 | @OptionGroup() var common: CommandShellOptions 17 | 18 | static public var configuration: CommandConfiguration = CommandConfiguration( 19 | commandName: "path", 20 | abstract: "Show the path of a package." 21 | ) 22 | 23 | public init() { 24 | } 25 | 26 | public func run() throws { 27 | let engine: Engine = common.loadEngine() 28 | var url: URL? = nil 29 | 30 | if asSelf { 31 | url = engine.xpkgCodeURL 32 | } else if vault { 33 | url = engine.vaultURL 34 | } else if projects { 35 | url = engine.projectsURL 36 | } else if let name = packageName { 37 | if let package = engine.possiblePackage(named: name, manifest: try engine.loadManifest()) { 38 | url = package.local 39 | } else { 40 | let project = engine.projectsURL.appendingPathComponent(name) 41 | if FileManager.default.fileExists(at: project) { 42 | url = project 43 | } 44 | } 45 | } else { 46 | throw ValidationError("Package name required.") 47 | } 48 | 49 | if let found = url { 50 | engine.output.log(found.path) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/XPkgCore/Commands/RebuildCommand.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane, 14/06/2018. 3 | // All code (c) 2018 - present day, Elegant Chaos Limited. 4 | // For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | import ArgumentParser 8 | import Runner 9 | import CommandShell 10 | 11 | public struct RebuildCommand: ParsableCommand { 12 | @OptionGroup() var common: CommandShellOptions 13 | 14 | static public var configuration: CommandConfiguration = CommandConfiguration( 15 | commandName: "rebuild", 16 | abstract: "Rebuild XPkg itself." 17 | ) 18 | 19 | public init() { 20 | } 21 | 22 | public func run() throws { 23 | let engine: Engine = common.loadEngine() 24 | try engine.runBootstrap() 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /Sources/XPkgCore/Commands/ReinstallCommand.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane, 20/06/2018. 3 | // All code (c) 2018 - present day, Elegant Chaos Limited. 4 | // For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | import ArgumentParser 8 | import CommandShell 9 | import Foundation 10 | 11 | public struct ReinstallCommand: ParsableCommand { 12 | @Argument(help: "The package to reinstall.") var packageName: String 13 | @OptionGroup() var common: CommandShellOptions 14 | 15 | static public var configuration: CommandConfiguration = CommandConfiguration( 16 | commandName: "reinstall", 17 | abstract: "Re-install the package. This is the equivalent of doing remove followed by install ." 18 | ) 19 | 20 | public init() { 21 | } 22 | 23 | public func run() throws { 24 | let engine: Engine = common.loadEngine() 25 | let manifest = try engine.loadManifest() 26 | let package = try engine.existingPackage(from: packageName, manifest: manifest) 27 | 28 | engine.attempt(action: "Reinstalling \(package.name).") { 29 | do { 30 | engine.verbose.log("Uninstalling \(package.name)") 31 | if try package.run(action: "remove", engine: engine) { 32 | engine.output.log("Removed \(package.name).") 33 | } 34 | engine.verbose.log("Installing \(package.name)") 35 | if try package.run(action: "install", engine: engine) { 36 | engine.output.log("Reinstalled \(package.name).") 37 | } 38 | } catch { 39 | engine.output.log("Reinstall of \(package.name) failed.") 40 | engine.verbose.log(error) 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/XPkgCore/Commands/RemoveCommand.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane, 08/06/2018. 3 | // All code (c) 2018 - present day, Elegant Chaos Limited. 4 | // For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | import ArgumentParser 8 | import CommandShell 9 | import Foundation 10 | import Runner 11 | 12 | public struct RemoveCommand: ParsableCommand { 13 | @Argument(help: "The package to remove.") var packageName: String 14 | @Flag(help: "Force the removal of the package even if there are local changes.") var force = false 15 | @OptionGroup() var common: CommandShellOptions 16 | 17 | static public var configuration: CommandConfiguration = CommandConfiguration( 18 | commandName: "remove", 19 | abstract: "Remove a package." 20 | ) 21 | 22 | public init() { 23 | } 24 | 25 | public func run() throws { 26 | let engine: Engine = common.loadEngine() 27 | let output = engine.output 28 | 29 | let manifest = try engine.loadManifest() 30 | let package = try engine.existingPackage(from: packageName, manifest: manifest) 31 | 32 | var safeToDelete = force 33 | if !safeToDelete { 34 | switch package.status(engine: engine) { 35 | case .unknown: 36 | output.log("Failed to check \(package.name) status - it might be modified or un-pushed. Use --force to force deletion.") 37 | case .pristine: 38 | safeToDelete = true 39 | case .modified: 40 | output.log("Package \(package.name) is modified. Use --force to force deletion.") 41 | case .uncommitted: 42 | output.log("Package \(package.name) has no commits. Use --force to force deletion.") 43 | case .ahead: 44 | output.log("Package \(package.name) has un-pushed commits. Use --force to force deletion.") 45 | case .untracked: 46 | output.log("Package \(package.name) is not tracking remotely or may have un-pushed commits. Use --force to force deletion.") 47 | } 48 | } 49 | 50 | // try to unlink the package 51 | if safeToDelete { 52 | safeToDelete = package.unedit(engine: engine) 53 | } 54 | 55 | if safeToDelete { 56 | // remove the package from the manifest 57 | var updatedManifest = manifest 58 | updatedManifest.remove(package: package) 59 | 60 | // run the remove action for any packages removed 61 | engine.processUpdate(from: manifest, to: updatedManifest) 62 | 63 | // try to write the updated manifest 64 | let resolved = try engine.updateManifest(from: manifest, to: updatedManifest) 65 | 66 | // check that the count went down 67 | guard resolved.dependencies.count < manifest.dependencies.count else { 68 | output.log("Couldn't remove `\(package.name)`.") 69 | return 70 | } 71 | 72 | output.log("Package \(package.name) removed.") 73 | } 74 | 75 | // 76 | // // check the git status 77 | // if package.installed { 78 | // } else { 79 | // // local directory seems to be missing - also safe to delete in that case 80 | // safeToDelete = true 81 | // } 82 | // 83 | // if safeToDelete { 84 | 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/XPkgCore/Commands/RenameCommand.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane, 02/08/2018. 3 | // All code (c) 2018 - present day, Elegant Chaos Limited. 4 | // For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | import ArgumentParser 8 | 9 | public struct RenameCommand: ParsableCommand { 10 | 11 | static public var configuration: CommandConfiguration = CommandConfiguration( 12 | commandName: "rename", 13 | abstract: "Rename a package." 14 | ) 15 | 16 | public init() { 17 | } 18 | 19 | public func run() throws { 20 | // let manifest = engine.loadManifest() 21 | // let package = engine.existingPackage(manifest: manifest) 22 | // let oldName = package.name 23 | // if package.installed { 24 | // engine.attempt(action: "Rename") { 25 | // let name = try engine.arguments.expectedArgument("name") 26 | // try package.rename(as: name, engine: engine) 27 | // engine.output.log("Renamed \(oldName) as \(name).") 28 | // } 29 | // } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/XPkgCore/Commands/RepairCommand.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane, 14/06/2018. 3 | // All code (c) 2018 - present day, Elegant Chaos Limited. 4 | // For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | import ArgumentParser 8 | import Runner 9 | import CommandShell 10 | 11 | public struct RepairCommand: ParsableCommand { 12 | @OptionGroup() var common: CommandShellOptions 13 | 14 | static public var configuration: CommandConfiguration = CommandConfiguration( 15 | commandName: "repair", 16 | abstract: "Attempt to repair a corrupt manifest." 17 | ) 18 | 19 | public init() { 20 | } 21 | 22 | public func run() throws { 23 | let engine: Engine = common.loadEngine() 24 | try engine.repair() 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /Sources/XPkgCore/Commands/RevealCommand.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane, 13/06/2018. 3 | // All code (c) 2018 - present day, Elegant Chaos Limited. 4 | // For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | import ArgumentParser 8 | import CommandShell 9 | 10 | public struct RevealCommand: ParsableCommand { 11 | @Argument(help: "The package to reveal.") var packageName: String 12 | @Flag(help: "Print the package path.") var path = false 13 | @OptionGroup() var common: CommandShellOptions 14 | 15 | static public var configuration: CommandConfiguration = CommandConfiguration( 16 | commandName: "reveal", 17 | abstract: "Reveal a package in the finder." 18 | ) 19 | 20 | public init() { 21 | } 22 | 23 | public func run() throws { 24 | let engine: Engine = common.loadEngine() 25 | let manifest = try engine.loadManifest() 26 | let package = try engine.existingPackage(from: packageName, manifest: manifest) 27 | if path { 28 | engine.output.log(package.path) 29 | } else { 30 | package.reveal() 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/XPkgCore/Commands/UpdateCommand.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane, 14/06/2018. 3 | // All code (c) 2018 - present day, Elegant Chaos Limited. 4 | // For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | import ArgumentParser 8 | import Runner 9 | import CommandShell 10 | 11 | public struct UpdateCommand: ParsableCommand { 12 | @Flag(name: .customLong("self"), help: "Update xpkg itself.") var updateSelf = false 13 | @Argument(help: "The package to update.") var packageName: String? 14 | @OptionGroup() var common: CommandShellOptions 15 | 16 | static public var configuration: CommandConfiguration = CommandConfiguration( 17 | commandName: "update", 18 | abstract: "Update a package to the latest version." 19 | ) 20 | 21 | public init() { 22 | } 23 | 24 | public func run() throws { 25 | let engine: Engine = common.loadEngine() 26 | if updateSelf { 27 | try engine.update() 28 | } else if let packageName = packageName { 29 | let manifest = try engine.loadManifest() 30 | let package = try engine.existingPackage(from: packageName, manifest: manifest) 31 | update(package: package, engine: engine) 32 | } else { 33 | engine.output.log("Checking all packages for updates...") 34 | let _ = try engine.forEachPackage { (package) in 35 | update(package: package, engine: engine) 36 | } 37 | engine.output.log("Done.") 38 | } 39 | } 40 | 41 | func update(package: Package, engine: Engine) { 42 | let state = package.needsUpdate(engine: engine) 43 | switch state { 44 | case .needsUpdate(let newerVersion): 45 | package.update(to: newerVersion, engine: engine) 46 | 47 | case .unchanged: 48 | engine.output.log("Package \(package.name) was unchanged.") 49 | 50 | case .notOnTag: 51 | engine.output.log("Package \(package.name) is modified locally or not at a published version.") 52 | 53 | default: 54 | engine.output.log("Could not fetch the latest version for \(package.name), so it has not been updated.") 55 | } 56 | } 57 | 58 | 59 | } 60 | -------------------------------------------------------------------------------- /Sources/XPkgCore/Engine.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane, 08/06/2018. 3 | // All code (c) 2018 - present day, Elegant Chaos Limited. 4 | // For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | import ArgumentParser 8 | import CommandShell 9 | import Expressions 10 | import Files 11 | import Foundation 12 | import Logger 13 | import Runner 14 | import SemanticVersion 15 | import XPkgPackage 16 | 17 | #if canImport(FoundationNetworking) 18 | import FoundationNetworking 19 | #endif 20 | 21 | extension URLSession { 22 | func synchronousDataTask(with request: URLRequest) -> (Data?, URLResponse?, Error?) { 23 | var data: Data? 24 | var response: URLResponse? 25 | var error: Error? 26 | 27 | let semaphore = DispatchSemaphore(value: 0) 28 | 29 | let dataTask = self.dataTask(with: request) { 30 | data = $0 31 | response = $1 32 | error = $2 33 | 34 | semaphore.signal() 35 | } 36 | dataTask.resume() 37 | 38 | _ = semaphore.wait(timeout: .distantFuture) 39 | 40 | return (data, response, error) 41 | } 42 | } 43 | 44 | public class Engine: CommandEngine { 45 | static let missingProductPattern = try! NSRegularExpression(pattern: #"'vault': product '(.*)' required by package 'vault' target 'Installed' not found in package '(.*)'."#) 46 | // 'vault': product 'Logger-xpkg-hooks' required by package 'vault' target 'Installed' not found in package 'Logger' 47 | let jsonChannel: Channel 48 | let fileManager = FileManager.default 49 | 50 | var defaultOrgs = ["elegantchaos", "samdeane"] // TODO: read from preference 51 | 52 | public required init(options: CommandShellOptions, info: [String:Any]?) { 53 | jsonChannel = Channel("json", handlers: [Channel.stdoutHandler]) 54 | super.init(options: options, info: info) 55 | } 56 | 57 | public override class var abstract: String { 58 | return "Cross Platform Package Manager." 59 | } 60 | 61 | public override class var subcommands: [ParsableCommand.Type] { 62 | return [ 63 | InitCommand.self, 64 | CheckCommand.self, 65 | InstallCommand.self, 66 | LinkCommand.self, 67 | ListCommand.self, 68 | PathCommand.self, 69 | RebuildCommand.self, 70 | ReinstallCommand.self, 71 | RemoveCommand.self, 72 | RenameCommand.self, 73 | RepairCommand.self, 74 | RevealCommand.self, 75 | UpdateCommand.self, 76 | ] 77 | } 78 | 79 | 80 | internal var xpkgURL: URL { 81 | let localPath = ("~/.local/share/xpkg" as NSString).expandingTildeInPath as String 82 | let localURL = URL(fileURLWithPath: localPath).resolvingSymlinksInPath() 83 | 84 | if fileManager.fileExists(at: localURL) { 85 | return localURL 86 | } else { 87 | let globalURL = URL(fileURLWithPath: "/usr/local/share/xpkg").resolvingSymlinksInPath() 88 | if fileManager.fileExists(atPath: globalURL.path) { 89 | return globalURL 90 | } 91 | } 92 | 93 | try? fileManager.createDirectory(at: localURL, withIntermediateDirectories: true) 94 | return localURL 95 | } 96 | 97 | internal var xpkgCodeURL: URL { 98 | return xpkgURL.appendingPathComponent("code") 99 | } 100 | 101 | /// Returns the latest semantic version tag for the remote repo. 102 | /// If the tag contains a "v", we return it along with the number. 103 | /// - Parameter url: the url of the repo 104 | internal func latestVersion(_ url: URL) -> String? { 105 | let runner = Runner(for: gitURL) 106 | let arguments = ["ls-remote", "--tags", "--refs", "--sort=v:refname", "--exit-code", url.absoluteString ] 107 | 108 | var error = "" 109 | let callback = Runner.Mode.callback({ text in 110 | error.append(text) 111 | // TODO: filter out some known errors and exit or prompt for the user to fix them? 112 | }) 113 | 114 | // TODO: add a timeout? 115 | guard let result = try? runner.sync(arguments: arguments, stdoutMode: .capture, stderrMode: callback), result.status == 0 else { 116 | return nil 117 | } 118 | 119 | guard let tag = result.stdout.split(separator: "/").last else { 120 | return "" 121 | } 122 | 123 | guard tag.contains(".") else { 124 | return "" 125 | } 126 | 127 | return String(tag.trimmingCharacters(in: .whitespacesAndNewlines)) 128 | } 129 | 130 | typealias RepoValidator = (URL) -> String? 131 | 132 | func validate(_ remote: URL) -> String? { 133 | let version = latestVersion(remote) 134 | return version?.replacingOccurrences(of: "v", with: "") 135 | } 136 | 137 | internal var gitLooksLikeItIsSetup: Bool { 138 | let hosts = FileManager.default.locations.home.folder(".ssh").file("known_hosts") 139 | return hosts.asText?.contains("github.com") ?? false 140 | } 141 | 142 | /// Given a package spec, try to find a URL and latest version for the package. 143 | /// The spec can be one of: 144 | /// - a full URL - which is used directly 145 | /// - an unqualified package name - we try to make a github URL using one of the default organisations 146 | /// - a qualified org/package page - we try to make this into a github URL 147 | /// - Parameter package: the package spec 148 | /// - Parameter skipValidation: whether to perform online validation; provided for testing 149 | internal func remotePackageURL(_ package: String, validator: RepoValidator? = nil) throws -> (URL, String?) { 150 | let validate = validator ?? { url in self.validate(url) } 151 | 152 | if let remote = URL(string: package), let version = validate(remote) { 153 | return (remote, version) 154 | } 155 | 156 | let tryPrefixing = !package.starts(with: "xpkg-") 157 | let containsSlash = package.contains("/") 158 | 159 | var paths = ["\(package)"] 160 | if !containsSlash { 161 | for org in defaultOrgs { 162 | paths.append("\(org)/\(package)") 163 | if tryPrefixing { 164 | paths.append("\(org)/xpkg-\(package)") 165 | } 166 | } 167 | } 168 | 169 | var methods = ["https://github.com/"] 170 | if gitLooksLikeItIsSetup { 171 | // try git@ first if it looks like github is in the known hosts etc 172 | methods.insert("git@github.com:", at: 0) 173 | } 174 | 175 | for method in methods { 176 | for path in paths { 177 | verbose.log("Trying \(method)\(path)") 178 | if let remote = URL(string: "\(method)\(path)"), let version = validate(remote) { 179 | return (remote, version) 180 | } 181 | } 182 | } 183 | 184 | throw Failure.badPackageSpec(package) 185 | } 186 | 187 | internal var vaultURL: URL { 188 | let url = xpkgURL.appendingPathComponent("vault") 189 | 190 | try? fileManager.createDirectory(at: url, withIntermediateDirectories: true) 191 | return url 192 | } 193 | 194 | internal var gitURL: URL { 195 | return URL(fileURLWithPath: "/usr/bin/git") 196 | } 197 | 198 | internal var swiftURL: URL { 199 | return URL(fileURLWithPath: "/usr/bin/swift") 200 | } 201 | 202 | internal var projectsURL: URL { 203 | let paths = ["~/Projects", "~/Developer/Projects"] 204 | for path in paths { 205 | let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath) 206 | if FileManager.default.fileExists(at: url) { 207 | return url 208 | } 209 | } 210 | return URL(fileURLWithPath: "Projects") 211 | } 212 | 213 | func swift(_ arguments: [String], failureMessage: @autoclosure () -> String = "") -> Runner.Result? { 214 | let runner = Runner(for: swiftURL, cwd: vaultURL) 215 | do { 216 | let result = try runner.sync(arguments: arguments) 217 | if result.status != 0 { 218 | let message = failureMessage() 219 | if !message.isEmpty { output.log(message) } 220 | verbose.log(result.stderr) 221 | } 222 | return result 223 | } catch { 224 | let message = failureMessage() 225 | if !message.isEmpty { output.log(message) } 226 | verbose.log(error) 227 | return nil 228 | } 229 | } 230 | 231 | func attempt(action: String, cleanup: (() throws -> Void)? = nil, block: () throws -> ()) { 232 | verbose.log(action) 233 | do { 234 | try block() 235 | } catch { 236 | try? cleanup?() 237 | output.log("\(action) failed.\n\(error)") 238 | } 239 | } 240 | 241 | var dependencyCacheURL: URL { 242 | vaultURL.appendingPathComponent("Dependencies.json") 243 | } 244 | 245 | var manifestURL: URL { 246 | vaultURL.appendingPathComponent("Package.swift") 247 | } 248 | 249 | func dependencyData(readCache: Bool, writeCache: Bool) throws -> Data { 250 | let cachedURL = dependencyCacheURL 251 | 252 | if readCache, let cached = try? Data(contentsOf: cachedURL) { 253 | verbose.log("Loaded cached dependencies.") 254 | return cached 255 | } 256 | 257 | verbose.log("Reading dependencies from \(manifestURL).") 258 | guard let showResult = swift(["package", "show-dependencies", "--format", "json"]) else { 259 | throw Failure.couldntLoadDependencyData 260 | } 261 | 262 | let code = showResult.status 263 | let output = showResult.stderr 264 | guard code == 0 else { 265 | let range = NSRange(location: 0, length: output.count) 266 | let matches = Self.missingProductPattern.matches(in: output, range: range) 267 | if let match = matches.first { 268 | let product = (output as NSString).substring(with: match.range(at: 1)) 269 | let package = (output as NSString).substring(with: match.range(at: 2)) 270 | throw Failure.packageMissingHooks(product, package) 271 | } 272 | 273 | throw Failure.errorLoadingDependencyData(code, output) 274 | } 275 | 276 | var json = showResult.stdout 277 | if let index = json.firstIndex(of: "{") { 278 | json.removeSubrange(json.startIndex ..< index) 279 | } 280 | 281 | jsonChannel.log(json) 282 | 283 | if writeCache { 284 | verbose.log("Saved cached dependencies.") 285 | try? json.write(to: cachedURL, atomically: true, encoding: .utf8) 286 | } 287 | 288 | return json.data(using: .utf8)! 289 | } 290 | 291 | func emptyManifest() -> Package { 292 | Package(name: "XPkgVault") 293 | } 294 | 295 | func loadManifest(readCache: Bool = true, writeCache: Bool = true, createIfMissing: Bool = false) throws -> Package { 296 | 297 | if !fileManager.fileExists(atURL: manifestURL), createIfMissing { 298 | return emptyManifest() 299 | } 300 | 301 | let data = try dependencyData(readCache: readCache, writeCache: writeCache) 302 | 303 | do { 304 | let decode = JSONDecoder() 305 | let manifestPackage = try decode.decode(Package.self, from: data) 306 | return manifestPackage 307 | } catch { 308 | verbose.log("Failed to decode manifest.") 309 | verbose.log(error) 310 | throw Failure.couldntDecodeDependencyData 311 | } 312 | } 313 | 314 | func saveManifestAndRemoveCachedDependencies(manifest: Package) { 315 | var dependencies = "" 316 | var products = "" 317 | 318 | for package in manifest.dependencies { 319 | let version = package.version 320 | if version.isEmpty || version == "unspecified" { 321 | dependencies.append(" .package(url: \"\(package.url)\", Version(0,0,1)...Version(10000,0,0)),\n") 322 | } else { 323 | dependencies.append(" .package(url: \"\(package.url)\", from:\"\(version)\"),\n") 324 | } 325 | products.append(" .product(name: \"\(package.name)-xpkg-hooks\", package: \"\(package.name)\"),\n") 326 | } 327 | 328 | let manifestText = """ 329 | // swift-tools-version:5.2 330 | 331 | import PackageDescription 332 | let package = Package( 333 | name: "XPkgVault", 334 | platforms: [ 335 | .macOS(.v10_15) 336 | ], 337 | products: [ 338 | ], 339 | dependencies: [ 340 | \(dependencies) 341 | ], 342 | targets: [ 343 | .target( 344 | name: "Installed", 345 | dependencies: [ 346 | \(products) 347 | ] 348 | ), 349 | ] 350 | ) 351 | """ 352 | 353 | let url = vaultURL.appendingPathComponent("Package.swift") 354 | do { 355 | try createInstalledSourceStub() 356 | try manifestText.write(to: url, atomically: true, encoding: .utf8) 357 | removeDependencyCache() 358 | } catch { 359 | verbose.log(error) 360 | } 361 | } 362 | 363 | func createInstalledSourceStub() throws { 364 | let sourcesURL = vaultURL.appendingPathComponent("Sources").appendingPathComponent("Installed") 365 | try? fileManager.createDirectory(at: sourcesURL, withIntermediateDirectories: true, attributes: nil) 366 | let mainURL = sourcesURL.appendingPathComponent("main.swift") 367 | try "".write(to: mainURL, atomically: true, encoding: .utf8) 368 | } 369 | 370 | func removeDependencyCache() { 371 | try? fileManager.removeItem(at: dependencyCacheURL) 372 | } 373 | 374 | func updateManifest(from oldPackage: Package, to newPackage: Package) throws -> Package { 375 | let backupURL = manifestURL.appendingPathExtension("backup") 376 | do { 377 | try fileManager.moveItem(at: manifestURL, to: backupURL) 378 | verbose.log("Backed up manifest to \(backupURL).") 379 | } catch { 380 | verbose.log("Failed to backup existing manifest.") 381 | } 382 | 383 | defer { 384 | verbose.log("Removing backup if still present.") 385 | try? fileManager.removeItem(at: backupURL) 386 | } 387 | 388 | saveManifestAndRemoveCachedDependencies(manifest: newPackage) 389 | 390 | do { 391 | let resolved = try loadManifest() 392 | return resolved 393 | } catch { 394 | // backup failed manifest for debugging 395 | let date = DateFormatter.localizedString(from: Date(), dateStyle: .short, timeStyle: .short) 396 | let failedURL = vaultURL.appendingPathComponent("Failed Package \(date).swift") 397 | try? fileManager.moveItem(at: manifestURL, to: failedURL) 398 | verbose.log("Failed to load updated manifest. Saved manifest as \(failedURL) for inspection.") 399 | 400 | do { 401 | try fileManager.moveItem(at: backupURL, to: manifestURL) 402 | verbose.log("Restored manifest from backup.") 403 | } catch { 404 | verbose.log("Failed to restore previous manifest. Will attempt to recreate it.") 405 | saveManifestAndRemoveCachedDependencies(manifest: oldPackage) 406 | } 407 | 408 | throw error 409 | } 410 | } 411 | 412 | func processUpdate(from: Package, to: Package) { 413 | let (_, before) = from.allPackages 414 | let (after, _) = to.allPackages 415 | 416 | let beforeSet = Set(before) 417 | for package in after { 418 | if !beforeSet.contains(package) { 419 | do { 420 | if try package.run(action: "install", engine: self) { 421 | output.log("Added \(package.name).") 422 | } 423 | } catch { 424 | output.log("Install action for \(package.name) failed.") 425 | } 426 | } 427 | } 428 | 429 | let afterSet = Set(after) 430 | for package in before { 431 | if !afterSet.contains(package) { 432 | do { 433 | if try package.run(action:"remove", engine: self) { 434 | output.log("Removed \(package.name).") 435 | } 436 | } catch { 437 | output.log("Remove action for \(package.name) failed.") 438 | } 439 | } 440 | } 441 | } 442 | 443 | func forEachPackage(_ block: (Package) -> ()) throws -> Bool { 444 | let manifest = try loadManifest() 445 | 446 | if manifest.dependencies.count == 0 { 447 | return false 448 | } 449 | for package in manifest.dependencies { 450 | block(package) 451 | } 452 | return true 453 | } 454 | 455 | /** 456 | Return a package structure for an existing package, if it exists 457 | */ 458 | 459 | func possiblePackage(named name: String, manifest: Package) -> Package? { 460 | return manifest.package(named: name) 461 | } 462 | 463 | /** 464 | Return a package structure for an existing package that was specified as 465 | an argument. 466 | */ 467 | 468 | func existingPackage(from packageName: String, manifest: Package) throws -> Package { 469 | guard let package = possiblePackage(named: packageName, manifest: manifest) else { 470 | throw Failure.packageNotInstalled(packageName) 471 | } 472 | 473 | return package 474 | } 475 | 476 | // func fail(_ failure: Failure) -> Never { 477 | // let message: String 478 | // let code: Int32 479 | // 480 | // switch failure { 481 | // case .packageNotInstalled(let packageName): 482 | // message = "Package \(packageName) is not installed." 483 | // code = 1 484 | // 485 | // case .couldntLoadManifest: 486 | // message = "Manifest is missing or corrupt." 487 | // code = 2 488 | // } 489 | // 490 | // output.log(message) 491 | // exit(code) 492 | // } 493 | 494 | func exit(_ code: Int32) -> Never { 495 | Manager.shared.flush() 496 | Foundation.exit(code) 497 | } 498 | 499 | func remoteOriginForCwd() -> String { 500 | return remoteOrigin() ?? "" 501 | } 502 | 503 | func remoteOrigin(forLocalURL url: URL? = nil) -> String? { 504 | let runner = Runner(for: gitURL, cwd: url) 505 | guard let result = try? runner.sync(arguments: ["remote", "get-url", "origin"]), result.status == 0 else { return nil } 506 | return result.stdout.trimmingCharacters(in: CharacterSet.newlines) 507 | } 508 | 509 | func remoteURL(forLocalURL url: URL?) -> URL? { 510 | guard let origin = remoteOrigin(forLocalURL: url) else { return nil } 511 | return URL(string: origin) 512 | 513 | } 514 | 515 | func localRepoForCwd()-> String { 516 | let runner = Runner(for: gitURL) 517 | if let result = try? runner.sync(arguments: ["rev-parse", "--show-toplevel"]) { 518 | if result.status == 0 { 519 | return result.stdout.trimmingCharacters(in: CharacterSet.newlines) 520 | } 521 | } 522 | return "" 523 | } 524 | 525 | func gitUserName() -> String? { 526 | let runner = Runner(for: gitURL) 527 | if let result = try? runner.sync(arguments: ["config", "--global", "user.name"]) { 528 | if result.status == 0 { 529 | return result.stdout.trimmingCharacters(in: CharacterSet.newlines) 530 | } 531 | } 532 | return nil 533 | } 534 | 535 | /// Pull the latest version of XPkg, then re-run the bootstrap script 536 | /// to build & install it. 537 | func update() throws { 538 | output.log("Updating xpkg.") 539 | let url = xpkgURL 540 | let codeURL = url.appendingPathComponent("code") 541 | let runner = Runner(for: gitURL, cwd: codeURL) 542 | let result = try runner.sync(arguments: ["pull"]) 543 | output.log(result.stdout) 544 | 545 | try runBootstrap() 546 | } 547 | 548 | /// Run the bootstrap script. 549 | /// This will rebuild Xpkg and (re)install any essential links. 550 | func runBootstrap() throws { 551 | output.log("Rebuilding.") 552 | let codeURL = xpkgURL.appendingPathComponent("code") 553 | let bootstrapURL = codeURL.appendingPathComponent(".bin").appendingPathComponent("bootstrap") 554 | let bootstrapRunner = Runner(for: bootstrapURL, cwd: codeURL) 555 | let result = try bootstrapRunner.sync() 556 | output.log(result.stdout) 557 | } 558 | 559 | /// Install a package. 560 | func install(packageSpec: String, asProject: Bool = false, asName: String? = nil, linkTo: URL? = nil) throws { 561 | // load the existing manifest; if it's missing we'll create a new empty one 562 | let manifest = try loadManifest(createIfMissing: true) 563 | 564 | // do a quick check first for an existing local package with the name/spec 565 | if let existingPackage = manifest.package(matching: packageSpec) { 566 | output.log("Package `\(existingPackage.name)` is already installed.") 567 | return 568 | } 569 | 570 | // resolve the spec to a full url and a version 571 | output.log("Searching for package \(packageSpec)...") 572 | let (url, version) = try remotePackageURL(packageSpec) 573 | 574 | // now we have a full spec, check again to see if it's already installed 575 | if let existingPackage = manifest.package(matching: url.deletingPathExtension().path) { 576 | output.log("Package `\(existingPackage.name)` is already installed. Use `\(name) upgrade \(packageSpec)` to upgrade it to the latest version.") 577 | return 578 | } 579 | 580 | if let version = version, !version.isEmpty { 581 | output.log("Installing \(url.path) \(version).") 582 | } else { 583 | output.log("Installing \(url.path).") 584 | } 585 | 586 | // add the package to the manifest 587 | verbose.log("Adding package to manifest.") 588 | let newPackage = Package(url: url, version: version ?? "") 589 | var updatedManifest = manifest 590 | updatedManifest.add(package: newPackage) 591 | 592 | // try to write the update 593 | verbose.log("Writing manifest.") 594 | let resolved = try updateManifest(from: manifest, to: updatedManifest) 595 | 596 | guard resolved.dependencies.count > manifest.dependencies.count else { 597 | output.log("Couldn't add `\(packageSpec)`.") 598 | return 599 | } 600 | 601 | // link into project if requested 602 | guard let installedPackage = resolved.package(withURL: url) else { 603 | output.log("Couldn't find package.") 604 | verbose.log(resolved.dependencies.map({ $0.url })) 605 | return 606 | } 607 | 608 | let specifyLink = asProject || (linkTo != nil) 609 | let name = asName ?? installedPackage.name 610 | var linkURL: URL? = nil 611 | if specifyLink { 612 | let pathURL = linkTo ?? projectsURL.appendingPathComponent(name) 613 | verbose.log("Linking package into \(pathURL.path).") 614 | linkURL = pathURL 615 | } else { 616 | verbose.log("Linking package into Packages/.") 617 | } 618 | 619 | installedPackage.edit(at: linkURL, engine: self) 620 | 621 | // if it wrote ok, run the install actions for any new packages 622 | // we need to reload the package once again as it has moved 623 | let reloadedManifest = try loadManifest(readCache: false, writeCache: true) 624 | 625 | verbose.log("Running actions for new packages.") 626 | processUpdate(from: manifest, to: reloadedManifest) 627 | } 628 | 629 | /// Returns the version of the package, according to `git describe` 630 | /// - Parameter engine: The current engine. 631 | func currentVersion(forLocalURL url: URL) -> String? { 632 | let runner = Runner(for: gitURL, cwd: url) 633 | guard let result = try? runner.sync(arguments: ["describe", "--tags"]), result.status == 0 else { return nil } 634 | let fullVersion = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) 635 | let version = SemanticVersion(fullVersion) 636 | return version.isInvalid ? nil : version.asString(dropTrailingZeros: false) 637 | } 638 | 639 | /// Given a local directory, try to make a Package. 640 | /// This will work if it's a git directory with an origin remote set, 641 | /// and a tagged version. 642 | func reconstructPackage(from localURL: URL) -> Package? { 643 | guard localURL.isFileURL else { return nil } 644 | 645 | guard let remoteURL = remoteURL(forLocalURL: localURL) else { return nil } 646 | let version = currentVersion(forLocalURL: localURL) ?? "unspecified" 647 | 648 | return Package(localURL: localURL, remoteURL: remoteURL, version: version) 649 | } 650 | 651 | /// Attempt to repair a corrupt manifest. 652 | /// We look in the Packages/ folder inside the value. Each folder in there should be a package, 653 | /// so we make a new empty manifest, and then try to install each package in turn. 654 | func repair() throws { 655 | let packages = fileManager.locations.folder(for: vaultURL).folder("Packages") 656 | if packages.exists { 657 | var manifest = emptyManifest() 658 | try packages.forEach(filter: .folders, recursive: false) { folder in 659 | if let package = reconstructPackage(from: folder.url) { 660 | manifest.add(package: package) 661 | } 662 | } 663 | saveManifestAndRemoveCachedDependencies(manifest: manifest) 664 | } 665 | } 666 | } 667 | 668 | extension SemanticVersion { 669 | public func asString(dropTrailingZeros: Bool = true) -> String { 670 | guard !isInvalid else { 671 | return "" 672 | } 673 | 674 | guard !isUnknown else { 675 | return "" 676 | } 677 | 678 | var string = "\(major).\(minor)" 679 | if !dropTrailingZeros || patch != 0 { 680 | string += ".\(patch)" 681 | } 682 | return string 683 | } 684 | 685 | } 686 | -------------------------------------------------------------------------------- /Sources/XPkgCore/Failure.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane on 03/08/22. 3 | // All code (c) 2022 - present day, Elegant Chaos Limited. 4 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 5 | 6 | import Foundation 7 | 8 | enum Failure: Error { 9 | case packageNotInstalled(String) 10 | case couldntLoadDependencyData 11 | case errorLoadingDependencyData(Int32, String) 12 | case couldntDecodeDependencyData 13 | case badPackageSpec(String) 14 | case packageMissingHooks(String, String) 15 | } 16 | 17 | extension Failure: CustomStringConvertible { 18 | var description: String { 19 | switch self { 20 | case .packageNotInstalled(let name): 21 | return "Package “\(name)” is not installed." 22 | 23 | case .packageMissingHooks(let product, let package): 24 | return "Package “\(package)” doesn't have an \(product) product. It is probably not an XPkg package." 25 | 26 | case .errorLoadingDependencyData(let code, let error): 27 | return "Swift returned error \(code) whilst fetching dependency data: \(error)" 28 | case .couldntLoadDependencyData: 29 | return "The dependency data is missing." 30 | 31 | case .couldntDecodeDependencyData: 32 | return "The depedency data is corrupt." 33 | 34 | case .badPackageSpec(let spec): 35 | return "Can't find package “\(spec)”. Packages should be in the form “user/package” or “oganisation/package”." 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/XPkgCore/Package.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane, 12/06/2018. 3 | // All code (c) 2018 - present day, Elegant Chaos Limited. 4 | // For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | import Foundation 8 | import Logger 9 | import Runner 10 | import XPkgPackage 11 | import SemanticVersion 12 | 13 | #if canImport(AppKit) 14 | import AppKit 15 | #endif 16 | 17 | enum RenameError: Error { 18 | case renameStore(from: URL, to: URL) 19 | case renameLocal 20 | case saveInfo 21 | } 22 | 23 | struct PackageInfo: Codable { 24 | let version: Int 25 | let name: String 26 | let remote: URL 27 | let local: URL 28 | let linked: Bool 29 | let removeable: Bool 30 | 31 | init(version: Int = 1, name: String, remote: URL, local: URL, linked: Bool, removeable: Bool) { 32 | self.version = version 33 | self.name = name 34 | self.remote = remote 35 | self.local = local 36 | self.linked = linked 37 | self.removeable = removeable 38 | } 39 | } 40 | 41 | enum PackageError: Error, CustomStringConvertible { 42 | case failedToClone(repo: String) 43 | 44 | var description: String { 45 | switch self { 46 | case .failedToClone(let repo): 47 | return "Couldn't clone from \(repo)." 48 | } 49 | } 50 | } 51 | 52 | enum PackageStatus { 53 | case pristine 54 | case modified 55 | case ahead 56 | case unknown 57 | case untracked 58 | case uncommitted 59 | } 60 | 61 | struct Package: Decodable { 62 | let name: String 63 | let version: String 64 | let path: String 65 | let url: String 66 | var dependencies: [Package] 67 | 68 | init(url: URL, version: String) { 69 | self.name = url.lastPathComponent 70 | self.path = "" 71 | self.url = url.absoluteString 72 | self.version = version 73 | self.dependencies = [] 74 | } 75 | 76 | init(name: String) { 77 | self.name = name 78 | self.path = "" 79 | self.url = "" 80 | self.version = "" 81 | self.dependencies = [] 82 | } 83 | 84 | init(localURL: URL, remoteURL: URL, version: String) { 85 | self.name = localURL.deletingPathExtension().lastPathComponent 86 | self.path = localURL.path 87 | self.url = remoteURL.absoluteString 88 | self.version = version 89 | self.dependencies = [] 90 | } 91 | 92 | var global: Bool { return false } 93 | var local: URL { return URL(fileURLWithPath: path) } 94 | 95 | lazy var remote: URL = { 96 | return URL(string: url)! 97 | }() 98 | 99 | lazy var normalized: URL = { 100 | remote.normalizedGitURL 101 | }() 102 | 103 | lazy var qualified: String = { 104 | let components = normalized.pathComponents 105 | return components[1...].joined(separator: "/") 106 | }() 107 | 108 | func package(matching spec: String) -> Package? { 109 | let url = URL(string:spec) 110 | let normalised = url == nil ? spec : url!.normalizedGitURL.absoluteString 111 | for var package in dependencies { 112 | if package.name == spec { 113 | return package 114 | } else if package.qualified == spec { 115 | return package 116 | } else if package.normalized.absoluteString == normalised { 117 | return package 118 | } 119 | } 120 | return nil 121 | } 122 | 123 | func name(contains string: String) -> Bool { 124 | let prefix = "git@github.com:" 125 | if url.starts(with: prefix) { 126 | let start = url.index(url.startIndex, offsetBy: prefix.count) 127 | let path = url[start...] 128 | return path.contains(string) 129 | } 130 | 131 | if let url = URL(string: url) { 132 | return url.path.contains(string) 133 | } 134 | 135 | return false 136 | } 137 | 138 | func package(named name: String) -> Package? { 139 | for package in dependencies { 140 | if (package.name == name) || (package.name == "xpkg-\(name)") || package.name(contains: name) { 141 | return package 142 | } 143 | } 144 | return nil 145 | } 146 | 147 | func package(withURL url: URL) -> Package? { 148 | let normalised = url.normalizedGitURL 149 | for var package in dependencies { 150 | if package.normalized == normalised { 151 | return package 152 | } 153 | } 154 | return nil 155 | } 156 | 157 | class DepthsIndex { 158 | var index: [Package:Depths] = [:] 159 | 160 | func record(package: Package, depth: Int) { 161 | if let depths = index[package] { 162 | depths.record(depth: depth) 163 | } else { 164 | index[package] = Depths(depth: depth) 165 | } 166 | } 167 | } 168 | 169 | class Depths { 170 | var highest: Int 171 | var lowest: Int 172 | 173 | init(depth: Int) { 174 | highest = depth 175 | lowest = depth 176 | } 177 | 178 | func record(depth: Int) { 179 | highest = max(depth, highest) 180 | lowest = min(depth, lowest) 181 | } 182 | } 183 | 184 | func recordPackages(index: DepthsIndex, depth: Int) { 185 | for package in dependencies { 186 | index.record(package: package, depth: depth) 187 | package.recordPackages(index: index, depth: depth + 1) 188 | } 189 | } 190 | 191 | var allPackages: ([Package], [Package]) { 192 | let index = DepthsIndex() 193 | recordPackages(index: index, depth: 1) 194 | 195 | let byMostDependent = index.index.sorted { (p1, p2) -> Bool in 196 | return p1.value.highest > p2.value.highest 197 | } 198 | 199 | let byLeastDependent = index.index.sorted { (p1, p2) -> Bool in 200 | return p1.value.lowest < p2.value.lowest 201 | } 202 | 203 | return (byMostDependent.map({ $0.key }), byLeastDependent.map({ $0.key })) 204 | } 205 | 206 | mutating func add(package: Package) { 207 | dependencies.append(package) 208 | } 209 | 210 | mutating func remove(package: Package) { 211 | if let index = dependencies.firstIndex(where: { $0.name == package.name }) { 212 | dependencies.remove(at: index) 213 | } 214 | } 215 | 216 | 217 | /** 218 | Return the default location to use for the local (hidden) clone of a package. 219 | */ 220 | 221 | static func defaultLocalURL(for name: String, in store: URL) -> URL { 222 | return store.appendingPathComponent("local").appendingPathComponent(name) 223 | } 224 | 225 | /** 226 | Link package to an existing folder. 227 | */ 228 | 229 | func edit(at url: URL? = nil, engine: Engine) { 230 | var args = ["package", "edit", name] 231 | let message: String 232 | if let url = url { 233 | args.append(contentsOf: ["--path", url.path]) 234 | message = "Failed to link \(name) into \(url)." 235 | } else { 236 | message = "Failed to edit \(name)." 237 | } 238 | 239 | engine.removeDependencyCache() 240 | let _ = engine.swift(args, failureMessage: message) 241 | } 242 | 243 | /** 244 | Link package to an existing folder. 245 | */ 246 | 247 | func unedit(engine: Engine) -> Bool { 248 | engine.removeDependencyCache() 249 | guard let result = engine.swift(["package", "unedit", name]) else { 250 | engine.output.log("Failed to unlink \(name) from \(path).") 251 | return false 252 | } 253 | 254 | return result.status == 0 || result.stderr.contains("not in edit mode") 255 | } 256 | 257 | 258 | /** 259 | Reveal the package in the Finder/Desktop. 260 | */ 261 | 262 | func reveal() { 263 | #if canImport(AppKit) 264 | NSWorkspace.shared.open([local], withAppBundleIdentifier: nil, options: .async, additionalEventParamDescriptor: nil, launchIdentifiers: nil) 265 | #else 266 | let runner = Runner(for: URL(fileURLWithPath: "/usr/bin/env"), cwd: local) 267 | let _ = try? runner.sync(arguments: ["xdg-open", "."]) 268 | #endif 269 | } 270 | 271 | /** 272 | What state is the local package in? 273 | */ 274 | 275 | func status(engine: Engine) -> PackageStatus { 276 | let runner = Runner(for: engine.gitURL, cwd: local) 277 | if let result = try? runner.sync(arguments: ["status", "--porcelain", "--branch"]) { 278 | engine.verbose.log(result.stdout) 279 | if result.status == 0 { 280 | let lines = result.stdout.split(separator: "\n") 281 | if lines.count > 0 { 282 | let branch = lines[0] 283 | if branch == "## No commits yet on master" { 284 | return .uncommitted 285 | } else { 286 | let output = lines.dropFirst().joined(separator: "\n") 287 | if output == "" { 288 | let branchOk = branch.contains("...") || branch == "## HEAD (no branch)" 289 | return branchOk ? .pristine : .untracked 290 | } else if output.contains("??") || output.contains(" D ") || output.contains(" M ") || output.contains("R ") || output.contains("A ") { 291 | return .modified 292 | } else if output.contains("[ahead ") { 293 | return .ahead 294 | } else { 295 | return .untracked 296 | } 297 | } 298 | } 299 | } 300 | } 301 | 302 | return .unknown 303 | } 304 | 305 | 306 | /// Returns the name of the branch that the package is on, or nil if it isn't on a branch. 307 | /// - Parameter engine: The current engine. 308 | func currentBranch(engine: Engine) -> String? { 309 | let runner = Runner(for: engine.gitURL, cwd: local) 310 | if let result = try? runner.sync(arguments: ["rev-parse", "--abbrev-ref", "HEAD"]) { 311 | engine.verbose.log("current branch: \(result.stdout)") 312 | if result.status == 0 { 313 | let branch = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) 314 | if branch != "HEAD" { 315 | return branch 316 | } 317 | } 318 | } 319 | return nil 320 | } 321 | 322 | /// Returns the latest version of the package, according to the server. 323 | /// - Parameter engine: the engine 324 | func latestVersion(engine: Engine) -> String? { 325 | return engine.latestVersion(URL(string:url)!) 326 | } 327 | 328 | enum UpdateState { 329 | case unchanged 330 | case needsUpdate(to: String) 331 | case notOnTag 332 | case couldntFetchLatest 333 | case couldntParseLatest 334 | } 335 | 336 | /// Does the package need updating? 337 | /// If it's on a version tag, and there's a newer version available, we return the newer version. 338 | /// If it's on the latest version tag, or checked out to a branch, we return nil. 339 | /// - Parameter engine: The current context. 340 | func needsUpdate(engine: Engine) -> UpdateState { 341 | let current = engine.currentVersion(forLocalURL: local) ?? "" 342 | guard !current.contains("-") else { 343 | let branch = currentBranch(engine: engine) 344 | if branch == nil { 345 | engine.verbose.log("\(name) is a modified version \(current).") 346 | } else { 347 | engine.verbose.log("\(name) is a modified version \(current) on branch \(branch!)") 348 | } 349 | return .notOnTag 350 | } 351 | 352 | 353 | guard let latest = latestVersion(engine: engine) else { 354 | engine.verbose.log("\(name) couldn't fetch latest version.") 355 | return .couldntFetchLatest 356 | } 357 | 358 | let sCurrent = SemanticVersion(current) 359 | let sLatest = SemanticVersion(latest) 360 | guard !sCurrent.isInvalid && !sLatest.isInvalid else { 361 | engine.verbose.log("\(name) couldn't parse versions.") 362 | return .couldntParseLatest 363 | } 364 | 365 | engine.verbose.log("\(name) \(current) \(latest)") 366 | return sCurrent < sLatest ? .needsUpdate(to: latest) : .unchanged 367 | } 368 | 369 | /// Checkout the package to a given branch, tag or other ref. 370 | /// - Parameter ref: the branch, tag, commit or ref to check out to 371 | /// - Parameter engine: the context 372 | func checkout(to ref: String, engine: Engine) { 373 | let runner = Runner(for: engine.gitURL, cwd: local) 374 | guard let result = try? runner.sync(arguments: ["checkout", ref]) else { 375 | engine.output.log("Couldn't checkout package \(name) to \(ref).") 376 | return 377 | } 378 | 379 | engine.verbose.log(result.stdout) 380 | engine.verbose.log(result.stderr) 381 | if result.status != 0 { 382 | if result.stderr.contains("The following untracked working tree files would be overwritten") { 383 | engine.output.log("Package \(name) has untracked files.") 384 | } 385 | 386 | engine.output.log("Couldn't update package \(name) to \(ref).") 387 | } 388 | } 389 | 390 | /// Fetch the latest commits. 391 | /// - Parameter engine: the context 392 | func fetch(engine: Engine) { 393 | let runner = Runner(for: engine.gitURL, cwd: local) 394 | if let result = try? runner.sync(arguments: ["fetch", "--tags"]) { 395 | engine.verbose.log(result.stdout) 396 | engine.verbose.log(result.stderr) 397 | } 398 | } 399 | 400 | /** 401 | Update the package. 402 | */ 403 | 404 | func update(to version: String, engine: Engine) { 405 | engine.output.log("Updating \(name) to \(version).") 406 | 407 | engine.verbose.log("Uninstalling \(name).") 408 | if let ok = try? run(action: "remove", engine: engine), ok { 409 | engine.verbose.log("Removed \(name).") 410 | } 411 | 412 | engine.verbose.log("Updating \(name).") 413 | fetch(engine: engine) 414 | checkout(to: version, engine: engine) 415 | 416 | engine.verbose.log("Installing \(name).") 417 | if let ok = try? run(action: "install", engine: engine), ok { 418 | engine.verbose.log("Reinstalled \(name).") 419 | } 420 | } 421 | 422 | /** 423 | Check that the package information seems to be valid. 424 | */ 425 | 426 | func check(engine: Engine) -> Bool { 427 | return engine.fileManager.fileExists(at: local) 428 | } 429 | 430 | /** 431 | Rename the package. If it's a project, we also rename the project folder. 432 | */ 433 | 434 | func rename(as newName: String, engine: Engine) throws { 435 | // let newStore = store.deletingLastPathComponent().appendingPathComponent(newName) 436 | // do { 437 | // try fileManager.moveItem(at: store, to: newStore) 438 | // self.store = newStore 439 | // } catch { 440 | // throw RenameError.renameStore(from: store, to: newStore) 441 | // } 442 | // 443 | // let oldLocal: URL 444 | // let newLocal: URL 445 | // if linked { 446 | // // package is linked elsewhere, so we just want to rename it 447 | // oldLocal = local 448 | // newLocal = local.deletingLastPathComponent().appendingPathComponent(newName) 449 | // 450 | // } else { 451 | // newLocal = Package.defaultLocalURL(for: newName, in: newStore) 452 | // 453 | // if local.lastPathComponent == name { 454 | // // package is inside the store, which has already been renamed 455 | // // but the local folder itself still needs to be renamed 456 | // oldLocal = Package.defaultLocalURL(for: name, in: newStore) 457 | // 458 | // } else { 459 | // // package is inside store, but was previously just in a folder called "local" 460 | // // we want to fix things up a bit 461 | // let oldStyleLocal = newStore.appendingPathComponent("local") 462 | // oldLocal = newStore.appendingPathComponent("temp-rename") 463 | // try? fileManager.moveItem(at: oldStyleLocal, to: oldLocal) 464 | // try? fileManager.createDirectory(at: oldStyleLocal, withIntermediateDirectories: true) 465 | // } 466 | // } 467 | // 468 | // do { 469 | // try fileManager.moveItem(at: oldLocal, to: newLocal) 470 | // self.local = newLocal 471 | // } catch { 472 | // throw RenameError.renameLocal 473 | // } 474 | // 475 | // self.name = newName 476 | } 477 | 478 | func run(action: String, engine: Engine) throws -> Bool { 479 | do { 480 | // run as a new-style package 481 | let runner = Runner(for: engine.swiftURL, cwd: engine.vaultURL) 482 | let hooks = "\(name)-xpkg-hooks" 483 | let arguments = ["run", hooks, name, path, action] 484 | let result = try runner.sync(arguments: arguments, stdoutMode: .passthrough) 485 | engine.verbose.log("\n> \(hooks) \(name) \(path) \(action)") 486 | if result.status == 0 { 487 | engine.verbose.log("stdout: \(result.stdout)") 488 | engine.verbose.log("stderr: \(result.stderr)\n") 489 | return true 490 | } 491 | 492 | if !result.stdout.contains("no exexcutable product") { 493 | engine.verbose.log("failure: \(result.status)") 494 | engine.verbose.log("stdout: \(result.stdout)") 495 | engine.verbose.log("stderr: \(result.stderr)\n") 496 | } 497 | 498 | } catch { 499 | engine.output.log("Couldn't run action \(action).") 500 | engine.verbose.log(error) 501 | throw error 502 | } 503 | 504 | engine.verbose.log("Ignoring \(name) as it isn't an xpkg package.") 505 | return false 506 | } 507 | } 508 | 509 | extension Package: Hashable { 510 | func hash(into hasher: inout Hasher) { 511 | url.hash(into: &hasher) 512 | } 513 | 514 | static func == (lhs: Self, rhs: Self) -> Bool { 515 | return lhs.url == rhs.url 516 | } 517 | } 518 | -------------------------------------------------------------------------------- /Sources/XPkgCore/URL+Extensions.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane on 04/08/22. 3 | // All code (c) 2022 - present day, Elegant Chaos Limited. 4 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 5 | 6 | import Foundation 7 | 8 | public extension URL { 9 | var normalizedGitURL: URL { 10 | let urlNoGitExtension = pathExtension == "git" ? deletingPathExtension() : self 11 | let string = urlNoGitExtension.absoluteString.replacingOccurrences(of: "git@github.com:", with: "https://github.com/") 12 | return URL(string: string)! 13 | } 14 | 15 | var asPackageSpec: String? { 16 | let components = normalizedGitURL.pathComponents 17 | let count = components.count 18 | guard count > 1 else { return nil } 19 | let specComponents = [components[count - 2], components[count - 1]] 20 | return specComponents.joined(separator: "/") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import XPkgTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += XPkgTests.__allTests() 7 | 8 | XCTMain(tests) 9 | -------------------------------------------------------------------------------- /Tests/XPkgTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | #if !canImport(ObjectiveC) 2 | import XCTest 3 | 4 | extension XPkgTests { 5 | // DO NOT MODIFY: This is autogenerated, use: 6 | // `swift test --generate-linuxmain` 7 | // to regenerate. 8 | static let __allTests__XPkgTests = [ 9 | ("testName", testName), 10 | ("testNameOrg", testNameOrg), 11 | ("testRepo", testRepo), 12 | ] 13 | } 14 | 15 | public func __allTests() -> [XCTestCaseEntry] { 16 | return [ 17 | testCase(XPkgTests.__allTests__XPkgTests), 18 | ] 19 | } 20 | #endif 21 | -------------------------------------------------------------------------------- /Tests/XPkgTests/XPkgTests.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane, 08/06/2018. 3 | // All code (c) 2018 - present day, Elegant Chaos Limited. 4 | // For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | import XCTest 8 | import CommandShell 9 | @testable import XPkgCore 10 | 11 | class XPkgTests: XCTestCase { 12 | var matched = false 13 | 14 | func validator(expecting: String) -> Engine.RepoValidator { 15 | self.matched = false 16 | return { url in 17 | if url.absoluteString == expecting { 18 | self.matched = true 19 | return "1.0.0" 20 | } else { 21 | return nil 22 | } 23 | } 24 | } 25 | 26 | func testName() { 27 | let engine = Engine(options: CommandShellOptions()) 28 | engine.defaultOrgs = ["testorg"] 29 | let _ = engine.remotePackageURL("test", validator: validator(expecting: "git@github.com:testorg/test")) 30 | XCTAssertTrue(self.matched) 31 | } 32 | 33 | func testNameOrg() { 34 | let engine = Engine(options: CommandShellOptions()) 35 | let _ = engine.remotePackageURL("someorg/someproj", validator: validator(expecting: "git@github.com:someorg/someproj")) 36 | XCTAssertTrue(self.matched) 37 | } 38 | 39 | func testRepo() { 40 | let engine = Engine(options: CommandShellOptions()) 41 | let _ = engine.remotePackageURL("git@mygit.com:someorg/someproj", validator: validator(expecting: "git@mygit.com:someorg/someproj")) 42 | XCTAssertTrue(self.matched) 43 | } 44 | } 45 | --------------------------------------------------------------------------------