├── testdata ├── util │ ├── visible │ ├── .invisible │ ├── .invisible-dir │ │ └── foo │ ├── visible-dir │ │ ├── bar │ │ └── foo │ ├── visible-link │ ├── .invisible-link │ └── visible-link-to-dir └── package-full.hcpkg │ ├── Windows │ ├── dlls │ │ └── not-a-dll │ └── bin │ │ └── bye │ ├── share │ └── package │ │ └── README.txt │ ├── Linux │ └── bin │ │ └── bye │ ├── Darwin │ └── bin │ │ └── bye │ └── bin │ └── hello ├── homectl.hcpkg ├── bin │ ├── hc │ ├── homectl-resolve-path │ └── homectl ├── shell-boot │ ├── rc.sh │ ├── functions.sh │ └── profile.sh └── python │ └── homectl.py ├── .gitignore ├── setup.sh ├── loader-bash.hcpkg └── overlay │ ├── .bash_profile │ └── .bashrc ├── loader-zsh.hcpkg └── overlay │ ├── .zshrc │ └── .zprofile ├── Makefile ├── loader-fish.hcpkg └── overlay │ └── .config │ └── fish │ └── conf.d │ └── homectl.fish ├── LICENSE ├── loader-emacs.hcpkg ├── emacs │ └── homectl-package.el └── overlay │ └── .emacs ├── README.asciidoc ├── NEWS.asciidoc ├── doc ├── hacking.asciidoc └── customization.asciidoc └── test.py /testdata/util/visible: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/util/.invisible: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/util/.invisible-dir/foo: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/util/visible-dir/bar: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/util/visible-dir/foo: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/util/visible-link: -------------------------------------------------------------------------------- 1 | .invisible -------------------------------------------------------------------------------- /homectl.hcpkg/bin/hc: -------------------------------------------------------------------------------- 1 | ../python/homectl.py -------------------------------------------------------------------------------- /testdata/util/.invisible-link: -------------------------------------------------------------------------------- 1 | .invisible -------------------------------------------------------------------------------- /testdata/util/visible-link-to-dir: -------------------------------------------------------------------------------- 1 | visible-dir -------------------------------------------------------------------------------- /testdata/package-full.hcpkg/Windows/dlls/not-a-dll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/package-full.hcpkg/share/package/README.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .coverage 3 | venv 4 | htmlcov 5 | -------------------------------------------------------------------------------- /testdata/package-full.hcpkg/Linux/bin/bye: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo '...' 4 | -------------------------------------------------------------------------------- /testdata/package-full.hcpkg/Darwin/bin/bye: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo 'Bye now!' 4 | -------------------------------------------------------------------------------- /testdata/package-full.hcpkg/bin/hello: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo 'Hello, world!' 4 | -------------------------------------------------------------------------------- /homectl.hcpkg/shell-boot/rc.sh: -------------------------------------------------------------------------------- 1 | source ~/.homectl/common/shell-boot/functions.sh 2 | homectl-run-hooks rc 3 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | homectl="$(dirname "$0")/homectl.hcpkg/bin/hc" 4 | exec "$homectl" init "$@" 5 | -------------------------------------------------------------------------------- /testdata/package-full.hcpkg/Windows/bin/bye: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo 'Restarting to install updates...' 4 | -------------------------------------------------------------------------------- /loader-bash.hcpkg/overlay/.bash_profile: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source ~/.homectl/common/shell-boot/profile.sh 4 | -------------------------------------------------------------------------------- /loader-zsh.hcpkg/overlay/.zshrc: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | # Sanity test to make sure our environment is setup correctly. If not, we load 4 | # ~/.zprofile, which is expected to do this. 5 | if ! type hc >/dev/null 2>/dev/null; then 6 | source ~/.zprofile 7 | fi 8 | 9 | source ~/.homectl/common/shell-boot/rc.sh 10 | -------------------------------------------------------------------------------- /loader-bash.hcpkg/overlay/.bashrc: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Sanity test to make sure our environment is setup correctly. If not, we load 4 | # ~/.bash_profile, which is expected to do this. 5 | if ! type hc >/dev/null 2>/dev/null; then 6 | source ~/.bash_profile 7 | fi 8 | 9 | source ~/.homectl/common/shell-boot/rc.sh 10 | -------------------------------------------------------------------------------- /homectl.hcpkg/shell-boot/functions.sh: -------------------------------------------------------------------------------- 1 | if [ ! -z "$ZSH_NAME" ]; then 2 | homectl-run-hooks() { 3 | for f in `hc tree "shell-$1" '*.sh' '*.zsh'`; do 4 | source "$f" 5 | done 6 | } 7 | elif [ ! -z "$BASH" ]; then 8 | homectl-run-hooks() { 9 | for f in `hc tree "shell-$1" '*.sh' '*.bash'`; do 10 | source "$f" 11 | done 12 | } 13 | else 14 | homectl-run-hooks() { 15 | for f in `hc tree "shell-$1" '*.sh'`; do 16 | source "$f" 17 | done 18 | } 19 | fi 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # Developer makefile for testing purposes; you can ignore this unless you want 3 | # to run tests yourself. 4 | # 5 | 6 | PY := venv/bin/python 7 | PIP := venv/bin/pip 8 | COV := venv/bin/coverage 9 | 10 | test check: test-py3 11 | .PHONY: test check 12 | 13 | test-py3: $(PY) $(COV) 14 | $(COV) run --branch ./test.py 15 | $(COV) html 16 | .PHONY: test-py3 17 | 18 | $(COV): $(PY) $(PIP) 19 | $(PIP) install coverage 20 | 21 | $(PIP) $(PY): 22 | python3 -m venv venv 23 | 24 | clean: 25 | tr '\n' '\0' < .gitignore |xargs -t0 rm -rf 26 | .PHONY: clean 27 | -------------------------------------------------------------------------------- /loader-zsh.hcpkg/overlay/.zprofile: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | # This seems to be needed on modern Ubuntu systems which don't do this in their 4 | # system zsh init scripts... 5 | if [[ -d /etc/profile.d && ( ! -f /etc/zsh/zprofile || -z "$(grep -v '^#' /etc/zsh/zprofile)" ) ]]; then 6 | # This weird find thing is to avoid zsh complaining about not being able to 7 | # expand globs if there are no files matching the glob. 8 | for f in $(find /etc/profile.d -maxdepth 1 -name '*.sh' -or -name '*.zsh'); do 9 | source $f 10 | done 11 | fi 12 | 13 | source ~/.homectl/common/shell-boot/profile.sh 14 | -------------------------------------------------------------------------------- /homectl.hcpkg/shell-boot/profile.sh: -------------------------------------------------------------------------------- 1 | if [ -x ~/.homectl/common/bin/hc ]; then 2 | PATH=`~/.homectl/common/bin/hc path bin PATH` 3 | export PATH 4 | 5 | case `uname -s` in 6 | Linux) 7 | LD_LIBRARY_PATH=`hc path lib LD_LIBRARY_PATH` 8 | LD_LIBRARY_PATH=`hc path lib32 LD_LIBRARY_PATH` 9 | LD_LIBRARY_PATH=`hc path lib64 LD_LIBRARY_PATH` 10 | export LD_LIBRARY_PATH 11 | ;; 12 | Darwin) 13 | DYLD_LIBRARY_PATH=`hc path lib DYLD_LIBRARY_PATH` 14 | DYLD_FRAMEWORK_PATH=`hc path Frameworks DYLD_FRAMEWORK_PATH` 15 | export DYLD_LIBRARY_PATH 16 | export DYLD_FRAMEWORK_PATH 17 | ;; 18 | esac 19 | fi 20 | 21 | source ~/.homectl/common/shell-boot/functions.sh 22 | homectl-run-hooks env 23 | -------------------------------------------------------------------------------- /loader-fish.hcpkg/overlay/.config/fish/conf.d/homectl.fish: -------------------------------------------------------------------------------- 1 | function homectl-run-hooks -a hook \ 2 | -d "Run all .fish files in the named homectl hook directory" 3 | 4 | for f in (hc tree -n $hook '*.fish') 5 | source "$f" 6 | end 7 | end 8 | 9 | if test -x ~/.homectl/common/bin/hc 10 | if status is-login 11 | set -x PATH (~/.homectl/common/bin/hc path bin PATH) 12 | switch (uname) 13 | case Linux 14 | set -x LD_LIBRARY_PATH (hc path lib LD_LIBRARY_PATH) 15 | set -x LD_LIBRARY_PATH (hc path lib32 LD_LIBRARY_PATH) 16 | set -x LD_LIBRARY_PATH (hc path lib64 LD_LIBRARY_PATH) 17 | case Darwin 18 | set -x DYLD_LIBRARY_PATH (hc path lib DYLD_LIBRARY_PATH) 19 | set -x DYLD_FRAMEWORK_PATH ( 20 | hc path Frameworks DYLD_FRAMEWORK_PATH) 21 | end 22 | 23 | homectl-run-hooks shell-env 24 | end 25 | 26 | if status is-interactive 27 | homectl-run-hooks shell-rc 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Joshua J. Berry 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /homectl.hcpkg/bin/homectl-resolve-path: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | This is a little utility program that acts like Linux's "readlink -f" on 5 | steroids. 6 | 7 | It has two jobs: 8 | 9 | - In its one-parameter form, it takes a path and turns it into an absolute path 10 | with no symlinks. In this mode it behaves exactly like a cross-platform 11 | readlink -f. 12 | 13 | - In its two-parameter form, it takes a path and turns it into a relative path, 14 | relative to some other, specified path (NOT pwd, unless you say "."), again 15 | removing all the symlinks. 16 | 17 | It's a good tool to use for creating symlinks, where you want a relative link, 18 | but you only know the target in absolute terms or relative to a different 19 | directory. 20 | """ 21 | 22 | import sys 23 | import os 24 | 25 | if len(sys.argv) not in (2, 3) or sys.argv[1].startswith('-'): 26 | sys.stderr.write("Usage: home-resolve-path path [relative-to]\n") 27 | sys.exit(1) 28 | 29 | if len(sys.argv) == 2: 30 | # Caller expects an absolute path, since they didn't say it was relative to 31 | # anything 32 | print os.path.realpath(sys.argv[1]) 33 | 34 | elif len(sys.argv) == 3: 35 | # Caller expects the returned path to be relative to something else. 36 | print os.path.relpath(os.path.realpath(sys.argv[1]), 37 | os.path.realpath(sys.argv[2])) 38 | -------------------------------------------------------------------------------- /loader-emacs.hcpkg/emacs/homectl-package.el: -------------------------------------------------------------------------------- 1 | ;;; Helper library which provides a nice wrapper around package.el. 2 | ;;; 3 | ;;; This is in its own module so user homectl packages can just require it. 4 | 5 | (require 'package) 6 | 7 | (defvar homectl-package-refreshed-recently-p nil) 8 | 9 | 10 | 11 | (defun homectl-package-add-repo (name url) 12 | "Adds a repository to package.el's `package-archives' variable if it's 13 | not already present." 14 | 15 | (add-to-list 'package-archives (cons name url))) 16 | 17 | 18 | 19 | (defun homectl-package-require (&rest pkg-symbols) 20 | "Loads packages, possibly installing them through package.el if necessary." 21 | 22 | (dolist (pkg pkg-symbols) 23 | ;; Try to load the package first locally 24 | (unless (require pkg nil t) 25 | 26 | ;; It wasn't found; refresh the package list if it hasn't been loaded yet 27 | ;; this session 28 | (unless homectl-package-refreshed-recently-p 29 | (package-refresh-contents) 30 | (setq homectl-package-refreshed-recently-p t)) 31 | 32 | ;; Install and load the package 33 | (message "[homectl] Installing package: %s" pkg) 34 | (package-install pkg) 35 | (require pkg)) 36 | 37 | ;; If the package was installed by package.el at some point (now or in the 38 | ;; past), explicitly mark it as user-selected. (This explicit marking is 39 | ;; necessary because of a bug in package.el where if the 40 | ;; package-selected-packages list is empty, package.el thinks the package is 41 | ;; already marked as user-selected.) 42 | (when (not (package-built-in-p pkg)) 43 | (add-to-list 'package-selected-packages pkg)))) 44 | 45 | 46 | 47 | ;; Add MELPA by default since that's where most packages come from. 48 | (homectl-package-add-repo "melpa" "https://melpa.org/packages/") 49 | 50 | ;; After startup, make sure package-selected-packages is saved to custom.el so 51 | ;; the user will notice the differences in their homectl setup. 52 | (defun homectl-package--save-selected-packages () 53 | (customize-save-variable 'package-selected-packages 54 | package-selected-packages)) 55 | 56 | (add-to-list 'emacs-startup-hook #'homectl-package--save-selected-packages) 57 | 58 | (provide 'homectl-package) 59 | -------------------------------------------------------------------------------- /README.asciidoc: -------------------------------------------------------------------------------- 1 | homectl - Simple Package Management for ~ 2 | ========================================= 3 | :toc: 4 | 5 | Introduction 6 | ------------ 7 | 8 | homectl is an easy way to keep your various dot-files, configuration files, 9 | custom shell scripts or add-on packages in your home directory 10 | version-controlled and organized across multiple machines and in different 11 | environments. 12 | 13 | .Features 14 | 15 | * Split your config files, scripts, etc. into "packages": 16 | ** Post the common stuff to the Internet 17 | ** Keep a private package for your work-related config and scripts. 18 | ** Make another package for your open-source development or hobbies. 19 | ** Only enable what you need, where you need it. 20 | 21 | * It automatically conforms to your environment: 22 | ** Different shells (bash, zsh). 23 | ** Different platforms (Linux, Darwin, probably others). 24 | ** Different values of +$HOME+ (no absolute paths, one hard-coded path: 25 | +$HOME/.homectl+). 26 | 27 | * Keep it all version-controlled inside git. 28 | ** A single +git clone+ copies your whole environment anywhere you like. 29 | ** Use git submodules for 3rd-party packages like +oh-my-zsh+ or obscure 30 | Emacs modes. 31 | ** Private packages can stay safely outside your homectl git repository. 32 | 33 | Prerequisites 34 | ------------- 35 | 36 | * You must be using the +bash+ or +zsh+ shell. 37 | 38 | * You need recent versions of Python and Git. 39 | 40 | * You should already be familiar with Git, since you will be using it to track 41 | your own homectl setup. 42 | 43 | Quick-Start 44 | ----------- 45 | 46 | Installation 47 | ~~~~~~~~~~~~ 48 | 49 | ----------------------------------- 50 | # Get a copy of the latest stable homectl. 51 | $ git clone git://github.com/josh-berry/homectl.git 52 | 53 | # Create your own homectl setup. 54 | $ ./homectl/setup.sh ~/homectl-setup 55 | 56 | # Move your existing .rc files into homectl 57 | mv .bashrc bashrc.old 58 | mv .zshenv zshenv.old 59 | # etc... 60 | 61 | # Link your setup into your home directory. 62 | $ ~/homectl-setup/deploy.sh 63 | ----------------------------------- 64 | 65 | .Next Steps: 66 | 67 | * Read link:doc/customization.asciidoc[] to learn how to create your own 68 | homectl packages. 69 | 70 | * Create packages to hold your personal configuration. 71 | 72 | * Restart your shell, and use the +hc+ command to enable and disable packages. 73 | 74 | Uninstallation 75 | ~~~~~~~~~~~~~~ 76 | 77 | This will disable homectl entirely, removing any dot-files in your home 78 | directory that point to homectl packages. 79 | 80 | ------------------------------------ 81 | $ homectl uninstall 82 | ------------------------------------ 83 | 84 | Further Reading 85 | --------------- 86 | 87 | We suggest you read these in order. 88 | 89 | link:doc/customization.asciidoc[]:: 90 | The anatomy of a homectl package, and how to create your own. 91 | 92 | link:doc/hacking.asciidoc[]:: 93 | A quick guide to hacking on homectl itself. Covers high-level design 94 | principles and submitting your own improvements. 95 | -------------------------------------------------------------------------------- /loader-emacs.hcpkg/overlay/.emacs: -------------------------------------------------------------------------------- 1 | ; Because we update Info-directory-* below 2 | (require 'info) 3 | 4 | (package-initialize) 5 | 6 | ;;; 7 | ;;; Customization 8 | ;;; 9 | 10 | (defconst homectl-dir (concat (getenv "HOME") "/.homectl") 11 | "The location of the homectl directory. You should not change 12 | this unless you're a homectl developer and you change it in all 13 | the other places where it's hard-coded. :)") 14 | 15 | (defconst homectl-bin (concat homectl-dir "/common/bin/hc")) 16 | 17 | (defgroup homectl nil "Homectl configuration options" :tag "Homectl") 18 | 19 | (defcustom homectl-darwin-fixup-path t 20 | "By default, homectl will try to set the PATH in Emacs itself, 21 | because when Emacs is run through Aqua, it won't pick up the PATH 22 | as set in the shell (via /etc/paths.d or via homectl). However, 23 | this might break other customizations you have in place, so you 24 | can disable it here. 25 | 26 | Note this ONLY applies on Darwin/OSX, so you don't need to worry 27 | about this behvior on Linux." 28 | :type '(boolean) :group 'homectl :tag "Fix-up $PATH (on Darwin only)") 29 | 30 | 31 | 32 | ;;; 33 | ;;; Startup-related utility functions 34 | ;;; 35 | 36 | (defun homectl-update-env (var hook) 37 | (let 38 | ((new-path (process-lines homectl-bin "path" "-n" hook var))) 39 | (setenv var (mapconcat #'identity new-path path-separator)) 40 | (message (concat "[homectl] Updating " var ": " (getenv var))))) 41 | 42 | 43 | 44 | ;;; 45 | ;;; Main entry point 46 | ;;; 47 | 48 | (defun homectl-startup () 49 | "Load all enabled homectl packages with Emacs customizations, 50 | and make sure the package's binaries are available in Emacs's 51 | environment." 52 | (interactive) 53 | 54 | ; The following is a Darwin-specific hack to make sure the shell's $PATH 55 | ; is picked up by Emacs when running in Aqua mode. 56 | (when (and (string= system-type "darwin") homectl-darwin-fixup-path) 57 | (let 58 | ((shellpath (car (process-lines (getenv "SHELL") 59 | "-l" "-c" "echo $PATH")))) 60 | (message (concat "[homectl] Fixing up PATH: " shellpath)) 61 | (setenv "PATH" shellpath) 62 | 63 | ;; Appending here to preserve Emacs-internal paths 64 | (setq exec-path (append (split-string (getenv "PATH") ":" t) exec-path)) 65 | (delete-dups exec-path))) 66 | 67 | ; Find all the enabled homectl packages and load them/add them to Emacs's 68 | ; paths. 69 | (homectl-update-env "PATH" "bin") 70 | (cond 71 | ((string= system-type "darwin") 72 | (homectl-update-env "DYLD_FRAMEWORK_PATH" "Frameworks") 73 | (homectl-update-env "DYLD_LIBRARY_PATH" "lib")) 74 | ((string= system-type "linux") 75 | (homectl-update-env "LD_LIBRARY_PATH" "lib") 76 | (homectl-update-env "LD_LIBRARY_PATH" "lib32") 77 | (homectl-update-env "LD_LIBRARY_PATH" "lib64"))) 78 | 79 | ; Update emacs internal paths from environment variables 80 | (setq exec-path (append (split-string (getenv "PATH") ":" t) exec-path)) 81 | (setq load-path (append (process-lines homectl-bin "path" "emacs") 82 | load-path)) 83 | (setq load-path (append (process-lines homectl-bin "path" "emacs-startup") 84 | load-path)) 85 | 86 | ; Load all emacs startup packages from homectl 87 | (dolist (pdir (process-lines homectl-bin "path" "emacs-startup")) 88 | (dolist (f (directory-files pdir nil "^[^.].*\\.el")) 89 | (let ((pkg (replace-regexp-in-string "\.el$" "" f))) 90 | (message "[homectl] Loading %s" pkg) 91 | (require (intern pkg))))) 92 | 93 | ;; Load customizations. We move them out of ~/.emacs (this file) so that 94 | ;; emacs isn't constantly making changes that appear in homectl's git 95 | ;; repository. 96 | (setq custom-file "~/.emacs.d/custom.el") 97 | (if (file-exists-p custom-file) 98 | (load custom-file))) 99 | 100 | 101 | 102 | ;;; 103 | ;;; Startup homectl 104 | ;;; 105 | 106 | (homectl-startup) 107 | 108 | (provide 'homectl) 109 | -------------------------------------------------------------------------------- /NEWS.asciidoc: -------------------------------------------------------------------------------- 1 | News for homectl 2 | ================ 3 | 4 | :toc: 5 | 6 | Version 0.3.0 7 | ------------- 8 | 9 | Released on: 01-Jan-2017 10 | 11 | .User Actions Required to Upgrade 12 | 13 | * The `homectl` command has been completely rewritten in Python, and the command 14 | name has been shortened to `hc`. You should switch to using the new `hc` 15 | command instead. The old `homectl` shell script still exists to aid in 16 | upgrading, but it will be removed in homectl 0.4. 17 | 18 | * The `~/.homectl` directory is now a Homebrew-style tree of symlinks to 19 | individual files in enabled packages. Enabled packages are now tracked in an 20 | `enabled-pkgs` file, rather than as a directory of symlinks. To upgrade your 21 | existing deployment and setup, run: 22 | 23 | ------------------------------------------------------------------------------ 24 | $ homectl refresh 25 | $ rehash # if needed by your shell 26 | 27 | $ hc upgrade 28 | $ hc init ~/your-home-setup # this is not destructive, don't worry :) 29 | ------------------------------------------------------------------------------ 30 | 31 | .New Features 32 | 33 | * The new Python implementation is much faster. 34 | 35 | * Homectl now understands different machine and OS types, via the notion of 36 | *systems*. See link:doc/customization.asciidoc[] for more details. 37 | 38 | * `hc upgrade` performs most of the manual labor required to convert a homectl 39 | 0.2 setup and deployment to a homectl 0.3 setup and deployment. 40 | 41 | * `hc path` discovers where homectl files live, and computes updated 42 | environment variables such as `$PATH`. 43 | 44 | * `hc tree` searches for files and directories within hook dirs in your 45 | packages, similar to how `find $hook_dirs -path ...` works. 46 | 47 | * `loader-emacs.hcpkg` loads Emacs packages directly from the new 48 | `emacs-startup` hook. It uses `(require)` to ensure package dependencies are 49 | respected. 50 | 51 | * More documentation, especially on how to create your own packages. 52 | 53 | .Deprecations 54 | 55 | * As mentioned above, `homectl` has been renamed to `hc`. The original 56 | `homectl` command, and its companion `homectl-resolve-path` tool, will be 57 | removed in homectl 0.4. 58 | 59 | * `loader-emacs.hcpkg`: The previous method of running `emacs.el` files during 60 | startup is now deprecated. Place startup packages in the `emacs-startup` 61 | hookdir instead. The old behavior will be removed in homectl 0.4. 62 | 63 | * `homectl find` (or `hc find`, as it's now called) has been deprecated in favor 64 | of `hc tree`. `hc find` will be removed in homectl 0.4. 65 | 66 | * `loader-bash` and `loader-zsh`: The previous mechanism of sourcing `shell.*` 67 | and `env.*` files during shell startup is deprecated and will be removed in 68 | homectl 0.4. Instead, place your `*.sh`, `*.bash`, or `*.zsh` files in the `shell-env` and `shell-rc` hook directories, respectively. 69 | 70 | .Bugs Fixed 71 | 72 | * `loader-emacs.hcpkg` now unconditionally refreshes the `package.el` package 73 | list if it needs to install a missing package. This ensures the package list 74 | is always available. 75 | 76 | .Bugs Introduced 77 | 78 | * Probably a few, since this is a rewrite. Hopefully the new unit tests caught 79 | most of them. :) 80 | 81 | Version 0.2.0 82 | ------------- 83 | 84 | Released on: 22-Jul-2013 85 | 86 | .Incompatible Changes 87 | 88 | * `homectl enable` and `homectl disable` now take package paths instead of just 89 | the basename. This allows you to have multiple packages in different 90 | directories with the same name, and it makes enabling/disabling packages more 91 | convenient since you can use Tab-completion. 92 | 93 | * `homectl list` now shows the full path to each enabled package, so you know 94 | exactly what is enabled and where it lives. 95 | 96 | .New Features 97 | 98 | * Homectl now understands Emacs. 99 | ** Add `emacs.el` files to your homectl packages to load and configure your 100 | Emacs packages. 101 | ** Automatically install packages from ELPA and other `package` archives with 102 | `homectl-require`. 103 | 104 | * The new `homectl find` command locates files with a particular name in enabled 105 | packages, and echoes their paths. 106 | 107 | .Bugs Fixed 108 | 109 | * The `deploy.sh` script generated by `homectl init` now properly initializes 110 | git submodules in freshly-cloned setups. 111 | 112 | ** Existing homectl setups need to have their `deploy.sh` scripts corrected by 113 | hand. Change your `deploy.sh` to call 114 | `git submodule update --init --recursive` 115 | instead of just `git submodule update`. 116 | 117 | Version 0.1.0 118 | ------------- 119 | 120 | Released On: 5-Nov-2012 121 | 122 | .New Features 123 | * First release! 124 | -------------------------------------------------------------------------------- /doc/hacking.asciidoc: -------------------------------------------------------------------------------- 1 | Hacking on homectl 2 | ================== 3 | :toc: 4 | 5 | This document describes homectl's preferred development practices. If you're 6 | trying to learn how to create your own homectl packages, see 7 | link:customization.asciidoc[]. 8 | 9 | 10 | 11 | Branches and Version Numbers 12 | ---------------------------- 13 | 14 | For the most part, homectl's version numbers don't matter -- most people can 15 | (and will) use the +master+ branch and be happy. Different versions exist only 16 | to mark particularly stable points during development. 17 | 18 | Versions are named using the standard .. scheme: 19 | 20 | * Major versions contain new, possibly-incompatible features. 21 | * Minor versions contain new, backward-compatible features (you can upgrade, 22 | but you may not be able to downgrade). 23 | * Revisions contain bugfixes only. 24 | 25 | We don't do alpha/beta releases, because if people want to try out the latest 26 | thing, they can just use +master+. We expect most people to do this. 27 | 28 | 29 | 30 | Code Quality 31 | ------------ 32 | 33 | Since people will often pull directly from +master+, +master+ must always be in 34 | a releasable state. This means anything going into +master+ must: 35 | 36 | * Work as intended. 37 | 38 | * Be documented (both in source code comments, and in the user-facing 39 | documentation). 40 | 41 | * Have major new changes and/or functionality covered by unit tests, which are 42 | runnable through +test.py+. 43 | 44 | In short: if you can't show a test proving it works, it doesn't work. 45 | 46 | 47 | 48 | Design Principles 49 | ----------------- 50 | 51 | .Don't Repeat Yourself. 52 | 53 | Enough said. 54 | 55 | .Don't assume anything about the environment. 56 | 57 | Accept and code for the fact that anything can change. The machine 58 | architecture, OS, distribution, or even +$HOME+ or +$SHELL+ can change without 59 | notice. Your code should adapt to these changes transparently. 60 | 61 | .Don't mess with the user's stuff. 62 | 63 | Don't put anything in a user's Git, or touch any of their actual files, unless 64 | they explicitly tell you to (for example, +homectl init+). If you *must* do 65 | this, let the user add and commit it themselves (for example, +homectl enable+). 66 | 67 | .Keep homectl small. 68 | 69 | The only packages that belong in the +homectl+ distribution are homectl itself, 70 | and loader packages for the various shells, Emacs, Vim, etc. If you are not 71 | providing a new, standard way to use homectl, put it somewhere else and link to 72 | it from the wiki. Since people will clone homectl directly, it's not great if 73 | they have to waste time and bandwidth getting a bunch of stuff they don't need. 74 | 75 | That said, if something is very commonly used, and reasonably self-contained, we 76 | can consider placing it in an +extras/+ directory or similar. But I would 77 | expect this to be the exception, not the rule. 78 | 79 | 80 | 81 | Code Submission Guidelines 82 | -------------------------- 83 | 84 | Split your changes up logically 85 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 86 | 87 | Each patch (commit, pull request, etc.) should do one thing, and one thing only. 88 | Don't jam multiple bugfixes or features into a single patch, and don't include 89 | unnecessary changes in a patch (e.g. whitespace changes to lines you didn't 90 | edit). However, you should group changes related to the same feature/bug 91 | together -- if you add a new command, for instance, it would be wise to include 92 | the command's documentation in the same patch as its code. 93 | 94 | 95 | 96 | Write user documentation 97 | ~~~~~~~~~~~~~~~~~~~~~~~~ 98 | 99 | If your pull request contains user-visible changes, please add or edit 100 | documentation accordingly, and include those changes with your pull request. 101 | All documentation should be in asciidoc format, and have the +.asciidoc+ 102 | extension. 103 | 104 | For more information on asciidoc, see: http://www.methods.co.nz/asciidoc/ 105 | 106 | 107 | 108 | Write clear source code 109 | ~~~~~~~~~~~~~~~~~~~~~~~ 110 | 111 | Choose descriptive variable names, indent properly, use blank lines to break 112 | code logically, etc. Don't try to be clever or fast at the expense of 113 | readability. *Readers should be able to understand what the code is doing even 114 | if ALL the comments are stripped out.* 115 | 116 | .Bad code 117 | ============================================================================== 118 | ------------------------------------------------------------------------------ 119 | val() { 120 | [[ "$1" == ".*" ]] && die "$1: $valmsg" 121 | [[ "$1" == */* ]] && die "$1: $valmsg" 122 | [[ "$1" == *\ * ]] && die "$1: $valmsg" 123 | } 124 | ------------------------------------------------------------------------------ 125 | ============================================================================== 126 | 127 | .Good code 128 | ============================================================================== 129 | ------------------------------------------------------------------------------ 130 | validate-pkg-name() { 131 | local name="$1" 132 | 133 | [[ "$name" == ".*" ]] && die "$name: $validate_pkg_name_msg" 134 | [[ "$name" == */* ]] && die "$name: $validate_pkg_name_msg" 135 | [[ "$name" == *\ * ]] && die "$name: $validate_pkg_name_msg" 136 | } 137 | ------------------------------------------------------------------------------ 138 | ============================================================================== 139 | 140 | 141 | 142 | Comment your source code 143 | ~~~~~~~~~~~~~~~~~~~~~~~~ 144 | 145 | Source code comments should explain *why* code is the way it is, *not what* it 146 | is doing. Comments should discuss the thought process that went into 147 | constructing the code -- background information, reasoning, even discarded 148 | approaches footnote:[As long as you explain why an approach was discarded!] are 149 | all good things to include in comments. 150 | 151 | Never write comments explaining what the code is doing. If your code is 152 | well-structured and uses descriptive names, these comments are totally 153 | superfluous. At best, they are a distraction that merely adds clutter. At 154 | worst, over time they can become misleading, or even totally wrong. 155 | 156 | .Bad comment 157 | ============================================================================== 158 | ------------------------------------------------------------------------------ 159 | for f in $(find "$ovldir" -not -type d); do 160 | # Resolve the parent directory first and then add back the filename. 161 | tgt_rel="$(resolve-path "$(dirname "$f")" "$ovldir")/$(basename "$f")" 162 | ------------------------------------------------------------------------------ 163 | ============================================================================== 164 | 165 | .Good comment 166 | ============================================================================== 167 | ------------------------------------------------------------------------------ 168 | for f in $(find "$ovldir" -not -type d); do 169 | # We have to do this weird dirname trick with $lnk because $f might 170 | # itself be a symlink, and we want to make sure we create the link 171 | # to point at $f itself, and not at what $f points to. 172 | tgt_rel="$(resolve-path "$(dirname "$f")" "$ovldir")/$(basename "$f")" 173 | ------------------------------------------------------------------------------ 174 | ============================================================================== 175 | 176 | 177 | 178 | Format your commit messages and pull requests 179 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 180 | 181 | Please format your pull requests (and commit messages, if appropriate) in the 182 | following standard format (copied pretty much wholesale from the Linux kernel): 183 | 184 | ------------------------------------------------------------------------ 185 | subsystem: Title/Short Summary 186 | 187 | Full description of the change, including: 188 | - What the change is trying to accomplish 189 | - Why that's a good thing 190 | 191 | Please hard-wrap your text at 72 characters, so git log displays it 192 | correctly. 193 | ------------------------------------------------------------------------ 194 | 195 | The *subsystem* is an informal way of identifying the scope of the patch; if it 196 | applies to the +homectl init+ command, for example, it might be "homectl/init". 197 | If it applies to the +homectl+ command as a whole, it might just be "homectl". 198 | If it applies to the entire package (which is rare), it is omitted. 199 | 200 | .Bad commit message 201 | ============================================================================== 202 | ------------------------------------------------------------------------ 203 | commit f75b60b260ae7d2ecc76c51aebbc09768746a683 204 | Author: Joshua J. Berry 205 | Date: Fri Oct 19 17:53:37 2012 -0700 206 | 207 | Rename all homectl packages to *.hcpkg 208 | ------------------------------------------------------------------------ 209 | ============================================================================== 210 | 211 | .Good commit message 212 | ============================================================================== 213 | ------------------------------------------------------------------------ 214 | commit f66433ea3ecd289ab6ab2c334caede274c959c12 215 | Author: Joshua J. Berry 216 | Date: Fri Oct 19 18:02:47 2012 -0700 217 | 218 | homectl: Enforce the .hcpkg naming convention 219 | 220 | Someone might unintentionally try to activate something that's not a 221 | homectl package, so we force all packages to end in .hcpkg to make sure 222 | this doesn't happen. 223 | ------------------------------------------------------------------------ 224 | ============================================================================== 225 | 226 | See also: https://www.kernel.org/doc/Documentation/SubmittingPatches 227 | -------------------------------------------------------------------------------- /homectl.hcpkg/bin/homectl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 4 | # The homectl manager program 5 | # 6 | 7 | DEFAULT_HOMECTL_URL="git://github.com/josh-berry/homectl.git" 8 | 9 | # 10 | # Internal/utility functions 11 | # 12 | 13 | show-usage() { 14 | echo " homectl $@" 15 | } 16 | 17 | run() { 18 | echo "\$ $@" 19 | "$@" 20 | return $? 21 | } 22 | 23 | runf() { 24 | run "$@" 25 | local rc=$? 26 | if [[ $rc -gt 0 ]]; then 27 | die "Failed command (rc = $rc): $@" 28 | fi 29 | } 30 | 31 | die() { 32 | echo "!!! $@" >&2 33 | exit 1 34 | } 35 | 36 | resolve-path() { 37 | "$(dirname "$0")/homectl-resolve-path" "$@" 38 | } 39 | 40 | foreach-overlay-file() { 41 | local pkgdir="$(resolve-path "$1")" 42 | shift 43 | 44 | local ovldir="$pkgdir/overlay" 45 | 46 | if [[ -d "$ovldir" ]]; then 47 | for f in $(find "$ovldir" -not -type d); do 48 | # We have to do this weird dirname trick with $lnk because $f might 49 | # itself be a symlink, and we want to make sure we create the link 50 | # to point at $f itself, and not at what $f points to. 51 | tgt_rel="$(resolve-path "$(dirname "$f")" "$ovldir")/$(basename "$f")" 52 | tgt="$HOME/$tgt_rel" 53 | lnk="$(resolve-path "$(dirname "$f")" "$(dirname "$tgt")")/$(basename "$f")" 54 | 55 | "$@" "$lnk" "$tgt" 56 | done 57 | fi 58 | } 59 | 60 | add-overlay-link() { 61 | local lnk="$1" 62 | local tgt="$2" 63 | 64 | if [[ ! -d $(dirname "$tgt") ]]; then 65 | run mkdir -p "$(dirname "$tgt")" 66 | fi 67 | 68 | if [[ -L "$tgt" ]]; then 69 | if [[ "$(readlink "$tgt")" != "$lnk" ]]; then 70 | run rm -f "$tgt" 71 | fi 72 | elif [[ -e "$tgt" ]]; then 73 | echo "!!! $tgt already exists; not touching it" >&2 74 | echo "!!! (You should move it out of the way and try again.)" >&2 75 | fi 76 | 77 | if [[ ! -e "$tgt" ]] && [[ ! -L "$tgt" ]]; then 78 | run ln -s "$lnk" "$tgt" || die "Couldn't create $tgt" 79 | fi 80 | } 81 | 82 | rm-overlay-link() { 83 | local lnk="$1" 84 | local tgt="$2" 85 | 86 | if [[ -L "$tgt" ]] && [[ "$(readlink "$tgt")" == "$lnk" ]]; then 87 | run rm "$tgt" 88 | elif [[ -e "$tgt" ]]; then 89 | echo "--- skipping $tgt (it was replaced)" >&2 90 | fi 91 | } 92 | 93 | run-hook-script() { 94 | local hookname="$1" 95 | local interp="$2" 96 | local script="$3" 97 | shift 98 | shift 99 | shift 100 | 101 | if [[ -e "$script" ]]; then 102 | run "$interp" "$script" "$@" || die "Hook '$hookname' failed: rc = $?" 103 | fi 104 | } 105 | 106 | run-hook() { 107 | local hook="$1" 108 | local pkgdir="$2" 109 | shift 110 | shift 111 | 112 | run-hook-script "$hook" bash "$pkgdir/$hook.sh" "$@" 113 | run-hook-script "$hook" bash "$pkgdir/$hook.bash" "$@" 114 | run-hook-script "$hook" zsh "$pkgdir/$hook.zsh" "$@" 115 | run-hook-script "$hook" tcsh "$pkgdir/$hook.csh" "$@" 116 | } 117 | 118 | mk-pkg-tag() { 119 | local pkgpath="$1" 120 | local link="$(resolve-path "$pkgpath" "$HOMECTL_DIR")" 121 | 122 | local tag="$(echo "$link" |sed ' 123 | s%@%@@%g 124 | s% %@+%g 125 | s%/%@_%g')" 126 | 127 | run ln -s "$link" "$HOMECTL_DIR/+$tag" \ 128 | || die "Couldn't mark $pkgpath enabled" 129 | } 130 | 131 | enable-internal() { 132 | local pkg="$1" 133 | 134 | foreach-overlay-file "$pkg" add-overlay-link 135 | run-hook install "$pkg" 136 | } 137 | 138 | disable-internal() { 139 | local pkg="$1" 140 | 141 | foreach-overlay-file "$pkg" rm-overlay-link 142 | run-hook uninstall "$pkg" 143 | } 144 | 145 | 146 | 147 | # 148 | # Homectl user commands 149 | # 150 | 151 | homectl-help() { 152 | local cmd="$1" 153 | shift 154 | 155 | if [[ -z "$cmd" ]]; then 156 | echo "Usage:" 157 | echo 158 | usage-init 159 | usage-uninstall 160 | echo 161 | usage-list 162 | usage-enable 163 | usage-disable 164 | echo 165 | usage-find 166 | usage-refresh 167 | echo 168 | show-usage "help [cmd]" 169 | echo "" 170 | 171 | else 172 | "usage-$cmd" "$@" 173 | "help-$cmd" "$@" 174 | fi 175 | } 176 | 177 | ######################################## 178 | # Init 179 | usage-init() { 180 | show-usage "init my-homectl-setup [git://my-homectl-fork]" 181 | } 182 | help-init() { 183 | echo "" 184 | echo "Start a new homectl deployment." 185 | echo "" 186 | echo "Creates a new Git repository at the path you specify, and populates" 187 | echo "it with a copy of homectl and a helper script for deploying your" 188 | echo "setup on new machines." 189 | echo "" 190 | echo "You can optionally specify a URL to a homectl Git repo, and 'init'" 191 | echo "will use that repo instead of the default. This is only useful if" 192 | echo "you work on homectl itself. Note that if you specify a local" 193 | echo "filesystem path for the URL, it HAS to be an absolute path." 194 | echo "" 195 | } 196 | homectl-init() { 197 | local root="$1" 198 | local homectl_url="$2" 199 | local warn_shell="" 200 | 201 | [[ -z "$homectl_url" ]] && homectl_url="$DEFAULT_HOMECTL_URL" 202 | 203 | runf git init "$root" 204 | cd "$root" || exit 1 205 | 206 | runf git submodule init 207 | runf git submodule add "$homectl_url" homectl 208 | runf mkdir enable.d 209 | runf ln -s ../homectl/homectl.hcpkg enable.d/homectl.hcpkg 210 | 211 | if [[ "$SHELL" == *zsh ]]; then 212 | runf ln -s ../homectl/loader-zsh.hcpkg enable.d/loader-zsh.hcpkg 213 | elif [[ "$SHELL" == *bash ]]; then 214 | runf ln -s ../homectl/loader-bash.hcpkg enable.d/loader-bash.hcpkg 215 | else 216 | warn_shell="1" 217 | fi 218 | 219 | runf git add enable.d 220 | 221 | echo "Creating README.asciidoc..." 222 | cat >README.asciidoc <deploy.sh <" -m "New homectl setup" 301 | 302 | echo "" 303 | echo ">>> If you want to start using this homectl setup in your home," 304 | echo ">>> you should now run:" 305 | echo ">>>" 306 | echo ">>> $root/deploy.sh" 307 | echo "" 308 | 309 | if [[ ! -z "$warn_shell" ]]; then 310 | echo "!!! We weren't able to determine what shell \"$SHELL\" is." 311 | echo "!!! That means your homectl setup won't have a loader." 312 | echo "!!! You will probably need to add or write one before" 313 | echo "!!! homectl will be useful." 314 | echo "" 315 | fi 316 | } 317 | 318 | ######################################## 319 | # Deploy 320 | 321 | usage-deploy() { 322 | show-usage "deploy enabled-pkg-dir" 323 | } 324 | help-deploy() { 325 | echo "" 326 | echo "Deploys the packages symlinked in enabled-pkg-dir to your \$HOME." 327 | echo "" 328 | } 329 | homectl-deploy() { 330 | local enable_dir="$1" 331 | 332 | if [[ -L ~/.homectl ]] || [[ -e ~/.homectl ]]; then 333 | die "~/.homectl already exists; you should run 'homectl uninstall' first." 334 | fi 335 | 336 | [[ -d "$enable_dir" ]] || die "Couldn't find enabled pkgs: $enable_dir" 337 | 338 | run ln -s "$(resolve-path "$enable_dir" "$HOME")" "$HOME/.homectl" \ 339 | || die "Couldn't put ~/.homectl in place." 340 | 341 | # Re-exec ourselves as a new process because that way HOMECTL_DIR gets set 342 | # correctly. 343 | runf "$0" refresh 344 | 345 | echo "" 346 | echo ">>> homectl is now deployed in your home directory." 347 | echo ">>> You should restart your shell to pick up any changes." 348 | echo "" 349 | } 350 | 351 | ######################################## 352 | # Uninstall 353 | usage-uninstall() { 354 | show-usage "uninstall" 355 | } 356 | help-uninstall() { 357 | echo "" 358 | echo "Completely removes all traces of homectl from your home directory." 359 | echo "" 360 | echo "It does not, however, touch your homectl setup/Git repository. If" 361 | echo "you accidentally uninstall, just run your deploy.sh script again." 362 | echo "" 363 | } 364 | homectl-uninstall() { 365 | for p in $(homectl-list); do 366 | disable-internal "$HOMECTL_DIR/$p" 367 | done 368 | runf rm -f ~/.homectl 369 | } 370 | 371 | ######################################## 372 | # List 373 | 374 | usage-list() { 375 | show-usage "list" 376 | } 377 | help-list() { 378 | echo "" 379 | echo "Lists the packages that are presently enabled." 380 | echo "" 381 | } 382 | homectl-list() { 383 | for pkg in "$HOMECTL_DIR/"*; do 384 | resolve-path "$pkg" 385 | done 386 | } 387 | homectl-ls() { 388 | homectl-list "$@" 389 | } 390 | 391 | ######################################## 392 | # Enable One Package 393 | homectl-enable-one() { 394 | local pkg="$1" 395 | 396 | [[ ! -d "$pkg" ]] && die "$pkg: Not a directory" 397 | [[ "$pkg" == *.hcpkg ]] || die "$pkg: Must have a .hcpkg extension" 398 | 399 | local pkgpath="$(resolve-path "$pkg")" 400 | 401 | for enpkg in "$HOMECTL_DIR/"*; do 402 | if [[ "$(resolve-path "$enpkg")" = "$pkgpath" ]]; then 403 | die "$pkg: Already enabled" 404 | fi 405 | done 406 | 407 | enable-internal "$pkg" 408 | mk-pkg-tag "$pkg" 409 | } 410 | 411 | ######################################## 412 | # Enable 413 | 414 | usage-enable() { 415 | show-usage "en[able] pkg-path [pkg-path ...]" 416 | } 417 | help-enable() { 418 | echo "" 419 | echo "Enables the homectl package stored in the pkg-path directory, " 420 | echo "linking it into your home directory and causing it to be loaded " 421 | echo "by default." 422 | echo "" 423 | echo "If the package contains new shell aliases, changes the PATH, etc.," 424 | echo "you will have to restart any affected programs to pick up the new" 425 | echo "features." 426 | echo "" 427 | } 428 | homectl-enable() { 429 | for pkg in "$@"; do 430 | homectl-enable-one "$pkg" 431 | done 432 | } 433 | homectl-en() { 434 | homectl-enable "$@" 435 | } 436 | 437 | ######################################## 438 | # Disable 439 | 440 | usage-disable() { 441 | show-usage "dis[able] pkg [pkg ...]" 442 | } 443 | help-disable() { 444 | echo "" 445 | echo "Undoes the effect of the 'enable' command. Unlinks the enabled" 446 | echo "package from your home directory, so it won't be loaded by default." 447 | echo "" 448 | echo "You may have to restart programs this package uses after it is" 449 | echo "disabled." 450 | echo "" 451 | } 452 | homectl-disable() { 453 | for pkg in "$@"; do 454 | local pkgpath="$(resolve-path "$pkg")" 455 | local ok="" 456 | 457 | for enpkg in "$HOMECTL_DIR/"*; do 458 | if [[ "$(resolve-path "$enpkg")" = "$pkgpath" ]]; then 459 | disable-internal "$pkgpath" 460 | run rm -f "$enpkg" || die "Couldn't mark $pkg disabled" 461 | echo "Disabled $pkg" 462 | ok="yes" 463 | break 464 | fi 465 | done 466 | 467 | if [[ -z "$ok" ]]; then 468 | [[ ! -d "$pkg" ]] && die "$pkg: No such file or directory" 469 | die "$pkg: Isn't enabled" 470 | fi 471 | done 472 | } 473 | homectl-dis() { 474 | homectl-disable "$@" 475 | } 476 | 477 | ######################################## 478 | # Refresh 479 | 480 | usage-refresh() { 481 | show-usage "refresh" 482 | } 483 | help-refresh() { 484 | echo "" 485 | echo "Refreshes all the enabled packages on your system." 486 | echo "" 487 | echo "This command re-runs all the install scripts and updates any links" 488 | echo "used by the packages." 489 | echo "" 490 | } 491 | homectl-refresh() { 492 | for pkg in $(homectl-list); do 493 | enable-internal "$pkg" 494 | done 495 | } 496 | 497 | ######################################## 498 | # Find 499 | 500 | usage-find() { 501 | show-usage "find path-in-pkg" 502 | } 503 | help-find() { 504 | echo "" 505 | echo "Searches for a file at a particular location in every enabled " 506 | echo "package. Reports the full paths to any files that were found." 507 | echo "" 508 | echo "For example, to find all files named \"install-hook\", you might" 509 | echo "do this:" 510 | echo "" 511 | echo " $ homectl find install-hook" 512 | echo " /home/$LOGNAME/.homectl/foo.hcpkg/install-hook" 513 | echo " /home/$LOGNAME/.homectl/bar.hcpkg/install-hook" 514 | echo "" 515 | echo "This works with nested paths as well, e.g.:" 516 | echo "" 517 | echo " $ homectl find bin/install-hook" 518 | echo " /home/$LOGNAME/.homectl/foo.hcpkg/bin/install-hook" 519 | echo " /home/$LOGNAME/.homectl/bar.hcpkg/bin/install-hook" 520 | echo "" 521 | } 522 | homectl-find() { 523 | local name=$1 524 | for pkg in $HOMECTL_DIR/*; do 525 | if [[ -e "$pkg/$name" ]]; then 526 | echo "$pkg/$name" 527 | fi 528 | done 529 | } 530 | 531 | 532 | # 533 | # Run a command 534 | # 535 | 536 | cmd="$1" 537 | shift 538 | 539 | if [[ -z "$(declare -f "homectl-$cmd")" ]]; then 540 | which "homectl-$cmd" >/dev/null 2>&1 541 | if [[ $? -gt 0 ]]; then 542 | cmd=help 543 | fi 544 | fi 545 | 546 | # help/init/deploy are special commands that don't need or only work when 547 | # uninitialized 548 | case "$cmd" in 549 | help) 550 | ;; 551 | init) 552 | ;; 553 | deploy) 554 | ;; 555 | *) 556 | # NOTE: We are forcing this because loaders depend on it being in a 557 | # known location. 558 | # 559 | # Also, we want to get the ACTUAL path to .homectl (minus any symlinks), 560 | # because when "enable" puts new links in here, they need to be relative 561 | # to the right place. 562 | HOMECTL_DIR="$(resolve-path "$HOME/.homectl")" 563 | export HOMECTL_DIR 564 | 565 | if [[ ! -d "$HOMECTL_DIR" ]]; then 566 | die "homectl doesn't appear to be set up yet (try 'homectl init')" 567 | fi 568 | ;; 569 | esac 570 | 571 | "homectl-$cmd" "$@" 572 | exit $? 573 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import os 5 | import shutil 6 | import imp 7 | import unittest 8 | import tempfile 9 | import subprocess 10 | 11 | pj = os.path.join 12 | 13 | HOMECTL_ROOT = os.path.realpath(os.path.dirname(sys.argv[0])) 14 | HOMECTL_BIN = pj(HOMECTL_ROOT, 'homectl.hcpkg', 'python', 'homectl.py') 15 | TESTDATA_DIR = pj(HOMECTL_ROOT, "testdata") 16 | 17 | # Since the hc command isn't really intended to be used as a module, we need to 18 | # fake it. This is a glorified "import hc", if hc were in fact importable. 19 | hc = imp.load_source('homectl', HOMECTL_BIN) 20 | 21 | 22 | 23 | # Since git doesn't allow you to store empty directories in version control, we 24 | # have to create them here, so they exist in our test data set. (XXX Since a 25 | # user's setup is stored in git, probably we should just make homectl ignore 26 | # them entirely, but that's a project for another day.) 27 | for d in [ 28 | ("package-empty.hcpkg",), 29 | ("package-full.hcpkg", "Darwin", "lib"), 30 | ("package-full.hcpkg", "Linux", "lib"), 31 | ]: 32 | hc.mkdirp(pj(TESTDATA_DIR, *d)) 33 | 34 | 35 | 36 | def fileset(base, *paths): 37 | return set([(f, pj(base, f)) for f in paths]) 38 | 39 | def file_contents(path): 40 | with open(path, 'r') as f: 41 | return f.read() 42 | 43 | 44 | 45 | class HomectlTest(unittest.TestCase): 46 | # Base class for other homectl tests; handles creating and tearing down a 47 | # homectl environment in the filesystem. 48 | 49 | # Useful packages in testdata 50 | EMPTY = pj(TESTDATA_DIR, 'package-empty.hcpkg') 51 | FULL = pj(TESTDATA_DIR, 'package-full.hcpkg') 52 | 53 | def setUp(self): 54 | self.dir = os.path.realpath(tempfile.mkdtemp(prefix='homectl-selftest-')) 55 | self.old_pwd = os.getcwd() 56 | self.old_env = dict(os.environ) 57 | os.environ.clear() 58 | os.environ.update({ 59 | 'SHELL': '/bin/sh', 60 | 'PATH': '/usr/bin:/bin', 61 | 'TERM': self.old_env['TERM'], 62 | 'HOME': self.dir, 63 | 'LOGNAME': self.old_env['LOGNAME'], 64 | 'USER': self.old_env['USER'], 65 | 'LANG': self.old_env['LANG'], 66 | }) 67 | os.chdir(self.dir) 68 | 69 | def tearDown(self): 70 | shutil.rmtree(self.dir) 71 | os.environ.clear() 72 | os.environ.update(self.old_env) 73 | os.chdir(self.old_pwd) 74 | 75 | 76 | 77 | class SilentSystem(hc.System): 78 | def __init__(self): 79 | super(SilentSystem, self).__init__(pretend=False) 80 | self.__log = [] 81 | 82 | def log(self, msg): 83 | self.__log.append(msg) 84 | 85 | 86 | 87 | def with_system(fn): 88 | def decorator(self): 89 | s = SilentSystem() 90 | #s = hc.ConsoleSystem() 91 | fn(self, s) 92 | return decorator 93 | 94 | 95 | 96 | def with_deployment(fn): 97 | def decorator(self): 98 | s = SilentSystem() 99 | #s = hc.ConsoleSystem() 100 | d = hc.Deployment(s, os.getcwd(), os.path.join(os.getcwd(), '.homectl')) 101 | fn(self, d) 102 | return decorator 103 | 104 | 105 | 106 | class UtilTest(HomectlTest): 107 | # Small tests for utility functions. 108 | 109 | DATA = pj(TESTDATA_DIR, 'util') 110 | 111 | def test_mkdirp(self): 112 | hc.mkdirp('') # should do nothing 113 | hc.mkdirp('.') # should do nothing 114 | hc.mkdirp('..') # should do nothing 115 | 116 | hc.mkdirp('foo') 117 | self.assertEqual(os.path.isdir('foo'), True) 118 | 119 | hc.mkdirp(pj('outer', 'inner')) 120 | self.assertEqual(os.path.isdir('outer'), True) 121 | self.assertEqual(os.path.isdir(pj('outer', 'inner')), True) 122 | 123 | def test_visible_dirs(self): 124 | self.assertEqual( 125 | set(hc.visible_dirs(self.DATA)), 126 | fileset(self.DATA, 127 | 'visible-dir', 128 | 'visible-link-to-dir', 129 | )) 130 | 131 | def test_visible_links(self): 132 | self.assertEqual( 133 | set(hc.visible_links(self.DATA)), 134 | fileset(self.DATA, 135 | 'visible-link', 136 | 'visible-link-to-dir', 137 | )) 138 | 139 | def test_fs_tree(self): 140 | self.assertEqual( 141 | set(hc.fs_tree(self.DATA)), 142 | fileset(self.DATA, 143 | ".invisible", 144 | ".invisible-dir", 145 | ".invisible-dir/foo", 146 | ".invisible-link", 147 | "visible", 148 | "visible-dir", 149 | "visible-dir/bar", 150 | "visible-dir/foo", 151 | "visible-link", 152 | "visible-link-to-dir", 153 | )) 154 | 155 | def test_fs_files_in(self): 156 | self.assertEqual( 157 | set(hc.fs_files_in(self.DATA)), 158 | fileset(self.DATA, 159 | ".invisible", 160 | ".invisible-dir/foo", 161 | ".invisible-link", 162 | "visible", 163 | "visible-dir/bar", 164 | "visible-dir/foo", 165 | "visible-link", 166 | "visible-link-to-dir", 167 | )) 168 | 169 | def test_sh_quote(self): 170 | for t, a in [ 171 | ("foo", "foo"), 172 | ("foo\"", "foo\\\""), 173 | ("foo bar", "\"foo bar\""), 174 | ("foo\nbar", "foo\\nbar"), 175 | ("foo\rbar", "foo\\rbar"), 176 | ("foo\tbar", "foo\\tbar"), 177 | ("foo\0bar", "foo\\0bar"), 178 | ("foo\\bar", "foo\\\\bar"), 179 | ]: 180 | self.assertEqual(hc.sh_quote(t), a) 181 | 182 | 183 | 184 | class PackageTest(HomectlTest): 185 | # Tests for the public Package class API. 186 | 187 | def test_systems(self): 188 | empty = hc.Package(self.EMPTY) 189 | p = hc.Package(self.FULL) 190 | self.assertEqual(set(empty.systems), set(['common'])) 191 | self.assertEqual(set(p.systems), 192 | set(['common', 'Linux', 'Darwin', 'Windows'])) 193 | 194 | def test_hooks_in_system(self): 195 | empty = hc.Package(self.EMPTY) 196 | p = hc.Package(self.FULL) 197 | self.assertEqual(set(empty.hooks_in_system('common')), set()) 198 | self.assertEqual(set(empty.hooks_in_system('Linux')), set()) 199 | self.assertEqual(set(p.hooks_in_system('common')), set(['bin', 'share'])) 200 | self.assertEqual(set(p.hooks_in_system('Linux')), set(['bin', 'lib'])) 201 | self.assertEqual(set(p.hooks_in_system('Plan9')), set()) 202 | 203 | def test_files_in_sys_hook(self): 204 | empty = hc.Package(self.EMPTY) 205 | p = hc.Package(self.FULL) 206 | self.assertEqual(set(empty.files_in_sys_hook('common', 'bin')), set()) 207 | self.assertEqual(set(empty.files_in_sys_hook('Linux', 'bin')), set()) 208 | self.assertEqual(set(p.files_in_sys_hook('common', 'bin')), 209 | fileset(pj(self.FULL, 'bin'), 'hello')) 210 | self.assertEqual(set(p.files_in_sys_hook('Linux', 'bin')), 211 | fileset(pj(self.FULL, 'Linux', 'bin'), 'bye')) 212 | self.assertEqual(set(p.files_in_sys_hook('Linux', 'share')), set()) 213 | self.assertEqual(set(p.files_in_sys_hook('Plan9', 'bin')), set()) 214 | 215 | def test_file_map(self): 216 | def filemap(*l): 217 | return set([ 218 | ((s, h, f), 219 | pj(self.FULL, s, h, f) if s != 'common' \ 220 | else pj(self.FULL, h, f)) 221 | for s, h, f in l]) 222 | 223 | p = hc.Package(self.FULL) 224 | 225 | self.assertEqual(set(p.file_map()), filemap( 226 | ('common', 'bin', 'hello'), 227 | ('common', 'share', 'package/README.txt'), 228 | ('Darwin', 'bin', 'bye'), 229 | ('Linux', 'bin', 'bye'), 230 | ('Windows', 'bin', 'bye'), 231 | ('Windows', 'dlls', 'not-a-dll'), 232 | )) 233 | 234 | self.assertEqual(set(p.file_map(systems=['common', 'Linux'])), filemap( 235 | ('common', 'bin', 'hello'), 236 | ('common', 'share', 'package/README.txt'), 237 | ('Linux', 'bin', 'bye'), 238 | )) 239 | 240 | self.assertEqual(set(p.file_map(hooks=['bin'])), filemap( 241 | ('common', 'bin', 'hello'), 242 | ('Darwin', 'bin', 'bye'), 243 | ('Linux', 'bin', 'bye'), 244 | ('Windows', 'bin', 'bye'), 245 | )) 246 | 247 | self.assertEqual( 248 | set(p.file_map(systems=['common', 'Linux'], hooks=['bin'])), 249 | filemap( 250 | ('common', 'bin', 'hello'), 251 | ('Linux', 'bin', 'bye'), 252 | )) 253 | 254 | 255 | 256 | class SystemTest(HomectlTest): 257 | # Tests for the System class API. Sadly not everything here is very 258 | # testable, since it's just a thin wrapper around the Python/OS interface. 259 | # I'm not going to bother testing trivial functionality. 260 | 261 | @with_system 262 | def test_update_file(self, s): 263 | s.update_file("foo", "bar") 264 | self.assertEqual(file_contents("foo"), "bar") 265 | s.update_file("foo", "other") 266 | self.assertEqual(file_contents("foo"), "other") 267 | 268 | @with_system 269 | def test_update_link(self, s): 270 | s.update_link(pj("foo", "bar"), "f") 271 | self.assertEqual(os.readlink("f"), pj("foo", "bar")) 272 | 273 | # XXX Use pj() here, if we want this to work on Windows ever... 274 | s.update_link("/dev/null", "f") 275 | self.assertEqual(os.readlink("f"), "/dev/null") 276 | 277 | @with_system 278 | def test_rm_link(self, s): 279 | s.update_link("f", "f") 280 | s.rm_link("f") 281 | self.assertEqual(os.path.exists("f"), False) 282 | 283 | 284 | 285 | class DeploymentTest(HomectlTest): 286 | # Tests for the Deployment class API. 287 | 288 | # 289 | # Utility functions 290 | # 291 | 292 | def enabled_list(self, d): 293 | with open(d.enabled_list.path, 'r') as f: 294 | return set([p.rstrip() for p in f.readlines()]) 295 | 296 | def check_links(self, *lmap, **kw): 297 | for src, lnk in lmap: 298 | src_f = pj(self.dir, *src.split('/')) 299 | src_dir = os.path.dirname(src_f) 300 | if kw.get('testdata', None): 301 | lnk_path = os.path.realpath(pj(TESTDATA_DIR, *lnk.split('/'))) 302 | else: 303 | lnk_path = os.path.realpath(pj(*lnk.split('/'))) 304 | self.assertEqual(os.path.realpath(src_f), lnk_path) 305 | 306 | def check_absence(self, *path): 307 | for p in path: 308 | self.assertFalse(os.path.exists(pj(self.dir, *p.split('/')))) 309 | 310 | def mk_files(self, name, *files): 311 | for f in files: 312 | path = pj(name, *f.split('/')) 313 | hc.mkdirp(os.path.dirname(path)) 314 | open(path, 'w').close() 315 | 316 | # 317 | # Test cases 318 | # 319 | 320 | @with_deployment 321 | def test_add_package_updates_enabled_list(self, d): 322 | self.assertEqual(d.packages, set()) 323 | 324 | d.packages = set([hc.Package(self.EMPTY)]) 325 | self.assertEqual(self.enabled_list(d), 326 | set([os.path.relpath(p, self.dir) for p in [self.EMPTY]])) 327 | self.assertEqual(d.packages, set([hc.Package(self.EMPTY)])) 328 | 329 | d.packages = d.packages.union([hc.Package(self.FULL)]) 330 | self.assertEqual(self.enabled_list(d), 331 | set([os.path.relpath(p, self.dir) for p in [self.EMPTY, self.FULL]])) 332 | self.assertEqual(d.packages, 333 | set([hc.Package(self.EMPTY), hc.Package(self.FULL)])) 334 | 335 | @with_deployment 336 | def test_add_package_creates_homectl_links(self, d): 337 | self.assertEqual(d.packages, set()) 338 | 339 | d.packages = set([hc.Package(self.FULL)]) 340 | 341 | # Just spot-check a few things 342 | self.check_links( 343 | ('.homectl/common/bin/hello', 'package-full.hcpkg/bin/hello'), 344 | ('.homectl/Linux/bin/bye', 'package-full.hcpkg/Linux/bin/bye'), 345 | ('.homectl/common/share/package/README.txt', 'package-full.hcpkg/share/package/README.txt'), 346 | testdata=True, 347 | ) 348 | 349 | # Platform-specific stuff should never show up in common 350 | self.check_absence( 351 | '.homectl/common/bin/bye', 352 | '.homectl/common/lib' 353 | ) 354 | 355 | @with_deployment 356 | def test_rm_package_deletes_homectl_links(self, d): 357 | d.packages = set([hc.Package(self.FULL)]) 358 | 359 | self.check_links( 360 | ('.homectl/common/bin/hello', 'package-full.hcpkg/bin/hello'), 361 | ('.homectl/Linux/bin/bye', 'package-full.hcpkg/Linux/bin/bye'), 362 | ('.homectl/common/share/package/README.txt', 'package-full.hcpkg/share/package/README.txt'), 363 | testdata=True, 364 | ) 365 | 366 | d.packages = set() 367 | 368 | self.check_absence( 369 | '.homectl/common/bin/hello', 370 | '.homectl/Linux/bin/bye', 371 | '.homectl/common/share/package/README.txt', 372 | ) 373 | 374 | @with_deployment 375 | def test_refresh_creates_links(self, d): 376 | self.mk_files('small.hcpkg', 'bin/foo', 'Linux/bin/bar') 377 | d.packages = set([hc.Package('small.hcpkg')]) 378 | self.check_links( 379 | ('.homectl/common/bin/foo', 'small.hcpkg/bin/foo'), 380 | ('.homectl/Linux/bin/bar', 'small.hcpkg/Linux/bin/bar'), 381 | ) 382 | self.check_absence( 383 | '.homectl/Linux/bin/foo', 384 | '.homectl/common/bin/bar', 385 | ) 386 | 387 | self.mk_files('small.hcpkg', 'bin/new') 388 | d.refresh() 389 | self.check_links( 390 | ('.homectl/common/bin/new', 'small.hcpkg/bin/new'), 391 | ) 392 | 393 | @with_deployment 394 | def test_refresh_deletes_links(self, d): 395 | self.mk_files('small.hcpkg', 'bin/foo', 'Linux/bin/bar') 396 | d.packages = set([hc.Package('small.hcpkg')]) 397 | self.check_links( 398 | ('.homectl/common/bin/foo', 'small.hcpkg/bin/foo'), 399 | ('.homectl/Linux/bin/bar', 'small.hcpkg/Linux/bin/bar'), 400 | ) 401 | self.check_absence( 402 | 'small.hcpkg/Linux/bin/foo', 403 | 'small.hcpkg/bin/bar', 404 | ) 405 | 406 | os.unlink(pj('small.hcpkg', 'bin', 'foo')) 407 | d.refresh() 408 | self.check_absence('.homectl/common/bin/foo') 409 | 410 | @with_deployment 411 | def test_overlay_create_links(self, d): 412 | self.mk_files('overlay.hcpkg', 'overlay/.mycfg', 'overlay/.config/my') 413 | d.packages = set([hc.Package('overlay.hcpkg')]) 414 | self.check_links( 415 | ('.homectl/common/overlay/.mycfg', 'overlay.hcpkg/overlay/.mycfg'), 416 | ('.homectl/common/overlay/.config/my', 'overlay.hcpkg/overlay/.config/my'), 417 | ('.mycfg', 'overlay.hcpkg/overlay/.mycfg'), 418 | ('.config/my', 'overlay.hcpkg/overlay/.config/my'), 419 | ) 420 | 421 | @with_deployment 422 | def test_overlay_delete_links_on_refresh(self, d): 423 | self.mk_files('overlay.hcpkg', 'overlay/.mycfg', 'overlay/.config/my') 424 | d.packages = set([hc.Package('overlay.hcpkg')]) 425 | self.check_links( 426 | ('.homectl/common/overlay/.mycfg', 'overlay.hcpkg/overlay/.mycfg'), 427 | ('.homectl/common/overlay/.config/my', 'overlay.hcpkg/overlay/.config/my'), 428 | ('.mycfg', 'overlay.hcpkg/overlay/.mycfg'), 429 | ('.config/my', 'overlay.hcpkg/overlay/.config/my'), 430 | ) 431 | 432 | os.unlink(os.path.join('overlay.hcpkg', 'overlay', '.mycfg')) 433 | d.refresh() 434 | self.check_absence( 435 | '.homectl/common/overlay/.mycfg', 436 | '.mycfg', 437 | ) 438 | 439 | @with_deployment 440 | def test_overlay_doesnt_touch_user_files(self, d): 441 | self.mk_files('overlay.hcpkg', 'overlay/.mycfg') 442 | self.mk_files('.', '.mycfg') 443 | 444 | d.packages = set([hc.Package('overlay.hcpkg')]) 445 | self.assertTrue(os.path.exists('.mycfg')) 446 | self.assertFalse(os.path.islink('.mycfg')) 447 | 448 | d.packages = set() 449 | self.assertTrue(os.path.exists('.mycfg')) 450 | 451 | @with_deployment 452 | def test_overlay_replaces_user_files_if_forced(self, d): 453 | self.mk_files('overlay.hcpkg', 'overlay/.mycfg') 454 | self.mk_files('.', '.mycfg') 455 | self.assertTrue(os.path.exists('.mycfg')) 456 | self.assertFalse(os.path.islink('.mycfg')) 457 | 458 | d.set_packages(set([hc.Package('overlay.hcpkg')]), force_replace=True) 459 | self.assertTrue(os.path.exists('.mycfg')) 460 | self.assertTrue(os.path.islink('.mycfg')) 461 | 462 | d.packages = set() 463 | self.assertFalse(os.path.exists('.mycfg')) 464 | 465 | @with_deployment 466 | def test_trigger_pwd(self, d): 467 | os.mkdir('trigger.hcpkg') 468 | # XXX This is a UNIX-ism 469 | with open(pj('trigger.hcpkg', '_trigger'), 'w') as f: 470 | f.write('#!/bin/sh\npwd > %s\n' % pj(self.dir, 'triggered')) 471 | os.chmod(pj('trigger.hcpkg', '_trigger'), 0o755) 472 | 473 | pkg = hc.Package('trigger.hcpkg') 474 | d.packages = set([pkg]) 475 | 476 | with open(pj(self.dir, 'triggered')) as f: 477 | self.assertEqual(pkg.path, f.readline().strip()) 478 | 479 | @with_deployment 480 | def test_refresh_trigger(self, d): 481 | os.mkdir('trigger.hcpkg') 482 | # XXX This is a UNIX-ism 483 | with open(pj('trigger.hcpkg', '_trigger'), 'w') as f: 484 | f.write('#!/bin/sh\n[ "$1" = refresh ] && touch triggered\n') 485 | os.chmod(pj('trigger.hcpkg', '_trigger'), 0o755) 486 | 487 | pkg = hc.Package('trigger.hcpkg') 488 | sentinel = pj(pkg.path, 'triggered') 489 | 490 | d.packages = set([pkg]) 491 | 492 | # Trigger should have run 493 | self.assertTrue(os.path.exists(sentinel)) 494 | 495 | # Triggers should not be linked into ~/.homectl 496 | self.check_absence( 497 | '.homectl/_trigger', 498 | '.homectl/common/_trigger', 499 | ) 500 | 501 | @with_deployment 502 | def test_disable_trigger(self, d): 503 | os.mkdir('trigger.hcpkg') 504 | # XXX This is a UNIX-ism 505 | with open(pj('trigger.hcpkg', '_trigger'), 'w') as f: 506 | f.write('#!/bin/sh\n[ "$1" = disable ] && touch triggered\n') 507 | os.chmod(pj('trigger.hcpkg', '_trigger'), 0o755) 508 | 509 | pkg = hc.Package('trigger.hcpkg') 510 | sentinel = pj(pkg.path, 'triggered') 511 | 512 | d.packages = set([pkg]) 513 | 514 | # Shouldn't have run yet 515 | self.assertFalse(os.path.exists(sentinel)) 516 | 517 | # Triggers should not be linked into ~/.homectl 518 | self.check_absence( 519 | '.homectl/_trigger', 520 | '.homectl/common/_trigger', 521 | ) 522 | 523 | d.packages = set() 524 | 525 | # Trigger should have run 526 | self.assertTrue(os.path.exists(sentinel)) 527 | 528 | @with_deployment 529 | def test_hook_tree(self, d): 530 | plat = os.uname()[0] 531 | d.packages = set([hc.Package(self.FULL)]) 532 | 533 | self.assertEqual( 534 | set(d.hook_tree('bin', 'h*')), 535 | set([os.path.abspath(f) for f in [ 536 | pj('.homectl', 'common', 'bin', 'hello') 537 | ]])) 538 | self.assertEqual( 539 | set(d.hook_tree('bin', 'b*')), 540 | set([os.path.abspath(f) for f in [ 541 | pj('.homectl', plat, 'bin', 'bye') 542 | ]])) 543 | 544 | 545 | 546 | class InitTest(HomectlTest): 547 | # Tests for creating new homectl deployments. 548 | pass 549 | 550 | 551 | 552 | class CLITest(HomectlTest): 553 | # System-level tests for CLI commands themselves. 554 | pass 555 | 556 | 557 | 558 | if __name__ == '__main__': # pragma: no branch 559 | unittest.main() 560 | -------------------------------------------------------------------------------- /doc/customization.asciidoc: -------------------------------------------------------------------------------- 1 | How to Customize homectl 2 | ======================== 3 | :toc: 4 | 5 | So you've installed homectl according to the README instructions; now what? How 6 | do you apply it to your setup with a minimum of fuss? 7 | 8 | This document describes how to create your own packages for homectl, or 9 | otherwise extend it to your liking. We'll start by defining some key concepts, 10 | then dig into how homectl packages are constructed, and finally discuss various 11 | extension points ("hooks" and "triggers") that are available for you to use in 12 | the construction of your own packages. 13 | 14 | 15 | 16 | Key Concepts 17 | ------------ 18 | 19 | Before we begin, you should know a few important things: 20 | 21 | . Everything you keep in homectl lives in a *package*, without exception. 22 | Packages provide functionality (including the +hc+ command itself) for a 23 | specific purpose. 24 | 25 | . Typically, packages live in your homectl git repository, which is called 26 | your *setup*. 27 | 28 | . You use the +hc+ command to *enable* and *disable* packages, or *refresh* 29 | your home directory after you make changes to a package. 30 | 31 | When you first `hc enable` a package, homectl symlinks the contents of the 32 | package into +~/.homectl+. Files from all your enabled packages will be mixed 33 | together under this directory. Then, files from your package's *overlay* 34 | (discussed later) will be linked directly into your home directory. 35 | 36 | When you `hc disable` a package, its files are unlinked from your home directory 37 | and from +~/.homectl+, thus undoing the effects of `hc enable`. 38 | 39 | As you modify your packages by adding/deleting/renaming files, you can use `hc 40 | refresh` to re-scan all your enabled packages and update your home directory and 41 | +~/.homectl+. 42 | 43 | 44 | 45 | Splitting Your Stuff Up 46 | ----------------------- 47 | 48 | Before we talk about packages themselves, it's worth taking a moment to think 49 | about how you want to organize your stuff. Consider splitting your stuff along 50 | these lines: 51 | 52 | * What it does (e.g. emacs vs. screen vs. ssh-agent vs. ...). 53 | * What hat you're wearing (e.g. work, personal, charity). 54 | * How often you use it (keep rarely-used things out of your environment). 55 | 56 | Don't worry about platform compatibility at this stage (e.g. "this only works on Linux"). As we'll see later, homectl can automatically include/ignore content based on platform. 57 | 58 | .The Author's homectl Packages 59 | ================================================================================ 60 | dev.hcpkg:: 61 | Things related to software development. This includes scripts, Emacs 62 | packages and tweaks, etc. 63 | 64 | emacs-ux.hcpkg:: 65 | Themes, usability customizations and other enhancements for Emacs. 66 | 67 | editor.hcpkg:: 68 | Script for finding and starting an editor, depending on what environment 69 | he's working in (GUI, text, ...). 70 | 71 | fs.hcpkg:: 72 | Various scripts for working with lots of files (renaming, moving, etc.) 73 | 74 | mail.hcpkg:: 75 | Various scripts for processing and archiving e-mail. 76 | 77 | music.hcpkg:: 78 | Scripts for manipulating the author's music collection. 79 | 80 | oh-my-zsh.hcpkg:: 81 | A set of extensions to zsh maintained by robbyrussell, plus configuration. 82 | 83 | root.hcpkg:: 84 | Scripts needed only by the root user (e.g. backup scripts). 85 | 86 | screen.hcpkg:: 87 | .screenrc 88 | 89 | ssh-agent.hcpkg:: 90 | Ensures an ssh-agent is available in the author's shell. 91 | 92 | work.hcpkg:: 93 | Scripts and configuration files relevant only to the author's employment. 94 | 95 | zsh.hcpkg:: 96 | Shell customizations (on top of oh-my-zsh). 97 | ================================================================================ 98 | 99 | 100 | 101 | Anatomy of a Package 102 | -------------------- 103 | 104 | homectl packages are just directories with a +.hcpkg+ extension. To create a 105 | new, empty package, all you need to do is: 106 | 107 | -------------------------------------------------------------------------------- 108 | $ mkdir my-stuff.hcpkg 109 | -------------------------------------------------------------------------------- 110 | 111 | The contents of a homectl package are broken down by *system* and by *hook*. 112 | 113 | A *system* is a particular environment, such as a machine with a particular 114 | hostname, a particular OS, or a particular OS/CPU-architecture combination. 115 | Except for the default +common+ system, which is always available no matter what 116 | machine you're on, system names begin with an upper-case letter. 117 | 118 | A *hook* is an extension point. Hooks are typically given well-known names, 119 | such as +bin+, +lib+ or +emacs+. Hook names always begin with a lower-case 120 | letter. 121 | 122 | All files in a homectl package belong to a particular system and hook. 123 | Except for the special +common+ system, all files live in a directory hierarchy 124 | inside the package which follows the pattern: 125 | 126 | -------------------------------------------------------------------------------- 127 | example.hcpkg/$System/$hook/my-file.txt 128 | -------------------------------------------------------------------------------- 129 | 130 | Files in the +common+ system can omit the +$System+ directory entirely: 131 | 132 | -------------------------------------------------------------------------------- 133 | example.hcpkg/$hook/my-file.txt # this file is in the "common" system 134 | -------------------------------------------------------------------------------- 135 | 136 | Any system or hook whose name begins with +_+ is ignored by homectl. So you can 137 | put stuff which you don't want linked into +~/.homectl+ into one of these 138 | directories. Note that this does not apply to nested directories underneath 139 | hooks/systems; here are some examples: 140 | 141 | -------------------------------------------------------------------------------- 142 | example.hcpkg/_data # not linked into ~/.homectl 143 | example.hcpkg/foo/_data # will be linked into ~/.homectl 144 | example.hcpkg/Linux/_data # not linked 145 | example.hcpkg/Linux/foo/_data # will be linked 146 | -------------------------------------------------------------------------------- 147 | 148 | .Layout of an example homectl package 149 | ================================================================================ 150 | -------------------------------------------------------------------------------- 151 | example.hcpkg/ 152 | bin/ <--- hook (in the "common" system) 153 | my-script 154 | 155 | emacs-startup/ 156 | my-settings.el 157 | 158 | Linux/ <--- system 159 | bin/ <--- system-specific hook 160 | my-binary 161 | lib/ 162 | libfoo.so 163 | ... 164 | 165 | _*/ <--- Directories matching this pattern are ignored 166 | 167 | [A-Z]*/ <--- this is what a system name looks like 168 | [a-z]*/ <--- this is what a hook name looks like 169 | ... 170 | _*/ <--- A sytem-specific ignored directory 171 | 172 | *.trigger <--- Trigger files will be discussed later 173 | _trigger 174 | -------------------------------------------------------------------------------- 175 | ================================================================================ 176 | 177 | Linking into +~/.homectl+ 178 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 179 | 180 | When a package is enabled, homectl symlinks the contents of each package into 181 | +~/.homectl+, following the pattern: +~/.homectl/$SYSTEM/$HOOK+. From the 182 | example above, if you were to create a script +example.hcpkg/bin/my-script+, 183 | homectl would create the following link in +~/.homectl+: 184 | 185 | -------------------------------------------------------------------------------- 186 | ~/.homectl/common/bin/my-script -> path/to/example.hcpkg/bin/my-script 187 | -------------------------------------------------------------------------------- 188 | 189 | Note that unlike in the package, the +common+ system is explicit here; this is 190 | so homectl can place its configuration files directly under +~/.homectl+ 191 | without fear of name clashes. 192 | 193 | Similarly, if you were to place a file under a system-specific hook, you would 194 | see a symlink like so: 195 | 196 | -------------------------------------------------------------------------------- 197 | ~/.homectl/Linux/bin/my-binary -> path/to/example.hcpkg/Linux/bin/my-binary 198 | -------------------------------------------------------------------------------- 199 | 200 | Symlinks created by homectl use relative paths when they are within your home 201 | directory, and absolute paths otherwise. This is done to accommodate a user 202 | whose home directory may be in different locations on different systems. 203 | 204 | Subdirectories Inside Hooks 205 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 206 | 207 | You may also place files in subdirectories inside a hook. Those files will be 208 | individually linked into +~/.homectl+. This allows you to build entire trees 209 | with files pulled from different packages. For example, a binary package may 210 | place manpages under +share/man/manX+: 211 | 212 | -------------------------------------------------------------------------------- 213 | $ hc enable my-stuff.hcpkg 214 | update /home/me/.homectl/enabled-pkgs 215 | mkdir -p /home/me/.homectl/common/share/man/man1 216 | ln -s ../../../home-setup/my-stuff.hcpkg/share/man/man1/foo.1 /home/me/.homectl/common/share/man/man1/foo.1 217 | ln -s ../../../home-setup/my-stuff.hcpkg/share/man/man1/bar.1 /home/me/.homectl/common/share/man/man1/bar.1 218 | -------------------------------------------------------------------------------- 219 | 220 | The special +overlay+ hook 221 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 222 | 223 | The special +overlay+ hook contains files that will be linked directly into your 224 | home directory when you enable the package. Typically, you would place 225 | dot-files here (e.g. +.vimrc+, +.screenrc+, etc.). 226 | 227 | The +overlay+ hook is only special in the +common+ system -- that is, 228 | system-specific overlay hooks will not be linked into your home directory. 229 | 230 | .Placing a .screenrc into a homectl Package 231 | ================================================================================ 232 | -------------------------------------------------------------------------------- 233 | $ mkdir screen.hcpkg 234 | $ mkdir screen.hcpkg/overlay 235 | $ touch screen.hcpkg/overlay/.screenrc 236 | 237 | $ hc enable screen.hcpkg 238 | ... 239 | ln -s ../../../home-setup/screen.hcpkg/overlay/.screenrc /home/me/.screenrc 240 | ... 241 | -------------------------------------------------------------------------------- 242 | 243 | If you place a +.screenrc+ into the overlay, +hc enable+ will link it into your 244 | home directory automatically. 245 | ================================================================================ 246 | 247 | As with subdirectories inside other hooks, only individual files are linked into 248 | your home directory; if you create a directory inside +overlay/+, a separate 249 | directory will be created in +~+, and the files inside the overlay will be 250 | linked into that directory. 251 | 252 | .Subdirectories in the Overlay 253 | ================================================================================ 254 | -------------------------------------------------------------------------------- 255 | $ mkdir unison.hcpkg 256 | $ mkdir unison.hcpkg/overlay 257 | $ mkdir unison.hcpkg/overlay/.unison 258 | $ touch unison.hcpkg/overlay/.unison/default.prf 259 | 260 | $ hc enable unison.hcpkg 261 | ... 262 | mkdir -p /home/me/.unison 263 | ln -s ../home-setup/unison.hcpkg/overlay/.unison/default.prf /home/me/.unison/default.prf 264 | ... 265 | -------------------------------------------------------------------------------- 266 | 267 | homectl will create the directory if it doesn't already exist, and place a 268 | symlink in that directory. We can also see that homectl has adjusted the 269 | symlink's target path to account for the fact that it lives in a subdirectory. 270 | ================================================================================ 271 | 272 | Exercise: Creating a Package for tmux 273 | ------------------------------------- 274 | 275 | Do you have a configuration file (or set of files) you'd like to keep in 276 | homectl? Now is a good time to try creating your own package. Let's take 277 | +tmux+ as an example; it keeps a configuration file in +~/.tmux.conf+. 278 | 279 | . Create a package for your tmux configuration: 280 | 281 | $ cd my-homectl-setup 282 | $ mkdir tmux.hcpkg 283 | 284 | . Enable your new tmux package: 285 | 286 | $ hc enable tmux.hcpkg # You can also shorten this to just "hc en". 287 | 288 | . Create an overlay directory to hold your +.tmux.conf+: 289 | 290 | $ mkdir tmux.hcpkg/overlay 291 | 292 | . Move your +.tmux.conf+ into your homectl package: 293 | 294 | $ mv ~/.tmux.conf tmux.hcpkg/overlay/ 295 | $ git add tmux.hcpkg/overlay/.tmux.conf 296 | $ git commit -m "Add my tmux.conf" 297 | 298 | . Now, refresh your home directory so that homectl will re-scan your enabled 299 | packages and find your +.tmux.conf+: 300 | 301 | $ hc refresh # or "hc ref", for short 302 | $ update /home/me/.homectl/enabled-pkgs 303 | $ ln -s ../../../my-homectl-setup/tmux.hcpkg/overlay/.tmux.conf 304 | /home/me/.homectl/common/overlay/.tmux.conf 305 | $ ln -s .homectl/common/overlay/.tmux.conf /home/me/.tmux.conf 306 | 307 | . Notice that homectl created two symlinks: one from +~/.homectl+ to your 308 | +.tmux.conf+, and one from +~/.tmux.conf+ to +~/.homectl+. 309 | 310 | 311 | 312 | Finding Things in Enabled Packages 313 | ---------------------------------- 314 | 315 | Often, you will want to write code that searches through all enabled packages looking for things. The +hc path+ and +hc tree+ commands help you do this by finding directories and/or files which pertain to specific hooks on the current system. 316 | 317 | +hc path+: Finding Hook Directories 318 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 319 | 320 | You can use the +hc path+ command to generate a list of directories to search 321 | for a particular hook. +hc path+ will always return hook directories in the 322 | +common+ system, as well as hooks in other available systems (as outlined in the 323 | "Systems" reference later in this document). 324 | 325 | For example, if you want to find all the available +bin+ 326 | hooks on the current system, you might do this: 327 | 328 | -------------------------------------------------------------------------------- 329 | $ hc path bin 330 | /home/me/.homectl/common/bin:/home/me/.homectl/Linux/bin 331 | -------------------------------------------------------------------------------- 332 | 333 | You can even use +hc path+ to update environment variables: 334 | 335 | -------------------------------------------------------------------------------- 336 | $ echo $PATH 337 | /usr/bin:/bin 338 | 339 | $ hc path bin PATH 340 | /home/me/.homectl/common/bin:/home/me/.homectl/Linux/bin:/usr/bin:/bin 341 | -------------------------------------------------------------------------------- 342 | 343 | If duplicates are present, +hc path+ will helpfully remove them: 344 | 345 | -------------------------------------------------------------------------------- 346 | $ echo $PATH 347 | /home/me/.homectl/common/bin:/home/me/.homectl/Linux/bin:/usr/bin:/bin 348 | 349 | $ hc path bin PATH 350 | /home/me/.homectl/common/bin:/home/me/.homectl/Linux/bin:/usr/bin:/bin 351 | -------------------------------------------------------------------------------- 352 | 353 | +hc tree+: Finding Specific Files 354 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 355 | 356 | You can use +hc tree+ to look for specific files inside hooks. For example, if 357 | you want to find all +*.el+ files in the +emacs-startup+ hook, you might do 358 | this: 359 | 360 | -------------------------------------------------------------------------------- 361 | $ hc tree emacs-startup '*.el' 362 | /home/me/.homectl/common/emacs-startup/commit-message-mode.el 363 | /home/me/.homectl/common/emacs-startup/graphviz-dot-mode.el ... 364 | -------------------------------------------------------------------------------- 365 | 366 | Formatting +hc path+ and +hc tree+ Output 367 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 368 | 369 | Both +hc path+ and +hc tree+ take a number of different command-line options 370 | which can be used to format their output in a more favorable way for processing 371 | by other tools. See each command's +--help+ for further details. 372 | 373 | 374 | 375 | Reference: Loader Packages 376 | -------------------------- 377 | 378 | Some packages (called *loader packages*) exist only to provide a way for other 379 | packages to insert their functionality into your environment. For example, 380 | homectl comes with the +loader-bash.hcpkg+ package. +loader-bash.hcpkg+ 381 | replaces your existing +.bashrc+ and +.bash_profile+ with scripts that do 382 | nothing but load +bash+ customizations from all your enabled homectl packages. 383 | 384 | homectl includes several loader packages by default, to help get you started and 385 | to ease the task of breaking up your configuration into packages. You should 386 | choose the ones that apply to you and enable them with: 387 | 388 | -------------------------------------------------------------------------------- 389 | $ hc enable homectl/loader-foo.hcpkg 390 | -------------------------------------------------------------------------------- 391 | 392 | +loader-bash.hcpkg+ and +loader-zsh.hcpkg+ 393 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 394 | 395 | These packages replace your shell's standard profile and rc-files with stubs 396 | that search through your enabled homectl packages and load any shell 397 | customizations they find (environment variables, functions, shell scripts, 398 | snazzy prompts, etc.). 399 | 400 | They look for shell customizations in the +shell-env+ and +shell-rc+ hooks, and load anything they find there. +loader-bash.hcpkg+ will look for files named +*.sh+ or +*.bash+, while +loader-zsh.hcpkg+ will look for files named +*.sh+ or +*.zsh*. 401 | 402 | As the name implies, +shell-env/*+ files are generally expected to contain 403 | environment variable or other settings that could apply to both interactive and 404 | non-interactive shells. They should not produce any output, nor expect any 405 | input. They may be run even as part of shell scripts, so it's best to keep them 406 | as small as possible. 407 | 408 | +shell-rc/*+, on the other hand, contain things one might use while sitting at a 409 | shell prompt. This would be a good place to change your prompt, set up an 410 | +ssh-agent+, or add shell aliases. 411 | 412 | If the loader sees a +bin/+ hook inside your package, that directory will 413 | be automatically added to your PATH. Similarly, +lib/+, +lib64/+, etc. are 414 | added to your linker path. This helps you to package 3rd-party programs for use 415 | in homectl with a minimum of fuss. 416 | 417 | +loader-emacs.hcpkg+ 418 | ~~~~~~~~~~~~~~~~~~~~ 419 | 420 | The Emacs loader replaces your +~/.emacs+ file with a script that loads Emacs 421 | packages and customizations from your enabled homectl packages. It also 422 | provides a convenient way to download and install +package.el+ packages from 423 | third-party sources. 424 | 425 | You can customize your Emacs by writing small a Emacs package (just a +foo.el+ 426 | file with +(provide 'foo)+ at the end) and placing it in the +emacs-startup/+ 427 | hook. 428 | 429 | 430 | 431 | Reference: Systems 432 | ------------------ 433 | 434 | +hc path+, +hc files+ and related commands will search for hooks in the 435 | following "system" subdirectories of +~/.homectl+: 436 | 437 | * +common+ 438 | * +$system+ 439 | ** e.g. +Linux+, +Darwin+ 440 | * +$system-$arch+ 441 | ** e.g. +Linux-i686+, +Darwin-x86_64+ 442 | * +$system-$release+ 443 | ** e.g. +Linux-2.6.32+, +Darwin-13.3.0+ 444 | * +$system-$release-$arch+ 445 | ** e.g. +Linux-2.6.32-i686+, +Darwin-13.3.0-x86_64+ 446 | 447 | 448 | 449 | Reference: Hooks 450 | ---------------- 451 | 452 | The loaders that come with homectl support the following hooks. Any loaders you 453 | write should follow these conventions as well. 454 | 455 | +overlay/+:: 456 | Files in +overlay+ are symlinked directly into your home directory. 457 | _[+common+ system only]_ 458 | 459 | +bin/+:: 460 | Added to +$PATH+. Contains scripts or binaries that belong to this package. 461 | 462 | +lib/+:: 463 | +lib64/+:: 464 | +lib32/+:: 465 | Added to +$LD_LIBRARY_PATH+, +$DYLD_LIBRARY_PATH+ or the equivalent on your 466 | platform. Contains libraries used by binaries in this package. 467 | 468 | +emacs/+:: 469 | Added to Emacs's +load-path+. You can place Emacs packages here and they 470 | will be accessible with +(require)+. 471 | 472 | +emacs-startup/+:: 473 | Added to Emacs's +load-path+. You can place Emacs packages here and they 474 | will be automatically loaded with +(require)+ at startup. 475 | 476 | +shell-env/+:: 477 | Defines shell environment variables and other settings which apply to both 478 | interactive and non-interactive shells. Files in this hook should have an 479 | extension which matches the shell (e.g. +\*.sh+, +*.bash+, etc.) 480 | 481 | +shell-rc/+:: 482 | Defines shell aliases, functions, prompts, and other settings which apply to 483 | interactive shells. Files in this hook should have an extension which 484 | matches the shell (e.g. +\*.sh+, +*.bash+, etc) 485 | 486 | 487 | 488 | Reference: Triggers 489 | ------------------- 490 | 491 | If simple symlinking isn't enough to deploy a package, homectl provides a way to 492 | run programs when packages are enabled (actually, refreshed) or disabled. Just 493 | create a program named +_trigger+ in the top-level package directory. 494 | 495 | Triggers are currently not system-specific, so they should be written in such a 496 | way as to apply to all platforms. They are also non-interactive (user input is 497 | not supported). 498 | 499 | Triggers are run with the trigger name as their sole parameter (for example: 500 | +_trigger build+). They are always run from the top-level +*.hcpkg+ directory 501 | (so +pwd+ or the equivalent will tell you where your package lives). 502 | 503 | The following triggers are supported: 504 | 505 | +build+:: 506 | Prior to symlinking the contents of a package into +~/.homectl+, the +build+ 507 | trigger is run. This trigger may create generated files inside the package 508 | itself, and homectl will pick up those files and symlink them. When a 509 | package is enabled, or when an already-enabled package is updated via +hc 510 | refresh+, homectl will look in the top level of each package directory for a 511 | file called +refresh.trigger+. If this file exists and is executable, 512 | homectl will run it after symlinking the package's files into +~/.homectl+. 513 | 514 | +refresh+:: 515 | After a package has been symlinked into +~/.homectl+, the +refresh+ trigger 516 | is run. If +refresh.trigger+ modifies any of the files in the homectl 517 | package, homectl will **not** notice this until the next time +hc refresh+ 518 | is run. 519 | 520 | +disable+:: 521 | Before a package is disabled and unlinked from +~/.homectl+, the +disable+ 522 | trigger is run. 523 | 524 | +clean+:: 525 | After a package is disabled and has been unlinked from +~/.homectl+, the 526 | +clean+ trigger is run. This is the place to remove build artifacts and do 527 | general cleanup of the package directory, if required. 528 | 529 | Here are a couple examples showing how to write shell scripts and/or makefiles 530 | to be run as triggers. 531 | 532 | Example: Shell Script 533 | ~~~~~~~~~~~~~~~~~~~~~ 534 | 535 | -------------------------------------------------------------------------------- 536 | #!/bin/sh 537 | 538 | case "$1" in 539 | build) 540 | # Things to do prior to enabling/refreshing the package 541 | ;; 542 | refresh) 543 | # Things to do when the package has just been refreshed/enabled 544 | ;; 545 | disable) 546 | # Things to do before the package is disabled 547 | ;; 548 | clean) 549 | # Things to do to cleanup after the package is disabled 550 | ;; 551 | esac 552 | -------------------------------------------------------------------------------- 553 | 554 | Example: Makefile 555 | ~~~~~~~~~~~~~~~~~ 556 | 557 | -------------------------------------------------------------------------------- 558 | #!/usr/bin/env make -f 559 | 560 | # Put this file in the top level of your package's directory, name it 561 | # "_trigger", and make it executable. 562 | 563 | build: 564 | # Things to do prior to enabling/refreshing the package 565 | 566 | refresh: 567 | # Things to do when the package has just been refreshed/enabled 568 | 569 | disable: 570 | # Things to do before the package is disabled 571 | 572 | clean: 573 | # Things to do to cleanup after the package is disabled 574 | 575 | .PHONY: build refresh disable clean 576 | 577 | -------------------------------------------------------------------------------- 578 | -------------------------------------------------------------------------------- /homectl.hcpkg/python/homectl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | import re 6 | import subprocess 7 | import collections 8 | from optparse import OptionParser, OptionGroup 9 | import fnmatch 10 | import shutil 11 | 12 | # If we are imported as a module, this is our API. 13 | __all__ = [ 14 | 'HOME', 'HOMECTL_DIR', 'DEFAULT_HOMECTL_URL', 15 | 'System', 'ConsoleSystem', 16 | 'Deployment', 'Package', 17 | 'main', 18 | 19 | 'HomectlError', 'PackageError', 20 | ] 21 | 22 | 23 | VERSION = '0.3' 24 | 25 | # System defaults that are hard to change. 26 | CMD_NAME = 'hc' 27 | HOME = os.environ['HOME'] 28 | CFG_DIR = os.environ.get('HOMECTL_DIR', os.path.join(HOME, '.homectl')) 29 | DEFAULT_HOMECTL_URL = "https://github.com/josh-berry/homectl.git" 30 | DEFAULT_HOMECTL_PKGS = ['homectl.hcpkg', 31 | 'loader-bash.hcpkg', 'loader-zsh.hcpkg'] 32 | 33 | ENABLED_LIST = 'enabled-pkgs' 34 | 35 | # Errors 36 | class HomectlError(Exception): pass 37 | class PackageError(HomectlError): pass 38 | 39 | # Util functions 40 | 41 | if sys.version_info[0] == 2: # pragma: no cover 42 | def iteritems(coll): return coll.iteritems() 43 | elif sys.version_info[0] == 3: 44 | def iteritems(coll): return coll.items() 45 | else: # pragma: no cover 46 | raise RuntimeError("Don't know what version of Python this is!") 47 | 48 | def mkdirp(path): 49 | if path == '': return 50 | if not os.path.isdir(path): 51 | os.makedirs(path) 52 | 53 | def visible_dirs(path): 54 | # Lists all the visible directories, or symlinks to directories, within 55 | # /path/. Yields a series of ("name", "path/name") tuples. 56 | for d in os.listdir(path): 57 | if d[0] == '.': continue 58 | p = os.path.join(path, d) 59 | if os.path.isdir(p): 60 | yield d, p 61 | 62 | def visible_links(path): 63 | # Lists all the visible symlinks within /path/. Yields a series of 64 | # ("name", "path/name") tuples. 65 | for d in os.listdir(path): 66 | if d[0] == '.': continue 67 | p = os.path.join(path, d) 68 | if os.path.islink(p): 69 | yield d, p 70 | 71 | def fs_tree(path): 72 | # List all the files, directories, etc. in /path/, recursively. 73 | # 74 | # As a convenience, for each entry, yields a tuple of two paths: 75 | # 76 | # (relative_to_/path/, abs_path) 77 | 78 | if not os.path.exists(path): 79 | return 80 | 81 | for ent in os.listdir(path): 82 | entp = os.path.join(path, ent) 83 | 84 | yield ent, entp 85 | 86 | # We treat symlinks as regular files to avoid directory loops, and 87 | # because that's generally what we want in homectl packages 88 | if os.path.isdir(entp) and not os.path.islink(entp): 89 | for relp, absp in fs_tree(entp): 90 | yield os.path.join(ent, relp), absp 91 | 92 | def fs_files_in(path): 93 | # List all the files in /path/. /path/ is expected to be a directory; if it 94 | # is not, returns no values. Assumes that all symlinks are files. 95 | return ((relp, absp) for relp, absp in fs_tree(path) 96 | if os.path.islink(absp) or not os.path.isdir(absp)) 97 | 98 | def sh_quote(text): 99 | # Quotes /text/ so it will be interpreted by shells as a single argument. 100 | # Takes a paranoid approach -- things that are not known to be safe are 101 | # escaped. 102 | 103 | bad_chars = re.compile("[^A-Za-z0-9_ .,/+-]") 104 | 105 | def escape_char(c): 106 | if c == "\n": return 'n' 107 | if c == "\r": return 'r' 108 | if c == "\t": return 't' 109 | if c == "\0": return '0' 110 | if ord(c) < 128: return c 111 | raise ValueError('Invalid character %d' % ord(c)) 112 | 113 | text = bad_chars.sub((lambda m: "\\" + escape_char(m.group(0))), text) 114 | if ' ' in text: 115 | return "\"%s\"" % text 116 | else: 117 | return text 118 | 119 | 120 | 121 | class Package(object): 122 | # A Package represents a single homectl package. Packages contain files for 123 | # specific "systems" and "hooks". homectl packages are stored as 124 | # directories in the filesystem with a ".hcpkg" extension, organized as 125 | # described below. 126 | # 127 | # A "system" is analagous to a type of computer system, e.g. a Linux system, 128 | # an Linux-x86_64 system, or a machine with a particular hostname. The 129 | # special "common" system will be available on all machines, regardless of 130 | # their type. Except for the special "common" system, all system names must 131 | # begin with an uppercase letter [A-Z]. 132 | # 133 | # A "hook" is a special directory where files from many different packages 134 | # reside together in a common location. One example of a hook is "bin" -- 135 | # executable programs from many different packages may be placed in a common 136 | # "bin" directory (such as /usr/bin or ~/bin), so they can be found in a 137 | # single, well-known place. All hook names must begin with a lowercase 138 | # letter [a-z]. 139 | # 140 | # Files in a homectl package are typically organized by system, then hook. 141 | # For example, a binary which runs on Linux x86_64 systems will be placed in 142 | # the foo.hcpkg/Linux-x86_64/bin subdirectory. 143 | # 144 | # The special "common" system does not have its own directory; hooks for the 145 | # "common" system are placed directly inside the package (e.g. shell scripts 146 | # would go under foo.hcpkg/bin). 147 | # 148 | # In the "common" system only, there is a special "overlay" hook which 149 | # contains files that are linked directly into the user's home directory 150 | # (for example, a .emacs or a .vimrc). Like other "common" hooks, these 151 | # files go directly under foo.hcpkg/overlay. 152 | 153 | def __init__(self, path): 154 | self.path = os.path.abspath(path) 155 | 156 | if not self.path.endswith('.hcpkg'): 157 | raise PackageError("%s: Not a homectl package" % self.path) 158 | 159 | def __eq__(self, other): 160 | return os.path.realpath(self.path) == os.path.realpath(other.path) 161 | 162 | def __ne__(self, other): 163 | return not (self == other) 164 | 165 | def __lt__(self, other): 166 | return os.path.realpath(self.path) < os.path.realpath(other.path) 167 | 168 | def __hash__(self): 169 | return hash(os.path.realpath(self.path)) 170 | 171 | @property 172 | def systems(self): 173 | yield 'common' 174 | for name, path in visible_dirs(self.path): 175 | if re.match('^[A-Z].*', name): 176 | yield name 177 | 178 | def _system_dir(self, system): 179 | if system == 'common': 180 | return self.path 181 | else: 182 | return os.path.join(self.path, system) 183 | 184 | def _hook_dir(self, system, hook): 185 | return os.path.join(self._system_dir(system), hook) 186 | 187 | def hooks_in_system(self, system): 188 | # Yields a list of hooks present in the specified system. If the 189 | # requested system doesn't exist, doesn't yield anything. 190 | d = self._system_dir(system) 191 | if not os.path.isdir(d): return 192 | 193 | for name, path in visible_dirs(d): 194 | if not re.match('^[A-Z_].*', name): 195 | yield name 196 | 197 | def files_in_sys_hook(self, system, hook): 198 | # Yields a list of files present in the specified system/hook, 199 | # recursively, in the style of fs_files_in(). If the requested system 200 | # and/or hook doesn't exist, doesn't yield anything. 201 | d = self._hook_dir(system, hook) 202 | if not os.path.isdir(d): return [] 203 | return fs_files_in(d) 204 | 205 | # 206 | # Derived Functionality 207 | # 208 | 209 | def file_map(self, systems=None, hooks=None): 210 | # For all hooks and systems in this package, return a list of tuples: 211 | # 212 | # ( (system, hook, hook_file), "/full/path/to/file/in/pkg" ) 213 | # 214 | # for each file that is present in this package. 215 | 216 | for s in self.systems: 217 | if systems and s not in systems: continue 218 | 219 | for h in self.hooks_in_system(s): 220 | if hooks and h not in hooks: continue 221 | 222 | for f, fpath in self.files_in_sys_hook(s, h): 223 | yield (s, h, f), fpath 224 | 225 | 226 | 227 | class System(object): 228 | # System serves a dual purpose -- it provides an independent interface for 229 | # homectl to interact with the user and the OS, and it provides information 230 | # on what capabilities the current machine/environment supports. 231 | # 232 | # Capability information is provided as a set of system names, such as 233 | # "common", "Linux", "Linux-x86_64", "Host-mymachine", etc. These names 234 | # correspond to the systems in homectl Package objects. homectl packages 235 | # provide functionality for specific systems (or all systems, via the 236 | # "commoon" system), and only those systems supported by the current machine 237 | # are reported by the System.names property. 238 | # 239 | # The System class also provides an interface to the host machine (and thus 240 | # the user) with a few utility functions for logging (log() and log_*()), 241 | # and executing programs and logging/returning the results (run()). 242 | 243 | def __init__(self, pretend=False): 244 | self.pretend = pretend 245 | 246 | @property 247 | def names(self): 248 | system, node, rel, ver, machine = os.uname() 249 | return ['common', 250 | system, 251 | '%s-%s' % (system, machine), 252 | '%s-%s' % (system, rel), 253 | '%s-%s-%s' % (system, rel, machine)] 254 | 255 | def log_cmd(self, *args): 256 | self.log('$ %s' % ' '.join([sh_quote(a) for a in args])) 257 | 258 | def log_warn(self, msg): 259 | self.log('!!! %s' % msg) 260 | 261 | def log_err(self, msg): 262 | self.log('!!! %s' % msg) 263 | 264 | def run(self, *args, **opts): 265 | self.log_cmd(*args) 266 | subprocess.run(args, **opts) 267 | 268 | def update_file(self, path, contents): 269 | self.log_cmd('update', path) 270 | if self.pretend: return 271 | 272 | mkdirp(os.path.dirname(path)) 273 | with open(path + '.tmp', 'w') as f: 274 | f.write(contents) 275 | f.flush() 276 | os.fsync(f) 277 | os.rename(path + '.tmp', path) 278 | # XXX Should make sure permissions, etc. match the old file 279 | 280 | def update_link(self, src, target): 281 | tgt_dir = os.path.dirname(target) 282 | 283 | if os.path.islink(target): 284 | if os.readlink(target) == src: 285 | # Link already in place; nothing to do 286 | return 287 | self.rm_link(target) 288 | 289 | elif os.path.exists(target): 290 | self.log_warn("Won't touch existing file: %s" % target) 291 | return 292 | 293 | self.log_cmd('ln', '-s', src, target) 294 | if not self.pretend: 295 | mkdirp(tgt_dir) 296 | os.symlink(src, target) 297 | 298 | def rm_link(self, path): 299 | if os.path.islink(path): 300 | self.log_cmd("rm", path) 301 | if not self.pretend: 302 | os.unlink(path) 303 | else: 304 | self.log_warn("Can't unlink %s: Not a symbolic link" % path) 305 | 306 | def rm_tree(self, path): 307 | if os.path.exists(path): 308 | self.log_cmd("rm", "-rf", path) 309 | if not self.pretend: 310 | if os.path.islink(path) or not os.path.isdir(path): 311 | os.unlink(path) 312 | else: 313 | shutil.rmtree(path) 314 | 315 | 316 | 317 | class Deployment(object): 318 | # A homectl Deployment is the set of "enabled" or activated packages, as 319 | # deployed into the user's home directory (via symlinks, etc.). 320 | # 321 | # Packages may be enabled/disabled (analagous to installing/uninstalling) by 322 | # modifying the Deployment.packages attribute, or calling enable() or 323 | # disable(). Deployment.packages is always a list of Package objects, 324 | # representing the packages enabled in the user's deployment. 325 | # 326 | # When Deployment.packages is modified (directly or via enable()/disable()), 327 | # the user's deployment is automatically refresh()ed. This updates all the 328 | # symlinks put in place to the user's various packages, removing old ones, 329 | # placing new ones, etc. refresh() may also be called any time a 330 | # previously-enabled package is modified, to ensure the deployment reflects 331 | # all the changes. 332 | # 333 | # The contents of enabled packages are symlinked in the config dir (see 334 | # CFG_DIR). A directory tree under $CFG_DIR is created for all the 335 | # systems and hooks in all the user's enabled packages. Links are 336 | # maintained in the form: 337 | # 338 | # $CFG_DIR/// 339 | # 340 | # Additionally, any files in the "common/overlay" system/hook are linked 341 | # directly underneath the user's home directory. This "overlay" hook is 342 | # used for dot-files and other config files. 343 | # 344 | # The contents of all enabled packages may be queried using the hook_dirs() 345 | # and hook_tree() calls. The former lists all directories for a hook which 346 | # apply to the current system. The latter enumerates all the files within a 347 | # particular hook which apply to the current system. 348 | 349 | def __init__(self, system, homedir=HOME, cfgdir=CFG_DIR): 350 | self.sys = system 351 | 352 | self.homedir = os.path.realpath(homedir) 353 | self.cfgdir = os.path.realpath(cfgdir) 354 | self.enabled_list = PackageListFile( 355 | system, os.path.join(self.cfgdir, ENABLED_LIST), self.homedir) 356 | 357 | @property 358 | def packages(self): 359 | return self.enabled_list.packages 360 | 361 | @packages.setter 362 | def packages(self, pkgs): 363 | self.set_packages(pkgs) 364 | 365 | def set_packages(self, pkgs, force_replace=False): 366 | # Run pre-removal triggers 367 | removing_pkgs = set(self.packages) - set(pkgs) 368 | for pkg in removing_pkgs: self.run_pkg_trigger(pkg, "disable") 369 | 370 | self.enabled_list.packages = pkgs 371 | self.refresh(force_replace=force_replace) 372 | 373 | # Run post-removal triggers 374 | for pkg in removing_pkgs: self.run_pkg_trigger(pkg, "clean") 375 | 376 | def hook_dirs(self, hook): 377 | for s in self.sys.names: 378 | p = os.path.join(self.cfgdir, s, hook) 379 | if os.path.isdir(p): 380 | yield p 381 | 382 | def hook_tree(self, hook, glob=None): 383 | for d in self.hook_dirs(hook): 384 | for r, a in fs_tree(d): 385 | if not glob or fnmatch.fnmatch(r, glob): 386 | yield a 387 | 388 | def run_pkg_trigger(self, pkg, trigger): 389 | tpath = os.path.join(pkg.path, '_trigger') 390 | if os.path.isfile(tpath) and os.access(tpath, os.X_OK): 391 | try: 392 | self.sys.run(tpath, trigger, cwd=pkg.path) 393 | return 0 394 | except subprocess.CalledProcessError as e: 395 | return e.returncode 396 | 397 | def refresh(self, force_replace=False): 398 | # Run pre-refresh triggers so packages can create things in 399 | # homectl-visible directories if necessary. 400 | for p in self.packages: 401 | self.run_pkg_trigger(p, "build") 402 | 403 | link_map = {} # link_path_in_cfgdir: link_text 404 | overlay_links = set() # relative paths under $cfgdir/common/overlay 405 | overlay_rel = os.path.join('common', 'overlay') 406 | overlay_path = os.path.join(self.cfgdir, overlay_rel) 407 | 408 | # First, build the set of links we know should exist for each package. 409 | for p in self.packages: 410 | for (s, h, f), path in p.file_map(): 411 | cfgrel = os.path.join(s, h, f) 412 | cfgabsdir = os.path.join(self.cfgdir, os.path.dirname(cfgrel)) 413 | if not path.startswith(self.homedir): 414 | link_text = path # use the abs path since it's outside ~ 415 | else: 416 | link_text = os.path.relpath(path, cfgabsdir) 417 | 418 | link_map[cfgrel] = link_text 419 | if s == 'common' and h == 'overlay': 420 | overlay_links.add(f) 421 | 422 | # Remove any overlay links in ~ that don't match files presently 423 | # existing in an enabled homectl package. We discover overlay links by 424 | # scanning $cfgdir/common/overlay (before removing anything from it), so 425 | # we don't have to do a recursive scan of ~. 426 | for rel, path in fs_files_in(overlay_path): 427 | home_path = os.path.join(self.homedir, rel) 428 | 429 | # Links we expect to ultimately be in the overlay should be left 430 | # alone, as they will be updated later. 431 | if rel in overlay_links: continue 432 | 433 | # Do not remove anything in ~/... that is not a link... 434 | if not os.path.islink(home_path): continue 435 | 436 | # ...or is a link that is not pointing into the overlay. We assume 437 | # the user has manually changed these things. 438 | dest = os.path.relpath(os.readlink(home_path), 439 | os.path.dirname(home_path)) 440 | if os.path.commonprefix((dest, overlay_path)) != overlay_path: 441 | continue 442 | 443 | # If we got this far, we have a link in ~/... which points into the 444 | # overlay, AND that link is not part of the set of links we 445 | # ultimately expect to have from ~ into the overlay. So we should 446 | # remove it. 447 | self.sys.rm_link(home_path) 448 | 449 | # Now that we have cleaned stale links out of ~, we can remove any link 450 | # in the cfgdir that doesn't point to a file which presently exists 451 | # inside an enabled homectl package. 452 | for rel, path in fs_files_in(self.cfgdir): 453 | if not os.path.islink(path): continue 454 | if rel not in link_map: 455 | self.sys.rm_link(path) 456 | 457 | # Update all the links in $cfgdir. 458 | for rel, text in iteritems(link_map): 459 | # Create in $cfgdir 460 | lnk = os.path.join(self.cfgdir, rel) 461 | self.sys.update_link(text, lnk) 462 | 463 | # Now update any missing/old links from ~ to $cfgdir. 464 | for rel in overlay_links: 465 | home_path = os.path.join(self.homedir, rel) 466 | cfg_path = os.path.join(overlay_path, rel) 467 | link_text = os.path.relpath(cfg_path, os.path.dirname(home_path)) 468 | 469 | if force_replace \ 470 | and not os.path.islink(home_path) \ 471 | and os.path.exists(home_path): 472 | self.sys.rm_tree(home_path) 473 | 474 | self.sys.update_link(link_text, home_path) 475 | 476 | # XXX cleanup empty dirs in $cfgdir 477 | 478 | # Run post-refresh triggers so packages can ensure everything is okay 479 | for p in self.packages: 480 | self.run_pkg_trigger(p, "refresh") 481 | 482 | # 483 | # Derived methods (implemented only in terms of the above) 484 | # 485 | 486 | def enable(self, pkg): 487 | self.packages = list(self.packages) + [pkg] 488 | 489 | def disable(self, pkg): 490 | self.packages = [ 491 | p for p in self.packages 492 | if os.path.realpath(p.path) != os.path.realpath(pkg.path)] 493 | 494 | def uninstall(self): 495 | self.packages = [] 496 | self.sys.run('rm', '-rf', self.cfgdir) 497 | 498 | self.sys.log('') 499 | self.sys.log('homectl has been uninstalled.') 500 | self.sys.log('') 501 | 502 | 503 | 504 | class PackageListFile(object): 505 | # A file containing a list of packages. The packages themselves are 506 | # accessible through the .packages property, which is expected to be a set 507 | # of Packages. When .packages is set, the file is updated incrementally (to 508 | # preserve comments and be friendly to version control) and written to disk. 509 | 510 | def __init__(self, sys, path, relative_to=None): 511 | self.sys = sys 512 | self.path = path 513 | self.relative_to = relative_to if relative_to \ 514 | else os.path.dirname(self.path) 515 | 516 | self.__pkgs = set((p for p in self._read_pkg_lines() 517 | if isinstance(p, Package))) 518 | 519 | def _read_pkg_lines(self): 520 | # Reads the package list line by line, replacing recognized package 521 | # paths with package objects, and yielding the remaining lines 522 | # (comments, etc.) as strings. 523 | try: 524 | with open(self.path, 'r') as f: 525 | for l in self.read_pkg_lines_from_fd(f, self.relative_to): 526 | yield l 527 | except IOError: pass 528 | 529 | @staticmethod 530 | def read_pkg_lines_from_fd(fd, relative_to): 531 | for l in fd.readlines(): 532 | if l.rstrip() == '' or l.startswith('#'): 533 | yield l.rstrip() 534 | continue 535 | yield Package(os.path.join(relative_to, l.strip())) 536 | 537 | @property 538 | def packages(self): 539 | return set(self.__pkgs) 540 | 541 | @packages.setter 542 | def packages(self, pkgs): 543 | pkgs = set(pkgs) 544 | 545 | for p in pkgs: 546 | if not isinstance(p, Package): 547 | raise TypeError('%r: Expected a Package' % (p,)) 548 | 549 | out = [] 550 | unrecorded = set(pkgs) 551 | 552 | # Read the package file, preserve comments and remove old packages as 553 | # needed. 554 | for line_or_pkg in self._read_pkg_lines(): 555 | if isinstance(line_or_pkg, Package): 556 | if line_or_pkg not in pkgs: 557 | # It was removed from the list; skip it 558 | continue 559 | else: 560 | # It's already in the file; keep it 561 | unrecorded.remove(line_or_pkg) 562 | out.append(os.path.relpath(line_or_pkg.path, self.relative_to)) 563 | else: 564 | out.append(line_or_pkg) 565 | 566 | # Append anything new that wasn't mentioned in the file before. 567 | out += sorted(set([os.path.relpath(p.path, self.relative_to) 568 | for p in unrecorded])) 569 | 570 | # Write the file. 571 | if out: 572 | self.sys.update_file(self.path, "\n".join(out) + "\n") 573 | else: 574 | self.sys.update_file(self.path, '') 575 | 576 | self.__pkgs = set(pkgs) 577 | 578 | 579 | 580 | class ConsoleSystem(System): 581 | def log(self, msg): 582 | print(msg.rstrip()) 583 | 584 | 585 | 586 | # 587 | # User-facing homectl commands 588 | # 589 | 590 | commands = {} 591 | 592 | def cmd_help(d, args): 593 | print("""Usage: %(cmd)s CMD OPTIONS ... 594 | 595 | For help on individual commands, run "cmd --help". 596 | 597 | init 598 | 599 | refresh 600 | uninstall 601 | 602 | list 603 | enable 604 | disable 605 | set-enabled 606 | 607 | path 608 | find 609 | """ % {'cmd': CMD_NAME}) 610 | 611 | commands['help'] = cmd_help 612 | 613 | def cmd_init(d, argv): 614 | if len(argv) < 2 or argv[1].startswith('-'): 615 | print("""Usage: %s init PATH-TO-YOUR-GIT-REPO [URL] 616 | 617 | Create a new homectl setup. 618 | 619 | Creates a new Git repository at the path you specify, and populates 620 | it with a copy of homectl and a helper script for deploying your 621 | setup on new machines. 622 | 623 | You can optionally specify a URL to a homectl Git repo, and 'init' 624 | will use that repo instead of the default. This is only useful if 625 | you work on homectl itself. Note that if you specify a local 626 | filesystem path for the URL, it must be an absolute path. 627 | """ % CMD_NAME) 628 | return 629 | 630 | hc_url = DEFAULT_HOMECTL_URL 631 | if len(argv) > 2: 632 | hc_url = argv[2] 633 | 634 | # Stuff to create or update 635 | gitrepo = argv[1] 636 | readme = os.path.join(gitrepo, 'README.md') 637 | deploy_sh = os.path.join(gitrepo, 'deploy.sh') 638 | 639 | # Figure out what packages to enable by default 640 | default_pkgs_str = '\n'.join( 641 | ['homectl/%s' % s for s in DEFAULT_HOMECTL_PKGS]) 642 | 643 | # Setup the git repo 644 | if not os.path.isdir(gitrepo) or \ 645 | not os.path.isdir(os.path.join(gitrepo, '.git')): 646 | d.sys.run('git', 'init', gitrepo) 647 | 648 | if not os.path.isdir(os.path.join(gitrepo, 'homectl')): 649 | d.sys.run('git', 'submodule', 'add', hc_url, 'homectl', 650 | cwd=gitrepo) 651 | 652 | d.sys.update_file(readme, """%(user)s's homectl setup 653 | ================================================================================ 654 | 655 | This is a Git repository which contains a homectl setup. homectl is a program 656 | which manages all the scripts, dot-files, vendor packages, etc. that you tend to 657 | accumulate over time in your home directory. Feel free to dump all of these 658 | things here (or in git submodules linked here). 659 | 660 | Quick Deployment 661 | ---------------- 662 | 663 | All you have to do is: 664 | 665 | $ ./deploy.sh 666 | 667 | And all your homectl packages, as they are stored here, will be reconsituted. 668 | 669 | WARNING: Once you have deployed, do not rename or move this Git repository. You 670 | will break your homectl installation if you do. (If you have to move it, 671 | uninstall first and then re-deploy.) 672 | 673 | Uninstallation 674 | -------------- 675 | 676 | $ %(cmd)s uninstall 677 | 678 | Your Git repository will be left alone but all homectl-created symlinks in your 679 | home directory will be removed. 680 | 681 | Additional Notes 682 | ---------------- 683 | 684 | NOTE: Be sure to check out the homectl documentation for tips on how to create 685 | your own homectl packages. 686 | 687 | A few files and directories have been included here to start you off. 688 | 689 | - `README.md`: This file. 690 | 691 | - `homectl/`: A git submodule containing homectl itself. It includes the 692 | `%(cmd)s` command and its accompaniing documentation. 693 | 694 | - `deploy.sh`: A handy, one-step script to run on new machines to instantly 695 | deploy your painstakingly-crafted homectl configuration. 696 | 697 | NOTE: Make sure the `homectl.hcpkg` package itself remains enabled, or you won't 698 | be able to use the `hc` command to manage your setup. 699 | """ % {'user': os.environ['LOGNAME'], 'cmd': CMD_NAME}) 700 | 701 | d.sys.run('git', 'add', os.path.basename(readme), cwd=gitrepo) 702 | 703 | d.sys.update_file(deploy_sh, """#!/bin/bash 704 | 705 | # This script deploys your homectl setup into your home directory, by enabling 706 | # a default set of homectl packages. 707 | 708 | cd "$(dirname "$0")" 709 | %(cmd)s=./homectl/homectl.hcpkg/bin/%(cmd)s 710 | 711 | set -e 712 | 713 | git submodule sync --recursive 714 | git submodule update --init --recursive 715 | 716 | $%(cmd)s set-enabled "$@" <', 728 | '-m', 'New homectl setup', cwd=gitrepo) 729 | 730 | d.sys.log('') 731 | d.sys.log('If you want to start using this homectl setup,') 732 | d.sys.log('you should now run:') 733 | d.sys.log('') 734 | d.sys.log(' %s' % deploy_sh) 735 | d.sys.log('') 736 | 737 | commands['init'] = cmd_init 738 | 739 | def cmd_refresh(d, argv): 740 | parser = OptionParser("""Usage: %s refresh [options] 741 | 742 | Scans for any changes in your homectl packages, and ensures those 743 | changes are reflected in your home directory (e.g. creates/removes 744 | symlinks so that scripts and binaries appear in your path).""" % CMD_NAME) 745 | parser.add_option('-f', '--force', dest='force', 746 | action='store_const', const=True, 747 | help="Replace any pre-existing files with homectl links") 748 | options, args = parser.parse_args(argv) 749 | 750 | if len(args) != 1: 751 | parser.print_usage() 752 | sys.exit(1) 753 | 754 | d.refresh(force_replace=options.force) 755 | commands['refresh'] = cmd_refresh 756 | commands['ref'] = cmd_refresh 757 | 758 | def cmd_uninstall(d, argv): 759 | if len(argv) > 1: 760 | print("""Usage: %s uninstall 761 | 762 | Completely remove homectl from your home directory. Leaves your 763 | homectl setup (git repository) intact. 764 | 765 | If you accidentally uninstall, you can run your deploy.sh script 766 | again to get your homectl deployment back. 767 | """ % CMD_NAME) 768 | return 769 | 770 | d.uninstall() 771 | commands['uninstall'] = cmd_uninstall 772 | 773 | def cmd_list(d, argv): 774 | parser = OptionParser( 775 | usage="""Usage: %s list [options] 776 | 777 | List all enabled packages.""" % CMD_NAME) 778 | parser.add_option('-r', '--relative', dest='relative', default=False, 779 | action='store_true', 780 | help="Print paths relative to the current directory") 781 | options, args = parser.parse_args(argv) 782 | 783 | pkgs = [p.path for p in d.packages] 784 | if options.relative: 785 | pkgs = [os.path.relpath(p) for p in pkgs] 786 | 787 | for p in sorted(pkgs): 788 | print(p) 789 | commands['list'] = cmd_list 790 | commands['ls'] = cmd_list 791 | 792 | def cmd_set_enabled(d, argv): 793 | parser = OptionParser( 794 | usage="""Usage: %(cmd)s set-enabled [options] 795 | 796 | Changes the entire set of enabled packages to be the list on stdin, one 797 | package per line (ignoring #-comments and blank lines). Packages which 798 | are currently enabled and do not appear in the list will be disabled; 799 | packages which appear in the list and are not enabled will be enabled. 800 | 801 | This command is intended for script-friendly use to bring your system 802 | into a known configuration; for example, assume the following is passed 803 | to stdin: 804 | 805 | # Built-in packages 806 | homectl/homectl.hcpkg 807 | 808 | my-package.hcpkg 809 | another.package.hcpkg 810 | ... 811 | 812 | Running `hc set-enabled` would set the entire set of enabled packages to 813 | the list above. Any package NOT in the list would be disabled. 814 | 815 | A refresh is done automatically as part of the enable/disable process.""" 816 | % {'cmd': CMD_NAME}) 817 | parser.add_option('-f', '--force', dest='force', 818 | action='store_const', const=True, 819 | help="Replace any pre-existing files with homectl links") 820 | options, args = parser.parse_args(argv) 821 | 822 | if len(args) != 1: 823 | parser.print_usage() 824 | sys.exit(1) 825 | 826 | pkgs = set((p 827 | for p in PackageListFile.read_pkg_lines_from_fd( 828 | sys.stdin, os.getcwd()) 829 | if isinstance(p, Package))) 830 | 831 | d.set_packages(pkgs, force_replace=options.force) 832 | commands['set-enabled'] = cmd_set_enabled 833 | 834 | def cmd_enable(d, argv): 835 | parser = OptionParser( 836 | usage="""Usage: %s enable [options] PKG [PKG ...] 837 | 838 | Enables one or more packages, linking their contents into 839 | your home directory. 840 | 841 | If the package contains new shell aliases, changes to $PATH, 842 | etc., you will have to restart any affected programs to pick 843 | up the new features.""" % CMD_NAME) 844 | parser.add_option('-f', '--force', dest='force', 845 | action='store_const', const=True, 846 | help="Replace any pre-existing files with homectl links") 847 | options, args = parser.parse_args(argv) 848 | 849 | if len(args) < 2: 850 | parser.print_usage() 851 | sys.exit(1) 852 | 853 | d.set_packages(d.packages.union([Package(path) for path in argv[1:]]), 854 | force_replace=options.force) 855 | 856 | commands['enable'] = cmd_enable 857 | commands['en'] = cmd_enable 858 | 859 | def cmd_disable(d, argv): 860 | if len(argv) <= 1 or argv[1].startswith('-'): 861 | print("""Usage: %s disable PKG [PKG ...] 862 | 863 | Undoes the effect of the 'enable' command. Unlinks the enabled 864 | package from your home directory, so it won't be loaded by default. 865 | 866 | You may have to restart programs this package uses after it is 867 | disabled. 868 | """ % CMD_NAME) 869 | return 870 | 871 | d.packages = d.packages.difference([Package(path) for path in argv[1:]]) 872 | 873 | commands['disable'] = cmd_disable 874 | commands['dis'] = cmd_disable 875 | 876 | def cmd_path(d, argv): 877 | parser = OptionParser( 878 | usage="""Usage: %s path [options] [HOOK] [ENV_VAR] 879 | 880 | The 'path' command generates a list of directories (or other items), combining 881 | several sources as described in the options. Once the list is generated, 'path' 882 | will remove duplicates from the list, keeping the item that appears earliest. 883 | 884 | The HOOK and ENV_VAR parameters are deprecated; they are the same as the -H and 885 | -E options, respectively.""" % CMD_NAME) 886 | 887 | delim = OptionGroup(parser, "Delimiters") 888 | delim.add_option('-d', '--delimiter', dest='delimiter', default=':', 889 | help="Separate items in the list with DELIMITER " 890 | + "(default: '%default')") 891 | delim.add_option('-n', '--newlines', dest='delimiter', 892 | action='store_const', const="\n", 893 | help="Separate items in the list with newlines " 894 | + "(conflicts with -d).") 895 | delim.add_option('-i', '--in-delimiter', dest='in_delimiter', default=':', 896 | help="When parsing environment variables, split them " 897 | + "using DELIMITER (default: '%default')") 898 | parser.add_option_group(delim) 899 | 900 | actions = OptionGroup(parser, "Adding Items to a Path", 901 | """Options are handled in the order listed below; that is, all -H items are 902 | added before all -E items. You may pass each option multiple times; within a 903 | particular option type, items are added in the order presented.""") 904 | actions.add_option('-P', '--prepend', dest='prepend', metavar='ITEM', 905 | action='append', 906 | help='Prepend a single item to the list') 907 | actions.add_option('-H', '--hook', dest='hook', metavar='NAME', 908 | action='append', 909 | help= 910 | """Include directories for a homectl hook. For example, if "bin" is used, 911 | 'path' will return a list of every directory in which "bin" files may be found 912 | for this system.""") 913 | actions.add_option('-E', '--env', dest='env', metavar='VAR', 914 | action='append', 915 | help= 916 | """Include an environment variable. Items are extracted from the variable by 917 | splitting it using the delimiter specified with -i (or ':' if -i isn't 918 | specified).""") 919 | actions.add_option('-A', '--append', dest='append', metavar='ITEM', 920 | action='append', 921 | help='Append a single item to the list') 922 | parser.add_option_group(actions) 923 | 924 | options, args = parser.parse_args(argv) 925 | 926 | if not options.hook: options.hook = [] 927 | if not options.env: options.env = [] 928 | if not options.append: options.append = [] 929 | if not options.prepend: options.prepend = [] 930 | 931 | if len(args) > 1: 932 | options.hook.append(args[1]) 933 | if len(args) > 2: 934 | options.env.append(args[2]) 935 | 936 | dirs = options.prepend 937 | for hook in options.hook: 938 | dirs += d.hook_dirs(hook) 939 | for var in options.env: 940 | dirs += os.environ.get(var, "").split(options.in_delimiter) 941 | dirs += options.append 942 | 943 | uniq_dirs = [] 944 | for d in dirs: 945 | if d not in uniq_dirs: 946 | uniq_dirs.append(d) 947 | 948 | print(options.delimiter.join(uniq_dirs)) 949 | 950 | commands['path'] = cmd_path 951 | 952 | def cmd_tree(d, argv): 953 | parser = OptionParser( 954 | usage="""Usage: %s tree [options] HOOK [GLOB [GLOB ...]] 955 | 956 | The 'tree' command searches through the specified HOOK for files and directories 957 | that match GLOBs (which may also be a path). 958 | 959 | This is equivalent to (but more convenient/faster than) using "hc path" and 960 | searching each returned path with "find -path GLOB". 961 | 962 | GLOBs are searched in the order presented; for example, if both '*.sh' and 963 | '*.zsh' are specified, all files ending in '.sh' will be returned first, 964 | followed by all files ending in '*.zsh'. If this isn't the behavior you want, 965 | consider piping the output through `sort`, `uniq`, or similar. 966 | 967 | """ % CMD_NAME) 968 | parser.add_option('-d', '--delimiter', dest='delimiter', default=' ', 969 | help="Separate items with DELIMITER (default: '%default')") 970 | parser.add_option('-n', '--newlines', dest='delimiter', 971 | action='store_const', const="\n", 972 | help='Separate items with newlines.') 973 | options, args = parser.parse_args(argv) 974 | 975 | if len(args) < 2: 976 | parser.print_usage() 977 | sys.exit(1) 978 | 979 | hook = args[1] 980 | files = [] 981 | 982 | if len(args) >= 3: 983 | for glob in args[2:]: 984 | files.extend([a for a in d.hook_tree(hook, glob)]) 985 | else: 986 | files.extend([a for a in d.hook_tree(hook)]) 987 | 988 | if files: 989 | print(options.delimiter.join(files)) 990 | 991 | commands['tree'] = cmd_tree 992 | 993 | def cmd_find(d, argv): 994 | if len(argv) <= 1 or argv[1].startswith('-'): 995 | print("""Usage: %s find FILE 996 | 997 | This command is deprecated. Use "path", "tree", or "files" instead. 998 | """ % CMD_NAME) 999 | return 1000 | 1001 | # XXX This is for compatibility with homectl <= 0.2 1002 | for p in sorted(d.packages): 1003 | for f in os.listdir(p.path): 1004 | if f == argv[1]: 1005 | print(os.path.join(p.path, f)) 1006 | commands['find'] = cmd_find 1007 | 1008 | def main(argv): 1009 | show_help = len(argv) < 2 or argv[1] == 'help' or argv[1] == '--help' 1010 | 1011 | if show_help: 1012 | cmd = commands['help'] 1013 | else: 1014 | try: 1015 | cmd = commands[argv[1]] 1016 | except KeyError: 1017 | raise 1018 | 1019 | system = ConsoleSystem(pretend=False) 1020 | 1021 | try: 1022 | d = Deployment(system) 1023 | cmd(d, argv[1:]) 1024 | 1025 | except KeyboardInterrupt: return 1 1026 | except SystemExit: raise 1027 | except (HomectlError, EnvironmentError) as e: 1028 | system.log_err('[%s] %s' % (type(e).__name__, str(e))) 1029 | return 1 1030 | 1031 | return 0 1032 | 1033 | 1034 | 1035 | if __name__ == '__main__': # pragma: no cover 1036 | sys.exit(main(sys.argv)) 1037 | --------------------------------------------------------------------------------