diff --git a/CLAUDE.md b/CLAUDE.md index 21e5f2fb..a96c9031 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -391,7 +391,7 @@ import petriplot as pp # For Petri-specific plotting helpers - **Secrets are per-project**: API keys require `setup-envrc` in each project. Running `npm postinstall` or `pip install` in a project without `.envrc` cannot access secrets (this is intentional — supply chain defense). Legacy `.secrets` / `.env` files may still exist locally but are no longer the intended runtime path. - **min-release-age quarantine**: All package managers have a 7-day delay on new releases. Packages published <7 days ago will fail to install. This is intentional. See `claude/rules/supply-chain-security.md` for override syntax - **Pueue + systemd slices**: `j*` aliases require pueue + systemd user session. `systemd --user` doesn't work inside Claude Code sandbox (bubblewrap blocks D-Bus) — test from normal shell. Cgroup delegation may need one-time `sudo systemctl set-property user-$(id -u).slice Delegate=yes`. Config in `config/resources.conf` (edit when scaling machine). -- **CLI tool package strategy**: macOS uses Homebrew (ecosystem, GUI apps, libraries). Linux uses apt for baseline + mise `github:` backend for modern versions of fast-moving CLI tools (fzf, bat, eza, fd, ripgrep, delta, dust, zoxide, jless, just, sd, duf, gum, vivid). apt packages are often years behind upstream; mise downloads release binaries from GitHub with version tracking (`mise upgrade --all`). Homebrew on Linux was rejected (too heavy, installs own gcc/glibc). See `PACKAGES_CORE` (apt/brew), `PACKAGES_MACOS` (brew), `PACKAGES_LINUX_MISE` (mise) in `config.sh`. **Node.js is the exception** — it's a global runtime (NodeSource `setup_lts.x` on Linux, brew on macOS), installed via `install_node` in `scripts/shared/helpers.sh`, NOT mise. Reason: tools shebang against `node` (e.g. obsidian-headless's `ob` → `#!/usr/bin/env node`) and systemd/cron can't see mise's shell-activated shims. Tracks the **current LTS line** (never an odd "Current" release); the skip-guard floor is the live latest-LTS major fetched from nodejs.org (never EOL — older node is converged up), so re-running `install.sh` after an LTS rollover force-upgrades the major and native modules must be rebuilt. `install_node` deliberately does NOT gate the `apt-get install` on the NodeSource script's exit code (it can exit non-zero after writing the repo, which once left a box on stock Ubuntu node 18). bun stays the package manager/runtime for JS/TS; it is NOT a Node version manager and can't run direct-V8 native modules like better-sqlite3 +- **CLI tool package strategy**: macOS uses Homebrew (ecosystem, GUI apps, libraries). Linux uses apt for baseline + mise `github:` backend for modern versions of fast-moving CLI tools (fzf, bat, eza, fd, ripgrep, delta, dust, zoxide, jless, just, sd, duf, gum, vivid). apt packages are often years behind upstream; mise downloads release binaries from GitHub with version tracking (`mise upgrade --all`). Homebrew on Linux was rejected (too heavy, installs own gcc/glibc). See `PACKAGES_CORE` (apt/brew), `PACKAGES_MACOS` (brew), `PACKAGES_LINUX_MISE` (mise) in `config.sh`. **Node.js is the exception** — it's a global runtime (NodeSource `setup_lts.x` on Linux, brew on macOS), installed via `install_node` in `scripts/shared/helpers.sh`, NOT mise. Reason: tools shebang against `node` (e.g. obsidian-headless's `ob` → `#!/usr/bin/env node`) and systemd/cron can't see mise's shell-activated shims. Tracks the **current LTS line** (never an odd "Current" release); the skip-guard floor is the live latest-LTS major fetched from nodejs.org (never EOL — older node is converged up), so re-running `install.sh` after an LTS rollover force-upgrades the major and native modules must be rebuilt. `install_node` deliberately does NOT gate the `apt-get install` on the NodeSource script's exit code (it can exit non-zero after writing the repo, which once left a box on stock Ubuntu node 18). Its skip-guard checks the **system node** (`/usr/bin/node`, brew paths) via `system_node_path()`, never `node` from PATH — a mise-installed node shadows a stale apt node in interactive shells and would mask the guard forever; `install_node` also evicts any mise-managed node (`evict_mise_node`) once the system node is healthy. bun stays the package manager/runtime for JS/TS; it is NOT a Node version manager and can't run direct-V8 native modules like better-sqlite3. **One home per JS-ecosystem CLI on Linux: bun global (`~/.bun/bin`)** — `update-ai-tools` updates via `bun add -g` and removes `~/.npm-global/bin` duplicates that shadow the bun copy (`~/.npm-global` remains only as the sudo-free `NPM_CONFIG_PREFIX` home for socket-cli); root-owned strays in `/usr/local/bin` are flagged for manual removal - **Rust + bash dual implementations**: Some tools have a Rust version (for speed) and a bash fallback. Keep both in sync. Rust source lives in `tools/claude-tools/src/`, bash in `claude/`. Recompile with `cd tools/claude-tools && cargo build --release` then `cp target/release/claude-tools ../../custom_bins/`. Current dual-impl tools: statusline (`statusline.rs` + `claude/statusline.sh`), usage (`usage.rs` + inline in `statusline.sh`) - **`mas 7.0.0` requires sudo for every install** (`mas install`, `mas get`, `mas purchase`). `mas` self-escalates (calls `sudo` internally). `install.sh --apps` pre-warms sudo with `sudo -v` (interactive TTY only) and keeps it alive with a background heartbeat for the duration of `brew bundle`, so a single password entry covers all mas apps. `mas account` was removed in 7.0.0 — no CLI way to confirm the signed-in store account (App Store UI only). iCloud account and Media & Purchases (store) account can differ; `mas` only cares about the store account. @@ -409,3 +409,4 @@ import petriplot as pp # For Petri-specific plotting helpers - rust-skills plugin removed (2026-05-26). UserPromptSubmit matcher was hyper-broad ("error", "async", "API", "implement", "explain", "how to" — injected ~100 lines on most prompts). Neuter-via-SessionStart-hook didn't hold (still fired same session) and mutates a tracked file in the marketplace clone, blocking future `git pull`. Re-add if Rust work picks up - mas `install` vs `get`: `brew bundle` drives App Store installs via `mas install` (re-download only — fails "Redownload Unavailable" on a new machine even for owned apps). `mas get` (= `mas purchase`) is acquire+install and actually works. Fixed by `custom_bins/mas-get`, called before `brew bundle` in install.sh. Ref: github.com/Homebrew/brew/issues/21559 (2026-06-20) - Hourly checks: `usage-ping` warms the subscription 5-hour window (must run with `ANTHROPIC_API_KEY` unset so it uses OAuth, not the metered API — tested and confirmed working 2026-06-21); `tmux-resume` auto-resumes rate-limited tmux Claude/Codex panes. Parser bug caught: `IFS='|' read` splits on `|` inside ERE patterns, garbling the send sequence — fixed to use ` | ` (space-pipe-space) as field delimiter. Also: deploy `--only=usage-ping,tmux-resume` may race on Linux crontab since both run in parallel; install each setup script sequentially for guaranteed cron entries. (2026-06-21) +- node/JS-CLI drift audit on the Linux box: `npm` resolved 4 ways (socket alias → mise node 24 → `/usr/bin/npm` 9.2 distro deb → usr-merge dup) and `codex` had 4 installs (`~/.npm-global` 0.144.5 shadowing the bun copy 0.144.1 the daily updater maintained, plus native standalone and a stale root-owned `/usr/local/bin` one). Root causes: `install_node`'s guard read `node -v` from PATH so mise's node 24 masked the stock Ubuntu node 18 (NodeSource repo configured, candidate 24.18.0 never installed), and `update-ai-tools` updated the bun copy without checking PATH shadowing. Fixed: guard now uses `system_node_path()`, `evict_mise_node` converges mise, `dedupe_bun_shadows` removes npm-global duplicates. bun's `bun add -g X@latest` also lags ~7 days behind npm by design (bunfig `minimumReleaseAge` quarantine) — a version gap between bun and npm copies is not a bug (2026-07-19) diff --git a/custom_bins/update-ai-tools b/custom_bins/update-ai-tools index 7c7e27c8..0ad14cdb 100755 --- a/custom_bins/update-ai-tools +++ b/custom_bins/update-ai-tools @@ -80,6 +80,31 @@ trap release_lock EXIT # ─── Core Update Logic ────────────────────────────────────────────────────── +# A bun-managed CLI can be shadowed by an npm -g copy: ~/.npm-global/bin +# precedes ~/.bun/bin on the interactive PATH, so `bun add -g` updates a binary +# the shell never resolves and the daily update silently has no effect (found +# 2026-07-19: codex 0.144.5 in npm-global shadowing the bun copy the updater +# maintained). One home per JS CLI: bun global. Remove user-owned npm +# duplicates; root-owned ones only get flagged (need sudo). +dedupe_bun_shadows() { + local tool_name="$1" package_name="$2" + local npm_copy="$HOME/.npm-global/bin/$tool_name" + if [[ -e "$npm_copy" ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + log "[DRY RUN] Would remove npm-global duplicate: $npm_copy" + elif command -v npm &>/dev/null; then + log "Removing npm-global duplicate of $tool_name (it shadows the bun-managed copy)" + NPM_CONFIG_PREFIX="$HOME/.npm-global" npm uninstall -g "$package_name" 2>&1 \ + || log_err "failed to remove npm-global duplicate at $npm_copy" + else + log_err "npm-global duplicate at $npm_copy shadows the bun copy — remove it manually" + fi + fi + if [[ -e "/usr/local/bin/$tool_name" ]]; then + log_err "root-owned duplicate at /usr/local/bin/$tool_name — remove manually: sudo rm /usr/local/bin/$tool_name" + fi +} + # Update a tool using platform-appropriate manager (brew on macOS, bun on Linux) # Usage: update_tool update_tool() { @@ -106,7 +131,10 @@ update_tool() { if [[ "$DRY_RUN" == "true" ]]; then case "$method" in brew) log "[DRY RUN] Would run: brew upgrade $brew_name" ;; - bun) log "[DRY RUN] Would run: bun add -g ${package_name}@latest" ;; + bun) + log "[DRY RUN] Would run: bun add -g ${package_name}@latest" + dedupe_bun_shadows "$tool_name" "$package_name" + ;; *) log "[DRY RUN] $tool_name: no known update method" ;; esac return 0 @@ -122,6 +150,7 @@ update_tool() { bun) bun add -g "${package_name}@latest" 2>&1 \ || log_err "$tool_name bun update failed" + dedupe_bun_shadows "$tool_name" "$package_name" ;; *) log_err "$tool_name installed but no known update method (expected brew or bun)" diff --git a/scripts/shared/helpers.sh b/scripts/shared/helpers.sh index ff99028c..c90fc082 100644 --- a/scripts/shared/helpers.sh +++ b/scripts/shared/helpers.sh @@ -893,6 +893,36 @@ install_gh_from_release() { # fails the guard and gets converged up to current LTS). Consequence: after an # LTS rollover, re-running install.sh force-upgrades the major (e.g. 24→26) — so # native modules (better-sqlite3) must be rebuilt against the new ABI afterward. +# Path of the SYSTEM node — the one cron/systemd/shebang contexts resolve. +# Deliberately ignores PATH: a mise-installed node shadows a stale apt node in +# interactive shells and would make version guards pass forever (found +# 2026-07-19: mise node 24 masking Ubuntu's stock node 18, with the NodeSource +# repo configured but its candidate never installed). +system_node_path() { + local cand + for cand in /opt/homebrew/bin/node /usr/local/bin/node /usr/bin/node; do + [[ -x "$cand" ]] && { echo "$cand"; return 0; } + done + return 1 +} + +# Node must never be mise-managed: mise's shims are invisible to cron/systemd, +# and its node shadows the system node (see system_node_path). Evict only once +# a healthy system node exists so we never delete the box's only modern node. +evict_mise_node() { + local want="$1" + cmd_exists mise || return 0 + mise ls node 2>/dev/null | grep -q . || return 0 + local sys_node + if ! sys_node=$(system_node_path) || (( $("$sys_node" -v | cut -d. -f1 | tr -d 'v') < want )); then + log_warning "mise-managed node present but system node is missing/stale — keeping mise node for now; re-run install.sh after the system node is fixed" + return 0 + fi + log_warning "Evicting node from mise (node is a global runtime: NodeSource/brew, never mise)" + mise unuse -g node 2>/dev/null || true + mise uninstall --all node 2>/dev/null || true +} + install_node() { # Current LTS major from nodejs.org; dist index is newest-first and r['lts'] # is the codename (truthy) for LTS releases, false otherwise. @@ -900,12 +930,15 @@ install_node() { want=$(curl -fsSL https://nodejs.org/dist/index.json 2>/dev/null \ | python3 -c "import sys,json; d=json.load(sys.stdin); print(next(r['version'] for r in d if r['lts'])[1:].split('.')[0])" 2>/dev/null) [[ "$want" =~ ^[0-9]+$ ]] || want=24 - if is_installed node && (( $(node -v | cut -d. -f1 | tr -d 'v') >= want )); then + local sys_node + if sys_node=$(system_node_path) && (( $("$sys_node" -v | cut -d. -f1 | tr -d 'v') >= want )); then + evict_mise_node "$want" return 0 fi log_info "Installing Node ${want} LTS..." if is_macos; then brew_install node + evict_mise_node "$want" return 0 fi # Linux: NodeSource setup_lts.x adds the repo + runs apt update. Its script @@ -917,6 +950,7 @@ install_node() { curl -fsSL https://deb.nodesource.com/setup_lts.x | $SUDO -E bash - \ || log_warning "NodeSource setup script exited non-zero (repo may still be configured) — continuing" $SUDO apt-get install -y nodejs || log_warning "Node install via apt failed — install Node LTS manually" + evict_mise_node "$want" } # ─── Mise (Universal Version Manager) ─────────────────────────────────────────