From 3fafc71cddf679f8daa43b45d95791bce5d7f97f Mon Sep 17 00:00:00 2001 From: Uber Veng Date: Fri, 22 May 2026 01:00:57 +0700 Subject: [PATCH] Added startup setup --- Cargo.toml | 2 +- README.md | 278 +++++++++++++++++++++++--------------------------- release.sh | 55 ++++++++++ src/config.rs | 59 ++++++----- src/main.rs | 38 ++++++- 5 files changed, 254 insertions(+), 178 deletions(-) create mode 100755 release.sh diff --git a/Cargo.toml b/Cargo.toml index 55f35b6..cf17369 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ostiary" -version = "1.0.5" +version = "1.0.6" edition = "2024" [dependencies] diff --git a/README.md b/README.md index bfddae4..bad915e 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,99 @@ # Ostiary — удалённый запуск скриптов -Консольный клиент с текстовым интерфейсом (TUI) на Rust. Загружает иерархическое меню с сервера и позволяет запускать bash-скрипты, скачивать файлы и выполнять HTTP-запросы прямо из терминала. Все диалоги (выбор из списка, подтверждение, ввод текста) реализованы внутри интерфейса — никаких сырых `read -p` в терминале. +Консольный клиент с текстовым интерфейсом (TUI) на Rust. Загружает иерархическое меню с сервера и позволяет запускать bash-скрипты, скачивать файлы и выполнять HTTP-запросы прямо из терминале. Все диалоги (выбор из списка, подтверждение, ввод текста) реализованы внутри интерфейса — никаких сырых `read -p` в терминале. --- ## Навигация +### Главное меню + | Клавиша | Действие | |---|---| -| `↑` / `↓` | Перемещение по списку | -| `Enter` | Войти в категорию / запустить действие | -| `Esc` | Выйти из категории / закрыть popup / отменить скрипт | +| `↑` / `↓` или `k` / `j` | Перемещение по списку | +| `Enter` или `l` | Войти в категорию / запустить действие | +| `Esc` или `h` | Выйти из категории | | `q` | Выйти из приложения | | `R` | Перезагрузить меню с сервера | -В попапе запущенного скрипта: +### Popup запущенного скрипта | Клавиша | Действие | |---|---| -| `↑` / `↓` | Прокрутка лога (если нет активного запроса) / навигация по селектору | +| `↑` / `↓` или `k` / `j` | Прокрутка лога / навигация по селектору | | `PgUp` / `PgDn` | Прокрутка лога на 10 строк | -| `Y` / `N` | Быстрый ответ на подтверждение | -| `Esc` | Прервать скрипт и закрыть popup | +| `Y` / `N` или `l` / `h` | Ответ на подтверждение (без Enter) | +| `Enter` или `l` | Выбрать пункт селектора / подтвердить | +| `Esc` или `h` | Прервать скрипт и закрыть popup | + +> Vim-motions (`j`/`k`/`l`/`h`) отключаются автоматически во время текстового ввода — символы идут в поле ввода как обычно. --- ## Конфигурация -При первом запуске создаётся файл `~/.config/tui-client/config.toml`: +При первом запуске клиент запрашивает URL сервера прямо в терминале — до входа в TUI: + +``` + ╔══════════════════════════════════╗ + ║ Ostiary — первый запуск ║ + ╚══════════════════════════════════╝ + + Конфиг будет сохранён в: + /home/user/.config/ostiary/config.toml + + URL сервера меню: https://your-server/api/menu +``` + +После ввода создаётся `~/.config/ostiary/config.toml`. Его можно редактировать вручную: ```toml -server_url = "http://your-server/api/menu" +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`. +Сервер отдаёт `GET /api/menu` с `Content-Type: application/json`. ### Корневая структура @@ -67,16 +119,9 @@ selected_bg = "blue" } ``` -| Поле | Обязательное | Описание | -|---|---|---| -| `id` | ✓ | Уникальный идентификатор | -| `title` | ✓ | Отображаемое название | -| `description` | — | Текст в нижней панели при выборе | -| `children` | ✓ | Вложенные категории и действия | - ### Действие -Отображается с иконкой `⚡`. +Отображается с иконкой `▶`. ```json { @@ -92,13 +137,6 @@ selected_bg = "blue" } ``` -| Поле | Обязательное | Описание | -|---|---|---| -| `id` | ✓ | Уникальный идентификатор | -| `title` | ✓ | Отображаемое название | -| `description` | — | Текст в нижней панели | -| `action` | ✓ | Объект действия | - #### `confirm` и `confirm_message` Если `confirm: true`, перед запуском появится диалог подтверждения. @@ -113,7 +151,7 @@ selected_bg = "blue" "script": "...", "interaction": "structured", "confirm": true, - "confirm_message": "Внимание! База данных будет удалена без возможности восстановления. Продолжить?" + "confirm_message": "База данных будет удалена без возможности восстановления. Продолжить?" } } ``` @@ -136,13 +174,13 @@ selected_bg = "blue" | Параметр | Значения | Описание | |---|---|---| | `script` | строка | Полный текст bash-скрипта включая shebang | -| `interaction` | `"structured"` / `"terminal"` | Режим взаимодействия (см. ниже) | +| `interaction` | `"structured"` / `"terminal"` | Режим взаимодействия | | `confirm` | `true` / `false` | Запросить подтверждение перед запуском | | `confirm_message` | строка | Кастомный текст диалога подтверждения | -**`interaction: "terminal"`** — скрипт запускается как дочерний процесс, вывод игнорируется клиентом. Подходит для простых неинтерактивных команд. +**`interaction: "terminal"`** — скрипт запускается дочерним процессом без захвата вывода. -**`interaction: "structured"`** — скрипт общается с клиентом через протокол команд (см. раздел ниже). Весь вывод отображается в прокручиваемом логе с поддержкой ANSI-цветов. Поддерживает интерактивные элементы: селекторы, поля ввода, подтверждения. +**`interaction: "structured"`** — весь вывод отображается в прокручиваемом логе с ANSI-цветами, скрипт может запрашивать ввод через протокол команд. ### `download` — скачивание файла @@ -156,12 +194,6 @@ selected_bg = "blue" } ``` -| Параметр | Описание | -|---|---| -| `url` | Адрес файла | -| `filename` | Имя сохраняемого файла (по умолчанию — из URL) | -| `target_dir` | Директория сохранения (по умолчанию — текущая) | - ### `download_and_run` — скачать и выполнить ```json @@ -174,11 +206,6 @@ selected_bg = "blue" } ``` -| Параметр | Описание | -|---|---| -| `run_args` | Аргументы командной строки | -| `keep_file` | Оставить файл после выполнения (по умолчанию `false`) | - ### `http_request` — HTTP-запрос ```json @@ -186,10 +213,7 @@ selected_bg = "blue" "type": "http_request", "method": "POST", "url": "https://api.example.com/deploy", - "headers": { - "Authorization": "Bearer token123", - "Content-Type": "application/json" - }, + "headers": { "Authorization": "Bearer token123" }, "body": "{\"env\": \"production\"}", "confirm": true } @@ -199,12 +223,10 @@ selected_bg = "blue" ## Интерактивные скрипты (structured) -При `interaction: "structured"` скрипт может запрашивать у пользователя данные через специальный протокол. Клиент показывает соответствующий элемент интерфейса ratatui и ждёт ответа. +При `interaction: "structured"` скрипт общается с клиентом через протокол команд. Команды отправляются в **stderr** (`>&2`), ответы приходят в **stdin**. ### Базовые хелперы -Добавьте эти функции в начало каждого интерактивного скрипта: - ```bash #!/bin/bash @@ -215,21 +237,16 @@ ask() { printf '%s' "$_response" } -# Отправить уведомление (не ждёт ответа) +# Отправить уведомление (не ждёт ответа, не блокирует скрипт) notify() { printf 'CMD:%s\n' "$1" >&2 } ``` -Команды отправляются в **stderr** (`>&2`), ответы приходят в **stdin** через `read`. Это позволяет использовать `$(ask '...')` для захвата результата. - --- ### Ввод текста (`input`) -Показывает текстовое поле с заголовком. Поддерживает маскировку для паролей. - -**Команда:** ```json {"type":"input","prompt":"Введите имя пользователя:","default":"admin","secret":false} ``` @@ -240,20 +257,17 @@ notify() { | `default` | Значение по умолчанию (опционально) | | `secret` | Если `true` — символы заменяются на `•` | -**Пример:** ```bash username=$(ask '{"type":"input","prompt":"Имя пользователя:","default":"admin"}') password=$(ask '{"type":"input","prompt":"Пароль:","secret":true}') -echo "Логин: $username" ``` --- ### Выбор из списка (`menu`) -Показывает список с навигацией `↑`/`↓` и выбором по `Enter`. Возвращает `id` выбранного пункта. +Список с навигацией `↑`/`↓`/`j`/`k`, выбор по `Enter`/`l`. Возвращает `id` выбранного пункта. -**Команда:** ```json { "type": "menu", @@ -266,14 +280,6 @@ echo "Логин: $username" } ``` -| Поле | Описание | -|---|---| -| `prompt` | Заголовок списка | -| `options` | Массив объектов `{"id": "...", "label": "..."}` | - -**Ответ:** строка `id` выбранного пункта. - -**Пример:** ```bash action=$(ask '{"type":"menu","prompt":"Выберите действие:","options":[ {"id":"dump","label":"Создать дамп"}, @@ -288,7 +294,7 @@ case $action in esac ``` -**Динамический список** (например, файлы на диске): +**Динамический список** (из массива bash): ```bash files=($(ls *.sql 2>/dev/null)) @@ -302,31 +308,24 @@ options+=']' idx=$(ask "{\"type\":\"menu\",\"prompt\":\"Выберите файл:\",\"options\":${options}}") selected="${files[$idx]}" -echo "Выбран: $selected" ``` --- ### Подтверждение (`confirm`) -Показывает вопрос с кнопками `[Y] Да` и `[N] Нет`. Нажатия `Y`/`N` обрабатываются без `Enter`. +Показывает вопрос с кнопками `[Y]`/`[N]`. Клавиши `Y`/`N`/`l`/`h` обрабатываются без Enter. -**Команда:** ```json {"type":"confirm","prompt":"Данные будут перезаписаны. Продолжить?"} ``` -**Ответ:** `"y"` или `"n"`. +Ответ: `"y"` или `"n"`. -**Пример:** ```bash answer=$(ask '{"type":"confirm","prompt":"Удалить временные файлы?"}') - if [[ "$answer" =~ ^[Yy]$ ]]; then rm -rf /tmp/myapp_* - echo "Файлы удалены" -else - echo "Отменено" fi ``` @@ -334,79 +333,55 @@ fi ### Сообщение (`message`) -Показывает информационное сообщение с цветовым выделением. Скрипт продолжается после нажатия `Enter`. +Показывает сообщение с цветом. Скрипт продолжается после нажатия `Enter`. -**Команда:** ```json {"type":"message","level":"info","text":"Операция завершена успешно!"} ``` -| `level` | Цвет | Когда использовать | -|---|---|---| -| `"info"` | Зелёный | Успешное завершение, информация | -| `"warn"` | Жёлтый | Предупреждение, нестандартная ситуация | -| `"error"` | Красный | Ошибка, критическая проблема | +| `level` | Цвет | +|---|---| +| `"info"` | Зелёный | +| `"warn"` | Жёлтый | +| `"error"` | Красный | -**Пример:** ```bash if ! command -v mysqldump &> /dev/null; then - ask '{"type":"message","level":"error","text":"mysqldump не найден. Установите пакет mysql-client."}' > /dev/null + ask '{"type":"message","level":"error","text":"mysqldump не найден."}' > /dev/null exit 1 fi - -ask '{"type":"message","level":"warn","text":"База данных занимает более 10 ГБ. Дамп может занять несколько минут."}' > /dev/null - -echo "Начинаю создание дампа..." ``` -> `> /dev/null` — сбрасывает пустой ответ клиента, чтобы он не попал в переменную. +> `> /dev/null` — сбрасывает пустой ответ клиента чтобы он не попал в переменную. --- ### Прогресс (`progress`) -Обновляет прогресс-бар без остановки скрипта. Скрипт продолжает выполнение немедленно. +Обновляет прогресс-бар не блокируя скрипт (используйте `notify`, не `ask`). -**Команда:** ```json {"type":"progress","percent":45,"message":"Копирование файлов..."} ``` -| Поле | Описание | -|---|---| -| `percent` | Число от 0 до 100 | -| `message` | Подпись под прогресс-баром (опционально) | - -**Пример:** ```bash notify '{"type":"progress","percent":0,"message":"Начало..."}' - mysqldump -h "$host" -u "$user" "$db" > backup.sql -notify '{"type":"progress","percent":60,"message":"Дамп создан, сжимаю..."}' - +notify '{"type":"progress","percent":70,"message":"Сжимаю..."}' gzip backup.sql notify '{"type":"progress","percent":100,"message":"Готово!"}' -echo "Файл: backup.sql.gz ($(du -h backup.sql.gz | cut -f1))" ``` --- ### Запуск внешнего приложения (`exec`) -Временно **передаёт терминал** внешней программе (mysql, vim, htop и т.д.). Клиент скрывает ratatui, программа работает на полном экране, после завершения TUI восстанавливается. Возвращает код завершения. +Временно **передаёт терминал** внешней программе (mysql, vim, htop и др.). Клиент скрывает TUI, программа работает на полном экране, после завершения TUI восстанавливается. Возвращает код завершения. -**Команда:** ```json {"type":"exec","shell":"mysql -h localhost -u root mydb"} ``` -| Поле | Описание | -|---|---| -| `shell` | Команда, которая будет выполнена через `bash -c` | - -**Ответ:** код завершения программы (`"0"`, `"1"` и т.д.). - -**Примеры:** ```bash # Интерактивная сессия mysql exit_code=$(ask "{\"type\":\"exec\",\"shell\":\"mysql -h \\\"$host\\\" -u \\\"$user\\\" -p\\\"$pass\\\" \\\"$db\\\"\"}") @@ -414,7 +389,6 @@ echo "Сессия завершена (код: $exit_code)" # Редактирование конфига ask '{"type":"exec","shell":"vim /etc/nginx/nginx.conf"}' > /dev/null -notify '{"type":"progress","percent":0,"message":"Перезагружаю nginx..."}' systemctl reload nginx # Мониторинг ресурсов @@ -428,29 +402,18 @@ ask '{"type":"exec","shell":"htop"}' > /dev/null ```bash #!/bin/bash -ask() { - printf 'CMD:%s\n' "$1" >&2 - read -r _response - printf '%s' "$_response" -} +ask() { printf 'CMD:%s\n' "$1" >&2; read -r _r; printf '%s' "$_r"; } +notify() { printf 'CMD:%s\n' "$1" >&2; } -notify() { - printf 'CMD:%s\n' "$1" >&2 -} - -# Запрос данных -host=$(ask '{"type":"input","prompt":"Хост базы данных:","default":"localhost"}') -db=$(ask '{"type":"input","prompt":"Имя базы данных:"}') +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}') -echo "Подключаюсь к $db на $host..." - -# Выбор действия action=$(ask "{\"type\":\"menu\",\"prompt\":\"Выберите действие:\",\"options\":[ - {\"id\":\"dump\", \"label\":\"Создать дамп\"}, - {\"id\":\"shell\", \"label\":\"Открыть mysql-консоль\"}, - {\"id\":\"cancel\", \"label\":\"Отмена\"} + {\"id\":\"dump\", \"label\":\"Создать дамп\"}, + {\"id\":\"shell\", \"label\":\"Открыть mysql-консоль\"}, + {\"id\":\"cancel\",\"label\":\"Отмена\"} ]}") case $action in @@ -463,7 +426,7 @@ case $action in if mysqldump -h "$host" -u "$user" -p"$pass" "$db" > "$backup"; then notify '{"type":"progress","percent":100,"message":"Готово!"}' - echo "Дамп сохранён: $backup ($(du -h "$backup" | cut -f1))" + echo "Дамп: $backup ($(du -h "$backup" | cut -f1))" else ask '{"type":"message","level":"error","text":"Ошибка при создании дампа!"}' > /dev/null exit 1 @@ -471,15 +434,11 @@ case $action in ;; shell) - echo "Открываю mysql-консоль..." code=$(ask "{\"type\":\"exec\",\"shell\":\"mysql -h \\\"$host\\\" -u \\\"$user\\\" -p\\\"$pass\\\" \\\"$db\\\"\"}") echo "Сессия завершена с кодом $code" ;; - cancel) - echo "Отменено" - exit 0 - ;; + cancel) exit 0 ;; esac ``` @@ -487,15 +446,14 @@ esac ## Структура сервера -Рекомендуемая организация серверной части: - ``` server/ -├── index.php # Собирает JSON из menu_structure.json + scripts/ +├── 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 ``` @@ -514,48 +472,66 @@ server/ } ``` -`index.php` подставляет содержимое файла при каждом запросе — скрипты редактируются без изменения JSON. +`index.php` читает файл скрипта при каждом запросе — скрипты редактируются без изменения JSON. ### Добавление нового пункта меню -1. Создать скрипт `server/scripts/myapp_action.sh` +1. Создать `server/scripts/category_action.sh` 2. Добавить пункт в `menu_structure.json`: ```json { - "id": "myapp_action", + "id": "my_action", "title": "Название в меню", "description": "Описание для нижней панели", "action": { "type": "bash", - "script_file": "myapp_action.sh", + "script_file": "category_action.sh", "interaction": "structured", "confirm": false } } ``` -Именование файлов: `{категория}_{подкатегория}_{действие}.sh` +Соглашение по именованию файлов: `{категория}_{подкатегория}_{действие}.sh` --- ## Сборка -**Linux:** +Скрипт `release.sh` собирает бинарники для всех платформ сразу: + ```bash -cd Ratatui -cargo build --release -# → target/release/tui-client +cd Ostiary +./release.sh +# → dist/ostiary-linux-x86_64 +# → dist/ostiary-linux-aarch64 +# → dist/ostiary-macos-aarch64 ``` -**Кросс-компиляция с macOS → Linux (musl, статический бинарный файл):** +**Вручную — 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/tui-client +# → 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) и не требует никаких зависимостей на целевом сервере. diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..c2cae98 --- /dev/null +++ b/release.sh @@ -0,0 +1,55 @@ +#!/bin/bash +set -e + +BINARY=$(cargo metadata --no-deps --format-version 1 | python3 -c "import sys,json; print(json.load(sys.stdin)['packages'][0]['name'])") +VERSION=$(cargo metadata --no-deps --format-version 1 | python3 -c "import sys,json; print(json.load(sys.stdin)['packages'][0]['version'])") +OUT_DIR="dist" + +TARGETS=( + "x86_64-unknown-linux-musl:linux-x86_64" + "aarch64-unknown-linux-musl:linux-aarch64" + "aarch64-apple-darwin:macos-aarch64" +) + +echo "Building $BINARY v$VERSION" +echo "" + +# Ensure rustup targets are installed +rustup target add \ + x86_64-unknown-linux-musl \ + aarch64-unknown-linux-musl \ + aarch64-apple-darwin 2>/dev/null + +mkdir -p "$OUT_DIR" +# Clean previous release artifacts +rm -f "$OUT_DIR/$BINARY"-* + +for entry in "${TARGETS[@]}"; do + RUST_TARGET="${entry%%:*}" + LABEL="${entry##*:}" + ARTIFACT="$OUT_DIR/${BINARY}-${LABEL}" + + echo "── $LABEL ($RUST_TARGET)" + + case "$RUST_TARGET" in + x86_64-unknown-linux-musl) + CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc \ + cargo build --release --target "$RUST_TARGET" --quiet + ;; + aarch64-unknown-linux-musl) + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-musl-gcc \ + cargo build --release --target "$RUST_TARGET" --quiet + ;; + aarch64-apple-darwin) + cargo build --release --target "$RUST_TARGET" --quiet + ;; + esac + + cp "target/$RUST_TARGET/release/$BINARY" "$ARTIFACT" + SIZE=$(du -sh "$ARTIFACT" | cut -f1) + echo " → $ARTIFACT ($SIZE)" +done + +echo "" +echo "Done. Artifacts in $OUT_DIR/:" +ls -lh "$OUT_DIR/$BINARY"-* diff --git a/src/config.rs b/src/config.rs index 57d4dec..d7e0027 100644 --- a/src/config.rs +++ b/src/config.rs @@ -29,33 +29,42 @@ fn default_theme() -> Theme { } } +pub fn config_path() -> PathBuf { + // Respect XDG_CONFIG_HOME; fall back to ~/.config on all platforms. + let base = std::env::var("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".config") + }); + base.join(env!("CARGO_PKG_NAME")).join("config.toml") +} + impl Config { + /// Returns true when a config file already exists. + pub fn exists() -> bool { + config_path().exists() + } + + /// Load config from disk. Panics-safe: returns error if file is missing or malformed. pub fn load() -> Result { - // Respect XDG_CONFIG_HOME; fall back to ~/.config on all platforms. - let config_base = std::env::var("XDG_CONFIG_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".config") - }); - let config_path = config_base - .join(env!("CARGO_PKG_NAME")) - .join("config.toml"); - - if !config_path.exists() { - let default = Config { - server_url: "http://localhost:8080/api/menu".to_string(), - timeout_sec: 10, - theme: default_theme(), - update_api: None, - }; - fs::create_dir_all(config_path.parent().unwrap())?; - fs::write(config_path, toml::to_string_pretty(&default)?)?; - return Ok(default); - } - - let content = fs::read_to_string(config_path)?; + let path = config_path(); + let content = fs::read_to_string(&path)?; Ok(toml::from_str(&content)?) } + + /// Create and persist a new config with the given server URL. + pub fn create_with_url(server_url: &str) -> Result { + let path = config_path(); + let config = Config { + server_url: server_url.to_string(), + timeout_sec: 10, + theme: default_theme(), + update_api: None, + }; + fs::create_dir_all(path.parent().unwrap())?; + fs::write(&path, toml::to_string_pretty(&config)?)?; + Ok(config) + } } diff --git a/src/main.rs b/src/main.rs index 584cfd9..0798687 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,7 +20,12 @@ use tokio::sync::mpsc; #[tokio::main] async fn main() -> Result<()> { - let config = config::Config::load()?; + let config = if config::Config::exists() { + config::Config::load()? + } else { + let url = prompt_server_url()?; + config::Config::create_with_url(&url)? + }; enable_raw_mode()?; let mut stdout = io::stdout(); @@ -143,6 +148,37 @@ async fn run_exec( Ok(()) } +/// Runs before ratatui starts. Prompts for the server URL on first launch. +fn prompt_server_url() -> Result { + use std::io::Write; + + println!(); + println!(" ╔══════════════════════════════════╗"); + println!(" ║ Ostiary — первый запуск ║"); + println!(" ╚══════════════════════════════════╝"); + println!(); + println!(" Конфиг будет сохранён в:"); + println!(" {}", config::config_path().display()); + println!(); + + loop { + print!(" URL сервера меню: "); + std::io::stdout().flush()?; + + let mut url = String::new(); + std::io::stdin().read_line(&mut url)?; + let url = url.trim().to_string(); + + if url.is_empty() { + println!(" URL не может быть пустым. Попробуйте ещё раз.\n"); + continue; + } + + println!(); + return Ok(url); + } +} + async fn read_crossterm_event() -> Result> { if crossterm::event::poll(std::time::Duration::from_millis(100))? { Ok(Some(crossterm::event::read()?))