# Ostiary — удалённый запуск скриптов Консольный клиент с текстовым интерфейсом (TUI) на Rust. Загружает иерархическое меню с сервера и позволяет запускать bash-скрипты, скачивать файлы и выполнять HTTP-запросы прямо из терминала. Все диалоги (выбор из списка, подтверждение, ввод текста) реализованы внутри интерфейса — никаких сырых `read -p` в терминале. --- ## Установка ### Быстрая установка (Linux / macOS) ```bash curl -fsSL https://vainend.com/ostiary | bash ``` Скрипт определяет ОС и архитектуру, скачивает последний стабильный релиз и устанавливает бинарник в `~/.local/bin/ostiary`. Если этого пути нет в `$PATH` — автоматически добавляет строку в `~/.bashrc` (или `~/.bash_profile` / `~/.profile`). Не требует `sudo`. ### Ручная установка Скачать нужный бинарник со страницы [Releases](https://git.vainend.com/admin/ostiary/releases): | Файл | Платформа | |---|---| | `ostiary-linux-x86_64` | Linux x86-64 | | `ostiary-linux-aarch64` | Linux ARM64 | | `ostiary-macos-aarch64` | macOS Apple Silicon | | `ostiary-macos-x86_64` | macOS Intel | ```bash mkdir -p ~/.local/bin chmod +x ostiary-linux-x86_64 mv ostiary-linux-x86_64 ~/.local/bin/ostiary # Добавить в PATH если нужно echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc source ~/.bashrc ``` ### Удаление ```bash curl -fsSL https://vainend.com/ostiary-remove | bash ``` Или вручную: ```bash rm ~/.local/bin/ostiary rm -rf ~/.config/ostiary ``` ### Переменная окружения | Переменная | Описание | |---|---| | `OSTIARY_API` | Базовый URL Gitea API (если используете собственный инстанс) | Пример с кастомным каталогом: ```bash INSTALL_DIR=~/.local/bin bash install.sh ``` --- ## Навигация ### Главное меню | Клавиша | Действие | |---|---| | `↑` / `↓` или `k` / `j` | Перемещение по списку | | `Enter` или `l` | Войти в категорию / запустить действие | | `Esc` или `h` | Выйти из категории | | `q` | Выйти из приложения | | `R` | Перезагрузить меню с сервера | ### Popup запущенного скрипта | Клавиша | Контекст | Действие | |---|---|---| | `↑` / `↓` или `k` / `j` | Лог / селектор / форма | Прокрутка лога или навигация между элементами | | `PgUp` / `PgDn` | Лог | Прокрутка на 10 строк | | `Space` | Форма | Переключить чекбокс / выбрать радиокнопку | | `Y` / `N` или `l` / `h` | Подтверждение | Ответ без Enter | | `Enter` или `l` | Любой ввод | Отправить / выбрать / применить форму | | `Esc` или `h` | — | Прервать скрипт и закрыть popup | > Vim-motions (`j`/`k`/`l`/`h`) отключаются автоматически во время текстового ввода — символы идут в поле ввода как обычно. --- ## Конфигурация При первом запуске клиент запрашивает URL сервера прямо в терминале — до входа в TUI: ``` ╔══════════════════════════════════╗ ║ Ostiary — первый запуск ║ ╚══════════════════════════════════╝ Конфиг будет сохранён в: /home/user/.config/ostiary/config.toml URL сервера меню: https://your-server/api/menu ``` После ввода создаётся `~/.config/ostiary/config.toml`. Его можно редактировать вручную: ```toml server_url = "https://your-server/api/menu" timeout_sec = 10 update_api = "https://git.example.com/api/v1/repos/user/ostiary" [theme] selected_bg = "blue" ``` | Параметр | Описание | |---|---| | `server_url` | URL эндпоинта `GET /api/menu` | | `timeout_sec` | Таймаут HTTP-запросов в секундах | | `update_api` | Базовый URL Gitea API для проверки обновлений (опционально) | --- ## Автообновление Если задан `update_api`, при каждом запуске клиент в фоне проверяет наличие нового релиза в Gitea. Если найдена новая стабильная версия — показывает диалог: ``` Доступно обновление 1.0.0 → 1.1.0 Размер: 4.2 МБ [Y / l] Обновить [N / h / Esc] Пропустить ``` При подтверждении скачивает бинарник, атомарно заменяет текущий исполняемый файл и перезапускает приложение через `execv` (процесс не пересоздаётся, PID остаётся тем же). ### Naming convention ассетов в Gitea Файлы релиза должны называться по шаблону `{name}-{os}-{arch}`: | Файл | Платформа | |---|---| | `ostiary-linux-x86_64` | Linux 64-bit | | `ostiary-linux-aarch64` | Linux ARM64 | | `ostiary-macos-aarch64` | macOS Apple Silicon | | `ostiary-macos-x86_64` | macOS Intel | --- ## Формат меню (JSON) Сервер отдаёт `GET /api/menu` с `Content-Type: application/json`. ### Корневая структура ```json { "version": "1.0", "menu": [ ...пункты... ] } ``` ### Категория Содержит вложенные пункты. Отображается с иконкой `📁`. ```json { "id": "bitrix", "title": "1С Битрикс", "description": "Инструменты администрирования", "children": [ ...пункты меню... ] } ``` ### Действие Отображается с иконкой `▶`. ```json { "id": "db_manage", "title": "Управление БД", "description": "Дамп, импорт, подключение к базе", "action": { "type": "bash", "script": "#!/bin/bash\necho hello", "interaction": "structured", "confirm": false } } ``` #### `confirm` и `confirm_message` Если `confirm: true`, перед запуском появится диалог подтверждения. `confirm_message` переопределяет текст вопроса. ```json { "id": "drop_db", "title": "Удалить базу данных", "action": { "type": "bash", "script": "...", "interaction": "structured", "confirm": true, "confirm_message": "База данных будет удалена без возможности восстановления. Продолжить?" } } ``` --- ## Типы действий ### `bash` — запуск скрипта ```json { "type": "bash", "script": "#!/bin/bash\n...", "interaction": "structured", "confirm": false } ``` | Параметр | Значения | Описание | |---|---|---| | `script` | строка | Полный текст bash-скрипта включая shebang | | `interaction` | `"structured"` / `"terminal"` | Режим взаимодействия | | `confirm` | `true` / `false` | Запросить подтверждение перед запуском | | `confirm_message` | строка | Кастомный текст диалога подтверждения | **`interaction: "terminal"`** — скрипт запускается дочерним процессом без захвата вывода. **`interaction: "structured"`** — весь вывод отображается в прокручиваемом логе с ANSI-цветами, скрипт может запрашивать ввод через протокол команд. ### `download` — скачивание файла ```json { "type": "download", "url": "https://example.com/file.tar.gz", "filename": "backup.tar.gz", "target_dir": "/var/backups", "confirm": false } ``` ### `download_and_run` — скачать и выполнить ```json { "type": "download_and_run", "url": "https://example.com/installer.sh", "run_args": ["--silent", "--prefix=/opt"], "keep_file": false, "confirm": true } ``` ### `http_request` — HTTP-запрос ```json { "type": "http_request", "method": "POST", "url": "https://api.example.com/deploy", "headers": { "Authorization": "Bearer token123" }, "body": "{\"env\": \"production\"}", "confirm": true } ``` --- ## Интерактивные скрипты (structured) При `interaction: "structured"` скрипт общается с клиентом через протокол команд. Команды отправляются в **stderr** (`>&2`), ответы приходят в **stdin**. ### Базовые хелперы ```bash #!/bin/bash # Отправить команду и получить ответ от пользователя ask() { printf 'CMD:%s\n' "$1" >&2 read -r _response printf '%s' "$_response" } # Отправить уведомление (не ждёт ответа, не блокирует скрипт) notify() { printf 'CMD:%s\n' "$1" >&2 } ``` --- ### Ввод текста (`input`) ```json {"type":"input","prompt":"Введите имя пользователя:","default":"admin","secret":false} ``` | Поле | Описание | |---|---| | `prompt` | Заголовок поля ввода | | `default` | Значение по умолчанию (опционально) | | `secret` | Если `true` — символы заменяются на `•` | ```bash username=$(ask '{"type":"input","prompt":"Имя пользователя:","default":"admin"}') password=$(ask '{"type":"input","prompt":"Пароль:","secret":true}') ``` --- ### Выбор из списка (`menu`) Список с навигацией `↑`/`↓`/`j`/`k`, выбор по `Enter`/`l`. Возвращает `id` выбранного пункта. ```json { "type": "menu", "prompt": "Выберите действие:", "options": [ {"id": "dump", "label": "Создать дамп базы"}, {"id": "import", "label": "Импортировать дамп"}, {"id": "connect","label": "Подключиться к БД"} ] } ``` ```bash action=$(ask '{"type":"menu","prompt":"Выберите действие:","options":[ {"id":"dump","label":"Создать дамп"}, {"id":"import","label":"Импортировать"}, {"id":"exit","label":"Отмена"} ]}') case $action in dump) echo "Создаю дамп..." ;; import) echo "Импортирую..." ;; exit) exit 0 ;; esac ``` **Динамический список** (из массива bash): ```bash files=($(ls *.sql 2>/dev/null)) options='[' for i in "${!files[@]}"; do [ $i -gt 0 ] && options+=',' escaped=$(printf '%s' "${files[$i]}" | sed 's/"/\\"/g') options+="{\"id\":\"$i\",\"label\":\"${escaped}\"}" done options+=']' idx=$(ask "{\"type\":\"menu\",\"prompt\":\"Выберите файл:\",\"options\":${options}}") selected="${files[$idx]}" ``` --- ### Подтверждение (`confirm`) Показывает вопрос с кнопками `[Y]`/`[N]`. Клавиши `Y`/`N`/`l`/`h` обрабатываются без Enter. ```json {"type":"confirm","prompt":"Данные будут перезаписаны. Продолжить?"} ``` Ответ: `"y"` или `"n"`. ```bash answer=$(ask '{"type":"confirm","prompt":"Удалить временные файлы?"}') if [[ "$answer" =~ ^[Yy]$ ]]; then rm -rf /tmp/myapp_* fi ``` --- ### Сообщение (`message`) Показывает сообщение с цветом. Скрипт продолжается после нажатия `Enter`. ```json {"type":"message","level":"info","text":"Операция завершена успешно!"} ``` | `level` | Цвет | |---|---| | `"info"` | Зелёный | | `"warn"` | Жёлтый | | `"error"` | Красный | ```bash if ! command -v mysqldump &> /dev/null; then ask '{"type":"message","level":"error","text":"mysqldump не найден."}' > /dev/null exit 1 fi ``` > `> /dev/null` — сбрасывает пустой ответ клиента чтобы он не попал в переменную. --- ### Прогресс (`progress`) Обновляет прогресс-бар не блокируя скрипт (используйте `notify`, не `ask`). ```json {"type":"progress","percent":45,"message":"Копирование файлов..."} ``` ```bash notify '{"type":"progress","percent":0,"message":"Начало..."}' mysqldump -h "$host" -u "$user" "$db" > backup.sql notify '{"type":"progress","percent":70,"message":"Сжимаю..."}' gzip backup.sql notify '{"type":"progress","percent":100,"message":"Готово!"}' ``` --- ### Форма с чекбоксами и радиокнопками (`form`) Отображает список полей с возможностью навигации и переключения. Возвращает строку с ID всех включённых/выбранных полей через пробел. ```json { "type": "form", "prompt": "Параметры поиска:", "fields": [ {"id": "verbose", "label": "Подробный вывод", "field_type": "checkbox", "default": false}, {"id": "php", "label": "PHP файлы (*.php)", "field_type": "radio", "group": "ftype", "default": true}, {"id": "js", "label": "JS файлы (*.js)", "field_type": "radio", "group": "ftype", "default": false} ] } ``` | Поле | Описание | |---|---| | `id` | Идентификатор, возвращается если поле включено | | `label` | Отображаемый текст | | `field_type` | `"checkbox"` или `"radio"` | | `default` | Начальное состояние | | `group` | Группа радиокнопок — одновременно активна только одна в группе | **Ответ:** строка с ID активных полей через пробел: `"verbose php"` **Клавиши внутри формы:** `↑`/`↓`/`j`/`k` — навигация, `Space` — переключить, `Enter`/`l` — применить, `Esc` — отмена. **Важно:** весь JSON должен быть на **одной строке** — протокол читает строки, не блоки. ```bash result=$(ask '{"type":"form","prompt":"Параметры:","fields":[{"id":"verbose","label":"Подробный вывод","field_type":"checkbox","default":false},{"id":"php","label":"PHP файлы","field_type":"radio","group":"t","default":true},{"id":"js","label":"JS файлы","field_type":"radio","group":"t","default":false}]}') is_on() { [[ " $result " == *" $1 "* ]]; } is_on "verbose" && FLAGS+=("-v") is_on "php" && INCLUDE="--include=*.php" is_on "js" && INCLUDE="--include=*.js" ``` --- ### Запуск внешнего приложения (`exec`) Временно **передаёт терминал** внешней программе (mysql, vim, htop и др.). Клиент скрывает TUI, программа работает на полном экране, после завершения TUI восстанавливается. Возвращает код завершения. ```json {"type":"exec","shell":"mysql -h localhost -u root mydb"} ``` ```bash # Интерактивная сессия mysql exit_code=$(ask "{\"type\":\"exec\",\"shell\":\"mysql -h \\\"$host\\\" -u \\\"$user\\\" -p\\\"$pass\\\" \\\"$db\\\"\"}") echo "Сессия завершена (код: $exit_code)" # Редактирование конфига ask '{"type":"exec","shell":"vim /etc/nginx/nginx.conf"}' > /dev/null systemctl reload nginx # Мониторинг ресурсов ask '{"type":"exec","shell":"htop"}' > /dev/null ``` --- ## Полный пример скрипта ```bash #!/bin/bash ask() { printf 'CMD:%s\n' "$1" >&2; read -r _r; printf '%s' "$_r"; } notify() { printf 'CMD:%s\n' "$1" >&2; } host=$(ask '{"type":"input","prompt":"Хост:","default":"localhost"}') db=$(ask '{"type":"input","prompt":"База данных:"}') user=$(ask '{"type":"input","prompt":"Пользователь:","default":"root"}') pass=$(ask '{"type":"input","prompt":"Пароль:","secret":true}') action=$(ask "{\"type\":\"menu\",\"prompt\":\"Выберите действие:\",\"options\":[ {\"id\":\"dump\", \"label\":\"Создать дамп\"}, {\"id\":\"shell\", \"label\":\"Открыть mysql-консоль\"}, {\"id\":\"cancel\",\"label\":\"Отмена\"} ]}") case $action in dump) confirm=$(ask '{"type":"confirm","prompt":"Создать дамп базы данных?"}') [[ ! "$confirm" =~ ^[Yy]$ ]] && exit 0 backup="backup_${db}_$(date +%Y%m%d_%H%M%S).sql" notify '{"type":"progress","percent":10,"message":"Создание дампа..."}' if mysqldump -h "$host" -u "$user" -p"$pass" "$db" > "$backup"; then notify '{"type":"progress","percent":100,"message":"Готово!"}' echo "Дамп: $backup ($(du -h "$backup" | cut -f1))" else ask '{"type":"message","level":"error","text":"Ошибка при создании дампа!"}' > /dev/null exit 1 fi ;; shell) code=$(ask "{\"type\":\"exec\",\"shell\":\"mysql -h \\\"$host\\\" -u \\\"$user\\\" -p\\\"$pass\\\" \\\"$db\\\"\"}") echo "Сессия завершена с кодом $code" ;; cancel) exit 0 ;; esac ``` --- ## Структура сервера ``` server/ ├── index.php # Собирает JSON: menu_structure.json + scripts/ ├── menu_structure.json # Иерархия меню (script_file вместо script) └── scripts/ ├── bitrix_db_manage.sh ├── bitrix_monitoring.sh ├── network_suspicious_connections.sh └── wordpress_db_backup.sh ``` В `menu_structure.json` вместо поля `"script"` используется `"script_file"`: ```json { "id": "db_manage", "title": "Управление БД", "action": { "type": "bash", "script_file": "bitrix_db_manage.sh", "interaction": "structured", "confirm": false } } ``` `index.php` читает файл скрипта при каждом запросе — скрипты редактируются без изменения JSON. ### Добавление нового пункта меню 1. Создать `server/scripts/category_action.sh` 2. Добавить пункт в `menu_structure.json`: ```json { "id": "my_action", "title": "Название в меню", "description": "Описание для нижней панели", "action": { "type": "bash", "script_file": "category_action.sh", "interaction": "structured", "confirm": false } } ``` Соглашение по именованию файлов: `{категория}_{подкатегория}_{действие}.sh` --- ## Сборка релиза Скрипт `release.sh` собирает бинарники для всех платформ сразу и кладёт их в `dist/`: ```bash cd Ostiary ./release.sh # → dist/ostiary-linux-x86_64 # → dist/ostiary-linux-aarch64 # → dist/ostiary-macos-aarch64 ``` После сборки загрузите файлы из `dist/` в [новый релиз Gitea](https://git.vainend.com/admin/ostiary/releases/new). Имена файлов должны точно совпадать с тем, что скачивает `install.sh`. **Вручную — Linux:** ```bash cargo build --release # → target/release/ostiary ``` **Вручную — кросс-компиляция macOS → Linux x86_64 (musl, статический):** ```bash rustup target add x86_64-unknown-linux-musl brew install FiloSottile/musl-cross/musl-cross CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc \ cargo build --release --target x86_64-unknown-linux-musl # → target/x86_64-unknown-linux-musl/release/ostiary ``` **Вручную — ARM64 Linux:** ```bash rustup target add aarch64-unknown-linux-musl CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-musl-gcc \ cargo build --release --target aarch64-unknown-linux-musl # → target/aarch64-unknown-linux-musl/release/ostiary ``` Бинарник собирается статически (musl) и не требует никаких зависимостей на целевом сервере.