first commit

This commit is contained in:
XeRo 2025-12-26 17:45:37 +05:00
commit 5203032b83
25 changed files with 2825 additions and 0 deletions

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
*.tmp
*.log
/vendor/
/bin/
*.exe
.idea
build/
.vscode/
*.env.local

3
Readme.md Normal file
View file

@ -0,0 +1,3 @@
# Мастер настроек GoSetup терминальная версия
Перенос [GoSetup+Fyne](https://dev.gossys.dev/XeRo/GoSetup) в терминал с использованием библиотек сообщества [charm](https://charm.sh/).

90
bootstrap/load.go Normal file
View file

@ -0,0 +1,90 @@
package bootstrap
import (
"bubble/types"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"io/fs"
"os"
"path/filepath"
)
const ConfDir = "./confs/"
func LoadApplications() []types.PackageApplication {
var apps []types.PackageApplication
err := filepath.WalkDir(ConfDir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d == nil || d.IsDir() {
return err
}
if filepath.Ext(d.Name()) != ".yaml" && filepath.Ext(d.Name()) != ".yml" {
return nil
}
app, err := loadFromYAML(path)
if err != nil {
logrus.Errorf("Ошибка загрузки %s: %v", path, err)
return nil
}
apps = append(apps, app)
return nil
})
if err != nil {
logrus.Fatal(err)
}
return apps
}
func loadFromYAML(filePath string) (types.PackageApplication, error) {
var app types.PackageApplication
data, err := os.ReadFile(filePath)
if err != nil {
return app, err
}
if err = yaml.Unmarshal(data, &app); err != nil {
return app, err
}
app = connectVariables(app)
return app, nil
}
// connectVariables — связывает поля с переменными по указателям
// и инициализирует Value = Default
func connectVariables(app types.PackageApplication) types.PackageApplication {
varMap := make(map[string]*types.Variable)
for i := range app.Variables {
v := &app.Variables[i]
v.Value = v.Default // сразу устанавливаем дефолт
varMap[v.Name] = v
}
for i := range app.Sections {
section := &app.Sections[i]
for j := range section.Fields {
field := &section.Fields[j]
if v, exists := varMap[field.VariableName]; exists {
field.Variable = v
field.Active = true
} else {
logrus.Warnf("Переменная не найдена: %s (в поле %s)", field.VariableName, field.Name)
// Создаём заглушку
field.Variable = &types.Variable{
Name: field.VariableName,
Type: "string",
Default: "",
Value: "",
}
field.Active = true
}
}
}
return app
}

27
bootstrap/logs.go Normal file
View file

@ -0,0 +1,27 @@
package bootstrap
import (
"github.com/sirupsen/logrus"
"gopkg.in/natefinch/lumberjack.v2"
"io"
)
func Logs() {
TextFormatter := &logrus.TextFormatter{
FullTimestamp: true,
TimestampFormat: "15:04:05 02-01-2006",
DisableQuote: true,
PadLevelText: true,
}
logWriter := &lumberjack.Logger{
Filename: "log.txt",
Compress: true,
MaxSize: 50,
LocalTime: true,
}
output := io.MultiWriter(logWriter)
logrus.SetFormatter(TextFormatter)
logrus.SetOutput(output)
logrus.SetLevel(logrus.InfoLevel)
}

315
confs/repko.yaml Normal file
View file

@ -0,0 +1,315 @@
---
name: "RepKo"
version: 1.0
description: "Сервер для управления собственным Debian репозиторием.
Позволяет работать с дистрибутивами и его компонентами, загружать пакеты, автоматически подписывать файлы Release для доступа apt."
icon: "_public/icons/repository.png"
type: "other"
conf-files:
- file: "_public/files/config.ini"
type: "ini"
name: "ini config"
- file: "_public/files/.env"
type: "env"
name: "env config"
- file: "_public/files/repko.service"
type: "service"
name: "app service"
- file: "_public/files/separated.repko.conf"
type: "nginx"
name: "nginx separated config"
- file: "_public/files/united.repko.conf"
type: "nginx"
name: "nginx united config"
variables:
- name: "user_type"
type: "string"
default: "Create"
- name: "repko_user"
type: "string"
default: "repko-user"
- name: "api_domain"
type: "string"
default: "api.repko.com"
- name: "panel_domain"
type: "string"
default: "panel.repko.com"
- name: "cookie_domain"
type: "string"
default: "repko.com"
- name: "is_secure"
type: "bool"
default: false
- name: "server_address"
type: "string"
default: "127.0.0.1:9988"
- name: "trusted_proxy"
type: "string"
default: "127.0.0.1,::1"
- name: "nginx_port"
type: "string"
default: "80"
- name: "ssl_type"
type: "string"
default: "path now"
- name: "ssl_cert_path"
type: "string"
default: "/usr/share/ca-certificates/repko/repko.crt"
- name: "ssl_key_path"
type: "string"
#default: "/usr/share/ca-certificates/repko/repko.key"
default: "/etc/repko/ssl/repko.key"
- name: "database_type"
type: "string"
default: "sqlite"
- name: "database_host"
type: "string"
default: "localhost"
- name: "database_port"
type: "string"
default: "5435"
- name: "database_name"
type: "string"
default: "repkoDB"
- name: "database_user"
type: "string"
default: "root"
- name: "database_password"
type: "string"
default: "admin"
- name: "debian_root"
type: "string"
default: "/var/www/repko/debian"
- name: "archive_root"
type: "string"
default: "/var/www/repko/archive"
sections:
- name: "Repko about"
type: "about"
description: "Описание приложения"
paragraphs:
- "# Repko - собственный сервер репозитория для debian систем. "
- "## Возможности: \n - Встроенная система аутентификации (локальные пользователи или LDAP).\n - Подпись gpg ключом.\n - Удобный веб интерфейс"
- "## Бла бла бла. \n lorem ipsum прочее прочее прочее прочее"
- "### Бла бла бла бла. \n lorem ipsum прочее прочее прочее прочее"
- "## Бла бла бла бла. \n lorem ipsum прочее прочее прочее прочее"
- name: "user"
description: "Настройка данных пользователя"
fields:
- name: "choose_user"
variable-name: "user_type"
field-type: "select"
options:
- "Create"
- "Existing"
- name: "select_user"
variable-name: "repko_user"
field-type: "select"
special-value: "code:users"
description: "Выберите существующего пользователя"
condition: "user_type=='Existing'"
parent:
- "user_type"
- name: "new_user"
variable-name: "repko_user"
field-type: "input"
description: "Пользователь от которого будет запускаться программа"
condition: "user_type=='Create'"
parent:
- "user_type"
validate:
- rule: "regex_match(repko_user, `^[a-z][a-z0-9_-]*$`)"
message: "Только строчные латинские буквы, цифры, _, -"
- rule: "not user_exists(repko_user)"
message: "Пользователь существует"
actions:
- name: "create_user"
type: "command"
command: "echo 'adduser --system --group --no-create-home --disabled-password --shell /bin/false {{.repko_user}}'"
condition: "user_type=='Create'"
run-as: "root"
require-packages:
- "adduser"
error: "Ошибка при создании пользователя"
- name: "domain"
description: "Настройка используемых доменов"
fields:
- name: "api_domain"
variable-name: "api_domain"
field-type: "input"
description: "Домен на котором будет работать api"
- name: "panel_domain"
variable-name: "panel_domain"
field-type: "input"
description: "Домен на котором будет работать сайт"
- name: "cookie_domain"
variable-name: "cookie_domain"
description: "Домен для куки"
field-type: "compute"
parent:
- "panel_domain"
- "api_domain"
compute: |
api_domain == panel_domain ? api_domain :
regex(api_domain, `^.*\.([a-zA-Z0-9-]+\.[a-zA-Z]{2,})$`, '$1') !=
regex(panel_domain, `^.*\.([a-zA-Z0-9-]+\.[a-zA-Z]{2,})$`, '$1') ?
error("Разные корневые домены") :
regex(api_domain, `^.*\.([a-zA-Z0-9-]+\.[a-zA-Z]{2,})$`, '$1')
- name: "is_secure"
variable-name: "is_secure"
field-type: "check"
description: "Использовать SSL"
- name: "server_address"
variable-name: "server_address"
field-type: "input"
description: "Адрес сервера"
- name: "ssl"
description: "Настройка ssl"
condition: "is_secure == true"
parent:
- "is_secure"
fields:
- name: "ssl_type"
variable-name: "ssl_type"
field-type: "select"
options:
- "path later"
- "path now"
- "self-signed"
- "lets encrypt"
- name: "ssl_cert_path"
variable-name: "ssl_cert_path"
field-type: "file"
file-field-extensions:
- ".crt"
- ".cer"
- ".pem"
description: "Путь к ssl сертификату"
condition: "ssl_type=='path now'"
parent:
- "ssl_type"
- name: "ssl_key_path"
variable-name: "ssl_key_path"
field-type: "file"
file-field-extensions:
- ".key"
description: "Путь к ssl ключу"
condition: "ssl_type=='path now'"
parent:
- "ssl_type"
actions:
- name: "create_ssl_repko_dir"
type: "command"
command: "mkdir -p /usr/share/ca-certificates/repko"
condition: "ssl_type == 'self-signed'"
run-as: "root"
error: "Ошибка при создании директории для сертификатов"
- name: "generate_self_signed"
type: "command"
command: |
openssl req -x509 -nodes -days 365 \
-newkey rsa:2048 \
-keyout {{ssl_key_path}} \
-out {{ssl_cert_path}} \
-subj "/CN={{api_domain}}"
condition: "ssl_type == 'self-signed'"
run-as: "root"
error: "Ошибка при генерации самоподписанного сертификата"
- name: "run_certbot"
type: "command"
command: "certbot --nginx -d {{api_domain}} -d {{panel_domain}} --non-interactive --agree-tos -m admin@{{api_domain}}"
condition: "ssl_type == 'lets encrypt'"
require-packages:
- "nginx"
- "certbot"
run-as: "root"
error: "Ошибка при генерации сертификата через lets encrypt"
# - name: "update_ca_certificates"
# type: "command"
# command: "update_ca_certificates"
# run-as: "root"
# error: "Не удалось обновить сертификаты"
# condition: "ssl_type != 'path later'"
# require-packages:
# - "dont know what"
- name: "database"
description: "Настройка используемой базы данных"
fields:
- name: "database_type"
variable-name: "database_type"
field-type: "select"
options:
- "sqlite"
- "postgres"
description: "Используемая база данных"
- name: "database_host"
variable-name: "database_host"
field-type: "input"
description: "Адрес база данных"
condition: "database_type=='postgres'"
parent:
- "database_type"
- name: "database_port"
variable-name: "database_port"
field-type: "input"
description: "Порт база данных"
condition: "database_type=='postgres'"
parent:
- "database_type"
- name: "database_name"
variable-name: "database_name"
field-type: "input"
description: "Имя базы данных"
condition: "database_type=='postgres'"
parent:
- "database_type"
- name: "database_user"
variable-name: "database_user"
field-type: "input"
description: "Имя пользователя база данных"
condition: "database_type=='postgres'"
parent:
- "database_type"
- name: "database_password"
variable-name: "database_password"
field-type: "input"
description: "Пароль база данных"
condition: "database_type=='postgres'"
parent:
- "database_type"
- name: "paths"
description: "Настройка директорий"
fields:
- name: "debian_root"
variable-name: "debian_root"
field-type: "directory"
description: "Директория debian"
- name: "finale"
description: "Последний этап, проверьте правильность введенных данных"
type: "final"
actions:
- name: "update_united_nginx_conf"
type: "file_template"
condition: "api_domain==panel_domain"
file-source: "/home/test/Projects/Personal/GoSetup/_public/files/donor/united.repko.donor.conf"
file-target: "/home/test/Projects/Personal/GoSetup/_public/files/target/united.repko.needy.conf"
error: "Ошибка при создании файла united.repko.needy.conf"
- name: "update_separated_nginx_conf"
type: "file_template"
condition: "api_domain !=panel_domain"
file-source: "/home/test/Projects/Personal/GoSetup/_public/files/donor/separated.repko.donor.conf"
file-target: "/home/test/Projects/Personal/GoSetup/_public/files/target/separated.repko.needy.conf"
error: "Ошибка при создании файла separated.repko.needy.conf"
- name: "update_repko_service"
type: "file_template"
file-source: "/home/test/Projects/Personal/GoSetup/_public/files/donor/repko.donor.service"
file-target: "/home/test/Projects/Personal/GoSetup/_public/files/target/repko.target.service"
error: "Ошибка при создании файла repko.target.service"
- name: "update_repko_ini"
type: "file_template"
# file-source: "/home/test/Projects/Personal/GoSetup/_public/files/donor/config.donor.ini"
# file-target: "/home/test/Projects/Personal/GoSetup/_public/files/target/config.target.ini"
file-source: "http://gsg.site/_public/config.donor.ini"
file-target: "/home/test/Projects/Personal/GoSetup/_public/files/target/http.config.ini"
error: "Ошибка при создании файла repko.target.service"
root: true

