| | #!/usr/bin/env bash | | | # pi-box — запуск pi в изолированной bwrap-песочнице. | | | # | | | # Проект монтируется в /work целиком; всё, что нужно инструменту, лежит в | | | # <проект>/.pi-box. Наружу ничего не пишется, домашние каталоги хоста | | | # (и ключи в них) внутри не видны — кроме явного -H, и то только для чтения. | | | # | | | # Внутри: /work (проект, rw), /box/profile, /box/sessions, /pi (ro), /home пусто. | | | # | | | # Полная справка: pi-box --help | | | set -uo pipefail | | | # ── цвета ──────────────────────────────────────────────────────────────────── | | | # Только для терминала: в конвейере лишние escape-последовательности мешают. |
| | if [ -t 1 ]; then |
| | C_RST=$'\033[0m'; C_B=$'\033[1m'; C_DIM=$'\033[2m' |
| | C_CYAN=$'\033[36m'; C_GREEN=$'\033[32m'; C_YEL=$'\033[33m'; C_RED=$'\033[31m' |
| | else | | | C_RST=""; C_B=""; C_DIM=""; C_CYAN=""; C_GREEN=""; C_YEL=""; C_RED="" | | | fi | | | # ── справка ──────────────────────────────────────────────────────────────── | | | # Текст целиком внутри функции, а не вырезан из шапки по номерам строк: | | | # правка шапки однажды уже обрезала help посреди предложения. | | | usage() { | | | cat <<EOF | | | ${C_B}pi-box${C_RST} — ${C_CYAN}pi${C_RST} в изолированной песочнице (bwrap). | | | Основной режим — запуск pi в текущем каталоге. Проект монтируется в /work и | | | доступен на запись; домашние каталоги хоста внутри не видны. | | | Повторный вызов подключается к уже запущенной песочнице. |
| | ${C_B}ЗАПУСК${C_RST} |
| | ${C_GREEN}pi-box${C_RST} [PATH] запустить (по умолчанию текущий каталог) |
| | ${C_GREEN}pi-box${C_RST} -p "промпт" [PATH] один запрос, без TUI |
| | ${C_GREEN}pi-box${C_RST} -a [ИМЯ\|PATH] подключиться к запущенной |
| | ${C_GREEN}pi-box${C_RST} -m меню песочниц (даже если есть своя) |
| | ${C_GREEN}pi-box${C_RST} -x + X11 и GPU: рендер 3D, окна |
| | ${C_GREEN}pi-box${C_RST} -H + домашний каталог, только чтение |
| | ${C_DIM}Без аргументов открывается меню песочниц.${C_RST} |
| | ${C_B}ФАЙЛЫ${C_RST} |
| | ${C_GREEN}pi-box${C_RST} -l список песочниц |
| | ${C_GREEN}pi-box${C_RST} --rm [ИМЯ\|PATH] удалить .pi-box проекта |
| | ${C_GREEN}pi-box${C_RST} -f не спрашивать подтверждения при удалении |
| | ${C_GREEN}pi-box${C_RST} --new пересоздать (сносит профиль и сессии) |
| | ${C_GREEN}pi-box${C_RST} -v SRC:DST[:ro\|rw] смонтировать каталог (можно несколько) |
| | ${C_B}ВНУТРИ ПЕСОЧНИЦЫ${C_RST} |
| | ${C_CYAN}/work${C_RST} проект, чтение и запись |
| | ${C_CYAN}\$HOME${C_RST} весь дом, только чтение (с -H) |
| | ${C_CYAN}/box/profile${C_RST} профиль pi: auth, settings, модели, плагины |
| | ${C_CYAN}/box/sessions${C_RST} сессии |
| | ${C_CYAN}/pi${C_RST} установленный pi (только чтение) |
| | ${C_DIM}Состояние инструмента — <проект>/.pi-box (профиль, сессии); сам каталог |
| | агенту не виден. /home внутри пуст, корень, /etc и /var — только чтение.${C_RST} | | | ${C_B}-H: ДОМАШНИЙ КАТАЛОГ${C_RST} | | | Дом монтируется рядом с /work по своему пути, ${C_B}только для чтения${C_RST}. | | | Агент видит конфиги и может искать файлы, но ничего там не меняет. | | | От чтения скрыты известные хранилища секретов: | | | ${C_DIM}.ssh .gnupg .aws .kube .docker .mozilla .netrc .git-credentials | | | .my.cnf .pgpass .password-store .config/gh .config/gcloud .local/share/keyrings${C_RST} |
| | ${C_B}ПЕРЕМЕННЫЕ${C_RST} |
| | ${C_CYAN}PI_BOX_HOME${C_RST} реестр песочниц (по умолчанию ~/.local/share/pi-box) |
| | ${C_CYAN}PI_BOX_PI${C_RST} каталог @earendil-works с установленным pi |
| | ${C_CYAN}PI_BOX_RUNTIME${C_RST} откуда брать auth, модели и плагины (по умолчанию ~/.pi/agent) |
| | ${C_DIM}Нужны bwrap, tmux и установленный pi.${C_RST} |
| | EOF | | | } | | | # ── настройки ──────────────────────────────────────────────────────────────── |
| | PI_BOX_HOME="${PI_BOX_HOME:-${XDG_DATA_HOME:-$HOME/.local/share}/pi-box}" |
| | PI_SRC="${PI_BOX_PI:-$HOME/.local/lib/node_modules/@earendil-works}" |
| | PI_BASENAME="pi-coding-agent" |
| | RUNTIME="${PI_BOX_RUNTIME:-$HOME/.pi/agent}" # источник auth/models/плагинов |
| | SESSION_ID="${PI_BOX_SESSION_ID:-main}" |
| | BOX_DIRNAME=".pi-box" |
| | TMUX_NAME="${PI_BOX_TMUX:-pi-box}" |
| | # XAUTHORITY нужен для -x/--x11: без него X11 отказывает в доступе. Если | | | # переменная не задана, ищем файл в обычных местах. |
| | if [ -z "${XAUTHORITY:-}" ]; then |
| | for c in "$HOME/.Xauthority" "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/xauth" \ |
| | "$HOME/.local/share/sx/xauthority"; do | | | [ -f "$c" ] && { XAUTHORITY="$c"; break; } | | | done | | | fi |
| | require_pi() { |
| | if [ ! -f "$PI_SRC/$PI_BASENAME/dist/bundle/cli.js" ]; then |
| | echo "${C_RED}Не найден pi${C_RST}: $PI_SRC/$PI_BASENAME" >&2 |
| | echo "Укажи установку через ${C_CYAN}PI_BOX_PI${C_RST} или поставь pi обычным способом." >&2 | | | exit 1 | | | fi | | | } | | | # Проверяем зависимости до работы: иначе падение будет невнятным (или, хуже, | | | # скрипт напишет «запущена», не запустив ничего). |
| | require_tools() { |
| | local miss=() t |
| | for t in bwrap tmux; do | | | command -v "$t" >/dev/null 2>&1 || miss+=("$t") | | | done | | | # node ищем один раз и запоминаем путь: запускать будем именно его, а не | | | # жёсткий /usr/bin/node — иначе с nvm проверка проходит, а запуск падает. |
| | NODE_BIN=$(command -v node 2>/dev/null) |
| | [ -n "$NODE_BIN" ] \|\| miss+=("node") |
| | [ ${#miss[@]} -eq 0 ] && return 0 |
| | echo "${C_RED}Нет программ${C_RST}: ${miss[*]}" >&2 |
| | echo "Нужны: bwrap (bubblewrap), tmux и node." >&2 |
| | echo "Пример установки (Debian/Ubuntu): sudo apt install bubblewrap tmux nodejs" >&2 | | | exit 1 | | | } | | | # Реестр песочниц: без общего каталога перечислять иначе нечего. | | | register() { | | | mkdir -p "$PI_BOX_HOME" | | | grep -qxF "$1" "$PI_BOX_HOME/boxes" 2>/dev/null || echo "$1" >> "$PI_BOX_HOME/boxes" | | | } | | | # Сокет в /tmp: путь внутри проекта может быть длинным, а sun_path — 107 байт. | | | box_socket() { echo "/tmp/pi-box-$(id -u)/$(printf %s "$1" | md5sum | cut -c1-12).sock"; } | | | # ── создание .pi-box ───────────────────────────────────────────────────────── | | | box_init() { | | | local project="$1" | | | local d="$project/$BOX_DIRNAME" | | | mkdir -p "$d"/{profile/extensions,sessions,tmp} | | | # Ключи не должны попасть в git: внутри .pi-box игнорируем всё. | | | [ -f "$d/.gitignore" ] || printf '*\n' > "$d/.gitignore" | | | [ -f "$RUNTIME/auth.json" ] && install -m 600 "$RUNTIME/auth.json" "$d/profile/auth.json" | | | [ -f "$RUNTIME/models-store.json" ] && install -m 600 "$RUNTIME/models-store.json" "$d/profile/models-store.json" | | | # Плагины не копируются: они берутся из $RUNTIME/extensions при запуске. | | | # Если нужны свои — положи их сюда, и они получат приоритет. | | | if [ ! -f "$d/profile/settings.json" ]; then | | | local provider model |
| | provider=$(sed -n 's/.*"defaultProvider" *: *"$[^"]*$".*/\1/p' "$RUNTIME/settings.json" 2>/dev/null \| head -1) |
| | model=$(sed -n 's/.*"defaultModel" *: *"$[^"]*$".*/\1/p' "$RUNTIME/settings.json" 2>/dev/null \| head -1) |
| | cat > "$d/profile/settings.json" <<EOF | | | { |
| | "packages": [], |
| | "defaultProvider": "${provider:-ez-ai}", |
| | "defaultModel": "${model:-deepseek/deepseek-v4.1-flash}", |
| | "theme": "dark" | | | } | | | EOF | | | fi | | | cat > "$d/tmp/tmux.conf" <<'EOF' |
| | set -g default-terminal "tmux-256color" |
| | set -as terminal-features ",*:RGB" |
| | set -g extended-keys on |
| | set -g extended-keys-format csi-u |
| | set -g mouse on | | | set -g history-limit 50000 | | | EOF | | | } | | | # ── argv для bwrap ───────────────────────────────────────────────────────────── | | | # /work монтируется как есть, но поверх .pi-box кладётся пустой tmpfs. Профиль | | | # rw (pi пишет lock-файлы), но auth.json и models-store.json — ro поверх, чтобы | | | # случайная запись или rm не сломали ключ. | | | box_bwrap() { | | | local project="$1" | | | local p="$project/$BOX_DIRNAME/profile" | | | BW=( | | | bwrap |
| | --ro-bind /usr /usr |
| | --ro-bind /etc /etc |
| | --ro-bind /var /var |
| | --proc /proc |
| | --dev /dev |
| | --tmpfs /tmp |
| | # /home не пробрасываем: там чужие каталоги, ключи и конфиги. Внутри | | | # остаётся пустой tmpfs, а нужное (pi, проект) смонтировано точечно. |
| | --tmpfs /home |
| | --bind "$project" /work |
| | --dir /box |
| | --bind "$p" /box/profile |
| | --bind "$project/$BOX_DIRNAME/sessions" /box/sessions | | | # pi берём из установки на хосте, только чтение. | | | --ro-bind "$PI_SRC" /pi | | | # Окружение и рабочий каталог — самим bwrap, без shell-обвязки: | | | # так значения с кавычками и $(...) не попадут в интерпретатор. | | | --setenv HOME /box | | | --setenv PI_CODING_AGENT_DIR /box/profile |
| | --chdir /work |
| | --unshare-pid |
| | --die-with-parent |
| | ) | | | # Ctrl-H: домашний каталог рядом с /work, только чтение. Именно ro, а не | | | # «дом как проект»: агент может читать конфиги и искать файлы, но ничего | | | # там не меняет. Секреты маскируем даже от чтения — они не нужны для работы, | | | # а утекут в контекст модели при первом же ls. | | | if [ "$HOMERO" = 1 ]; then | | | local hp="$HOME" | | | BW+=(--ro-bind "$hp" "$hp") | | | # Каталог — пустым tmpfs, файл — /dev/null: --tmpfs на файл bwrap | | | # отвергает («Destination is not a directory», проверено). | | | local sec | | | for sec in .ssh .gnupg .password-store .aws .kube .docker .mozilla \ | | | .config/gh .config/gcloud .local/share/keyrings \ | | | .netrc .git-credentials .my.cnf .pgpass; do |
| | [ -e "$hp/$sec" ] \|\| continue |
| | if [ -d "$hp/$sec" ]; then |
| | BW+=(--tmpfs "$hp/$sec") |
| | else | | | BW+=(--ro-bind /dev/null "$hp/$sec") | | | fi | | | done | | | fi | | | # Маскируем .pi-box внутри /work — свой и чужие (вложенные песочницы). | | | # | | | # Именно --tmpfs на существующий каталог, а не безусловный --tmpfs на путь: | | | # bwrap создаёт точку монтирования в реальной файловой системе, если её нет | | | # (проверено). Иначе на хосте появлялся пустой каталог-ловушка, а find_root | | | # затем находил его и подменял им проект. | | | # | | | # Пути вложенных берём из реестра, а не find: обход всего $HOME занимал | | | # десятки секунд, а известных песочниц единицы. | | | local nested rel |
| | [ -d "$project/$BOX_DIRNAME" ] && BW+=(--tmpfs "/work/$BOX_DIRNAME") |
| | while IFS= read -r nested; do |
| | [ -n "$nested" ] \|\| continue |
| | [ -d "$nested/$BOX_DIRNAME" ] \|\| continue |
| | case "$nested" in |
| | "$project"/*) rel="${nested#$project}" ;; |
| | *) continue ;; |
| | esac | | | BW+=(--tmpfs "/work$rel/$BOX_DIRNAME") | | | done < "$PI_BOX_HOME/boxes" | | | # Раскладку /bin, /lib, /lib64, /sbin повторяем с хоста, а не угадываем: на | | | # merged-usr это симлинки в usr, но на Fedora /lib64 -> usr/lib64, и жёсткий | | | # usr/lib оставил бы /lib64 битым. Симлинк — копируем цель, настоящий | | | # каталог — монтируем. | | | local l tgt | | | for l in /bin /lib /lib64 /sbin; do |
| | tgt=$(readlink "$l" 2>/dev/null) |
| | if [ -n "$tgt" ]; then |
| | BW+=(--symlink "$tgt" "$l") |
| | elif [ -d "$l" ]; then |
| | BW+=(--ro-bind "$l" "$l") |
| | fi | | | done | | | # node бывает вне /usr (nvm, /opt, /usr/local), а этих каталогов внутри нет: | | | # запуск упал бы, хотя проверка command -v прошла. Пробрасываем тот самый | | | # бинарник, которым запускаем, чтобы проверка и запуск не расходились. | | | case "$NODE_BIN" in |
| | /usr/*\|/etc/*\|/var/*) ;; |
| | *) [ -n "$NODE_BIN" ] && BW+=(--ro-bind "$NODE_BIN" "$NODE_BIN") ;; |
| | esac | | | # Плагины: нет своих — подключаем каталог из $RUNTIME read-only, чтобы | | | # правка плагина была видна во всех песочницах сразу. Есть свои — ничего не | | | # монтируем, чтобы не перекрыть намеренную настройку. |
| | if [ -z "$(ls -A "$p/extensions" 2>/dev/null)" ] && [ -d "$RUNTIME/extensions" ]; then |
| | BW+=(--ro-bind "$RUNTIME/extensions" /box/profile/extensions) |
| | fi | | | # Ключи — только чтение, поверх rw-профиля. |
| | [ -f "$p/auth.json" ] && BW+=(--ro-bind "$p/auth.json" /box/profile/auth.json) |
| | [ -f "$p/models-store.json" ] && BW+=(--ro-bind "$p/models-store.json" /box/profile/models-store.json) |
| | # -v src:dst[:ro\|rw] — как в docker. Целевую точку bwrap создаёт сам, включая |
| | # вложенную. | | | local v src dst mode rest |
| | for v in ${VOLUMES[@]+"${VOLUMES[@]}"}; do |
| | [ -n "$v" ] \|\| continue |
| | src="${v%%:*}"; rest="${v#*:}" |
| | [ "$rest" = "$v" ] && { echo "-v: нужен вид <источник>:<куда>[:ro\|rw], получил '$v'" >&2; exit 1; } |
| | dst="${rest%%:*}" |
| | mode="${rest#*:}"; [ "$mode" = "$rest" ] && mode="rw" |
| | src="${src/#\~/$HOME}" |
| | [ -e "$src" ] \|\| { echo "-v: нет источника '$src'" >&2; exit 1; } |
| | case "$mode" in |
| | ro\|readonly) BW+=(--ro-bind "$src" "$dst") ;; |
| | rw\|readwrite) BW+=(--bind "$src" "$dst") ;; |
| | *) echo "-v: режим '$mode' неизвестен (ro или rw)" >&2; exit 1 ;; |
| | esac | | | done | | | # -x/--x11: проброс X11 и GPU. /sys нужен Mesa, иначе рендер уходит на llvmpipe. |
| | if [ "$X11" = 1 ]; then |
| | [ -d /dev/dri ] && BW+=(--dev-bind /dev/dri /dev/dri) |
| | [ -d /sys ] && BW+=(--ro-bind /sys /sys) |
| | [ -d /tmp/.X11-unix ] && BW+=(--ro-bind /tmp/.X11-unix /tmp/.X11-unix) |
| | [ -n "${XAUTHORITY:-}" ] && [ -f "$XAUTHORITY" ] && BW+=(--ro-bind "$XAUTHORITY" /box/.Xauthority) |
| | BW+=(--setenv DISPLAY "${DISPLAY:-:0}") |
| | [ -n "${XAUTHORITY:-}" ] && BW+=(--setenv XAUTHORITY /box/.Xauthority) |
| | BW+=(--setenv LIBGL_ALWAYS_SOFTWARE 0) |
| | # Звук и сессия — только если есть. |
| | [ -n "${XDG_RUNTIME_DIR:-}" ] && [ -d "$XDG_RUNTIME_DIR" ] && \ |
| | BW+=(--ro-bind "$XDG_RUNTIME_DIR" "$XDG_RUNTIME_DIR" --setenv XDG_RUNTIME_DIR "$XDG_RUNTIME_DIR") |
| | fi | | | } | | | # ── поиск песочниц ─────────────────────────────────────────────────────────── | | | # Из реестра: "<путь>" по имени каталога, точному пути или части имени. | | | find_box() { | | | local want="$1" | | | [ -f "$PI_BOX_HOME/boxes" ] || return 1 | | | local line hit="" |
| | while IFS= read -r line; do |
| | [ -n "$line" ] \|\| continue |
| | [ -d "$line" ] \|\| continue |
| | if [ "$line" = "$want" ] \|\| [ "$(basename "$line")" = "$want" ]; then |
| | echo "$line"; return 0 | | | fi | | | case "$(basename "$line")" in "$want") hit="$line" ;; esac | | | done < "$PI_BOX_HOME/boxes" | | | [ -n "$hit" ] && { echo "$hit"; return 0; } | | | return 1 | | | } |
| | is_running() { |
| | local s; s=$(box_socket "$1") |
| | [ -S "$s" ] && tmux -S "$s" has-session -t "$TMUX_NAME" 2>/dev/null |
| | } | | | # Режимы запущенной песочницы читаем из argv живого bwrap: скрипт их нигде не | | | # хранит, а процесс о них знает. Так статус честный и после перезапуска | | | # инструмента. Вывод: " X11 ~ro" или пусто. | | | box_modes() { | | | local s pid argv |
| | s=$(box_socket "$1") |
| | [ -S "$s" ] \|\| return 0 |
| | pid=$(tmux -S "$s" list-panes -a -F '#{pane_pid}' 2>/dev/null \| head -1) |
| | [ -n "$pid" ] && [ -r "/proc/$pid/cmdline" ] \|\| return 0 |
| | argv=$(tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null) |
| | grep -qx '/dev/dri' <<< "$argv" && printf ' X11' | | | # Дом виден как ro-bind самого $HOME — ищем именно такую пару аргументов. | | | grep -A1 -x -- '--ro-bind' <<< "$argv" | grep -qx -- "$HOME" && printf ' ~ro' | | | } |
| | running_boxes() { |
| | [ -f "$PI_BOX_HOME/boxes" ] \|\| return 0 |
| | local line |
| | while IFS= read -r line; do |
| | [ -n "$line" ] && [ -d "$line" ] && is_running "$line" && echo "$line" |
| | done < "$PI_BOX_HOME/boxes" | | | } |
| | cmd_list() { |
| | [ -s "$PI_BOX_HOME/boxes" ] \|\| { echo "песочниц нет"; return; } |
| | local line n sz sess run |
| | while IFS= read -r line; do |
| | [ -n "$line" ] \|\| continue |
| | if [ ! -d "$line/$BOX_DIRNAME" ]; then |
| | printf " %-24s (удалена)\n" "$(basename "$line")" |
| | continue | | | fi |
| | n=$(basename "$line"); sz=$(du -sh "$line/$BOX_DIRNAME" 2>/dev/null \| cut -f1) |
| | sess=$(find "$line/$BOX_DIRNAME/sessions" -maxdepth 1 -name '*.jsonl' 2>/dev/null \| wc -l) |
| | run=""; is_running "$line" && run=" [запущена]" |
| | printf " %-24s %6s сессий:%s%s\n" "$n" "$sz" "$sess" "${C_GREEN}$run${C_RST}" |
| | done < "$PI_BOX_HOME/boxes" | | | echo | | | echo "удалить: pi-box --rm <имя>" | | | } |
| | cmd_rm() { |
| | local target="${1:-}" line sock pid |
| | [ -n "$target" ] \|\| { echo "укажи песочницу (pi-box -l)"; exit 1; } |
| | line=$(find_box "$target") \|\| { echo "нет песочницы '$target' (pi-box -l)" >&2; exit 1; } |
| | confirm_rm "$line" || return 1 | | | # Гасим ровно процессы песочницы: PID панелей tmux, а потом сам сервер. | | | # pkill по строке argv тут опасен — под шаблон попадает и наш собственный | | | # shell, если в его аргументах есть тот же путь. |
| | sock=$(box_socket "$line") |
| | for pid in $(tmux -S "$sock" list-panes -a -F '#{pane_pid}' 2>/dev/null); do |
| | kill "$pid" 2>/dev/null | | | done | | | tmux -S "$sock" kill-server 2>/dev/null | | | sleep 1 | | | rm -rf "$line/$BOX_DIRNAME" && echo "${C_GREEN}удалено${C_RST}: $line/$BOX_DIRNAME" | | | # Убираем из реестра. | | | if [ -f "$PI_BOX_HOME/boxes" ]; then | | | grep -vxF "$line" "$PI_BOX_HOME/boxes" > "$PI_BOX_HOME/boxes.tmp" 2>/dev/null || true | | | mv "$PI_BOX_HOME/boxes.tmp" "$PI_BOX_HOME/boxes" | | | fi | | | } | | | # Удаление запущенной песочницы прерывает работу и сносит профиль с историей — | | | # отменить это нельзя. Спрашиваем только у активной: у остановленной терять | | | # нечего, и подтверждение там было бы лишним шумом. |
| | confirm_rm() { |
| | local line="$1" ans="" |
| | is_running "$line" || return 0 |
| | [ "$FORCE" = 1 ] && return 0 |
| | printf '%s' "${C_YEL}«$(basename "$line")» запущена${C_RST} — работа прервётся, профиль и история сессий удалятся. Удалить? [y/N] " >&2 |
| | # Читаем с терминала, а не со stdin: в режиме --rm stdin бывает занят | | | # конвейером, а в меню его уже прочитал fzf. Нет терминала (cron, скрипт) — | | | # отвечать некому, поэтому не удаляем: пусть решает --force. | | | { read -r ans < /dev/tty; } 2>/dev/null || { | | | echo "нет терминала для подтверждения — используй --force" >&2 | | | return 1 | | | } | | | case "$ans" in | | | [yYдД]*) return 0 ;; | | | esac | | | echo "отменено" >&2 | | | return 1 | | | } | | | # ── интерактивное меню ──────────────────────────────────────────────────────── | | | # ── интерактивное меню ──────────────────────────────────────────────────────── | | | # Живёт в текущем экране (fzf --height), история остаётся видна. Цикл: | | | # после удаления или отказа показываем список снова, пока не выбрана песочница | | | # или пользователь не нажал Esc. | | | # | | | # Enter открыть (создать, если выбран текущий каталог) | | | # Ctrl-D удалить выбранную | | | # Esc выйти | | | # | | | # Цель — одной командой сделать всё: завести, открыть, снести. |
| | cmd_menu() { |
| | command -v fzf >/dev/null 2>&1 \|\| { |
| | echo "${C_RED}Нужен fzf${C_RST} для интерактивного выбора." >&2 |
| | echo "Установи fzf или используй аргументы (pi-box -l, pi-box PATH)." >&2 |
| | exit 1 | | | } | | | local here; here=$(pwd -P) | | | # Из домашнего каталога песочницу не создаём (см. проверку ниже), поэтому | | | # и строки «создать» для него нет. |
| | [ "$here" = "$HOME" ] && here="" |
| | while :; do |
| | # Однострочные строки: fzf 0.74 разворачивает \n внутри элемента | | | # нестабильно — при единственной строке рисует «␊» вместо переноса, | | | # поэтому многострочность не используем. Разделитель полей — \x1f, | | | # а не таб: таб может встретиться в пути. | | | local -a rows=() | | | # Создание — отдельная заметная строка, а не серый путь: раньше её было | | | # не отличить от «открыть существующую». |
| | [ -n "$here" ] && rows+=("${C_CYAN}+ создать здесь${C_RST} ${C_DIM}$here${C_RST}\x1fHERE") |
| | if [ -f "$PI_BOX_HOME/boxes" ]; then |
| | local line name run st m |
| | while IFS= read -r line; do |
| | [ -n "$line" ] \|\| continue |
| | [ -d "$line/$BOX_DIRNAME" ] \|\| continue |
| | [ "$line" = "$here" ] && continue |
| | name=$(basename "$line") |
| | # Статус и режимы — в первой колонке: её видно. Режимы берём у живого | | | # процесса, чтобы после перезапуска инструмента они не пропадали. | | | if is_running "$line"; then |
| | m=$(box_modes "$line") |
| | st="${C_GREEN}●${C_RST}${C_YEL}${m}${C_RST}" |
| | else | | | st="${C_DIM}·${C_RST}" | | | fi |
| | sz=$(du -sh "$line/$BOX_DIRNAME" 2>/dev/null \| cut -f1) |
| | rows+=("$(printf '%-16s %s' "$name" "$st") ${C_DIM}${sz} $line${C_RST}\x1f$line") |
| | done < "$PI_BOX_HOME/boxes" | | | fi | | | # Активные режимы — в приглашении: видно, с чем откроется выбранная. | | | local modes="" |
| | [ "$X11" = 1 ] && modes=" · ${C_YEL}X11${C_RST}" |
| | [ "$HOMERO" = 1 ] && modes="$modes · ${C_YEL}\$HOME ro${C_RST}" |
| | local out="" |
| | out=$(printf '%b\0' "${rows[@]}" \| fzf --ansi --read0 \ |
| | --height="${FZF_HEIGHT:-~45%}" \ |
| | --reverse --border --no-multi \ |
| | --expect=ctrl-d,ctrl-x,ctrl-h \ |
| | --prompt="песочница${modes}> " \ |
| | --header='Enter — открыть · Ctrl-D — удалить · Ctrl-X — X11 · Ctrl-H — $HOME ro · Esc — выход' \ |
| | --delimiter='\x1f' --with-nth=1 --accept-nth=2) \|\| exit 0 |
| | local -a kl=() |
| | readarray -t kl < <(printf '%s\n' "$out") |
| | # ${kl[1]:-}: при set -u обращение к отсутствующему элементу — ошибка, |
| | # а при Enter fzf отдаёт одну строку (без имени клавиши). | | | local pressed="${kl[0]:-}" want="${kl[1]:-}" | | | # При Enter строки с клавишей нет — выбор оказывается первым. | | | [ "${#kl[@]}" -lt 2 ] && { pressed=""; want="${kl[0]:-}"; } | | | case "$pressed" in |
| | ctrl-x) X11=$((1 - X11)); continue ;; |
| | ctrl-h) HOMERO=$((1 - HOMERO)); continue ;; |
| | esac |
| | [ -n "$want" ] \|\| continue |
| | if [ "$pressed" = ctrl-d ]; then |
| | if [ "$want" = HERE ]; then |
| | echo "${C_YEL}Здесь ещё нет песочницы — удалять нечего${C_RST}" >&2 |
| | continue | | | fi | | | cmd_rm "$want" | | | continue | | | fi | | | [ "$want" = HERE ] && MENU_PROJECT="$here" || MENU_PROJECT="$want" | | | return 0 | | | done | | | } |
| | cmd_attach() { |
| | local want="${1:-}" line |
| | if [ -z "$want" ]; then |
| | local run=(); while IFS= read -r l; do run+=("$l"); done < <(running_boxes) |
| | case "${#run[@]}" in |
| | 0) echo "нет запущенных песочниц (pi-box -l)" >&2; exit 1 ;; |
| | 1) line="${run[0]}" ;; |
| | *) echo "запущено несколько — укажи имя:" >&2 |
| | for l in "${run[@]}"; do echo " $(basename "$l")" >&2; done; exit 1 ;; |
| | esac | | | else | | | line=$(find_box "$want") || { echo "нет песочницы '$want' (pi-box -l)" >&2; exit 1; } | | | fi |
| | is_running "$line" \|\| { echo "песочница '$(basename "$line")' не запущена" >&2; exit 1; } |
| | exec tmux -S "$(box_socket "$line")" attach -t "$TMUX_NAME" |
| | } | | | # ── разбор аргументов ──────────────────────────────────────────────────────── |
| | NEW=0; PROMPT=""; ATTACH=""; X11=0; HOMERO=0; VOLUMES=(); PROJECT=""; MENU=0; FORCE=0 |
| | while [ $# -gt 0 ]; do |
| | case "$1" in |
| | -h\|--help) usage; exit 0 ;; |
| | -l\|--list) cmd_list; exit 0 ;; |
| | --rm) shift; cmd_rm "${1:-}"; exit $? ;; |
| | -f\|--force) FORCE=1; shift ;; |
| | --new) NEW=1; shift ;; |
| | -m\|--menu) MENU=1; shift ;; |
| | -x\|--x11) X11=1; shift ;; |
| | -H\|--home) HOMERO=1; shift ;; |
| | -v\|--volume) VOLUMES+=("${2:-}"); shift 2 ;; |
| | -a\|--attach) ATTACH="1"; shift |
| | case "${1:-}" in ''\|-*) ;; *) ATTACH="$1"; shift ;; esac ;; |
| | -p\|--print) PROMPT="${2:-}"; shift 2 ;; |
| | -*) echo "неизвестный флаг: $1" >&2; exit 1 ;; |
| | *) PROJECT="$1"; shift ;; |
| | esac | | | done | | | # Зависимости проверяем сразу после разбора аргументов: --help и -l должны | | | # работать без них, а всё остальное — нет. | | | require_tools | | | [ -n "$ATTACH" ] && { [ "$ATTACH" = 1 ] && cmd_attach "" || cmd_attach "$ATTACH"; } | | | # Ищем .pi-box вверх по дереву — как git. Запуск из подкаталога подхватывает | | | # уже существующую песочницу, а не плодит вложенную. | | | find_root() { | | | local dir="$1" |
| | while :; do |
| | [ -d "$dir/$BOX_DIRNAME" ] && { echo "$dir"; return 0; } |
| | [ "$dir" = "/" ] && break |
| | dir=$(dirname "$dir") |
| | done | | | return 1 | | | } |
| | PROJECT="${PROJECT:-$PWD}" |
| | [ -d "$PROJECT" ] \|\| { echo "${C_RED}нет каталога${C_RST}: $PROJECT" >&2; exit 1; } |
| | # realpath -P: без него один проект попадёт в реестр под разными путями | | | # (симлинки, .. , относительный путь), и песочница «раздвоится». |
| | PROJECT=$(cd "$PROJECT" 2>/dev/null && pwd -P) |
| | [ -n "$PROJECT" ] \|\| { echo "${C_RED}не удалось определить каталог${C_RST}: $PWD" >&2; exit 1; } |
| | ROOT=$(find_root "$PROJECT") \|\| ROOT="" |
| | # Меню — только когда песочницы нет ни здесь, ни выше. Если мы уже в проекте | | | # с .pi-box, запуск/подключение ожидается сразу, без списка. |
| | if [ "$MENU" = 1 ] \|\| { [ -z "$ROOT" ] && [ "$NEW" = 0 ] && [ -z "$PROMPT" ] \ |
| | && [ "$X11" = 0 ] && [ ${#VOLUMES[@]} = 0 ] && [ -z "$ATTACH" ]; }; then |
| | cmd_menu |
| | PROJECT="${MENU_PROJECT:-$PROJECT}" |
| | ROOT=$(find_root "$PROJECT") \|\| ROOT="" |
| | fi | | | [ -n "$ROOT" ] || ROOT="$PROJECT" | | | # Защита: корень и дом — это не проект. Иначе /work получил бы весь $HOME | | | # (включая .ssh, .pi, чужие проекты), и агент мог бы их править. Дом для чтения | | | # даёт Ctrl-H — он монтирует $HOME рядом с /work, не делая его проектом. | | | case "$ROOT" in |
| | "$HOME") echo "${C_RED}Нельзя${C_RST} запускать в домашнем каталоге: /work получил бы весь $HOME." >&2 |
| | echo "Зайди в каталог проекта: ${C_CYAN}cd ~/code/myproject && pi-box${C_RST}" >&2 |
| | echo "Весь дом только для чтения — ${C_CYAN}pi-box -m${C_RST} и Ctrl-H." >&2 |
| | exit 1 ;; |
| | "/") echo "${C_RED}Нельзя${C_RST} использовать корень / как проект." >&2; exit 1 ;; |
| | esac | | | # .pi-box не должен быть симлинком. Проект монтируется в /work rw, поэтому | | | # симлинк в .pi-box позволял бы писать за пределы песочницы (в ~/.ssh, /etc) | | | # и увёл бы --rm на чужой каталог. Не тюрьма — страховка от случайности. |
| | reject_link() { |
| | [ ! -L "$1" ] \|\| { |
| | echo "${C_RED}опасный симлинк${C_RST}: $1" >&2 |
| | echo ".pi-box и всё внутри должны быть настоящими каталогами и файлами." >&2 | | | exit 1 | | | } | | | } | | | reject_link "$ROOT/$BOX_DIRNAME" | | | for sub in profile profile/extensions sessions tmp profile/auth.json \ | | | profile/models-store.json profile/settings.json tmp/tmux.conf; do | | | reject_link "$ROOT/$BOX_DIRNAME/$sub" | | | done | | | register "$ROOT" | | | # --new на работающей песочнице сносит профиль из-под живой сессии: она | | | # продолжит держать старые файлы (bind), а новая запустится с чистым профилем — | | | # и два процесса будут писать в одно место. Отказываемся. |
| | if [ "$NEW" = 1 ] && is_running "$ROOT"; then |
| | echo "${C_RED}Песочница запущена${C_RST} — сначала останови её:" >&2 |
| | echo " ${C_CYAN}pi-box --rm $(basename "$ROOT")${C_RST} (или подключись: pi-box -a)" >&2 |
| | exit 1 | | | fi | | | require_pi | | | [ "$NEW" = 1 ] && rm -rf "$ROOT/$BOX_DIRNAME" | | | box_init "$ROOT" | | | # pi монтируется в /pi целиком как каталог @earendil-works, поэтому путь к | | | # cli.js повторяет его структуру. | | | PI="/pi/$PI_BASENAME/dist/bundle/cli.js" | | | box_bwrap "$ROOT" | | | # ── один запрос ────────────────────────────────────────────────────────────── | | | # Без bash -c: переменные и cwd ставятся самим bwrap. Через bash -c значение | | | # с кавычкой или $(...) попадало в shell — проверено, PI_BOX_SESSION_ID мог | | | # выполнить произвольную команду. Теперь значения идут аргументами, не строкой. |
| | if [ -n "$PROMPT" ]; then |
| | exec "${BW[@]}" "$NODE_BIN" "$PI" -p \ |
| | --session-dir /box/sessions --session-id "$SESSION_ID" "$PROMPT" |
| | fi | | | # ── интерактив ─────────────────────────────────────────────────────────────── | | | # Через tmux: опции терминала надо выставить до создания панели, иначе панель | | | # унаследует screen-256color и потеряет truecolor, а Shift+Enter — Enter. | | | # | | | # -c "$ROOT": каталог сессии, из него открываются новые окна (prefix+c). | | | # tmux-сервер живёт СНАРУЖИ bwrap, поэтому новые окна — обычные шеллы хоста, | | | # и путь должен быть настоящим: /work существует только внутри песочницы. |
| | SOCK=$(box_socket "$ROOT") |
| | mkdir -p "$(dirname "$SOCK")" |
| | if is_running "$ROOT"; then |
| | [ -t 0 ] && exec tmux -S "$SOCK" attach -t "$TMUX_NAME" |
| | echo "${C_GREEN}песочница${C_RST} '${C_B}$(basename "$ROOT")${C_RST}' уже запущена — ${C_CYAN}pi-box -a${C_RST}" |
| | exit 0 | | | fi | | | # tmux new-session возвращает 0 даже когда панель не поднялась (проверено), | | | # поэтому успех подтверждаем отдельной проверкой, а не кодом возврата. |
| | tmux -S "$SOCK" -f "$ROOT/$BOX_DIRNAME/tmp/tmux.conf" new-session -d -s "$TMUX_NAME" -c "$ROOT" \ |
| | "${BW[@]}" "$NODE_BIN" "$PI" \ |
| | --session-dir /box/sessions --session-id "$SESSION_ID" |
| | if ! is_running "$ROOT"; then | | | echo "${C_RED}не удалось запустить песочницу${C_RST}" >&2 | | | echo "Проверь, что проект лежит в доступном каталоге; запусти без tmux:" >&2 | | | echo " ${C_CYAN}pi-box -p "echo test"${C_RST}" >&2 | | | exit 1 | | | fi |
| | [ -t 0 ] && exec tmux -S "$SOCK" attach -t "$TMUX_NAME" |
| | echo "${C_GREEN}песочница${C_RST} '${C_B}$(basename "$ROOT")${C_RST}' запущена — ${C_CYAN}pi-box -a${C_RST}" |