diff --git a/README.md b/README.md index 3811e57..1550ccb 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,14 @@ Already have a clone? Just run it directly: `~/.local/bin`. Persists across redeploys; only rebuilt when the venv is missing or broken. Building it may install `python3`/`python3-venv` via the system package manager (the one place this module uses sudo). +- `vscode/` — installs Visual Studio Code from upstream's portable tarball into + `~/.local/opt/vscode`, symlinks `code` into `~/.local/bin`, and writes a + user-level `.desktop` entry so it shows up in the application menu. **No + sudo, nothing outside `$HOME`** — the same contract as `bin/`, so it survives + a Kasm redeploy. Pinned via `VSCODE_VERSION` in the module; bump it to upgrade. + Also sets `password-store: basic` in `~/.vscode/argv.json` so KDE's kwallet + does not prompt for an unlock on every launch (see below). **No-ops on + headless hosts** — it's a ~400MB GUI app — override with `DOTFILES_VSCODE`. - `docker-rootless-cgroup/` — on systemd-less hosts (e.g. Kasm), hand-delegates cgroup v2 controllers to the rootless Docker daemon so per-container cgroups work (rootless Docker otherwise comes up with @@ -111,6 +119,18 @@ item instead of `~/.zshrc.local`: don't set this by hand: run `mcp-config/install.sh` once and its shell hook exports it from `~/.config/mcp-sync/key`. Set it in `~/.zshrc.local` only on a box without mcp-sync. +- `DOTFILES_VSCODE` — controls the `vscode/` module. Unset (default) installs + VS Code only when a graphical session is detected; `1` forces the install on + a headless host (e.g. for `code tunnel`); `0` skips it everywhere. A plain + shell/env var, not a vault secret. + + On the keyring: VS Code's secret storage reaches for the OS keyring through + libsecret, which on KDE makes kwalletd prompt for an unlock before the editor + is usable — every session, since `kwallet-pam` is not installed. The module + sets `"password-store": "basic"` in `~/.vscode/argv.json` to stop that, which + means VS Code's stored tokens are obfuscated on disk rather than sealed + behind a wallet password. To use the wallet instead, install `kwallet-pam` so + it unlocks at login, then change that value to `"kwallet5"`. - `OP_SERVICE_ACCOUNT_TOKEN` — 1Password service-account token. Not a shell variable any more: `secrets/install.sh` asks for it once and stores it in `~/.config/dotfiles/op.env`. Pass it in the env only for an unattended run. diff --git a/bootstrap.sh b/bootstrap.sh index 1a43d57..c5118b0 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -124,6 +124,10 @@ else run_module hermes fi run_module ansible +# GUI editor, installed from upstream's tarball into ~/.local/opt (no sudo, same +# contract as bin/). No ordering constraints — it needs nothing from bin/, +# secrets/ or node/ — and no-ops on headless hosts. +run_module vscode # Runs last: needs rootless Docker already up (started at boot) so it can # delegate cgroups to it. No-ops where that does not apply. run_module docker-rootless-cgroup diff --git a/lib/fetch.sh b/lib/fetch.sh index c810370..a381d9d 100644 --- a/lib/fetch.sh +++ b/lib/fetch.sh @@ -8,8 +8,8 @@ # reset. # # Source this (callers already run `set -eu`), then use fetch_arch / -# fetch_arch_deb, have_version + mark_version, install_tarball_bin, and -# install_raw_bin. +# fetch_arch_deb, have_version + mark_version, install_tarball_bin, +# install_tarball_tree, and install_raw_bin. BIN_DIR="${BIN_DIR:-$HOME/.local/bin}" VERSION_DIR="$BIN_DIR/.versions" @@ -93,6 +93,34 @@ install_zip_bin() { fsay "installed $BIN_DIR/$3" } +# install_tarball_tree — download a +# .tar.gz that holds a whole application directory rather than one binary, and +# swap it into . install_tarball_bin cannot be reused here: VS Code +# is an Electron bundle of thousands of files that only runs from an intact +# tree, not a single binary that can be picked out of the archive. +# +# The new tree is staged beside the target and moved into place only once the +# download and extraction have both succeeded, so an interrupted run leaves the +# previously installed version intact instead of a half-extracted directory. +install_tarball_tree() { + _dest="$3" + _tmp="$(mktemp -d)" + _fetch_dl "$1" "$_tmp/archive.tar.gz" + tar -xzf "$_tmp/archive.tar.gz" -C "$_tmp" + if [ ! -d "$_tmp/$2" ]; then + fsay "fetch: '$2' not found in archive"; rm -rf "$_tmp"; return 1 + fi + mkdir -p "$(dirname "$_dest")" + rm -rf "$_dest.new" "$_dest.old" + mv "$_tmp/$2" "$_dest.new" + if [ -e "$_dest" ]; then + mv "$_dest" "$_dest.old" + fi + mv "$_dest.new" "$_dest" + rm -rf "$_dest.old" "$_tmp" + fsay "installed $_dest" +} + # install_raw_bin — download a single raw binary. install_raw_bin() { mkdir -p "$BIN_DIR" diff --git a/vscode/install.sh b/vscode/install.sh new file mode 100755 index 0000000..8efa679 --- /dev/null +++ b/vscode/install.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env sh +# vscode/install.sh — install Visual Studio Code into ~/.local/opt/vscode. +# +# WHY: VS Code is the one GUI app this repo installs, and the obvious way to get +# it — Microsoft's rpm/deb repo — needs root, writes outside $HOME, and is wiped +# by a Kasm redeploy, so the editor quietly disappears on every rebuild. The box +# this was written for had exactly that half-state: /etc/yum.repos.d/vscode.repo +# present, `code` never installed, and nothing in this repo recording that VS +# Code was meant to be there at all. Upstream's portable tarball is the same +# build with none of that: everything lands under $HOME, no sudo, and it +# survives a redeploy. The only thing given up is dnf/apt-driven updates, which +# is what the pinned VSCODE_VERSION below replaces. +# +# It is not in bin/install.sh because that script installs single self-contained +# binaries; VS Code is an Electron tree of thousands of files plus a menu entry +# and an icon. It follows node/ and ansible/ instead: unpack under ~/.local/opt +# and symlink the CLI into ~/.local/bin. +# +# The Chromium sandbox still works from a userland install: the bundled +# chrome-sandbox helper is not setuid here, so Electron falls back to the +# unprivileged user-namespace sandbox. That needs user namespaces enabled +# (user.max_user_namespaces > 0, the default on every distro this repo targets). +# Verified by launching without --no-sandbox. Nothing is given up for dropping +# root; if a host ever has userns disabled, VS Code says so on startup. +# +# Idempotent and safe to re-run. To upgrade, bump VSCODE_VERSION. +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +REPO_ROOT="$(dirname -- "$SCRIPT_DIR")" + +# shellcheck source=../lib/fetch.sh +. "$REPO_ROOT/lib/fetch.sh" + +say() { printf '%s\n' "$*" >&2; } + +# Pinned deliberately. VS Code auto-updates itself on other platforms; a tarball +# install does not, which is the point — the editor runs your code and holds +# your Git and extension credentials, so the version is a supply-chain decision +# rather than whatever upstream served today. `code --version` prints this +# string verbatim, so it must match with no leading "v". +VSCODE_VERSION="1.139.0" + +PREFIX="$HOME/.local/opt/vscode" +DESKTOP_DIR="$HOME/.local/share/applications" +ARGV_JSON="$HOME/.vscode/argv.json" + +# has_desktop — true when this host has a graphical session to run a GUI editor +# in. VS Code is ~400MB unpacked and $HOME is the thing that persists across +# redeploys, so it is not worth carrying on a box that will never display it. +# Desktop images ship session files under /usr/share/{x,wayland-}sessions. +has_desktop() { + [ -n "${XDG_CURRENT_DESKTOP:-}" ] && return 0 + for d in /usr/share/xsessions /usr/share/wayland-sessions; do + [ -d "$d" ] && [ -n "$(ls -A "$d" 2>/dev/null)" ] && return 0 + done + return 1 +} + +# --- skip on headless hosts -------------------------------------------------- +# DOTFILES_VSCODE=1 forces the install anyway (e.g. for `code tunnel` on a +# server); DOTFILES_VSCODE=0 skips it even on a desktop. +case "${DOTFILES_VSCODE:-auto}" in + 0) say "vscode: DOTFILES_VSCODE=0; skipping."; exit 0 ;; + 1) ;; + *) if ! has_desktop; then + say "vscode: no graphical session detected; skipping (DOTFILES_VSCODE=1 to force)." + exit 0 + fi ;; +esac + +# Upstream keys its download URLs on x64/arm64 rather than amd64/arm64. +case "$(fetch_arch)" in + x86_64) VSCODE_ARCH=x64 ;; + aarch64) VSCODE_ARCH=arm64 ;; + *) say "vscode: unsupported arch $(fetch_arch)"; exit 1 ;; +esac + +# --- install the app tree ---------------------------------------------------- +# Verify it RUNS, not just that the tree exists: a Kasm redeploy can leave a +# present-but-broken directory behind (the same failure node/ and ansible/ +# guard against). +# +# The root fallback is not optional. Run as root, `code --version` refuses -- +# it prints "You are trying to start Visual Studio Code as a super user" +# instead of the version, and still exits 0 -- so a bare run-check never +# matches and would re-download 325MB on every single bootstrap, silently. +# That is the forced-install path (DOTFILES_VSCODE=1 in a container or on a +# server), so for root only, fall back to the recorded version stamp. +installed_version="$("$PREFIX/bin/code" --version 2>/dev/null | head -1 || true)" +if [ "$installed_version" = "$VSCODE_VERSION" ]; then + say "vscode: $VSCODE_VERSION already present at $PREFIX." +elif [ "$(id -u)" = "0" ] && have_version code "$VSCODE_VERSION"; then + say "vscode: $VSCODE_VERSION already present at $PREFIX (stamped; root cannot query --version)." +else + say "vscode: installing $VSCODE_VERSION into $PREFIX (this is a ~325MB download)..." + install_tarball_tree \ + "https://update.code.visualstudio.com/$VSCODE_VERSION/linux-$VSCODE_ARCH/stable" \ + "VSCode-linux-$VSCODE_ARCH" "$PREFIX" + + # bin/code is upstream's CLI wrapper (`code .`, `code --install-extension`); + # the Electron binary at the tree root is what the menu entry launches. + mkdir -p "$BIN_DIR" + ln -sf "$PREFIX/bin/code" "$BIN_DIR/code" + mark_version code "$VSCODE_VERSION" + say "vscode: installed $VSCODE_VERSION and linked $BIN_DIR/code." +fi + +# --- menu entry -------------------------------------------------------------- +# Icon= is an absolute path rather than a theme name: the bundled icon is a +# single 1024x1024 PNG and hicolor tops out at 512x512, so there is no correct +# themed directory to drop it in. The spec allows an absolute path, and it needs +# no icon-cache rebuild to take effect. +mkdir -p "$DESKTOP_DIR" +cat >"$DESKTOP_DIR/code.desktop" </dev/null 2>&1; then + update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true +fi +if command -v kbuildsycoca5 >/dev/null 2>&1; then + kbuildsycoca5 --noincremental >/dev/null 2>&1 || true +fi + +# --- keyring prompt suppression ---------------------------------------------- +# WHY: on first launch Electron's safeStorage reaches for the OS keyring through +# libsecret. On a KDE session that wakes kwalletd, which pops an unlock/create +# dialog before the editor is usable -- and since kwallet-pam is not installed +# on these hosts, nothing unlocks the wallet at login, so it prompts every +# session. "basic" keeps VS Code's secrets in its own store and never reaches +# for the wallet at all. Verified with dbus-monitor: no org.kde.KWallet traffic +# on launch. +# +# TRADE-OFF: basic is weaker than a real keyring. Secrets VS Code stores (Git +# and GitHub auth tokens, extension credentials) end up obfuscated on disk +# rather than sealed behind a wallet password. To move to the wallet later, +# install kwallet-pam so it unlocks at login, then change the value to +# "kwallet5" and restart VS Code. +# +# The path comes from product.json's dataFolderName (".vscode"), NOT the +# user-data-dir: argv.json lives at ~/.vscode/argv.json and a copy written +# anywhere else is read by nothing and silently ignored. +if [ ! -e "$ARGV_JSON" ]; then + mkdir -p "$(dirname "$ARGV_JSON")" + cat >"$ARGV_JSON" <<'ARGV' +// Runtime arguments for VS Code. See dotfiles vscode/install.sh. +{ + // Keep secrets in VS Code's own store rather than the OS keyring, so KDE's + // kwallet does not prompt for an unlock on every launch. + "password-store": "basic" +} +ARGV + say "vscode: wrote $ARGV_JSON (password-store=basic)" +elif grep -q '"password-store"' "$ARGV_JSON"; then + say "vscode: $ARGV_JSON already sets password-store; leaving it alone." +else + # Merge rather than overwrite: this file is JSON-with-comments that VS Code + # and the user both own, and it carries a crash-reporter-id that should + # survive. Insert the key after the opening brace and keep the rest byte for + # byte. + cp -p "$ARGV_JSON" "$ARGV_JSON.bak" + if awk ' + BEGIN { done = 0 } + { + print + if (!done && $0 ~ /^[[:space:]]*\{[[:space:]]*$/) { + print "\t// Added by dotfiles vscode/install.sh: keep secrets in VS Code'"'"'s" + print "\t// own store so kwallet does not prompt for an unlock on every launch." + print "\t\"password-store\": \"basic\"," + done = 1 + } + } + END { exit(done ? 0 : 1) } + ' "$ARGV_JSON.bak" >"$ARGV_JSON.new"; then + mv "$ARGV_JSON.new" "$ARGV_JSON" + say "vscode: set password-store=basic in $ARGV_JSON (backup: $ARGV_JSON.bak)" + else + rm -f "$ARGV_JSON.new" + say "vscode: WARNING: could not find the object opening in $ARGV_JSON." + say " Add '\"password-store\": \"basic\"' by hand to stop the keyring prompt." + fi +fi + +# A system-wide VS Code (from Microsoft's rpm/deb repo) shadows this one in the +# menu and shows a duplicate entry. Flag it rather than touching it -- removing +# it needs the root this module deliberately avoids. +if [ -e /usr/share/applications/com.microsoft.VSCode.desktop ]; then + say "" + say "vscode: NOTE: a system-wide VS Code package is also installed." + say " You will see two menu entries until it is removed:" + say " sudo dnf remove code # or: sudo apt remove code" + say "" +fi + +say "vscode: ready."