Files
socialmag/install.sh
T
juxtapoandClaude Opus 5 9968ba040f v0.7.4: the dash unit walked into the selectors.py trap
socialmag-dash crash-looped on a real box. WorkingDirectory was the repo and
ExecStart was `python -m http.server`, so socketserver's `import selectors`
found the repo's selectors.py and the unit died with "module 'selectors' has no
attribute 'SelectSelector'" every 5 seconds. :8321 never bound.

This is the SAME root cause already fixed twice this evening — once for
`-m socialmag` (console scripts, cli.py) and once for `-m venv` (run python
from /). Both fixes were about keeping the repo off sys.path, and then the unit
file put it right back on. A `-m` anywhere near this directory is the hazard,
not any particular entry point.

The dash now serves with --directory and sits in / instead of standing in the
tree it serves. The by-hand fallback instructions carried the identical bug and
now do the same, with a line saying why.

openbridge and refresh were never affected: they exec console scripts, which do
not put the working directory on sys.path. That is exactly why cli.py exists.

The real fix is renaming selectors.py and queue.py. Until then this trap keeps
finding new ways in, and that is not a prediction — it is three for three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 02:46:06 +08:00

260 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# socialmag installer — clone, run this, sign in, done.
#
# What it does not do: log you in. socialmag holds no passwords, which is the
# whole design. The last step is yours and it only happens once.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV="${SOCIALMAG_VENV:-$REPO/.venv}"
CDP_ENDPOINT="${SOCIALMAG_CDP_ENDPOINT:-http://127.0.0.1:9336}"
UNIT_DIR="$HOME/.config/systemd/user"
HERMES_CONFIG="${HERMES_CONFIG:-$HOME/.hermes/config.yaml}"
say() { printf '\n\033[1;36m==>\033[0m %s\n' "$*"; }
ok() { printf ' \033[32mok\033[0m %s\n' "$*"; }
warn() { printf ' \033[33m!!\033[0m %s\n' "$*"; }
die() { printf '\n\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; }
# ---------------------------------------------------------------- python
say "Python"
command -v python3 >/dev/null 2>&1 || die "python3 not found on PATH"
# Every python3 call below runs from / rather than from here. The repo root is
# the package dir, so it ships selectors.py and queue.py — names the stdlib also
# uses. Python puts the current directory first on sys.path, so `python3 -m venv`
# run from inside a fresh clone dies at `import subprocess` with
# "module 'selectors' has no attribute 'SelectSelector'". Running from / is the
# whole fix. (This is the same trap cli.py exists to keep off users.)
(cd / && python3 - <<'PY') || die "socialmag needs Python 3.10 or newer"
import sys
sys.exit(0 if sys.version_info >= (3, 10) else 1)
PY
ok "$(cd / && python3 -V)"
# `import venv` succeeding proves nothing: Debian ships the venv module but
# splits ensurepip into a separate package, and `python3 -m venv` then dies
# with "ensurepip is not available" only after creating a half-built .venv.
# Ask for ensurepip by name, before anything is written to disk.
if ! (cd / && python3 -c "import ensurepip" 2>/dev/null); then
_pyver="$(cd / && python3 -c 'import sys;print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
die "python3 has the venv module but not ensurepip, so it cannot create a venv.
Debian/Ubuntu split this out. Install it and re-run:
sudo apt install python3.${_pyver#*.}-venv
(or: sudo apt install python3-venv)"
fi
ok "ensurepip present"
# ---------------------------------------------------------------- venv
say "Virtualenv + package"
if [[ ! -x "$VENV/bin/python" ]]; then
# Python's own failure text names the exact missing package. Do not paper over
# it with a guess — say where to look and get out of the way.
(cd / && python3 -m venv "$VENV") || die "could not create a venv at $VENV
Python printed the reason above; it is more specific than anything this
script could infer. A leftover half-built $VENV may need removing first."
ok "created $VENV"
else
ok "reusing $VENV"
fi
# Editable on purpose. socialmag resolves socialmag.db, state.json and
# dashboard.json relative to its own __file__; a non-editable install would put
# all three inside site-packages, where nothing else looks for them.
(cd / && "$VENV/bin/pip" install --quiet --upgrade pip) >/dev/null
(cd / && "$VENV/bin/pip" install --quiet -e "$REPO") || die "pip install failed"
ok "socialmag installed (editable), websockets pulled in"
for script in socialmag socialmag-refresh socialmag-openbridge; do
[[ -x "$VENV/bin/$script" ]] || die "console script $script was not generated — pyproject.toml [project.scripts] did not take"
done
ok "console scripts: socialmag, socialmag-refresh, socialmag-openbridge"
# ---------------------------------------------------------------- systemd
say "systemd user units"
# systemctl being installed does NOT mean a user bus is reachable. Inside an LXC,
# over `su`, or on a box with no logind session, `systemctl --user` fails with
# "Failed to connect to user scope bus". Ask the real question — can we talk to
# the user manager — instead of asking whether the binary exists.
USER_BUS=""
BUS_FIXED_UP=""
if command -v systemctl >/dev/null 2>&1; then
if systemctl --user show-environment >/dev/null 2>&1; then
USER_BUS="yes"
else
# A shell entered via `su` keeps the ORIGINAL user's XDG_RUNTIME_DIR, so
# `systemctl --user` looks for the bus in someone else's directory and finds
# nothing — even when this user's manager is running perfectly well. The
# runtime dir is /run/user/<uid> by definition, so work it out instead of
# asking someone to export it by hand and get the digits right.
_own_runtime="/run/user/$(id -u)"
if [[ -d "$_own_runtime" && "${XDG_RUNTIME_DIR:-}" != "$_own_runtime" ]]; then
export XDG_RUNTIME_DIR="$_own_runtime"
if systemctl --user show-environment >/dev/null 2>&1; then
USER_BUS="yes"
BUS_FIXED_UP="yes"
fi
fi
fi
fi
if [[ -n "$BUS_FIXED_UP" ]]; then
ok "user bus found at $XDG_RUNTIME_DIR (the inherited XDG_RUNTIME_DIR pointed elsewhere)"
warn "this shell still has the old value. For later commands in THIS shell:"
warn " export XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"
fi
if [[ -z "$USER_BUS" ]]; then
warn "no reachable systemd user bus — units NOT installed."
if ! command -v systemctl >/dev/null 2>&1; then
warn "reason: systemctl is not on PATH."
else
warn "reason: systemctl --user cannot reach a user manager (XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-unset})."
if [[ ! -d "/run/user/$(id -u)" ]]; then
warn "/run/user/$(id -u) does not exist — $USER has no logind session at all."
warn " sudo loginctl enable-linger $USER # then re-run; no fresh login needed"
else
warn "/run/user/$(id -u) exists but the manager there did not answer."
warn " systemctl status user@$(id -u).service # start here"
fi
fi
warn "until then, run the three by hand (from anywhere EXCEPT $REPO):"
warn " $VENV/bin/socialmag-openbridge &"
warn " $VENV/bin/python -m http.server 8321 --directory $REPO &"
warn " $VENV/bin/socialmag-refresh # one pass, whenever you want it"
warn " (-m from inside the repo imports its selectors.py over the stdlib's)"
else
mkdir -p "$UNIT_DIR"
for src in "$REPO"/deploy/user/*.in; do
dest="$UNIT_DIR/$(basename "${src%.in}")"
sed -e "s|@REPO@|$REPO|g" \
-e "s|@VENV@|$VENV|g" \
-e "s|@CDP_ENDPOINT@|$CDP_ENDPOINT|g" \
"$src" > "$dest"
ok "$(basename "$dest")"
done
cp "$REPO/deploy/user/socialmag-refresh.timer" "$UNIT_DIR/socialmag-refresh.timer"
ok "socialmag-refresh.timer"
systemctl --user daemon-reload
systemctl --user enable --now socialmag-dash.service socialmag-openbridge.service >/dev/null
systemctl --user enable --now socialmag-refresh.timer >/dev/null
ok "enabled: dash (:8321), openbridge (:8322), refresh timer (30min)"
# These die at logout unless lingering is on. Say so rather than let someone
# discover it when the dashboard is empty tomorrow.
if ! loginctl show-user "$USER" -p Linger 2>/dev/null | grep -q 'Linger=yes'; then
warn "user services stop when you log out. To keep them running:"
warn " sudo loginctl enable-linger $USER"
fi
fi
# ---------------------------------------------------------------- hermes
say "Hermes MCP registration"
# Hermes installs in more than one shape — /usr/local/bin/hermes wrapping a venv
# under /usr/local/lib, or ~/.local/bin/hermes from a user install. Read the
# interpreter out of the wrapper rather than hardcoding one box's layout.
HERMES_PY=""
if command -v hermes >/dev/null 2>&1; then
_hermes_bin="$(command -v hermes)"
if head -c 2 "$_hermes_bin" 2>/dev/null | grep -q '#!'; then
# A wrapper script: the python it exec's is the interpreter that loads MCP.
HERMES_PY="$(grep -oE '"[^"]*/bin/python[0-9.]*"|[^ "]*/bin/python[0-9.]*' "$_hermes_bin" 2>/dev/null | head -1 | tr -d '"')"
fi
# A console script generated by pip: its own shebang names the interpreter.
if [[ -z "$HERMES_PY" ]]; then
_shebang="$(head -1 "$_hermes_bin" 2>/dev/null)"
[[ "$_shebang" == '#!'*python* ]] && HERMES_PY="${_shebang#\#!}"
fi
fi
if ! command -v hermes >/dev/null 2>&1; then
warn "hermes not on PATH — skipping. Register manually later:"
printf '\n mcp_servers:\n socialmag:\n command: "%s/bin/socialmag"\n args: []\n env:\n SOCIALMAG_CDP_ENDPOINT: "%s"\n\n' "$VENV" "$CDP_ENDPOINT"
elif [[ ! -f "$HERMES_CONFIG" ]]; then
die "hermes is installed but $HERMES_CONFIG does not exist.
Start hermes once so it writes a config, then re-run this installer."
else
# Hermes treats the mcp python package as optional: without it the entire MCP
# subsystem is a no-op that logs at debug level. A server that never loaded
# looks exactly like one that loaded and did nothing, so check it here rather
# than let that be discovered later.
if [[ -x "$HERMES_PY" ]]; then
if "$HERMES_PY" -c "import importlib.util,sys; sys.exit(0 if importlib.util.find_spec('mcp') else 1)"; then
ok "hermes has the mcp package"
else
die "hermes cannot load MCP servers: the 'mcp' package is missing from
$HERMES_PY
Without it hermes silently ignores every mcp_servers entry — no error, only a
debug log line. Install it into that interpreter, then re-run:
$HERMES_PY -m pip install mcp"
fi
else
warn "could not find hermes's interpreter at $HERMES_PY — cannot check for the mcp package"
warn "if socialmag's tools never appear in hermes, that is the first thing to check"
fi
if grep -qE '^\s*mcp_servers\s*:' "$HERMES_CONFIG"; then
warn "$HERMES_CONFIG already has an mcp_servers: key — not touching it."
warn "Merging YAML blind is how someone's other servers disappear. Add this by hand:"
printf '\n socialmag:\n command: "%s/bin/socialmag"\n args: []\n env:\n SOCIALMAG_CDP_ENDPOINT: "%s"\n\n' "$VENV" "$CDP_ENDPOINT"
else
backup="$HERMES_CONFIG.bak-pre-socialmag-$(date +%Y%m%d%H%M%S)"
cp "$HERMES_CONFIG" "$backup"
cat >> "$HERMES_CONFIG" <<EOF
mcp_servers:
socialmag:
command: "$VENV/bin/socialmag"
args: []
env:
SOCIALMAG_CDP_ENDPOINT: "$CDP_ENDPOINT"
EOF
ok "registered in $HERMES_CONFIG (backup: $(basename "$backup"))"
fi
fi
# ---------------------------------------------------------------- done
cat <<EOF
$(printf '\033[1;32mInstalled.\033[0m') Two things left, both yours:
1. Start the browser socialmag drives:
$REPO/launch-brave.sh
2. Sign in to your platforms in that window — Threads, Facebook, Instagram,
YouTube, TikTok, X. Once. socialmag stores no passwords; it uses the
session that now belongs to that browser profile.
Then:
EOF
# Only name the dashboard and the journal when units actually got installed.
# Pointing someone at `journalctl --user -u socialmag-refresh` for a unit that
# was never created is the same class of lie as a silent fallback: it reads as
# working and answers with nothing.
if [[ -n "$USER_BUS" ]]; then
cat <<EOF
dashboard http://127.0.0.1:8321/dashboard.html
check $VENV/bin/socialmag-refresh
logs journalctl --user -u socialmag-refresh -f
EOF
else
cat <<EOF
check $VENV/bin/socialmag-refresh
dashboard start the file server yourself (see the warnings above), then
http://127.0.0.1:8321/dashboard.html
No units were installed, so nothing refreshes on a timer yet and there is no
journal to read. Fix the user bus above and re-run to get those.
EOF
fi