diff --git a/README.md b/README.md index 6679eb6..90ae6d5 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ $env:GITEA_TOKEN = '' # on any machine, including a fresh one .\scripts\install.ps1 # download from the release and install .\scripts\settings.ps1 -Pull # apply settings, keybindings, marketplace extensions + +# the same on Linux or macOS, no PowerShell needed +scripts/install.sh +scripts/settings.sh --pull ``` Set the server first — either in [`config.json`](config.json) or with `GITEA_URL`, @@ -55,8 +59,10 @@ are skipped, because those are the three above and they come from the release. | [`settings.ps1`](scripts/settings.ps1) | `-Push` machine to repo, `-Pull` repo to machine, `-Diff` compares | | [`dev-link.ps1`](scripts/dev-link.ps1) | Junctions the sources into `~/.vscode/extensions` for development. `-Unlink` undoes it | | [`ci-release.sh`](scripts/ci-release.sh) | The bash equivalent of `publish.ps1`, for the Gitea Actions runner. `SKIP_PACKAGE=1` uploads `dist/` without rebuilding | +| [`install.sh`](scripts/install.sh) | The bash equivalent of `install.ps1`: `--tag`, `--local`, `--only`, `--uninstall`, `--token` | +| [`settings.sh`](scripts/settings.sh) | The bash equivalent of `settings.ps1`: `--push`, `--pull`, `--diff`, `--no-extensions` | -Every script takes `-?` for its full help. +Every PowerShell script takes `-?` for its full help, the bash ones `--help`. ### Tags @@ -176,8 +182,9 @@ once installed. Both were checked by hand. - **`dev-link.ps1` and `install.ps1` conflict.** A junctioned source and an installed `.vsix` are two copies of the same extension id; VS Code loads one arbitrarily. `dev-link.ps1 -Unlink` before installing. -- **Linux and macOS need pwsh.** The scripts run under PowerShell 7 (`pwsh`) there and - find VS Code's user folder at `~/.config/Code/User` (`~/Library/Application Support/Code/User` +- **Linux and macOS.** `install.sh` and `settings.sh` are bash twins of the PowerShell + scripts and need only bash, curl and one of jq, node or python3 for JSON. The `.ps1` + scripts also run there under PowerShell 7 (`pwsh`). Both find VS Code's user folder at `~/.config/Code/User` (`~/Library/Application Support/Code/User` on macOS) and the CLI in the usual apt, snap, flatpak and Homebrew locations. Set `VSCODE_USER_DIR` for Insiders or a portable install. `dev-link.ps1` makes a symlink instead of a junction. `ci-release.sh` needs only bash. diff --git a/scripts/common.sh b/scripts/common.sh new file mode 100644 index 0000000..746145f --- /dev/null +++ b/scripts/common.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# Shared pieces for install.sh and settings.sh: the bash half of common.ps1. +# +# Sourced, not run. JSON is read through whichever of jq, node or python3 is present, +# in that order -- a fresh Linux box has python3, a dev box has node, and jq is the +# nicest when it is there. Nothing here needs more than bash, curl and one of those. + +set -euo pipefail + +setup_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +setup_config="$setup_root/config.json" +setup_dist="$setup_root/dist" + +die() { echo "error: $*" >&2; exit 1; } +note() { printf '\033[90m%s\033[0m\n' "$*"; } +ok() { printf '\033[32m%s\033[0m\n' "$*"; } +warn() { printf '\033[33m%s\033[0m\n' "$*"; } + +# --- JSON ------------------------------------------------------------------------ + +json_tool() { + if command -v jq >/dev/null; then echo jq + elif command -v node >/dev/null; then echo node + elif command -v python3 >/dev/null; then echo python3 + else die "need jq, node or python3 to read JSON" + fi +} + +# json_get -> the scalar at that path, or nothing. +json_get() { + local file="$1" path="$2" + case "$(json_tool)" in + jq) jq -r "try (.$path) // empty" "$file" 2>/dev/null ;; + node) node -e ' + const fs = require("fs"); + const src = process.argv[1] === "-" ? 0 : process.argv[1]; + let j; try { j = JSON.parse(fs.readFileSync(src, "utf8")); } catch { process.exit(0); } + const v = process.argv[2].split(".").reduce((o, k) => (o == null ? undefined : o[k]), j); + if (v !== undefined && v !== null) console.log(v); + ' "$file" "$path" ;; + python3) python3 - "$file" "$path" <<'EOF' +import json, sys +src = sys.stdin if sys.argv[1] == "-" else open(sys.argv[1], encoding="utf-8") +try: + j = json.load(src) +except Exception: + sys.exit(0) +for k in sys.argv[2].split("."): + j = j.get(k) if isinstance(j, dict) else None + if j is None: + sys.exit(0) +print(j) +EOF + ;; + esac +} + +# json_rows ... -> one TSV line per element. +json_rows() { + local file="$1" path="$2"; shift 2 + local fields="$*" + case "$(json_tool)" in + jq) jq -r --arg f "$fields" "try (.$path[] | [ (\$f | split(\" \"))[] as \$k | (.[\$k] // \"\" | tostring) ] | @tsv) // empty" "$file" 2>/dev/null ;; + node) node -e ' + const fs = require("fs"); + const src = process.argv[1] === "-" ? 0 : process.argv[1]; + let j; try { j = JSON.parse(fs.readFileSync(src, "utf8")); } catch { process.exit(0); } + const arr = process.argv[2].split(".").reduce((o, k) => (o == null ? undefined : o[k]), j); + const fields = process.argv[3].split(" "); + for (const item of Array.isArray(arr) ? arr : []) console.log(fields.map(f => item[f] ?? "").join("\t")); + ' "$file" "$path" "$fields" ;; + python3) python3 - "$file" "$path" "$fields" <<'EOF' +import json, sys +src = sys.stdin if sys.argv[1] == "-" else open(sys.argv[1], encoding="utf-8") +try: + j = json.load(src) +except Exception: + sys.exit(0) +for k in sys.argv[2].split("."): + j = j.get(k) if isinstance(j, dict) else None +for item in (j if isinstance(j, list) else []): + print("\t".join(str(item.get(f, "")) for f in sys.argv[3].split(" "))) +EOF + ;; + esac +} + +# --- config --------------------------------------------------------------------- + +# GITEA_URL / GITEA_OWNER / GITEA_REPO override config.json, as in common.ps1. +load_config() { + [ -f "$setup_config" ] || die "config.json not found at $setup_config" + gitea_url="${GITEA_URL:-$(json_get "$setup_config" gitea.url)}" + gitea_owner="${GITEA_OWNER:-$(json_get "$setup_config" gitea.owner)}" + gitea_repo="${GITEA_REPO:-$(json_get "$setup_config" gitea.repo)}" + gitea_url="${gitea_url%/}" +} + +require_gitea() { + case "$gitea_url" in + *example.com*|"") die "config.json still has the placeholder Gitea URL. Set it there, or set GITEA_URL, GITEA_OWNER and GITEA_REPO." ;; + esac +} + +# Prints "idpathrepo" for every extension, optionally only the given ids. +configured_extensions() { + local only="${1:-}" + local rows + rows="$(json_rows "$setup_config" extensions id path repo)" + if [ -z "$only" ]; then + echo "$rows" + return + fi + local id + for id in $only; do + grep -P "^$id\t" <<<"$rows" || die "not in config.json: $id" + done +} + +# --- Gitea ---------------------------------------------------------------------- + +# The token, from the argument, GITEA_TOKEN, or ~/.gitea-token. Never from the repo. +gitea_token() { + local token="${1:-}" + if [ -n "$token" ]; then echo "$token"; return; fi + if [ -n "${GITEA_TOKEN:-}" ]; then echo "$GITEA_TOKEN"; return; fi + if [ -s "$HOME/.gitea-token" ]; then tr -d '[:space:]' < "$HOME/.gitea-token"; return; fi + die "No Gitea token. Set GITEA_TOKEN, write one to ~/.gitea-token, or pass --token." +} + +# gitea_release -> the release JSON, or nothing for 404. +gitea_release() { + local tag="$1" token="$2" + local encoded + encoded="$(printf '%s' "$tag" | sed 's/ /%20/g; s/\//%2F/g')" + local status body + body="$(curl -sS -w '\n%{http_code}' -H "Authorization: token $token" \ + "$gitea_url/api/v1/repos/$gitea_owner/$gitea_repo/releases/tags/$encoded")" + status="${body##*$'\n'}" + body="${body%$'\n'*}" + case "$status" in + 200) echo "$body" ;; + 404) ;; + *) die "Gitea answered HTTP $status for release '$tag': $body" ;; + esac +} + +# gitea_download +# +# curl re-sends the Authorization header across redirects only to the same host, which +# is exactly the rule wanted: Gitea answers browser_download_url with a redirect to the +# attachment on the same instance, and a redirect elsewhere must not carry the token. +gitea_download() { + local url="$1" destination="$2" token="$3" + curl -fsSL --max-redirs 5 -H "Authorization: token $token" -o "$destination" "$url" +} + +# --- VS Code -------------------------------------------------------------------- + +code_command() { + if command -v code >/dev/null; then command -v code; return; fi + local candidate + for candidate in /usr/bin/code /usr/share/code/bin/code /snap/bin/code \ + /var/lib/flatpak/exports/bin/com.visualstudio.code "$HOME/.local/bin/code" \ + /usr/local/bin/code "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code" \ + /usr/bin/code-insiders; do + [ -x "$candidate" ] && { echo "$candidate"; return; } + done + die "The 'code' CLI was not found. In VS Code run 'Shell Command: Install code command in PATH', or add its bin/ to PATH." +} + +# VS Code's user folder; VSCODE_USER_DIR overrides, as in the PowerShell scripts. +code_user_dir() { + if [ -n "${VSCODE_USER_DIR:-}" ]; then echo "$VSCODE_USER_DIR"; return; fi + case "$(uname -s)" in + Darwin) echo "$HOME/Library/Application Support/Code/User" ;; + *) echo "${XDG_CONFIG_HOME:-$HOME/.config}/Code/User" ;; + esac +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..07bac60 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Installs the extensions into VS Code, from a Gitea release or from dist/. +# +# The bash twin of install.ps1, for a Linux or macOS machine without PowerShell. By +# default it downloads the .vsix assets of the "latest" release and installs them, so a +# fresh machine needs nothing but VS Code, curl, and this repo. --local installs what is +# already in dist/ instead. +# +# Every install passes --force: without it VS Code declines to reinstall a version it +# already has, which would silently do nothing every time the same version number is +# republished. +# +# Usage: +# scripts/install.sh install the "latest" release +# scripts/install.sh --tag v0.1.0 a specific release +# scripts/install.sh --local from dist/ +# scripts/install.sh --only vertical-tabs --only colored-references +# scripts/install.sh --uninstall +# scripts/install.sh --token otherwise GITEA_TOKEN or ~/.gitea-token + +# shellcheck source=common.sh +. "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +tag=latest +token="" +only="" +local_install="" +uninstall="" + +while [ $# -gt 0 ]; do + case "$1" in + --tag) tag="$2"; shift 2 ;; + --token) token="$2"; shift 2 ;; + --only) only="$only $2"; shift 2 ;; + --local) local_install=1; shift ;; + --uninstall) uninstall=1; shift ;; + -h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) die "unknown argument: $1 (try --help)" ;; + esac +done + +load_config +code="$(code_command)" +extensions="$(configured_extensions "$only")" + +if [ -n "$uninstall" ]; then + while IFS=$'\t' read -r id _path _repo; do + [ -n "$id" ] || continue + # The manifest's publisher is "local", so the installed id is local.. + note "uninstalling local.$id" + "$code" --uninstall-extension "local.$id" || true + done <<<"$extensions" + echo "Reload the window for this to take effect." + exit 0 +fi + +packages=() +staging="" + +if [ -n "$local_install" ]; then + while IFS=$'\t' read -r id _path _repo; do + [ -n "$id" ] || continue + found="$(ls -1 "$setup_dist"/"$id"-*.vsix 2>/dev/null | sort -r | head -n1 || true)" + [ -n "$found" ] || die "no package for $id in $setup_dist. Run build.ps1 or ci-release.sh first." + packages+=("$found") + done <<<"$extensions" +else + require_gitea + token="$(gitea_token "$token")" + release="$(gitea_release "$tag" "$token")" + [ -n "$release" ] || die "no release tagged '$tag' in $gitea_owner/$gitea_repo. Run publish.ps1 first." + assets="$(json_rows - assets name browser_download_url <<<"$release")" + [ -n "$assets" ] || die "release '$tag' has no assets" + + # Downloads go to a temp folder, not dist/, so an install never disturbs a build. + staging="$(mktemp -d -t vsix-XXXXXX)" + trap 'rm -rf "$staging"' EXIT + + while IFS=$'\t' read -r id _path _repo; do + [ -n "$id" ] || continue + match="$(grep -P "^$id-.*\.vsix\t" <<<"$assets" | sort -r | head -n1 || true)" + if [ -z "$match" ]; then + warn "release '$tag' has no asset for $id, skipping" + continue + fi + name="${match%%$'\t'*}" + url="${match#*$'\t'}" + note "downloading $name" + gitea_download "$url" "$staging/$name" "$token" + packages+=("$staging/$name") + done <<<"$extensions" +fi + +[ ${#packages[@]} -gt 0 ] || die "nothing to install" + +failed=0 +for package in "${packages[@]}"; do + printf 'installing %s' "$(basename "$package")" + if "$code" --install-extension "$package" --force >/dev/null 2>&1; then + ok ' ok' + else + printf '\033[31m FAILED\033[0m\n' + failed=$((failed + 1)) + fi +done + +echo +[ "$failed" -eq 0 ] || die "$failed of ${#packages[@]} failed to install" +echo "installed ${#packages[@]} extension(s). Reload the window." diff --git a/scripts/settings.sh b/scripts/settings.sh new file mode 100644 index 0000000..ebac9dc --- /dev/null +++ b/scripts/settings.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# Moves VS Code user settings between this repo and the machine. +# +# The bash twin of settings.ps1: +# --push copies the machine's settings into User/ so you can commit them +# --pull copies User/ onto the machine, backing up whatever was there +# --diff says what differs without touching anything (the default) +# +# It copies rather than symlinks on purpose: an editor that saves atomically replaces a +# symlink with a regular file and the sync silently stops. Copying is explicit. +# +# --push also records marketplace extensions in User/extensions.txt and --pull installs +# the missing ones. Ids starting "local." are skipped: those are the extensions in this +# repo, and install.sh gets them from the Gitea release. --no-extensions skips that part. +# +# Usage: +# scripts/settings.sh [--diff] +# scripts/settings.sh --push +# scripts/settings.sh --pull [--no-extensions] + +# shellcheck source=common.sh +. "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +mode=diff +extensions=1 +while [ $# -gt 0 ]; do + case "$1" in + --push) mode=push; shift ;; + --pull) mode=pull; shift ;; + --diff) mode=diff; shift ;; + --no-extensions) extensions=""; shift ;; + -h|--help) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) die "unknown argument: $1 (try --help)" ;; + esac +done + +repo_user="$setup_root/User" +code_user="$(code_user_dir)" +[ -d "$code_user" ] || die "VS Code's user folder was not found at $code_user. Set VSCODE_USER_DIR to it or copy by hand." + +# Only the things worth version-controlling. Everything else in User/ is state. +items="settings.json keybindings.json tasks.json snippets" + +# A hash of a file, or of every file in a directory with its relative path; empty if absent. +fingerprint() { + local path="$1" + if [ -f "$path" ]; then + sha256sum "$path" | cut -d' ' -f1 + elif [ -d "$path" ]; then + (cd "$path" && find . -type f | sort | while read -r f; do + printf '%s:%s|' "$f" "$(sha256sum "$f" | cut -d' ' -f1)" + done) | sha256sum | cut -d' ' -f1 + fi +} + +copy_item() { + local from="$1" to="$2" + mkdir -p "$(dirname "$to")" + if [ -d "$from" ]; then + rm -rf "$to" + cp -R "$from" "$to" + else + cp "$from" "$to" + fi +} + +backup_existing() { + local path="$1" + [ -e "$path" ] || return 0 + local backup="$path.backup-$(date +%Y%m%d-%H%M%S)" + cp -R "$path" "$backup" + note " backed up to $(basename "$backup")" +} + +echo "repo: $repo_user" +echo "code: $code_user" +echo + +for item in $items; do + in_repo="$repo_user/$item" + on_disk="$code_user/$item" + repo_print="$(fingerprint "$in_repo")" + disk_print="$(fingerprint "$on_disk")" + + if [ -z "$repo_print" ] && [ -z "$disk_print" ]; then + note " $item: absent both sides"; continue + fi + if [ "$repo_print" = "$disk_print" ]; then + note " $item: identical"; continue + fi + + case "$mode" in + push) + if [ -z "$disk_print" ]; then warn " $item: not on this machine, leaving the repo copy alone"; continue; fi + copy_item "$on_disk" "$in_repo" + ok " $item: machine -> repo" ;; + pull) + if [ -z "$repo_print" ]; then warn " $item: not in the repo, leaving the machine alone"; continue; fi + backup_existing "$on_disk" + copy_item "$in_repo" "$on_disk" + ok " $item: repo -> machine" ;; + *) + if [ -z "$repo_print" ]; then where="only on this machine" + elif [ -z "$disk_print" ]; then where="only in the repo" + else where="differs"; fi + warn " $item: $where" ;; + esac +done + +[ -n "$extensions" ] || exit 0 + +echo +list="$repo_user/extensions.txt" +code="$(code_command)" + +if [ "$mode" = push ]; then + mkdir -p "$repo_user" + "$code" --list-extensions | grep -v '^local\.' | grep -v '^$' | sort > "$list" + ok " extensions.txt: recorded $(wc -l < "$list" | tr -d ' ') marketplace extension(s)" + exit 0 +fi + +if [ ! -f "$list" ]; then + warn " extensions.txt: not in the repo yet, run --push on your main machine" + exit 0 +fi + +present="$("$code" --list-extensions)" +missing=() +while read -r wanted; do + [ -n "$wanted" ] || continue + case "$wanted" in \#*) continue ;; esac + grep -qixF "$wanted" <<<"$present" || missing+=("$wanted") +done < "$list" + +if [ "$mode" = diff ]; then + if [ ${#missing[@]} -eq 0 ]; then + note " extensions.txt: all present" + else + warn " extensions.txt: ${#missing[@]} missing: ${missing[*]}" + fi + exit 0 +fi + +if [ ${#missing[@]} -eq 0 ]; then + note " extensions.txt: all present" + exit 0 +fi +for id in "${missing[@]}"; do + printf ' installing %s' "$id" + if "$code" --install-extension "$id" >/dev/null 2>&1; then ok ' ok'; else printf '\033[31m FAILED\033[0m\n'; fi +done +echo "Reload the window."