314
confs/shop.yaml Normal file
View file

@ -0,0 +1,314 @@
---
name: "Shop"
version: 1.2
description: "Магазин приложений в котором собрана куча данных"
icon: "_public/icons/repository.png"
type: "other"
conf-files:
- file: "_public/files/config.ini"
type: "ini"
name: "ini config"
- file: "_public/files/.env"
type: "env"
name: "env config"
- file: "_public/files/repko.service"
type: "service"
name: "app service"
- file: "_public/files/separated.repko.conf"
type: "nginx"
name: "nginx separated config"
- file: "_public/files/united.repko.conf"
type: "nginx"
name: "nginx united config"
variables:
- name: "user_type"
type: "string"
default: "Create"
- name: "repko_user"
type: "string"
default: "repko-user"
- name: "api_domain"
type: "string"
default: "api.repko.com"
- name: "panel_domain"
type: "string"
default: "panel.repko.com"
- name: "cookie_domain"
type: "string"
default: "repko.com"
- name: "is_secure"
type: "bool"
default: false
- name: "server_address"
type: "string"
default: "127.0.0.1:9988"
- name: "trusted_proxy"
type: "string"
default: "127.0.0.1,::1"
- name: "nginx_port"
type: "string"
default: "80"
- name: "ssl_type"
type: "string"
default: "path now"
- name: "ssl_cert_path"
type: "string"
default: "/usr/share/ca-certificates/repko/repko.crt"
- name: "ssl_key_path"
type: "string"
#default: "/usr/share/ca-certificates/repko/repko.key"
default: "/repko.key"
- name: "database_type"
type: "string"
default: "sqlite"
- name: "database_host"
type: "string"
default: "localhost"
- name: "database_port"
type: "string"
default: "5435"
- name: "database_name"
type: "string"
default: "repkoDB"
- name: "database_user"
type: "string"
default: "root"
- name: "database_password"
type: "string"
default: "admin"
- name: "debian_root"
type: "string"
default: "/var/www/repko/debian"
- name: "archive_root"
type: "string"
default: "/var/www/repko/archive"
sections:
- name: "Repko about"
type: "about"
description: "Описание приложения"
paragraphs:
- "# Repko - собственный сервер репозитория для debian систем. "
- "## Возможности: \n - Встроенная система аутентификации (локальные пользователи или LDAP).\n - Подпись gpg ключом.\n - Удобный веб интерфейс"
- "## Бла бла бла. \n lorem ipsum прочее прочее прочее прочее"
- "### Бла бла бла бла. \n lorem ipsum прочее прочее прочее прочее"
- "## Бла бла бла бла. \n lorem ipsum прочее прочее прочее прочее"
- name: "user"
description: "Настройка данных пользователя"
fields:
- name: "choose_user"
variable-name: "user_type"
field-type: "select"
options:
- "Create"
- "Existing"
- name: "select_user"
variable-name: "repko_user"
field-type: "select"
special-value: "code:users"
description: "Выберите существующего пользователя"
condition: "user_type=='Existing'"
parent:
- "user_type"
- name: "new_user"
variable-name: "repko_user"
field-type: "input"
description: "Пользователь от которого будет запускаться программа"
condition: "user_type=='Create'"
parent:
- "user_type"
validate:
- rule: "regex_match(repko_user, `^[a-z][a-z0-9_-]*$`)"
message: "Только строчные латинские буквы, цифры, _, -"
- rule: "not user_exists(repko_user)"
message: "Пользователь существует"
actions:
- name: "create_user"
type: "command"
command: "echo 'adduser --system --group --no-create-home --disabled-password --shell /bin/false {{.repko_user}}'"
condition: "user_type=='Create'"
run-as: "root"
require-packages:
- "adduser"
error: "Ошибка при создании пользователя"
- name: "domain"
description: "Настройка используемых доменов"
fields:
- name: "api_domain"
variable-name: "api_domain"
field-type: "input"
description: "Домен на котором будет работать api"
- name: "panel_domain"
variable-name: "panel_domain"
field-type: "input"
description: "Домен на котором будет работать сайт"
- name: "cookie_domain"
variable-name: "cookie_domain"
description: "Домен для куки"
field-type: "compute"
parent:
- "panel_domain"
- "api_domain"
compute: |
api_domain == panel_domain ? api_domain :
regex(api_domain, `^.*\.([a-zA-Z0-9-]+\.[a-zA-Z]{2,})$`, '$1') !=
regex(panel_domain, `^.*\.([a-zA-Z0-9-]+\.[a-zA-Z]{2,})$`, '$1') ?
error("Разные корневые домены") :
regex(api_domain, `^.*\.([a-zA-Z0-9-]+\.[a-zA-Z]{2,})$`, '$1')
- name: "is_secure"
variable-name: "is_secure"
field-type: "check"
description: "Использовать SSL"
- name: "server_address"
variable-name: "server_address"
field-type: "input"
description: "Адрес сервера"
- name: "ssl"
description: "Настройка ssl"
condition: "is_secure == true"
parent:
- "is_secure"
fields:
- name: "ssl_type"
variable-name: "ssl_type"
field-type: "select"
options:
- "path later"
- "path now"
- "self-signed"
- "lets encrypt"
- name: "ssl_cert_path"
variable-name: "ssl_cert_path"
field-type: "file"
file-field-extensions:
- ".crt"
- ".cer"
- ".pem"
description: "Путь к ssl сертификату"
condition: "ssl_type=='path now'"
parent:
- "ssl_type"
- name: "ssl_key_path"
variable-name: "ssl_key_path"
field-type: "input"
file-field-extensions:
- ".key"
description: "Путь к ssl ключу"
condition: "ssl_type=='path now'"
parent:
- "ssl_type"
actions:
- name: "create_ssl_repko_dir"
type: "command"
command: "mkdir -p /usr/share/ca-certificates/repko"
condition: "ssl_type == 'self-signed'"
run-as: "root"
error: "Ошибка при создании директории для сертификатов"
- name: "generate_self_signed"
type: "command"
command: |
openssl req -x509 -nodes -days 365 \
-newkey rsa:2048 \
-keyout {{ssl_key_path}} \
-out {{ssl_cert_path}} \
-subj "/CN={{api_domain}}"
condition: "ssl_type == 'self-signed'"
run-as: "root"
error: "Ошибка при генерации самоподписанного сертификата"
- name: "run_certbot"
type: "command"
command: "certbot --nginx -d {{api_domain}} -d {{panel_domain}} --non-interactive --agree-tos -m admin@{{api_domain}}"
condition: "ssl_type == 'lets encrypt'"
require-packages:
- "nginx"
- "certbot"
run-as: "root"
error: "Ошибка при генерации сертификата через lets encrypt"
# - name: "update_ca_certificates"
# type: "command"
# command: "update_ca_certificates"
# run-as: "root"
# error: "Не удалось обновить сертификаты"
# condition: "ssl_type != 'path later'"
# require-packages:
# - "dont know what"
- name: "database"
description: "Настройка используемой базы данных"
fields:
- name: "database_type"
variable-name: "database_type"
field-type: "select"
options:
- "sqlite"
- "postgres"
description: "Используемая база данных"
- name: "database_host"
variable-name: "database_host"
field-type: "input"
description: "Адрес база данных"
condition: "database_type=='postgres'"
parent:
- "database_type"
- name: "database_port"
variable-name: "database_port"
field-type: "input"
description: "Порт база данных"
condition: "database_type=='postgres'"
parent:
- "database_type"
- name: "database_name"
variable-name: "database_name"
field-type: "input"
description: "Имя базы данных"
condition: "database_type=='postgres'"
parent:
- "database_type"
- name: "database_user"
variable-name: "database_user"
field-type: "input"
description: "Имя пользователя база данных"
condition: "database_type=='postgres'"
parent:
- "database_type"
- name: "database_password"
variable-name: "database_password"
field-type: "input"
description: "Пароль база данных"
condition: "database_type=='postgres'"
parent:
- "database_type"
- name: "paths"
description: "Настройка директорий"
fields:
- name: "debian_root"
variable-name: "debian_root"
field-type: "directory"
description: "Директория debian"
- name: "finale"
description: "Последний этап, проверьте правильность введенных данных"
type: "final"
actions:
- name: "update_united_nginx_conf"
type: "file_template"
condition: "api_domain==panel_domain"
file-source: "/home/test/Projects/Personal/GoSetup/_public/files/donor/united.repko.donor.conf"
file-target: "/home/test/Projects/Personal/GoSetup/_public/files/target/united.repko.needy.conf"
error: "Ошибка при создании файла united.repko.needy.conf"
- name: "update_separated_nginx_conf"
type: "file_template"
condition: "api_domain !=panel_domain"
file-source: "/home/test/Projects/Personal/GoSetup/_public/files/donor/separated.repko.donor.conf"
file-target: "/home/test/Projects/Personal/GoSetup/_public/files/target/separated.repko.needy.conf"
error: "Ошибка при создании файла separated.repko.needy.conf"
- name: "update_repko_service"
type: "file_template"
file-source: "/home/test/Projects/Personal/GoSetup/_public/files/donor/repko.donor.service"
file-target: "/home/test/Projects/Personal/GoSetup/_public/files/target/repko.target.service"
error: "Ошибка при создании файла repko.target.service"
- name: "update_repko_ini"
type: "file_template"
# file-source: "/home/test/Projects/Personal/GoSetup/_public/files/donor/config.donor.ini"
# file-target: "/home/test/Projects/Personal/GoSetup/_public/files/target/config.target.ini"
file-source: "http://gsg.site/_public/config.donor.ini"
file-target: "/home/test/Projects/Personal/GoSetup/_public/files/target/http.config.ini"
error: "Ошибка при создании файла repko.target.service"
root: true

