#!/bin/bash # tasque installer: a remote for all your agents, installed on this machine. # # curl -fsSL https://tasque.dev/install.sh | bash # curl -fsSL https://tasque.dev/install.sh | bash -s -- --launch install, then open tasque web # # What it does, in order (nothing else): # 1. checks this is macOS and that it is not running as root (tasque is macOS only for now: only the # Apple silicon program is built; the Linux and Intel code paths below stay dormant until they are built); # 2. finds the version to install: $TASQUE_VERSION / --version, or "version" in /latest.json; # 3. downloads /releases//SHA256SUMS, the list of release files and their checksums; # 4. downloads the single-file program for this machine, tasque--, and checks its sha256 # (stops if it doesn't match, if there is no program for this machine, or if it doesn't start); # 5. puts it in ~/.tasque/versions// and points ~/.tasque/bin/tasque at it (a new ~/.tasque is made # private, 0700, which tasque's daemon needs; one an earlier install left open is fixed the same way); # 6. runs `tasque --version` once to prove it starts; # 7. adds ~/.tasque/bin to your PATH in your shell's startup file, once (skip: --no-modify-path); # 8. removes versions it installed before, keeping the previous one so you can go back. # No sudo. Your queue, settings and connected accounts in ~/.tasque are never touched. # Run it again to upgrade; run it with the same version and it only checks. # # Flags (after `bash -s --`): # --launch run `tasque web` right after installing (it opens your browser and returns) # --no-launch don't offer to open it # --no-modify-path never edit shell startup files; print the line to add instead # --version install this version instead of the latest (same as TASQUE_VERSION) # --uninstall remove what this script installed (keeps your data in ~/.tasque) # -h, --help show this help # # Settings (environment variables, all optional): # TASQUE_VERSION version to install, e.g. 0.0.2 (default: latest) # TASQUE_INSTALL_DIR where the `tasque` link goes (default: ~/.tasque/bin) # TASQUE_HOME tasque's own folder; versions go in /versions (default: ~/.tasque) # TASQUE_SITE where releases come from (default: https://tasque.dev) # TASQUE_LAUNCH 1 = same as --launch, 0 = same as --no-launch # TASQUE_NO_MODIFY_PATH 1 = same as --no-modify-path # TASQUE_ALLOW_ROOT 1 = allow running as root (containers, CI images) # NO_COLOR set to anything to turn colours off # # The release contract this script reads (see install.md next to this file for the full version): # # /latest.json Cache-Control: no-cache. One key per line: the installer reads "version" # with sed, so keep it as a top-level scalar on a line of its own. # { # "schema": 1, # "version": "0.0.2", # "released": "2026-09-26T18:00:00Z", # "notes": "https://tasque.dev/releases/0.0.2/NOTES.md", # "platforms": ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64", "linux-x64-musl", "linux-arm64-musl"] # } # # /releases// (served from R2: a Bun binary is over the 25 MiB static-asset cap) # SHA256SUMS " " per line, as written by `shasum -a 256` / sha256sum # tasque-darwin-arm64 single-file Bun program, one per platform in "platforms" # tasque-darwin-x64 # tasque-linux-x64 (glibc) # tasque-linux-x64-musl (Alpine and friends; optional) # tasque-linux-arm64 # tasque-linux-arm64-musl (optional) # # Checksums always come from SHA256SUMS, never from latest.json, so a pinned older version installs # exactly the same way as the latest one. # # Uninstall: # curl -fsSL https://tasque.dev/install.sh | bash -s -- --uninstall # # Everything runs inside main(), called on the last line, so a download cut off halfway runs nothing. set -euo pipefail TASQUE_SITE="${TASQUE_SITE:-https://tasque.dev}" TASQUE_SITE="${TASQUE_SITE%/}" TASQUE_HOME="${TASQUE_HOME:-$HOME/.tasque}" VERSIONS_DIR="$TASQUE_HOME/versions" BIN_DIR="${TASQUE_INSTALL_DIR:-$TASQUE_HOME/bin}" PATH_MARKER="# added by the tasque installer (https://tasque.dev/install.sh)" TMP="" CURL_SECURE=() if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then BOLD=$'\033[1m'; DIM=$'\033[2m'; BLUE=$'\033[1;34m'; RED=$'\033[1;31m'; YELLOW=$'\033[1;33m'; GREEN=$'\033[1;32m'; RESET=$'\033[0m' else BOLD=""; DIM=""; BLUE=""; RED=""; YELLOW=""; GREEN=""; RESET="" fi say_step() { printf '%s==>%s %s%s%s\n' "$BLUE" "$RESET" "$BOLD" "$*" "$RESET"; } say_ok() { printf '%s✓%s %s\n' "$GREEN" "$RESET" "$*"; } say_warn() { printf '%sWarning:%s %s\n' "$YELLOW" "$RESET" "$*" >&2; } abort() { printf '%sError:%s %s\n' "$RED" "$RESET" "$1" >&2 shift local line for line in "$@"; do printf ' %s\n' "$line" >&2; done exit 1 } cleanup() { if [ -n "$TMP" ]; then rm -rf "$TMP"; fi; } # A path as the user would type it: $HOME shown as ~ (a literal ~, for display only). # shellcheck disable=SC2088 pretty() { case "$1" in "$HOME"/*) printf '~/%s' "${1#"$HOME"/}" ;; *) printf '%s' "$1" ;; esac; } # The same path for a shell startup file: $HOME written as "$HOME", so the line survives a renamed home. # shellcheck disable=SC2016 # the literal $HOME is the point: the startup file expands it later shell_path() { case "$1" in "$HOME"/*) printf '$HOME/%s' "${1#"$HOME"/}" ;; *) printf '%s' "$1" ;; esac; } usage() { cat </dev/null 2>&1; then sha256sum "$1" | cut -d ' ' -f 1 else shasum -a 256 "$1" | cut -d ' ' -f 1; fi } # Downloads refuse plain http unless the site is this machine (a local test server). set_transport() { case "$TASQUE_SITE" in https://*) CURL_SECURE=(--proto '=https' --tlsv1.2) ;; http://localhost|http://localhost:*|http://127.0.0.1|http://127.0.0.1:*) CURL_SECURE=() ;; *) abort "TASQUE_SITE must be an https:// address (got $TASQUE_SITE)." ;; esac } # download [progress]: quiet unless asked, and never leaves a half file behind. download() { local url="$1" out="$2" progress="${3:-}" local flags=(-fL --retry 3 --retry-delay 1 --connect-timeout 15) if [ -n "$progress" ] && [ -t 2 ]; then flags+=(--progress-bar); else flags+=(-sS); fi if curl "${flags[@]}" ${CURL_SECURE[@]+"${CURL_SECURE[@]}"} "$url" -o "$out.part"; then mv "$out.part" "$out" else rm -f "$out.part" return 1 fi } refuse_root() { if [ "${EUID:-$(id -u)}" = "0" ] && [ "${TASQUE_ALLOW_ROOT:-}" != "1" ]; then abort "please don't run this installer with sudo or as root." \ "tasque installs into your own home folder and needs no administrator rights." \ "Run it again as yourself: curl -fsSL $TASQUE_SITE/install.sh | bash" \ "(In a container where root is the only user, set TASQUE_ALLOW_ROOT=1.)" fi } check_tools() { local tool for tool in curl uname mktemp awk sed; do command -v "$tool" >/dev/null 2>&1 || abort "this installer needs \`$tool\`, and it wasn't found." "Install it with your package manager and run the installer again." done if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1; then abort "this installer needs \`sha256sum\` or \`shasum\` to check the download, and neither was found." fi } # Sets OS (darwin|linux), ARCH (x64|arm64) and LIBC ("" or "-musl"). detect_platform() { local os arch os="$(uname -s)" case "$os" in Darwin) OS=darwin ;; Linux) OS=linux ;; MINGW*|MSYS*|CYGWIN*|Windows_NT) abort "tasque is macOS only for now, and this looks like Windows ($os)." \ "Nothing was installed." ;; *) abort "tasque's installer doesn't support $os yet." \ "Nothing was installed. tasque is macOS only for now." ;; esac arch="$(uname -m)" # A shell running under Rosetta reports x86_64 on an Apple silicon Mac: take the native build. if [ "$OS" = darwin ] && [ "$arch" = x86_64 ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || true)" = "1" ]; then arch=arm64 fi case "$arch" in x86_64|amd64) ARCH=x64 ;; arm64|aarch64) ARCH=arm64 ;; *) ARCH="$arch" ;; # no program is built for this one; install_binary says so esac LIBC="" if [ "$OS" = linux ]; then if ls /lib/ld-musl-* >/dev/null 2>&1 || { command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; }; then LIBC="-musl" fi fi } # The link we own points into VERSIONS_DIR. Anything else at that path belongs to someone else. link_is_ours() { local target [ -L "$BIN_DIR/tasque" ] || return 1 target="$(readlink "$BIN_DIR/tasque")" case "$target" in "$VERSIONS_DIR"/*) return 0 ;; *) return 1 ;; esac } # The version folder the link points at now, if the link is ours; nothing otherwise. current_version_dir() { local target if link_is_ours; then target="$(readlink "$BIN_DIR/tasque")" printf '%s' "${target%/*}" fi } # fetch_verified [progress]: download one release file and check its sha256. fetch_verified() { local file="$1" sums="$2" dest="$3" progress="${4:-}" expected actual expected="$(sum_for "$file" "$sums")" [ -n "$expected" ] || return 2 download "$RELEASE_URL/$file" "$dest" "$progress" \ || abort "the download failed: $RELEASE_URL/$file" "Nothing was installed. Check your internet connection and try again." actual="$(sha256_of "$dest")" if [ "$actual" != "$expected" ]; then rm -f "$dest" abort "the downloaded file doesn't match its checksum, so it wasn't installed." \ "file $file" \ "expected sha256 $expected" \ "got sha256 $actual" \ "This can happen if the download was interrupted. Please run the installer again." fi } # Install the single-file program into $1 (a fresh folder). Stops with a message if the release has # no program for this machine, or if it doesn't start here. There is no other build to fall back on: # tasque is a Bun program (its store uses bun:sqlite). install_binary() { local dir="$1" file="tasque-$OS-$ARCH$LIBC" if [ -z "$(sum_for "$file" "$TMP/SHA256SUMS")" ] && [ -n "$LIBC" ]; then file="tasque-$OS-$ARCH" # no musl build: the glibc one sometimes runs (gcompat); it's checked below fi if [ -z "$(sum_for "$file" "$TMP/SHA256SUMS")" ]; then abort "release $VERSION has no tasque program for this machine ($OS-$ARCH$LIBC)." \ "Nothing was installed. tasque is macOS only for now: it is built for Apple silicon Macs." \ "Intel Macs and Linux are not supported yet. Updates: $TASQUE_SITE." fi say_step "Downloading tasque $VERSION for $OS-$ARCH$LIBC" fetch_verified "$file" "$TMP/SHA256SUMS" "$TMP/$file" progress say_ok "checksum matches" mkdir -p "$dir" mv "$TMP/$file" "$dir/tasque" chmod 755 "$dir/tasque" if ! "$dir/tasque" --version >/dev/null 2>&1; then rm -rf "$dir" abort "the $file program didn't start on this machine, so it wasn't installed." \ "Nothing was installed. Please report this at $TASQUE_SITE, with the output of: uname -a" fi } # The line that puts BIN_DIR on the PATH, for the running shell's syntax. path_line() { local p p="$(shell_path "$BIN_DIR")" # shellcheck disable=SC2016 # $PATH is written literally; the user's shell expands it at startup case "$1" in fish) printf 'if not contains "%s" $PATH; set -gx PATH "%s" $PATH; end' "$p" "$p" ;; *) printf 'export PATH="%s:$PATH"' "$p" ;; esac } # The startup file for this user's shell, or nothing if we don't know it. startup_file() { case "$1" in zsh) printf '%s' "${ZDOTDIR:-$HOME}/.zshrc" ;; bash) if [ "$OS" = darwin ]; then printf '%s' "$HOME/.bash_profile"; else printf '%s' "$HOME/.bashrc"; fi ;; fish) printf '%s' "${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/tasque.fish" ;; esac } path_hint() { local shell_name="$1" file="$2" echo say_warn "$(pretty "$BIN_DIR") is not on your PATH, so the \`tasque\` command won't be found yet." if [ -n "$file" ]; then echo " Add this line to $(pretty "$file"), then open a new terminal:" else echo " Add this line to your shell's startup file, then open a new terminal:" fi echo echo " $(path_line "$shell_name")" echo } # Put BIN_DIR on the PATH for new terminals: one marked block, added once, removed by --uninstall. ensure_path() { local shell_name file shell_name="$(basename "${SHELL:-sh}")" file="$(startup_file "$shell_name")" case ":${PATH}:" in *":$BIN_DIR:"*) return 0 ;; esac if [ -n "$NO_MODIFY_PATH" ] || [ -z "$file" ]; then path_hint "$shell_name" "$file" return 0 fi if [ -f "$file" ] && grep -qF "$PATH_MARKER" "$file"; then NEW_SHELL_NEEDED=1 return 0 fi mkdir -p "$(dirname "$file")" 2>/dev/null || true if { printf '\n%s\n%s\n' "$PATH_MARKER" "$(path_line "$shell_name")" >> "$file"; } 2>/dev/null; then say_ok "added $(pretty "$BIN_DIR") to your PATH in $(pretty "$file")" NEW_SHELL_NEEDED=1 else path_hint "$shell_name" "$file" fi } # Remove our marked block (the marker line and the line after it) from one startup file. strip_path_block() { local file="$1" tmp [ -f "$file" ] || return 0 grep -qF "$PATH_MARKER" "$file" || return 0 tmp="$(mktemp "${TMPDIR:-/tmp}/tasque-rc.XXXXXX")" # Drops the marker, the line after it, and the blank line the installer put before it. awk -v m="$PATH_MARKER" ' skip { skip = 0; next } $0 == m { held = 0; skip = 1; next } held { print ""; held = 0 } $0 == "" { held = 1; next } { print } END { if (held) print "" }' "$file" > "$tmp" cat "$tmp" > "$file" # write through, so a symlinked dotfile stays a symlink rm -f "$tmp" echo "Removed the PATH line from $(pretty "$file")" if [ "${file##*/}" = tasque.fish ] && ! grep -q '[^[:space:]]' "$file"; then rm -f "$file"; fi } # Can we talk to a person? A terminal on stdout, a /dev/tty we can open, and not CI. interactive() { [ -t 1 ] || return 1 [ -z "${CI:-}" ] || return 1 { : /dev/null || return 1 return 0 } # `tasque web` makes sure the daemon is running, opens the browser at its own one-time URL and # returns; the daemon keeps serving. The installer never needs to know the address. launch_web() { say_step "Opening tasque web" # stdin is this script when piped from curl, so the command gets the terminal instead. if "$BIN_DIR/tasque" web /dev/null 2>&1; then echo "Removed the hooks tasque added to your agents" else say_warn "couldn't remove tasque's agent hooks automatically. If you added any, see $TASQUE_SITE/#faq." fi if "$BIN_DIR/tasque" daemon uninstall --yes >/dev/null 2>&1; then echo "Stopped the tasque daemon and removed its login item" else say_warn "couldn't remove the tasque daemon's login item automatically." echo " Look for ~/Library/LaunchAgents/*tasque* (macOS)." >&2 fi rm -f "$BIN_DIR/tasque"; echo "Removed $(pretty "$BIN_DIR/tasque")"; removed=1 elif [ -e "$BIN_DIR/tasque" ] || [ -L "$BIN_DIR/tasque" ]; then say_warn "$(pretty "$BIN_DIR/tasque") wasn't installed by this script, so it was left alone." fi if [ -d "$VERSIONS_DIR" ]; then rm -rf "$VERSIONS_DIR"; echo "Removed $(pretty "$VERSIONS_DIR")"; removed=1 fi if [ -d "$BIN_DIR" ] && [ "$BIN_DIR" = "$TASQUE_HOME/bin" ]; then rmdir "$BIN_DIR" 2>/dev/null || true; fi for file in "${ZDOTDIR:-$HOME}/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" "${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/tasque.fish"; do strip_path_block "$file" done [ "$removed" = 1 ] || echo "tasque wasn't installed by this script; nothing to remove." echo echo "Your queue, settings and connected accounts are still in $(pretty "$TASQUE_HOME")." echo "To delete them too (this can't be undone):" echo " rm -rf $(pretty "$TASQUE_HOME")" } # tasque's own folder holds its socket, token and queue, and the daemon runs only when it is private (0700): under # the default umask a plain `mkdir -p` makes it 0755 and the daemon would refuse it. A new folder is made 0700. An # existing one that others can open is made private only when it holds nothing but tasque's own files (an earlier # install made it); anything else is left as it is, with the exact command to run. file_mode() { stat -f %Lp "$1" 2>/dev/null || stat -c %a "$1" 2>/dev/null; } ensure_private_home() { if [ ! -e "$TASQUE_HOME" ] && [ ! -L "$TASQUE_HOME" ]; then mkdir -p "$(dirname "$TASQUE_HOME")" || abort "couldn't create $(dirname "$TASQUE_HOME")." (umask 077 && mkdir "$TASQUE_HOME") || abort "couldn't create $TASQUE_HOME." chmod 700 "$TASQUE_HOME" || abort "couldn't make $TASQUE_HOME private." return 0 fi if [ -L "$TASQUE_HOME" ] || [ ! -d "$TASQUE_HOME" ]; then abort "$(pretty "$TASQUE_HOME") exists but isn't a folder, so it was left alone." \ "Nothing was installed. Move it out of the way (or set TASQUE_HOME) and run the installer again." fi local mode name ours=1 mode="$(file_mode "$TASQUE_HOME")" case "$mode" in *00) return 0 ;; esac # group and others have no access: private already if [ -O "$TASQUE_HOME" ]; then for name in "$TASQUE_HOME"/* "$TASQUE_HOME"/.[!.]* "$TASQUE_HOME"/..?*; do [ -e "$name" ] || [ -L "$name" ] || continue case "${name##*/}" in versions|bin|sock|hook-token|hooks|hooks.lock|settings.json|tasque.db|tasque.db-wal|tasque.db-shm|tasque.db-journal|owner-commands.log|owner-commands.log.*) ;; *) ours=0 ;; esac done else ours=0 fi if [ "$ours" = 1 ] && chmod 700 "$TASQUE_HOME"; then say_ok "made $(pretty "$TASQUE_HOME") private (0700); tasque's daemon needs that" else say_warn "$(pretty "$TASQUE_HOME") can be opened by other users (mode $mode), and it holds files tasque didn't make, so it was left as it is." echo " tasque's daemon won't start until it is private. To fix it, run: chmod 700 $(pretty "$TASQUE_HOME")" >&2 fi } install() { refuse_root check_tools detect_platform set_transport TMP="$(mktemp -d "${TMPDIR:-/tmp}/tasque-install.XXXXXX")" || abort "couldn't make a temporary folder." trap cleanup EXIT if [ -z "$VERSION" ]; then say_step "Checking for the latest tasque" if ! download "$TASQUE_SITE/latest.json" "$TMP/latest.json"; then # A 404 means the site answered but has no release yet; anything else is the network or the site being down. status="$(curl -sS -o /dev/null -w '%{http_code}' ${CURL_SECURE[@]+"${CURL_SECURE[@]}"} --connect-timeout 15 "$TASQUE_SITE/latest.json" 2>/dev/null || true)" if [ "$status" = 404 ]; then abort "no tasque release is published at $TASQUE_SITE yet." "Nothing was installed. Please try again later." fi abort "couldn't reach $TASQUE_SITE/latest.json." "Check your internet connection and try again." fi VERSION="$(json_value version "$TMP/latest.json")" [ -n "$VERSION" ] || abort "the release information at $TASQUE_SITE/latest.json has no version." "Nothing was installed. Please try again later." fi VERSION="${VERSION#v}" case "$VERSION" in [0-9]*) ;; *) abort "\"$VERSION\" doesn't look like a tasque version (expected something like 0.0.2)." ;; esac case "$VERSION" in *[!0-9A-Za-z.+-]*) abort "\"$VERSION\" doesn't look like a tasque version (expected something like 0.0.2)." ;; esac RELEASE_URL="$TASQUE_SITE/releases/$VERSION" FINAL_DIR="$VERSIONS_DIR/$VERSION" ensure_private_home local previous_dir previous_dir="$(current_version_dir)" FRESH=1 if [ -x "$FINAL_DIR/tasque" ] && [ "$previous_dir" = "$FINAL_DIR" ] && "$FINAL_DIR/tasque" --version >/dev/null 2>&1; then say_ok "tasque $VERSION is already installed and up to date" FRESH=0 else download "$RELEASE_URL/SHA256SUMS" "$TMP/SHA256SUMS" \ || abort "couldn't find release $VERSION at $RELEASE_URL/." \ "Nothing was installed. Check the version number, or leave TASQUE_VERSION unset for the latest." local work="$VERSIONS_DIR/.$VERSION.partial" mkdir -p "$VERSIONS_DIR" || abort "couldn't create $VERSIONS_DIR." rm -rf "$work" install_binary "$work" rm -rf "$FINAL_DIR" mv "$work" "$FINAL_DIR" || abort "couldn't move tasque into $FINAL_DIR." "$FINAL_DIR/tasque" --version >/dev/null 2>&1 \ || abort "tasque was installed into $(pretty "$FINAL_DIR") but didn't start." \ "Nothing else was changed. Please report this at $TASQUE_SITE, with this message." fi mkdir -p "$BIN_DIR" || abort "couldn't create $BIN_DIR." if { [ -e "$BIN_DIR/tasque" ] || [ -L "$BIN_DIR/tasque" ]; } && ! link_is_ours; then abort "$(pretty "$BIN_DIR/tasque") already exists and wasn't installed by this script, so it was left alone." \ "tasque $VERSION is unpacked in $(pretty "$FINAL_DIR")." \ "Move the old $(pretty "$BIN_DIR/tasque") out of the way and run the installer again." fi ln -sfn "$FINAL_DIR/tasque" "$BIN_DIR/tasque" || abort "couldn't create the link $BIN_DIR/tasque." # Older versions this script installed: keep the one we just replaced (for going back), drop the rest. # Only when the version changed, so a rerun never prunes the one kept for going back. # Only folders we made (they hold a tasque program, or are an unfinished .partial) are removed. local old if [ "$FRESH" = 1 ]; then for old in "$VERSIONS_DIR"/* "$VERSIONS_DIR"/.*.partial; do [ -d "$old" ] || continue [ "$old" = "$FINAL_DIR" ] && continue [ "$old" = "$previous_dir" ] && continue if [ -e "$old/tasque" ] || [ "${old%.partial}" != "$old" ]; then rm -rf "$old"; fi done fi "$BIN_DIR/tasque" --version >/dev/null 2>&1 \ || say_warn "tasque was installed but didn't start when checked. Try running $(pretty "$BIN_DIR/tasque") yourself." if [ "$FRESH" = 1 ]; then say_ok "installed tasque $VERSION into $(pretty "$FINAL_DIR")" fi NEW_SHELL_NEEDED=0 ensure_path local found found="$(command -v tasque 2>/dev/null || true)" if [ -n "$found" ] && [ "$found" != "$BIN_DIR/tasque" ]; then echo say_warn "another tasque comes first on your PATH: $found" echo " Run $(pretty "$BIN_DIR/tasque") to use this one, or remove the other." fi cat <&2; exit 2; }; VERSION="$2"; shift ;; --version=*) VERSION="${1#--version=}" ;; -h|--help) usage; exit 0 ;; *) printf 'Unknown option: %s\n\n' "$1" >&2; usage >&2; exit 2 ;; esac shift done "$action" } main "$@"