Bash install.sh and settings.sh for Linux and macOS

Twins of the PowerShell scripts, needing only bash, curl and one of jq,
node or python3 for JSON. install.sh downloads a release (or installs
from dist/), settings.sh pushes, pulls or diffs User/, both resolve the
code CLI and user folder per platform.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169iPWwKHZoBTNN9qwXiwqk
This commit is contained in:
max
2026-09-08 19:50:07 +02:00
co-authored by Claude Fable 5.1
parent 9085266dd3
commit c058c09050
4 changed files with 451 additions and 3 deletions
+10 -3
View File
@@ -19,6 +19,10 @@ $env:GITEA_TOKEN = '<token>'
# on any machine, including a fresh one # on any machine, including a fresh one
.\scripts\install.ps1 # download from the release and install .\scripts\install.ps1 # download from the release and install
.\scripts\settings.ps1 -Pull # apply settings, keybindings, marketplace extensions .\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`, 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 | | [`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 | | [`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 | | [`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 ### 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 - **`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. `.vsix` are two copies of the same extension id; VS Code loads one arbitrarily.
`dev-link.ps1 -Unlink` before installing. `dev-link.ps1 -Unlink` before installing.
- **Linux and macOS need pwsh.** The scripts run under PowerShell 7 (`pwsh`) there and - **Linux and macOS.** `install.sh` and `settings.sh` are bash twins of the PowerShell
find VS Code's user folder at `~/.config/Code/User` (`~/Library/Application Support/Code/User` 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 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 `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. instead of a junction. `ci-release.sh` needs only bash.
+179
View File
@@ -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 <file-or-'-'> <dotted.path> -> 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 <file-or-'-'> <array.path> <field1> <field2> ... -> 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 "id<TAB>path<TAB>repo" 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 <tag> <token> -> 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 <url> <destination> <token>
#
# 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
}
+109
View File
@@ -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 <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.<name>.
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."
+153
View File
@@ -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."