50
go.mod Normal file
View file

@ -0,0 +1,50 @@
module bubble
go 1.24.11
require (
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/expr-lang/expr v1.17.7
github.com/sirupsen/logrus v1.9.3
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.6.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.31.0 // indirect
golang.org/x/text v0.24.0 // indirect
)

114
go.sum Normal file
View file

@ -0,0 +1,114 @@
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

0
log.txt Normal file
View file

16
main.go Normal file
View file

@ -0,0 +1,16 @@
package main
import (
"bubble/bootstrap"
"bubble/setup"
)
func main() {
bootstrap.Logs()
setup.Start()
//p := tea.NewProgram(ui.NewBodyModel(), tea.WithAltScreen())
//if _, err := p.Run(); err != nil {
// panic(err)
//}
}

336
setup/condition.go Normal file
View file

@ -0,0 +1,336 @@
package setup
import (
"bubble/types"
"fmt"
"github.com/expr-lang/expr"
"github.com/sirupsen/logrus"
"io"
"net/http"
"os"
"os/exec"
"reflect"
"regexp"
"strconv"
"strings"
)
// userRE - с начала строки, делит все по знаку двоеточия, берется первые 3 группы, пример user_name:x:1000:
var userRE = regexp.MustCompile(`^([^:]+):[^:]*:([0-9]+):`)
// ParamsForCondition — собирает все текущие значения переменных приложения
// для использования в expr-условиях (condition)
func ParamsForCondition(app *types.PackageApplication) map[string]interface{} {
params := make(map[string]interface{})
for _, variable := range app.Variables {
value := variable.Value
if value == nil {
value = variable.Default
}
// Приводим к нужному типу, как в твоём старом коде
switch variable.Type {
case "bool":
if s, ok := value.(string); ok {
value = strings.ToLower(s) == "true"
}
case "number", "int", "float":
if s, ok := value.(string); ok {
if n, err := strconv.ParseFloat(s, 64); err == nil {
value = n
}
}
}
params[variable.Name] = value
}
// Также добавляем значения из полей секций (на случай, если переменная используется только в поле)
for _, section := range app.Sections {
for _, field := range section.Fields {
if field.Variable == nil || !field.Active {
continue
}
value := field.Variable.Value
if value == nil {
value = field.Variable.Default
}
switch field.Variable.Type {
case "bool":
if s, ok := value.(string); ok {
value = strings.ToLower(s) == "true"
}
case "number", "int", "float":
if s, ok := value.(string); ok {
if n, err := strconv.ParseFloat(s, 64); err == nil {
value = n
}
}
}
params[field.VariableName] = value
}
}
return params
}
// CheckCondition — проверяет условие visibility (condition) для секции или поля
func CheckCondition(condition string, app *types.PackageApplication) bool {
if condition == "" {
return true
}
result, err := expr.Eval(condition, ParamsForCondition(app))
if err != nil {
logrus.Errorf("Ошибка в условии '%s': %v", condition, err)
return false
}
boolResult, ok := result.(bool)
if !ok {
logrus.Warnf("Условие '%s' не вернуло bool, а %T: %v", condition, result, result)
return false
}
return boolResult
}
// ParamsForCompute — аналогично, но только по переменным (как в твоём старом коде)
func ParamsForCompute(app *types.PackageApplication) map[string]interface{} {
params := make(map[string]interface{})
for i := range app.Variables {
v := &app.Variables[i]
value := v.Value
if value == nil {
value = v.Default
}
params[v.Name] = value
}
// Добавляем значения из активных полей
for _, section := range app.Sections {
for _, field := range section.Fields {
if field.Variable == nil || !field.Active {
continue
}
value := field.Variable.Value
if value == nil {
value = field.Variable.Default
}
params[field.VariableName] = value
}
}
return params
}
// ComputeValue — вычисляет значение для compute-поля
func ComputeValue(computeExpr string, app *types.PackageApplication) (interface{}, error) {
if computeExpr == "" {
return nil, nil
}
env := ParamsForCompute(app)
options := append([]expr.Option{
expr.Env(env),
expr.AsKind(reflect.String), // по умолчанию строка, если не указано иное
expr.AllowUndefinedVariables(),
}, DefineExprCustomFunctions()...)
program, err := expr.Compile(computeExpr, options...)
if err != nil {
return nil, fmt.Errorf("ошибка компиляции compute: %v", err)
}
result, err := expr.Run(program, env)
if err != nil {
return nil, fmt.Errorf("ошибка выполнения compute: %v", err)
}
return result, nil
}
func Recompute(app *types.PackageApplication) {
for i := range app.Sections {
section := &app.Sections[i]
for j := range section.Fields {
field := &section.Fields[j]
if field.FieldType == "compute" && field.Compute != "" {
if field.Variable != nil {
continue //?почему мы пропускаем
}
value, err := ComputeValue(field.Compute, app)
if err != nil {
value = fmt.Sprintf("Ошибка вычисления: %v", err)
}
field.Variable.Value = value
}
}
}
}
func DefineExprCustomFunctions() []expr.Option {
return []expr.Option{
expr.Function("regex",
func(args ...interface{}) (interface{}, error) {
if len(args) != 3 {
return "", fmt.Errorf("regex() ожидает 3 аргумента")
}
input := fmt.Sprintf("%v", args[0])
pattern := fmt.Sprintf("%v", args[1])
repl := fmt.Sprintf("%v", args[2])
re, err := regexp.Compile(pattern)
if err != nil {
return input, nil
}
return re.ReplaceAllString(input, repl), nil
}, new(func(string, string, string) string),
),
expr.Function("regex_match",
func(args ...interface{}) (interface{}, error) {
if len(args) != 2 {
return false, fmt.Errorf("regex_match() ожидает 2 аргумента")
}
input := fmt.Sprintf("%v", args[0])
pattern := fmt.Sprintf("%v", args[1])
re, err := regexp.Compile(pattern)
if err != nil {
return false, fmt.Errorf("некоректный regex %s, err:%v", pattern, err)
}
return re.MatchString(input), nil
}, new(func(string, string) bool),
),
expr.Function("error",
func(args ...interface{}) (interface{}, error) {
if len(args) == 0 {
return nil, fmt.Errorf("error() требует сообщения")
}
return nil, fmt.Errorf("%v", args[0])
},
new(func(string) error),
),
expr.Function("http_get",
func(args ...interface{}) (interface{}, error) {
if len(args) == 0 {
return "", nil
}
url := fmt.Sprintf("%v", args[0])
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return string(body), nil
},
new(func(string) string),
),
expr.Function("shell",
func(args ...interface{}) (interface{}, error) {
if len(args) == 0 {
return "", nil
}
cmd := fmt.Sprintf("%v", args[0])
parts := strings.Fields(cmd)
if len(parts) == 0 {
return "", nil
}
out, err := exec.Command(parts[0], parts[1:]...).CombinedOutput()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
},
new(func(string) error),
),
expr.Function("env",
func(args ...interface{}) (interface{}, error) {
if len(args) == 0 {
return "", nil
}
return os.Getenv(fmt.Sprintf("%v", args[0])), nil
}, new(func(string) error),
),
expr.Function("file_exists",
func(args ...interface{}) (interface{}, error) {
if len(args) == 0 {
return false, nil
}
path := fmt.Sprintf("%v", args[0])
_, err := os.Stat(path)
return err == nil, nil
}, new(func(string) bool),
),
expr.Function("dir_exists",
func(args ...interface{}) (interface{}, error) {
if len(args) == 0 {
return false, nil
}
path := fmt.Sprintf("%v", args[0])
fileInfo, err := os.Stat(path)
if err != nil {
return nil, err
}
if fileInfo.IsDir() {
return true, nil
}
return false, nil
}, new(func(string) bool),
),
expr.Function("port_free",
func(args ...interface{}) (interface{}, error) {
if len(args) == 0 {
return false, nil
}
port := fmt.Sprintf("%v", args[0])
cmd := exec.Command("ss", "-ltn", "sport =: "+port)
return cmd.Run() != nil, nil
}, new(func(string) error),
),
expr.Function("user_exists",
func(args ...interface{}) (interface{}, error) {
if len(args) == 0 {
return false, nil
}
name := fmt.Sprintf("%v", args[0])
cmd := exec.Command("id", "-u", name)
return cmd.Run() == nil, nil
}, new(func(string) bool),
),
}
}
func SpecialValueHandle(special string) interface{} {
specialValue := strings.Split(special, "code:")[1]
switch specialValue {
case "users":
return GetSystemUsers()
}
return true
}
// GetSystemUsers получение списка обычных пользователей в системе
func GetSystemUsers() []string {
//результат выполнения команды читающей файл passwd
output, _ := exec.Command("getent", "passwd").Output()
//массив строк разделенных новой строкой
lines := strings.Split(string(output), "\n")
var users []string
for _, line := range lines {
//линия совпадает с регулярным выражением
if matches := userRE.FindStringSubmatch(line); len(matches) == 3 {
//идентификатор больше или равен 1000, uid обычного пользователя в системе
if uid, _ := strconv.Atoi(matches[2]); uid >= 1000 {
users = append(users, matches[1])
}
}
}
return users
}

