first commit
This commit is contained in:
commit
5203032b83
25 changed files with 2825 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
*.tmp
|
||||||
|
*.log
|
||||||
|
|
||||||
|
/vendor/
|
||||||
|
/bin/
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
.idea
|
||||||
|
build/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
*.env.local
|
||||||
3
Readme.md
Normal file
3
Readme.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Мастер настроек GoSetup терминальная версия
|
||||||
|
|
||||||
|
Перенос [GoSetup+Fyne](https://dev.gossys.dev/XeRo/GoSetup) в терминал с использованием библиотек сообщества [charm](https://charm.sh/).
|
||||||
90
bootstrap/load.go
Normal file
90
bootstrap/load.go
Normal 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 := §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
|
||||||
|
}
|
||||||
27
bootstrap/logs.go
Normal file
27
bootstrap/logs.go
Normal 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
315
confs/repko.yaml
Normal 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
314
confs/shop.yaml
Normal 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
50
go.mod
Normal 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
114
go.sum
Normal 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
0
log.txt
Normal file
16
main.go
Normal file
16
main.go
Normal 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
336
setup/condition.go
Normal 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 := §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
|
||||||
|
}
|
||||||
64
setup/fields/checkbox.go
Normal file
64
setup/fields/checkbox.go
Normal 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
48
setup/fields/compute.go
Normal 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
236
setup/fields/dirSelect.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
14
setup/fields/fieldModel.go
Normal file
14
setup/fields/fieldModel.go
Normal 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
257
setup/fields/fileSelect.go
Normal 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
59
setup/fields/input.go
Normal 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
128
setup/fields/select.go
Normal 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
150
setup/final.go
Normal 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
205
setup/form.go
Normal 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
69
setup/list.go
Normal 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
183
setup/main.go
Normal 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 = §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
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
setup/ui/style.go
Normal file
36
setup/ui/style.go
Normal 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
29
types/msg.go
Normal 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
70
types/types.go
Normal 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"`
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue