From 5203032b831145aab241c2c9af85c753357d7885 Mon Sep 17 00:00:00 2001 From: XeRo Date: Fri, 26 Dec 2025 17:45:37 +0500 Subject: [PATCH] first commit --- .gitignore | 12 ++ Readme.md | 3 + bootstrap/load.go | 90 ++++++++++ bootstrap/logs.go | 27 +++ confs/repko.yaml | 315 ++++++++++++++++++++++++++++++++++ confs/shop.yaml | 314 ++++++++++++++++++++++++++++++++++ go.mod | 50 ++++++ go.sum | 114 +++++++++++++ log.txt | 0 main.go | 16 ++ setup/condition.go | 336 +++++++++++++++++++++++++++++++++++++ setup/fields/checkbox.go | 64 +++++++ setup/fields/compute.go | 48 ++++++ setup/fields/dirSelect.go | 236 ++++++++++++++++++++++++++ setup/fields/fieldModel.go | 14 ++ setup/fields/fileSelect.go | 257 ++++++++++++++++++++++++++++ setup/fields/input.go | 59 +++++++ setup/fields/select.go | 128 ++++++++++++++ setup/final.go | 150 +++++++++++++++++ setup/form.go | 205 ++++++++++++++++++++++ setup/list.go | 69 ++++++++ setup/main.go | 183 ++++++++++++++++++++ setup/ui/style.go | 36 ++++ types/msg.go | 29 ++++ types/types.go | 70 ++++++++ 25 files changed, 2825 insertions(+) create mode 100644 .gitignore create mode 100644 Readme.md create mode 100644 bootstrap/load.go create mode 100644 bootstrap/logs.go create mode 100644 confs/repko.yaml create mode 100644 confs/shop.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 log.txt create mode 100644 main.go create mode 100644 setup/condition.go create mode 100644 setup/fields/checkbox.go create mode 100644 setup/fields/compute.go create mode 100644 setup/fields/dirSelect.go create mode 100644 setup/fields/fieldModel.go create mode 100644 setup/fields/fileSelect.go create mode 100644 setup/fields/input.go create mode 100644 setup/fields/select.go create mode 100644 setup/final.go create mode 100644 setup/form.go create mode 100644 setup/list.go create mode 100644 setup/main.go create mode 100644 setup/ui/style.go create mode 100644 types/msg.go create mode 100644 types/types.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78361d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +*.tmp +*.log + +/vendor/ +/bin/ +*.exe + +.idea +build/ +.vscode/ + +*.env.local \ No newline at end of file diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..048a977 --- /dev/null +++ b/Readme.md @@ -0,0 +1,3 @@ +# Мастер настроек GoSetup терминальная версия + +Перенос [GoSetup+Fyne](https://dev.gossys.dev/XeRo/GoSetup) в терминал с использованием библиотек сообщества [charm](https://charm.sh/). diff --git a/bootstrap/load.go b/bootstrap/load.go new file mode 100644 index 0000000..63db0c4 --- /dev/null +++ b/bootstrap/load.go @@ -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 := §ion.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 +} diff --git a/bootstrap/logs.go b/bootstrap/logs.go new file mode 100644 index 0000000..f74aed3 --- /dev/null +++ b/bootstrap/logs.go @@ -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) +} diff --git a/confs/repko.yaml b/confs/repko.yaml new file mode 100644 index 0000000..f8885c9 --- /dev/null +++ b/confs/repko.yaml @@ -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 \ No newline at end of file diff --git a/confs/shop.yaml b/confs/shop.yaml new file mode 100644 index 0000000..d3fe8ce --- /dev/null +++ b/confs/shop.yaml @@ -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 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7b0936a --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..113fa12 --- /dev/null +++ b/go.sum @@ -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= diff --git a/log.txt b/log.txt new file mode 100644 index 0000000..e69de29 diff --git a/main.go b/main.go new file mode 100644 index 0000000..b4e1894 --- /dev/null +++ b/main.go @@ -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) + //} +} diff --git a/setup/condition.go b/setup/condition.go new file mode 100644 index 0000000..c37dc14 --- /dev/null +++ b/setup/condition.go @@ -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 := §ion.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 +} diff --git a/setup/fields/checkbox.go b/setup/fields/checkbox.go new file mode 100644 index 0000000..74b7f6d --- /dev/null +++ b/setup/fields/checkbox.go @@ -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 +} diff --git a/setup/fields/compute.go b/setup/fields/compute.go new file mode 100644 index 0000000..c1f281b --- /dev/null +++ b/setup/fields/compute.go @@ -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 +} diff --git a/setup/fields/dirSelect.go b/setup/fields/dirSelect.go new file mode 100644 index 0000000..4fed6ef --- /dev/null +++ b/setup/fields/dirSelect.go @@ -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 + } +} diff --git a/setup/fields/fieldModel.go b/setup/fields/fieldModel.go new file mode 100644 index 0000000..1950609 --- /dev/null +++ b/setup/fields/fieldModel.go @@ -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{} +} diff --git a/setup/fields/fileSelect.go b/setup/fields/fileSelect.go new file mode 100644 index 0000000..c84c2ee --- /dev/null +++ b/setup/fields/fileSelect.go @@ -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 } diff --git a/setup/fields/input.go b/setup/fields/input.go new file mode 100644 index 0000000..9bd95d2 --- /dev/null +++ b/setup/fields/input.go @@ -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() +} diff --git a/setup/fields/select.go b/setup/fields/select.go new file mode 100644 index 0000000..6e956d9 --- /dev/null +++ b/setup/fields/select.go @@ -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 +} diff --git a/setup/final.go b/setup/final.go new file mode 100644 index 0000000..b7622f7 --- /dev/null +++ b/setup/final.go @@ -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, + ) +} diff --git a/setup/form.go b/setup/form.go new file mode 100644 index 0000000..10e9af3 --- /dev/null +++ b/setup/form.go @@ -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...) +} diff --git a/setup/list.go b/setup/list.go new file mode 100644 index 0000000..afb7c73 --- /dev/null +++ b/setup/list.go @@ -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) +} diff --git a/setup/main.go b/setup/main.go new file mode 100644 index 0000000..dc7e8f8 --- /dev/null +++ b/setup/main.go @@ -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 = §ion + 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 = §ion + 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 := §ion.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 + + } + } + } +} diff --git a/setup/ui/style.go b/setup/ui/style.go new file mode 100644 index 0000000..671fa06 --- /dev/null +++ b/setup/ui/style.go @@ -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) +) diff --git a/types/msg.go b/types/msg.go new file mode 100644 index 0000000..fc1e7c8 --- /dev/null +++ b/types/msg.go @@ -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 +} diff --git a/types/types.go b/types/types.go new file mode 100644 index 0000000..9cecb19 --- /dev/null +++ b/types/types.go @@ -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"` +}