64
setup/fields/checkbox.go Normal file
View file

@ -0,0 +1,64 @@
package fields
import (
tea "github.com/charmbracelet/bubbletea"
)
type CheckboxModel struct {
Label string
Checked bool
}
func NewCheckboxModel(label string, checked bool) *CheckboxModel {
return &CheckboxModel{
Label: label,
Checked: checked,
}
}
func (m *CheckboxModel) Init() tea.Cmd {
return nil
}
func (m *CheckboxModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case " ", "enter":
m.Checked = !m.Checked
}
}
return m, nil
}
func (m *CheckboxModel) View() string {
res := ""
res += m.Label + ": "
if m.Checked {
res += "[x]"
} else {
res += "[ ]"
}
return res
}
func (m *CheckboxModel) Focus() {
}
func (m *CheckboxModel) Blur() {
}
func (m *CheckboxModel) CaptureKeys() bool {
return false
}
func (m *CheckboxModel) GetLabel() string {
return m.Label
}
func (m *CheckboxModel) GetValue() interface{} {
return m.Checked
}

48
setup/fields/compute.go Normal file
View file

@ -0,0 +1,48 @@
package fields
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
)
type ComputeModel struct {
Label string
value string
}
func NewComputeModel(label, value string) *ComputeModel {
return &ComputeModel{
Label: label,
value: value,
}
}
func (m *ComputeModel) Init() tea.Cmd {
return nil
}
func (m *ComputeModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
return m, nil
}
func (m *ComputeModel) View() string {
return fmt.Sprintf("%s: %s", m.Label, m.value)
}
func (m *ComputeModel) Focus() {}
func (m *ComputeModel) Blur() {
}
func (m *ComputeModel) CaptureKeys() bool {
return false
}
func (m *ComputeModel) GetLabel() string {
return m.Label
}
func (m *ComputeModel) GetValue() interface{} {
return m.value
}

236
setup/fields/dirSelect.go Normal file
View file

@ -0,0 +1,236 @@
package fields
import (
"bubble/setup/ui"
"bubble/types"
"fmt"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/sirupsen/logrus"
"os"
"path/filepath"
"strings"
)
type DirSelectModel struct {
input textinput.Model
label string
focus int
open bool
dir string
currentDirectory string
options []os.FileInfo
optionsCount int
}
func NewDirSelectModel(label, dir string) *DirSelectModel {
inputModel := textinput.New()
inputModel.SetValue(dir)
return &DirSelectModel{
input: inputModel,
label: label,
currentDirectory: filepath.Dir(dir),
open: false,
dir: dir,
optionsCount: 3,
}
}
func (m *DirSelectModel) Init() tea.Cmd {
return textinput.Blink
}
func (m *DirSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if m.open {
switch msg.String() {
case "esc":
m.open = false
case "up":
m.focus--
if m.focus < 0 {
m.focus = len(m.options) - 1
}
case "down":
m.focus++
if m.focus == len(m.options) {
m.focus = 0
}
case "enter":
if len(m.options) == 0 || m.focus == len(m.options) {
return m, nil
}
file := m.options[m.focus]
fileName := file.Name()
if fileName == ".." {
m.currentDirectory = filepath.Dir(m.currentDirectory)
m.focus = 0
m.RenderOptions()
newValue := m.currentDirectory + string(filepath.Separator)
m.input.SetValue(newValue)
m.dir = newValue
m.input.CursorEnd()
return m, nil
}
if file.IsDir() {
m.currentDirectory = filepath.Join(m.currentDirectory, fileName)
newValue := m.currentDirectory + string(filepath.Separator)
m.input.SetValue(newValue)
m.dir = newValue
m.input.CursorEnd()
m.open = false
selected := types.SelectedDirMsg{
DirectoryName: fileName,
Index: m.focus,
}
m.focus = 0
return m, func() tea.Msg {
return selected
}
}
}
} else {
switch msg.String() {
case "enter":
m.open = true
newDir := strings.TrimSpace(m.input.Value())
if len(newDir) > 1 && strings.HasSuffix(newDir, string(filepath.Separator)) {
newDir = strings.TrimSuffix(newDir, string(filepath.Separator))
}
if info, err := os.Stat(newDir); err == nil && info.IsDir() {
m.currentDirectory = newDir
m.dir = newDir + string(filepath.Separator)
m.input.SetValue(m.dir)
m.input.CursorEnd()
} else {
m.currentDirectory = filepath.Dir(newDir)
}
m.RenderOptions()
return m, nil
default:
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
if m.input.Value() != m.dir {
m.dir = m.input.Value()
}
}
}
default:
if !m.open {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
}
func (m *DirSelectModel) View() string {
res := fmt.Sprintf("%s: %s", m.label, m.input.View())
if m.open && len(m.options) > 0 {
visibleCount := m.optionsCount
if visibleCount > len(m.options) {
visibleCount = len(m.options)
}
half := visibleCount / 2
start := m.focus - half
if start < 0 {
start = 0
}
end := start + visibleCount
if end > len(m.options) {
end = len(m.options)
start = end - visibleCount
if start < 0 {
start = 0
}
}
for i := start; i < end; i++ {
file := m.options[i]
name := file.Name()
prefix := "\n\t"
if m.focus == i {
prefix += "-> "
} else {
prefix += " "
}
if name == ".." {
res += prefix + ui.SelectFileDirParentStyle.Render(name)
} else if file.IsDir() {
res += prefix + ui.SelectDirStyle.Render(name)
} else {
res += prefix + ui.SelectFileDirDeniedStyle.Render(name)
}
}
}
return res
}
func (m *DirSelectModel) Focus() {
m.input.Focus()
}
func (m *DirSelectModel) Blur() {
m.input.Blur()
}
func (m *DirSelectModel) CaptureKeys() bool {
return m.open
}
func (m *DirSelectModel) GetLabel() string {
return m.label
}
func (m *DirSelectModel) GetValue() interface{} {
return m.dir
}
func (m *DirSelectModel) RenderOptions() {
filesInDir, err := os.ReadDir(m.currentDirectory)
if err != nil {
logrus.Errorf("Ошибка чтения директории: %v", err)
return
}
m.options = nil
var allOptions []os.FileInfo
if m.currentDirectory != "/" && filepath.Clean(m.currentDirectory) != "/" {
allOptions = append(allOptions, virtualDirInfo{name: ".."})
}
for _, entry := range filesInDir {
info, _ := entry.Info()
allOptions = append(allOptions, info)
}
filter := ""
if m.open {
inputValue := strings.TrimSpace(m.input.Value())
if inputValue != "" {
lastPart := inputValue[strings.LastIndex(inputValue, "/")+1:]
if lastPart != "" {
filter = strings.ToLower(lastPart)
}
}
}
m.options = m.options[:0]
for _, opt := range allOptions {
name := opt.Name()
if filter == "" || strings.HasPrefix(strings.ToLower(name), filter) || name == ".." {
m.options = append(m.options, opt)
}
}
if len(m.options) == 0 {
m.focus = 0
return
}
if m.focus >= len(m.options) {
m.focus = len(m.options) - 1
}
if m.focus < 0 {
m.focus = 0
}
}

View file

@ -0,0 +1,14 @@
package fields
import tea "github.com/charmbracelet/bubbletea"
type FieldModel interface {
Init() tea.Cmd
Update(msg tea.Msg) (FieldModel, tea.Cmd)
View() string
Focus()
Blur()
CaptureKeys() bool
GetLabel() string
GetValue() interface{}
}

257
setup/fields/fileSelect.go Normal file
View file

@ -0,0 +1,257 @@
package fields
import (
"bubble/setup/ui"
"bubble/types"
"fmt"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/sirupsen/logrus"
"os"
"path/filepath"
"slices"
"strings"
"time"
)
type FileSelectModel struct {
input textinput.Model
label string
focus int
open bool
file string
currentDirectory string
options []os.FileInfo
extensions []string
optionsCount int
}
func NewFileSelectModel(label, file string, extensions []string) *FileSelectModel {
inputModel := textinput.NewModel()
inputModel.SetValue(file)
return &FileSelectModel{
input: inputModel,
label: label,
extensions: extensions,
currentDirectory: filepath.Dir(file),
open: false,
file: file,
optionsCount: 3,
}
}
func (m *FileSelectModel) Init() tea.Cmd {
return textinput.Blink
}
func (m *FileSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if m.open {
switch msg.String() {
case "esc":
m.open = false
case "up":
m.focus--
if m.focus < 0 {
m.focus = len(m.options) - 1
}
case "down":
m.focus++
if m.focus == len(m.options) {
m.focus = 0
}
case "enter":
if len(m.options) == 0 || m.focus == len(m.options) {
return m, nil
}
file := m.options[m.focus]
fileName := file.Name()
if fileName == ".." {
m.currentDirectory = filepath.Dir(m.currentDirectory)
m.focus = 0
m.RenderOptions()
newValue := m.currentDirectory + string(filepath.Separator)
m.input.SetValue(newValue)
m.file = newValue
m.input.CursorEnd()
return m, nil
}
if file.IsDir() {
m.currentDirectory = filepath.Join(m.currentDirectory, fileName)
m.focus = 0
newValue := m.currentDirectory + string(filepath.Separator)
m.input.SetValue(newValue)
m.file = newValue
m.input.CursorEnd()
m.RenderOptions()
} else if slices.Contains(m.extensions, filepath.Ext(fileName)) {
fullPath := filepath.Join(m.currentDirectory, fileName)
m.file = fullPath
m.input.SetValue(fullPath)
m.open = false
selected := types.SelectedFileMsg{
Filename: fileName,
Index: m.focus,
}
m.input.CursorEnd()
m.focus = 0
return m, func() tea.Msg {
return selected
}
}
}
} else {
switch msg.String() {
case "enter":
m.open = true
m.RenderOptions()
return m, nil
default:
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
if m.input.Value() != m.file {
m.file = m.input.Value()
m.currentDirectory = filepath.Dir(m.file)
}
}
}
default:
if !m.open {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
}
func (m *FileSelectModel) View() string {
res := fmt.Sprintf("%s: %s", m.label, m.input.View())
if m.open && len(m.options) > 0 {
visibleCount := m.optionsCount //количество отображаемых
if visibleCount > len(m.options) { //если количество отображаемых больше, чем есть опций на самом деле
visibleCount = len(m.options) //то меняем количество видимых на количество опций
}
half := visibleCount / 2 //количество объектов сверху и снизу
start := m.focus - half //стартовая позиция
if start < 0 { //если стартовая позиция меньше 0, то меняем на 0
start = 0
}
end := start + visibleCount //последний элемент
if end > len(m.options) { //если последний элемент вне опций
end = len(m.options) //меняем на количество опций
start = end - visibleCount //перерасчет стартовой позиции
if start < 0 {
start = 0
}
}
for i := start; i < end; i++ {
file := m.options[i]
name := file.Name()
prefix := "\n\t"
if m.focus == i {
prefix += "-> "
} else {
prefix += " "
}
if name == ".." {
res += prefix + ui.SelectFileDirParentStyle.Render(name)
} else if file.IsDir() {
res += prefix + ui.SelectDirStyle.Render(name)
} else if !slices.Contains(m.extensions, filepath.Ext(name)) {
res += prefix + ui.SelectFileDirDeniedStyle.Render(name)
} else {
res += prefix + ui.SelectFileStyle.Render(name)
}
}
}
return res
}
func (m *FileSelectModel) Focus() {
m.input.Focus()
}
func (m *FileSelectModel) Blur() {
m.input.Blur()
}
func (m *FileSelectModel) CaptureKeys() bool {
return m.open
}
func (m *FileSelectModel) GetLabel() string {
return m.label
}
func (m *FileSelectModel) GetValue() interface{} {
return m.file
}
func (m *FileSelectModel) RenderOptions() {
filesInDir, err := os.ReadDir(m.currentDirectory)
if err != nil {
logrus.Errorf("Ошибка чтения директории: %v", err)
return
}
m.options = nil
var allOptions []os.FileInfo
if m.currentDirectory != "/" && filepath.Clean(m.currentDirectory) != "/" {
allOptions = append(allOptions, virtualDirInfo{name: ".."})
//m.options = append(m.options, virtualDirInfo{name: ".."})
}
for _, entry := range filesInDir {
info, _ := entry.Info()
allOptions = append(allOptions, info)
//m.options = append(m.options, info)
}
filter := ""
if m.open {
inputValue := strings.TrimSpace(m.input.Value())
if inputValue != "" {
lastPart := inputValue[strings.LastIndex(inputValue, "/")+1:]
if lastPart != "" {
filter = strings.ToLower(lastPart)
}
}
}
m.options = m.options[:0]
for _, opt := range allOptions {
name := opt.Name()
if filter == "" || strings.HasPrefix(strings.ToLower(name), filter) || name == ".." {
m.options = append(m.options, opt)
}
}
//если у нас нет опций, то и фокус нельзя куда-либо поставить, то есть надо быть осторожным,
//но после этой функции у нас задействуется view, где есть условие, отрисовка только если опций больше 0,
//так что у нас стоит защита на использование m.options[m.focus] при фокусе равном 0 и отсутствии опций.
if len(m.options) == 0 {
m.focus = 0
return
}
if m.focus >= len(m.options) {
m.focus = len(m.options) - 1
}
if m.focus < 0 {
m.focus = 0
}
}
type virtualDirInfo struct {
name string
}
func (v virtualDirInfo) Name() string { return v.name }
func (v virtualDirInfo) Size() int64 {
return 0
}
func (v virtualDirInfo) Mode() os.FileMode { return os.ModeDir }
func (v virtualDirInfo) ModTime() time.Time { return time.Time{} }
func (v virtualDirInfo) IsDir() bool { return true }
func (v virtualDirInfo) Sys() interface{} { return nil }

59
setup/fields/input.go Normal file
View file

@ -0,0 +1,59 @@
package fields
import (
"fmt"
"github.com/charmbracelet/bubbles/cursor"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type InputModel struct {
label string
input textinput.Model
}
func NewInputModel(label, value string) *InputModel {
t := textinput.New()
t.SetValue(value)
t.Cursor.SetMode(cursor.CursorBlink)
return &InputModel{
label: label,
input: t,
}
}
func (m *InputModel) Init() tea.Cmd {
return textinput.Blink
}
func (m *InputModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
func (m *InputModel) View() string {
res := fmt.Sprintf("%s: %v", m.label, m.input.View())
return res
}
func (m *InputModel) Focus() {
m.input.Focus()
}
func (m *InputModel) Blur() {
m.input.Blur()
}
func (m *InputModel) CaptureKeys() bool {
//return m.input.Focused()
return false
}
func (m *InputModel) GetLabel() string {
return m.label
}
func (m *InputModel) GetValue() interface{} {
return m.input.Value()
}

128
setup/fields/select.go Normal file
View file

@ -0,0 +1,128 @@
package fields
import (
"bubble/types"
"fmt"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type SelectModel struct {
label string
options []string
focus int
open bool
chosen string
}
func NewSelectModel(label, value string, options []string) *SelectModel {
found := false
for i := range options {
if options[i] == value {
found = true
}
}
if !found {
value = ""
}
return &SelectModel{
options: options,
label: label,
focus: len(options),
chosen: value,
}
}
func (m *SelectModel) Init() tea.Cmd {
return textinput.Blink
}
func (m *SelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if m.open {
switch msg.String() {
case "esc":
m.open = false
case "up":
m.focus--
if m.focus < 0 {
m.focus = len(m.options) - 1
}
case "down":
m.focus++
if m.focus == len(m.options) {
m.focus = 0
}
case "enter":
if len(m.options) == 0 || m.focus == len(m.options) {
return m, nil
}
m.chosen = m.options[m.focus]
selected := types.SelectedOptionMsg{
FieldIndex: m.focus,
Value: m.chosen,
}
m.open = false
m.focus = len(m.options)
return m, func() tea.Msg {
return selected
}
}
} else {
switch msg.String() {
case "enter":
m.open = true
for optionIdx := range m.options {
if m.options[optionIdx] == m.chosen {
m.focus = optionIdx
}
}
if m.chosen == "" && len(m.options) > 0 {
m.focus = 0
}
}
}
}
return m, cmd
}
func (m *SelectModel) View() string {
res := fmt.Sprintf("%s: %s", m.label, m.chosen)
if m.open && len(m.options) > 0 {
for i := range m.options {
if m.focus == i {
res += "\n\t-> " + m.options[i]
} else {
res += "\n\t " + m.options[i]
}
}
}
return res
}
func (m *SelectModel) Close() {
m.open = false
}
func (m *SelectModel) Focus() {
}
func (m *SelectModel) Blur() {
m.open = false
}
func (m *SelectModel) CaptureKeys() bool {
return m.open
}
func (m *SelectModel) GetLabel() string {
return m.label
}
func (m *SelectModel) GetValue() interface{} {
return m.chosen
}

150
setup/final.go Normal file
View file

@ -0,0 +1,150 @@
package setup
import (
"bubble/setup/ui"
"bubble/types"
"fmt"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
)
type FinalModel struct {
variables []types.Variable
table *table.Table
viewport viewport.Model
ready bool
width int
height int
}
func NewFinalModel(variables []types.Variable, width, height int) *FinalModel {
m := &FinalModel{
variables: variables,
width: width,
height: height,
ready: false,
}
if width > 0 && height > 0 {
m.initViewport(width, height)
}
return m
}
func (m *FinalModel) Init() tea.Cmd {
return nil
}
func (m *FinalModel) initViewport(width, height int) {
m.viewport = viewport.New(width, height-4)
m.viewport.Width = width - 4
m.viewport.Height = height - 6
m.ready = true
m.rebuildTable(m.viewport.Width)
m.viewport.SetContent(m.table.Render())
}
func (m *FinalModel) rebuildTable(width int) {
if len(m.variables) == 0 {
m.table = table.New().Headers("Нет переменных")
return
}
availableWidth := width - 8 // примерно: 2 (отступы) + 4 (бордер) + запас
if availableWidth < 60 {
availableWidth = 60
}
t := table.New().
Border(lipgloss.ThickBorder()).
BorderStyle(ui.TableBorderStyle).
StyleFunc(func(row, col int) lipgloss.Style {
var style lipgloss.Style
switch {
case row == table.HeaderRow:
return ui.TableHeaderStyle
case row%2 == 0:
style = ui.TableEvenRowStyle
default:
style = ui.TableOddRowStyle
}
switch col {
case 0, 1:
style = style.Width(25)
case 2:
style = style.Width(availableWidth - 50 - 10)
}
return style
}).
Headers("Variable name", "Default value", "Used value")
for _, v := range m.variables {
used := fmt.Sprintf("%v", v.Value)
if used == "" {
used = "<пусто>"
}
t.Row(v.Name, v.Default, used)
}
m.table = t
}
func (m *FinalModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "up":
m.viewport.ScrollUp(1)
case "down":
m.viewport.ScrollDown(1)
case "pageup":
m.viewport.HalfPageUp()
case "pagedown":
m.viewport.HalfPageDown()
case "home":
m.viewport.GotoTop()
case "end":
m.viewport.GotoBottom()
}
case tea.WindowSizeMsg:
if !m.ready {
m.initViewport(msg.Width, msg.Height)
} else {
m.viewport.Width = msg.Width - 4
m.viewport.Height = msg.Height - 6
}
m.rebuildTable(m.viewport.Width)
rendered := m.table.Render()
m.viewport.SetContent(rendered)
}
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
func (m *FinalModel) View() string {
if !m.ready {
return "Загрузка таблицы"
}
header := lipgloss.NewStyle().
Width(m.viewport.Width).
Align(lipgloss.Center).
Bold(true).
Background(ui.ColorPurple).
Foreground(ui.ColorWhite).
Padding(1).
Render("Итоговые значения переменных")
footer := lipgloss.NewStyle().
Width(m.viewport.Width).
Align(lipgloss.Center).
Foreground(ui.ColorGrey).
Padding(0, 1).
Render(fmt.Sprintf("Строк: %d • q — выход • ↑↓ / jk — скролл", len(m.variables)))
body := m.viewport.View()
return lipgloss.JoinVertical(lipgloss.Center,
header,
body,
footer,
)
}

205
setup/form.go Normal file
View file

@ -0,0 +1,205 @@
package setup
import (
"bubble/setup/fields"
"bubble/setup/ui"
"bubble/types"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/sirupsen/logrus"
"strings"
)
type FormModel struct {
CurrentSection *types.Section
FieldModels []fields.FieldModel
focus int
renderedContent string
}
func NewFormModel(application *types.PackageApplication, sectionIdx, focus int) *FormModel {
currentSection := &application.Sections[sectionIdx]
var fieldModels []fields.FieldModel
for _, sectionField := range currentSection.Fields {
if !CheckCondition(sectionField.Condition, application) {
continue
}
switch sectionField.FieldType {
case "select":
var options []string
for j := range sectionField.Options {
options = append(options, fmt.Sprintf("%v", sectionField.Options[j]))
}
if sectionField.SpecialValue != "" {
specialOptions := SpecialValueHandle(sectionField.SpecialValue)
if strSpecial, ok := specialOptions.([]string); ok {
options = strSpecial
}
}
selected := fmt.Sprintf("%v", sectionField.Variable.Value)
fieldModels = append(fieldModels, fields.NewSelectModel(sectionField.Name, selected, options))
case "check":
checked := false
if value, ok := sectionField.Variable.Value.(bool); ok {
checked = value
} else {
checked = sectionField.Variable.Value == "true"
}
fieldModels = append(fieldModels, fields.NewCheckboxModel(sectionField.Name, checked))
case "compute":
value, err := ComputeValue(sectionField.Compute, application)
if err != nil {
value = fmt.Sprintf("Ошибка вычисления: %v", err)
}
fieldModels = append(fieldModels, fields.NewComputeModel(sectionField.Name, fmt.Sprintf("%v", value)))
//compute расчет
case "file":
fieldModels = append(fieldModels, fields.NewFileSelectModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value), sectionField.FileFieldExtensions))
case "directory":
fieldModels = append(fieldModels, fields.NewDirSelectModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value)))
default:
fieldModels = append(fieldModels, fields.NewInputModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value)))
}
}
if len(fieldModels) > focus {
fieldModels[focus].Focus()
}
m := &FormModel{
FieldModels: fieldModels,
focus: focus,
CurrentSection: currentSection,
}
if currentSection.Type == "abouta" { //"about" {
md := strings.Join(currentSection.Paragraphs, "\n\n")
renderer, _ := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(80))
rendered, _ := renderer.Render(md)
m.renderedContent = rendered
m.focus = 0
}
return m
}
func (m *FormModel) Init() tea.Cmd {
return nil
}
func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
//вернуться к списку приложений, чтобы выбрать другое приложение.
case "enter":
logrus.Infof("sections %s, focus %d, fields len %d", m.CurrentSection.Name, m.focus, len(m.FieldModels))
if len(m.FieldModels) == m.focus {
return m, func() tea.Msg {
return types.NextSectionMsg{}
}
}
case "tab", "down", "shift+tab", "up":
key := msg.String()
if m.focus != len(m.FieldModels) {
logrus.Infof("1")
//todo исправить фокус
field := m.FieldModels[m.focus]
logrus.Infof("field %s, capture keys %t", field.GetLabel(), field.CaptureKeys())
if !field.CaptureKeys() {
if key == "tab" || key == "down" {
m.focus++
} else if key == "shift+tab" || key == "up" {
m.focus--
}
}
} else {
logrus.Infof("2")
if key == "tab" || key == "down" {
m.focus++
} else if key == "shift+tab" || key == "up" {
m.focus--
}
}
if m.focus == len(m.FieldModels)+1 {
m.focus = 0
} else if m.focus < 0 {
m.focus = len(m.FieldModels)
}
}
case types.SelectedOptionMsg:
logrus.Infof("chose %+v", msg)
}
cmd := m.updateFields(msg)
return m, cmd
}
func (m *FormModel) View() string {
res := ""
if len(m.FieldModels) > 0 {
for i := range m.FieldModels {
inputView := m.FieldModels[i].View()
if m.focus == i {
res += ui.FormFocusedStyle.Render(inputView)
} else {
res += ui.FormUnfocusedStyle.Render(inputView)
}
res += "\n"
}
}
button := "\n[ next section ]"
if m.CurrentSection.Type == "about" {
res += m.renderedContent
}
if m.focus == len(m.FieldModels) {
res += ui.FormFocusedStyle.Render(button)
} else {
res += ui.FormUnfocusedStyle.Render(button)
}
return ui.FormStyle.Render(res)
}
func (m *FormModel) updateFields(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
for i, fieldModel := range m.FieldModels {
var fieldCmd tea.Cmd
var updated fields.FieldModel
if i == m.focus { //если фокус установлен на текущем поле, то текущее поле активируем и передаем ему сообщения для обновления
fieldModel.Focus()
updated, fieldCmd = fieldModel.Update(msg)
} else { //если фокус не установлен на текущем поле, то убираем с неё фокус, передаем сообщения, но только те, что не содержат действий клавиатуры
fieldModel.Blur()
if _, ok := msg.(tea.KeyMsg); !ok {
updated, fieldCmd = fieldModel.Update(msg)
}
}
if updated != nil {
m.FieldModels[i] = updated
for _, sectionField := range m.CurrentSection.Fields {
if sectionField.Name == fieldModel.GetLabel() {
oldValue := sectionField.Variable.Value
newValue := fieldModel.GetValue()
if oldValue != newValue {
cmds = append(cmds, func() tea.Msg {
return types.SugarMsg{
VariableName: sectionField.VariableName,
FieldName: sectionField.Name,
Compute: sectionField.Compute,
Focus: m.focus,
}
})
sectionField.Variable.Value = newValue //recompute вызывать.
}
}
}
}
if fieldCmd != nil {
cmds = append(cmds, fieldCmd)
}
}
return tea.Batch(cmds...)
}

69
setup/list.go Normal file
View file

@ -0,0 +1,69 @@
package setup
import (
"bubble/setup/ui"
"bubble/types"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type ListModel struct {
options []string
focus int
}
func NewListModel(applications []types.PackageApplication) *ListModel {
options := make([]string, len(applications))
for i, app := range applications {
options[i] = app.Name
}
return &ListModel{
options: options,
}
}
func (m *ListModel) Init() tea.Cmd {
return nil
}
func (m *ListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "up", "w", "shift+tab":
m.focus--
if m.focus < 0 {
m.focus = len(m.options) - 1
}
case "down", "s", "tab":
m.focus++
if m.focus == len(m.options) {
m.focus = 0
}
case "enter":
return m, func() tea.Msg {
return types.SelectedAppMsg{Index: m.focus}
}
}
}
return m, cmd
}
func (m *ListModel) View() string {
res := ""
listStyle := lipgloss.NewStyle().Padding(1, 1).Foreground(ui.ColorPurple)
for i := range m.options {
if i == m.focus {
res += "[x] " + m.options[i]
} else {
res += "[ ] " + m.options[i]
}
if i != len(m.options)-1 {
res += "\n"
}
}
return listStyle.Render(res)
}

183
setup/main.go Normal file
View file

@ -0,0 +1,183 @@
package setup
import (
"bubble/bootstrap"
"bubble/types"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/sirupsen/logrus"
)
func Start() {
p := tea.NewProgram(NewMain(), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
panic(err)
}
}
type Main struct {
current tea.Model
Applications []types.PackageApplication
CurrentApplication *types.PackageApplication
CurrentSection *types.Section
CurrentSectionIdx int
width int
height int
}
func NewMain() *Main {
apps := bootstrap.LoadApplications()
return &Main{
Applications: apps,
current: NewListModel(apps),
CurrentSectionIdx: 0,
}
}
func (m *Main) Init() tea.Cmd {
return m.current.Init()
}
func (m *Main) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.current, cmd = m.current.Update(msg)
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.current, cmd = m.current.Update(msg)
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
}
case types.NextSectionMsg:
m.CurrentSectionIdx++
if m.CurrentSectionIdx == len(m.CurrentApplication.Sections) {
return m, tea.Quit
}
for sectionIdx := m.CurrentSectionIdx; sectionIdx < len(m.CurrentApplication.Sections); sectionIdx++ {
//todo переделать. Учесть динамические индексы.
section := m.CurrentApplication.Sections[sectionIdx]
if CheckCondition(section.Condition, m.CurrentApplication) {
m.CurrentSectionIdx = sectionIdx
m.CurrentSection = &section
break
}
}
if m.CurrentSection == nil {
logrus.Infof("next section, eq nil, out, idx %d", m.CurrentSectionIdx)
return m, tea.Quit
}
//todo переделать. Учесть динамические индексы.
if m.CurrentSectionIdx == len(m.CurrentApplication.Sections)-1 && m.CurrentSection.Type == "final" {
m.current = NewFinalModel(m.CurrentApplication.Variables, m.width, m.height)
} else {
m.current = NewFormModel(m.CurrentApplication, m.CurrentSectionIdx, 0)
}
case types.SugarMsg:
m.Sugar(msg)
case types.SelectedAppMsg:
//получаем сообщение о выборе приложения
m.CurrentApplication = &m.Applications[msg.Index]
//ищем доступную секцию
for sectionIdx := 0; sectionIdx < len(m.CurrentApplication.Sections); sectionIdx++ {
section := m.CurrentApplication.Sections[sectionIdx]
if CheckCondition(section.Condition, m.CurrentApplication) {
m.CurrentSectionIdx = sectionIdx
m.CurrentSection = &section
break
}
}
//если секция не была найдена, то выходим
if m.CurrentSection == nil {
logrus.Infof("select app, eq nil, out")
return m, tea.Quit
}
//создаем новую форму
m.current = NewFormModel(m.CurrentApplication, m.CurrentSectionIdx, 0)
}
return m, cmd
}
func (m *Main) View() string {
return m.current.View()
}
func (m *Main) Sugar(sugar types.SugarMsg) {
if m.IsMultiField(sugar.VariableName) {
m.DeactivateField(sugar.VariableName, sugar.FieldName)
}
if IsParent(sugar.VariableName, m.CurrentApplication.Sections) {
if sugar.Compute != "" {
Recompute(m.CurrentApplication)
}
m.current = NewFormModel(m.CurrentApplication, m.CurrentSectionIdx, sugar.Focus)
}
}
func IsParent(variableName string, sections []types.Section) bool {
for _, section := range sections {
for _, parent := range section.Parent {
if parent == variableName {
return true
}
}
for _, field := range section.Fields {
for _, parent := range field.Parent {
if parent == variableName {
return true
}
}
}
}
return false
}
func (m *Main) IsMultiField(variableName string) bool {
var count int
for _, section := range m.CurrentApplication.Sections {
for _, field := range section.Fields {
if field.Name == variableName {
count++
}
}
}
return count > 1
}
func (m *Main) DeactivateField(variableName, fieldName string) {
for sectionIdx, section := range m.CurrentApplication.Sections {
for fieldIdx, field := range section.Fields {
if field.Variable.Name == variableName {
if field.Name != fieldName {
m.CurrentApplication.Sections[sectionIdx].Fields[fieldIdx].Active = false
} else {
m.CurrentApplication.Sections[sectionIdx].Fields[fieldIdx].Active = true
}
}
}
}
}
func (m *Main) RecomputeFields() {
for i := range m.CurrentApplication.Sections {
section := &m.CurrentApplication.Sections[i]
for j := range section.Fields {
field := &section.Fields[j]
if field.FieldType == "compute" && field.Compute != "" {
if field.Variable != nil {
continue
}
value, err := ComputeValue(field.Compute, m.CurrentApplication)
if err != nil {
value = fmt.Sprintf("Ошибка вычисления: %v", err)
}
field.Variable.Value = value
}
}
}
}

36
setup/ui/style.go Normal file
View file

@ -0,0 +1,36 @@
package ui
import "github.com/charmbracelet/lipgloss"
var (
ColorWhite = lipgloss.Color("#ffffff")
ColorGrey = lipgloss.Color("#626262")
ColorBlack = lipgloss.Color("#000000")
ColorRed = lipgloss.Color("#FF0000")
ColorGreen = lipgloss.Color("#00FF00")
ColorBlue = lipgloss.Color("#0000FF")
ColorPurple = lipgloss.Color("#6f03fc")
ColorGreenCyan = lipgloss.Color("#11CAAA")
TableHeaderStyle = lipgloss.NewStyle().Foreground(ColorPurple).Bold(true).Align(lipgloss.Center)
TableCellStyle = lipgloss.NewStyle().Padding(0, 1).Width(14)
TableOddRowStyle = TableCellStyle.Foreground(ColorWhite).Background(ColorGrey)
TableEvenRowStyle = TableCellStyle.Foreground(ColorWhite)
TableBorderStyle = lipgloss.NewStyle().Foreground(ColorPurple)
SelectFileDirDeniedStyle = lipgloss.NewStyle().Foreground(ColorGrey)
SelectFileDirParentStyle = lipgloss.NewStyle().Foreground(ColorPurple)
SelectFileStyle = lipgloss.NewStyle().Foreground(ColorGreenCyan)
SelectDirStyle = lipgloss.NewStyle().Foreground(ColorBlue)
SelectStyle = lipgloss.NewStyle().Foreground(ColorGreenCyan)
InputStyle = lipgloss.NewStyle().Foreground(ColorGreenCyan)
ComputeStyle = lipgloss.NewStyle().Foreground(ColorGrey)
CheckboxStyle = lipgloss.NewStyle().Foreground(ColorGreenCyan)
FormFocusedStyle = lipgloss.NewStyle().Foreground(ColorRed).Bold(true).
Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(ColorRed)
FormUnfocusedStyle = lipgloss.NewStyle().Foreground(ColorGreenCyan)
FormStyle = lipgloss.NewStyle().Padding(1, 2).Foreground(ColorBlack).
Border(lipgloss.RoundedBorder()).BorderForeground(ColorRed)
)

29
types/msg.go Normal file
View file

@ -0,0 +1,29 @@
package types
type SelectedAppMsg struct {
Index int
}
type SelectedOptionMsg struct {
FieldIndex int
Value string
}
type NextSectionMsg struct{}
type SugarMsg struct {
VariableName string
FieldName string
Compute string
Focus int
}
type SelectedDirMsg struct {
DirectoryName string
Index int
}
type SelectedFileMsg struct {
Filename string
Index int
}

70
types/types.go Normal file
View file

@ -0,0 +1,70 @@
package types
type PackageApplication struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Description string `yaml:"description"`
Icon string `yaml:"icon"`
Type string `yaml:"type"`
ConfFiles []ConfFile `yaml:"conf-files"`
Variables []Variable `yaml:"variables"`
Sections []Section `yaml:"sections"`
Changed bool `yaml:"-"`
}
type ConfFile struct {
File string `yaml:"file"`
Type string `yaml:"type"`
Name string `yaml:"name"`
}
type Variable struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Default string `yaml:"default"`
Value interface{} `yaml:"-"`
}
type Section struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Description string `yaml:"description"`
Fields []SectionField `yaml:"fields"`
Paragraphs []string `yaml:"paragraphs"`
Parent []string `yaml:"parent"`
Condition string `yaml:"condition"`
Actions []SectionAction `yaml:"actions"`
}
type SectionField struct {
Name string `yaml:"name"`
VariableName string `yaml:"variable-name"`
FieldType string `yaml:"field-type"`
FileFieldExtensions []string `yaml:"file-field-extensions"`
Description string `yaml:"description"`
Condition string `yaml:"condition"`
Options []interface{} `yaml:"options"`
SpecialValue string `yaml:"special-Value"`
Compute string `yaml:"compute"`
Validate []FieldValidate `yaml:"validate"`
Parent []string `yaml:"parent"`
Variable *Variable `yaml:"-"`
Active bool `yaml:"-"`
}
type FieldValidate struct {
Rule string `yaml:"rule"`
Message string `yaml:"message"`
}
type SectionAction struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Command string `yaml:"command"`
FileTarget string `yaml:"file-target"`
FileSource string `yaml:"file-source"`
Condition string `yaml:"condition"`
RunAs string `yaml:"run-as"`
RequirePackages []string `yaml:"require-packages"`
Error string `yaml:"error"`
}