├── .air.toml ├── .env.example ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── Dockerfile ├── LICENSE ├── Readme.md ├── cmd ├── create.go ├── form.go ├── forms.go └── root.go ├── config.yaml ├── docker-compose.yaml ├── go.mod ├── go.sum ├── images ├── create.gif ├── create.tape ├── form.gif ├── form.tape ├── forms.gif ├── forms.tape ├── open.gif └── open.tape ├── internal ├── config │ └── config.go ├── constants │ ├── form.go │ ├── logo.go │ └── messages.go ├── database │ └── database.go ├── logger │ └── default.go ├── models │ ├── answer.go │ ├── form.go │ ├── item.go │ ├── option.go │ ├── question.go │ ├── response.go │ └── user.go ├── repository │ ├── form.go │ ├── response.go │ └── user.go ├── services │ ├── crypto.go │ ├── form.go │ ├── response.go │ ├── user.go │ └── validator.go ├── styles │ └── styles.go ├── types │ └── list.go └── ui │ ├── create │ ├── create.go │ └── form.go │ ├── form │ └── form.go │ └── forms │ ├── forms.go │ ├── main.go │ ├── response.go │ └── responses.go ├── main.go └── server ├── auth.go ├── handler.go ├── logger.go └── server.go /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/main" 8 | cmd = "go build -o ./tmp/main ." 9 | delay = 1000 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html"] 18 | include_file = [] 19 | kill_delay = "0s" 20 | log = "build-errors.log" 21 | poll = false 22 | poll_interval = 0 23 | post_cmd = [] 24 | pre_cmd = [] 25 | rerun = false 26 | rerun_delay = 500 27 | send_interrupt = false 28 | stop_on_error = false 29 | 30 | [color] 31 | app = "" 32 | build = "yellow" 33 | main = "magenta" 34 | runner = "green" 35 | watcher = "cyan" 36 | 37 | [log] 38 | main_only = false 39 | silent = false 40 | time = false 41 | 42 | [misc] 43 | clean_on_exit = false 44 | 45 | [proxy] 46 | app_port = 0 47 | enabled = false 48 | proxy_port = 0 49 | 50 | [screen] 51 | clear_on_rebuild = false 52 | keep_scroll = true 53 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | CONFIG_FILE= 2 | 3 | POSTGRES_USER= 4 | POSTGRES_PASSWORD= 5 | POSTGRES_NAME= 6 | POSTGRES_HOST= 7 | POSTGRES_PORT= 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "\U0001F41E [BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Open this 16 | 2. Do this... 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **Screenshots** 22 | If applicable, add screenshots to help explain your problem. 23 | 24 | **Platform (please complete the following information):** 25 | - OS: [e.g. MacOS] 26 | - Terminal [e.g. Kitty, Alacritty] 27 | - WindowSize 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "\U0001F680 [FEATURE]" 5 | labels: feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## PR Type 2 | 3 | - [ ] Feature 4 | - [ ] Bug Fix 5 | - [ ] Documentation 6 | - [ ] Refactor 7 | - [ ] Other (please specify) 8 | 9 | ## Overview 10 | 11 | 12 | 13 | ## Changes 14 | 15 | 16 | 17 | - 18 | - 19 | - 20 | 21 | ## Checklist 22 | 23 | - [ ] Tested locally 24 | - [ ] Updated docs 25 | - [ ] No errors/warnings 26 | 27 | ## Screenshots 28 | 29 | 30 | 31 | ## Notes 32 | 33 | 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | tmp/ 3 | .ssh 4 | .env 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:latest 2 | 3 | WORKDIR / 4 | 5 | COPY go.* . 6 | 7 | RUN go mod download 8 | 9 | COPY . . 10 | 11 | ENV TERM=xterm-256color 12 | 13 | RUN go build -o ./main . 14 | 15 | EXPOSE 22 16 | 17 | CMD ["./main"] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ojas Tyagi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # ⚡ BashForm 2 | 3 | **Create and share forms using SSH**. Users can participate in forms without installing any additional packages. 4 | 5 | ## Key Features 6 | 7 | - **💻 Terminal-Based Forms**: Create forms and fill them out directly from the terminal. 8 | - **🔑 Secure Authorization**: Users authenticate using their SSH keys, eliminating the need for passwords and ensuring a secure experience. 9 | - **👶 No Installation Required**: Users respond to forms via SSH, eliminating the need for installing client software. 10 | - **🫂 Easy Form Sharing**: Share forms using unique codes, enabling global participation. 11 | - **🚀 Customizable Forms**: Create forms with any number of questions, tailored to your needs. 12 | 13 | ## How It Works 14 | 15 | Bashform leverages SSH to provide a simple and secure interface for creating and interacting with forms. Here's how: 16 | 17 | - **Form Creation**: Generate forms with a specified number of questions and a unique code. 18 | - **Form Filling**: Respond to forms securely via SSH using the assigned code. 19 | - **Form Responses**: Retrieve form responses, allowing for easy data collection. 20 | 21 |
22 |
23 |
48 | # or
49 | ssh -t bashform.me f
50 | ```
51 |
52 | Replace `` with the unique code of the form you wish to fill out.
53 |
54 |
55 |
56 |
57 |
58 | ### Create a New Form
59 |
60 | To create a new form, use the command:
61 |
62 | ```bash
63 | ssh -t bashform.me create
64 | # or
65 | ssh -t bashform.me c
66 | ```
67 |
68 | Replace `` with the number of questions you want in the form, and `` with the unique code for your form.
69 |
70 |
71 |
72 |
73 |
74 | ### Get Forms and Responses
75 |
76 | To get a list of forms and responses, use the following command:
77 |
78 | ```bash
79 | ssh -t bashform.me forms
80 | ```
81 |
82 | This will display a list of forms and by selecting a form, you can view the responses.
83 |
84 |
85 |
86 |
87 |
88 | ## Example
89 |
90 | ### Creating a Form
91 |
92 | ```bash
93 | ssh -t bashform.me create 2 myform
94 | ```
95 |
96 | This creates a form with 2 questions and the code `myform`.
97 |
98 | ### Filling Out a Form
99 |
100 | ```bash
101 | ssh -t bashform.me form myform
102 | ```
103 |
104 | This allows you to respond to the form with the code `myform`.
105 |
106 | ### Getting Form Responses
107 |
108 | ```bash
109 | ssh -t bashform.me forms
110 | ```
111 |
112 | ## Try It Out
113 |
114 | You can try out Bashform by using the following commands:
115 |
116 | ```bash
117 | ssh -t bashform.me f devmegablaster
118 | ```
119 |
120 | ## Contributing
121 |
122 | Contributions are welcome! If you'd like to improve Bashform, follow these steps:
123 |
124 | 1. Fork the repository.
125 | 2. Create a new branch: `git checkout -b feature-branch`.
126 | 3. Make your changes and commit: `git commit -m 'Add new feature'`.
127 | 4. Push to the branch: `git push origin feature-branch`.
128 | 5. Submit a pull request.
129 |
--------------------------------------------------------------------------------
/cmd/create.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "log/slog"
5 | "os"
6 | "strconv"
7 |
8 | tea "github.com/charmbracelet/bubbletea"
9 | "github.com/devmegablaster/bashform/internal/constants"
10 | "github.com/devmegablaster/bashform/internal/styles"
11 | "github.com/devmegablaster/bashform/internal/ui/create"
12 | "github.com/spf13/cobra"
13 | )
14 |
15 | func (c *CLI) createForm() *cobra.Command {
16 | newFormCmd := &cobra.Command{
17 | Use: "create [number of questions] [share code]",
18 | Args: cobra.ExactArgs(2),
19 | Short: "Create a new form with a specific number of questions and shareable code",
20 | Aliases: []string{"c"},
21 | RunE: func(cmd *cobra.Command, args []string) error {
22 | n, err := strconv.Atoi(args[0])
23 | if err != nil {
24 | return err
25 | }
26 |
27 | if n < 1 {
28 | cmd.Println(styles.Error.Render("number of questions must be greater than 0"))
29 | return nil
30 | }
31 |
32 | var code string
33 |
34 | if len(args) < 2 {
35 | code = ""
36 | } else {
37 | code = args[1]
38 | }
39 |
40 | avl := c.formSvc.CheckCodeAvailability(code)
41 |
42 | if !avl {
43 | cmd.Println(styles.Error.Render(constants.MessageCodeNotAvailable))
44 | return nil
45 | }
46 |
47 | cr := create.NewModel(c.cfg, c.Session, n, code, c.formSvc)
48 |
49 | p := tea.NewProgram(cr,
50 | tea.WithAltScreen(),
51 | tea.WithInput(c.Session),
52 | tea.WithOutput(c.Session))
53 |
54 | if _, err := p.Run(); err != nil {
55 | slog.Error("Error running program", "error", err)
56 | os.Exit(1)
57 | }
58 |
59 | return nil
60 | },
61 | }
62 |
63 | return newFormCmd
64 | }
65 |
--------------------------------------------------------------------------------
/cmd/form.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | tea "github.com/charmbracelet/bubbletea"
5 | "github.com/devmegablaster/bashform/internal/styles"
6 | "github.com/devmegablaster/bashform/internal/ui/form"
7 | "github.com/spf13/cobra"
8 | )
9 |
10 | func (c *CLI) fillForm() *cobra.Command {
11 | formCmd := &cobra.Command{
12 | Use: "form [code]",
13 | Short: "Fill out a form using form code",
14 | Args: cobra.ExactArgs(1),
15 | Aliases: []string{"f"},
16 | SilenceUsage: true,
17 | RunE: func(cmd *cobra.Command, args []string) error {
18 | formData, err := c.formSvc.GetByCode(args[0], c.user)
19 | if err != nil {
20 | c.logger.Error("Error getting form", "error", err)
21 | cmd.Println(styles.Error.Render(err.Error()))
22 | return nil
23 | }
24 |
25 | c.logger.Info("Form retrieved", "form", formData.ID)
26 |
27 | model := form.NewModel(formData, c.responseSvc, c.Session)
28 | p := tea.NewProgram(model,
29 | tea.WithAltScreen(),
30 | tea.WithInput(cmd.InOrStdin()),
31 | tea.WithOutput(cmd.OutOrStdout()),
32 | )
33 | _, err = p.Run()
34 | return err
35 | },
36 | }
37 |
38 | return formCmd
39 | }
40 |
--------------------------------------------------------------------------------
/cmd/forms.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | tea "github.com/charmbracelet/bubbletea"
5 | "github.com/devmegablaster/bashform/internal/models"
6 | "github.com/devmegablaster/bashform/internal/styles"
7 | "github.com/devmegablaster/bashform/internal/ui/forms"
8 | "github.com/spf13/cobra"
9 | )
10 |
11 | func (c *CLI) getForms() *cobra.Command {
12 | formCmd := &cobra.Command{
13 | Use: "forms",
14 | Short: "Get your forms",
15 | SilenceUsage: true,
16 | RunE: func(cmd *cobra.Command, args []string) error {
17 | userForms, err := c.formSvc.GetForUser(c.user)
18 | if err != nil {
19 | cmd.Println(styles.Error.Render(err.Error()))
20 | return nil
21 | }
22 |
23 | items := models.FormsToItems(userForms)
24 | model := forms.NewModel(items, c.formSvc, c.Session)
25 |
26 | p := tea.NewProgram(model,
27 | tea.WithAltScreen(),
28 | tea.WithInput(c.Session),
29 | tea.WithOutput(c.Session),
30 | )
31 |
32 | _, err = p.Run()
33 |
34 | return err
35 | },
36 | }
37 |
38 | return formCmd
39 | }
40 |
--------------------------------------------------------------------------------
/cmd/root.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "log/slog"
5 | "os"
6 |
7 | "github.com/charmbracelet/ssh"
8 | "github.com/devmegablaster/bashform/internal/config"
9 | "github.com/devmegablaster/bashform/internal/constants"
10 | "github.com/devmegablaster/bashform/internal/database"
11 | "github.com/devmegablaster/bashform/internal/models"
12 | "github.com/devmegablaster/bashform/internal/services"
13 | "github.com/spf13/cobra"
14 | )
15 |
16 | type CLI struct {
17 | cfg config.Config
18 | database *database.Database
19 | Session ssh.Session
20 | user *models.User
21 | RootCmd *cobra.Command
22 | logger *slog.Logger
23 |
24 | formSvc *services.FormService
25 | responseSvc *services.ResponseService
26 | }
27 |
28 | func NewCLI(cfg config.Config, db *database.Database, session ssh.Session) *CLI {
29 | rootCmd := &cobra.Command{
30 | Use: "bashform",
31 | RunE: func(cmd *cobra.Command, args []string) error {
32 | cmd.Println(constants.Logo)
33 | cmd.Help()
34 | return nil
35 | },
36 | }
37 |
38 | u := session.Context().Value("user").(*models.User)
39 |
40 | logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{})).With("user", u.ID).With("username", u.Name)
41 |
42 | c := &CLI{
43 | cfg: cfg,
44 | Session: session,
45 | user: u,
46 | RootCmd: rootCmd,
47 | logger: logger,
48 | formSvc: services.NewFormService(cfg, db, logger),
49 | responseSvc: services.NewResponseService(cfg, db, logger),
50 | }
51 |
52 | c.Init()
53 | return c
54 | }
55 |
56 | func (c *CLI) AddCommand(cmd *cobra.Command) {
57 | c.RootCmd.AddCommand(cmd)
58 | }
59 |
60 | func (c *CLI) Init() {
61 | c.RootCmd.SetArgs(c.Session.Command())
62 | c.RootCmd.SetIn(c.Session)
63 | c.RootCmd.SetOut(c.Session)
64 | c.RootCmd.SetErr(c.Session.Stderr())
65 | c.RootCmd.SetContext(c.Session.Context())
66 |
67 | // Add Commands
68 | c.AddCommand(c.fillForm())
69 | c.AddCommand(c.createForm())
70 | c.AddCommand(c.getForms())
71 | }
72 |
73 | func (c *CLI) Run() error {
74 | return c.RootCmd.Execute()
75 | }
76 |
--------------------------------------------------------------------------------
/config.yaml:
--------------------------------------------------------------------------------
1 | ssh:
2 | url: bashform.me
3 | host: 0.0.0.0
4 | port: 22
5 | key_path: ./.ssh/id_ed25519
6 |
7 | database:
8 | host: ${POSTGRES_HOST}
9 | port: ${POSTGRES_PORT}
10 | user: ${POSTGRES_USER}
11 | password: ${POSTGRES_PASSWORD}
12 | name: ${POSTGRES_NAME}
13 |
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | ssh:
3 | tty: true
4 | build:
5 | context: .
6 | dockerfile: Dockerfile
7 | container_name: bashform-ssh
8 | ports:
9 | - "22:22"
10 | restart: always
11 | networks:
12 | - bashform
13 |
14 | db:
15 | image: "postgres:12"
16 | ports:
17 | - ${POSTGRES_PORT}:5432
18 | container_name: postgres
19 | environment:
20 | POSTGRES_USER: ${POSTGRES_USER}
21 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
22 | POSTGRES_DB: ${POSTGRES_NAME}
23 | networks:
24 | - bashform
25 |
26 | networks:
27 | bashform:
28 | name: bashform
29 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/devmegablaster/bashform
2 |
3 | go 1.23.2
4 |
5 | require (
6 | github.com/charmbracelet/bubbles v0.20.0
7 | github.com/charmbracelet/bubbletea v1.2.4
8 | github.com/charmbracelet/huh v0.6.0
9 | github.com/charmbracelet/lipgloss v1.0.0
10 | github.com/charmbracelet/ssh v0.0.0-20241211182756-4fe22b0f1b7c
11 | github.com/charmbracelet/wish v1.4.4
12 | github.com/go-playground/validator/v10 v10.23.0
13 | github.com/google/uuid v1.6.0
14 | github.com/joho/godotenv v1.5.1
15 | github.com/spf13/cobra v1.8.1
16 | github.com/spf13/viper v1.19.0
17 | gorm.io/driver/postgres v1.5.11
18 | gorm.io/gorm v1.25.12
19 | )
20 |
21 | require (
22 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
23 | github.com/atotto/clipboard v0.1.4 // indirect
24 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
25 | github.com/catppuccin/go v0.2.0 // indirect
26 | github.com/charmbracelet/keygen v0.5.1 // indirect
27 | github.com/charmbracelet/log v0.4.0 // indirect
28 | github.com/charmbracelet/x/ansi v0.4.5 // indirect
29 | github.com/charmbracelet/x/conpty v0.1.0 // indirect
30 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
31 | github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
32 | github.com/charmbracelet/x/term v0.2.1 // indirect
33 | github.com/charmbracelet/x/termios v0.1.0 // indirect
34 | github.com/creack/pty v1.1.21 // indirect
35 | github.com/dustin/go-humanize v1.0.1 // indirect
36 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
37 | github.com/fsnotify/fsnotify v1.7.0 // indirect
38 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect
39 | github.com/go-logfmt/logfmt v0.6.0 // indirect
40 | github.com/go-playground/locales v0.14.1 // indirect
41 | github.com/go-playground/universal-translator v0.18.1 // indirect
42 | github.com/hashicorp/hcl v1.0.0 // indirect
43 | github.com/inconshreveable/mousetrap v1.1.0 // indirect
44 | github.com/jackc/pgpassfile v1.0.0 // indirect
45 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
46 | github.com/jackc/pgx/v5 v5.5.5 // indirect
47 | github.com/jackc/puddle/v2 v2.2.1 // indirect
48 | github.com/jinzhu/inflection v1.0.0 // indirect
49 | github.com/jinzhu/now v1.1.5 // indirect
50 | github.com/leodido/go-urn v1.4.0 // indirect
51 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
52 | github.com/magiconair/properties v1.8.7 // indirect
53 | github.com/mattn/go-isatty v0.0.20 // indirect
54 | github.com/mattn/go-localereader v0.0.1 // indirect
55 | github.com/mattn/go-runewidth v0.0.16 // indirect
56 | github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
57 | github.com/mitchellh/mapstructure v1.5.0 // indirect
58 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
59 | github.com/muesli/cancelreader v0.2.2 // indirect
60 | github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
61 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect
62 | github.com/rivo/uniseg v0.4.7 // indirect
63 | github.com/sagikazarmark/locafero v0.4.0 // indirect
64 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect
65 | github.com/sahilm/fuzzy v0.1.1 // indirect
66 | github.com/sourcegraph/conc v0.3.0 // indirect
67 | github.com/spf13/afero v1.11.0 // indirect
68 | github.com/spf13/cast v1.6.0 // indirect
69 | github.com/spf13/pflag v1.0.5 // indirect
70 | github.com/subosito/gotenv v1.6.0 // indirect
71 | go.uber.org/atomic v1.9.0 // indirect
72 | go.uber.org/multierr v1.9.0 // indirect
73 | golang.org/x/crypto v0.31.0 // indirect
74 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
75 | golang.org/x/net v0.25.0 // indirect
76 | golang.org/x/sync v0.10.0 // indirect
77 | golang.org/x/sys v0.28.0 // indirect
78 | golang.org/x/text v0.21.0 // indirect
79 | gopkg.in/ini.v1 v1.67.0 // indirect
80 | gopkg.in/yaml.v3 v3.0.1 // indirect
81 | )
82 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
2 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
3 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
4 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
5 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
6 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
7 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
8 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
9 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
10 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
11 | github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
12 | github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
13 | github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
14 | github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
15 | github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE=
16 | github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM=
17 | github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8=
18 | github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
19 | github.com/charmbracelet/keygen v0.5.1 h1:zBkkYPtmKDVTw+cwUyY6ZwGDhRxXkEp0Oxs9sqMLqxI=
20 | github.com/charmbracelet/keygen v0.5.1/go.mod h1:zznJVmK/GWB6dAtjluqn2qsttiCBhA5MZSiwb80fcHw=
21 | github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
22 | github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
23 | github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
24 | github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
25 | github.com/charmbracelet/ssh v0.0.0-20241211182756-4fe22b0f1b7c h1:treQxMBdI2PaD4eOYfFux8stfCkUxhuUxaqGcxKqVpI=
26 | github.com/charmbracelet/ssh v0.0.0-20241211182756-4fe22b0f1b7c/go.mod h1:CY1xbl2z+ZeBmNWItKZyxx0zgDgnhmR57+DTsHOobJ4=
27 | github.com/charmbracelet/wish v1.4.4 h1:wtfoAMkf8Db9zi+9Lme2f7XKMxL6BqfgDWbqcTUHLaU=
28 | github.com/charmbracelet/wish v1.4.4/go.mod h1:XB8v51UxIFMRlUod9lLaAgOsj/wpe+qW9HjsoYIiNMo=
29 | github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM=
30 | github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
31 | github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
32 | github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
33 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
34 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
35 | github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
36 | github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
37 | github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
38 | github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
39 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
40 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
41 | github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k=
42 | github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U=
43 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
44 | github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
45 | github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
46 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
47 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
48 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
49 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
50 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
51 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
52 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
53 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
54 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
55 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
56 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
57 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
58 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
59 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
60 | github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
61 | github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
62 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
63 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
64 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
65 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
66 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
67 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
68 | github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
69 | github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
70 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
71 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
72 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
73 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
74 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
75 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
76 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
77 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
78 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
79 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
80 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
81 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
82 | github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
83 | github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
84 | github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
85 | github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
86 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
87 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
88 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
89 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
90 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
91 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
92 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
93 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
94 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
95 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
96 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
97 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
98 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
99 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
100 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
101 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
102 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
103 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
104 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
105 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
106 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
107 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
108 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
109 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
110 | github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
111 | github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
112 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
113 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
114 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
115 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
116 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
117 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
118 | github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
119 | github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
120 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
121 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
122 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
123 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
124 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
125 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
126 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
127 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
128 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
129 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
130 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
131 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
132 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
133 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
134 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
135 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
136 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
137 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
138 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
139 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
140 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
141 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
142 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
143 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
144 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
145 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
146 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
147 | github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
148 | github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
149 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
150 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
151 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
152 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
153 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
154 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
155 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
156 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
157 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
158 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
159 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
160 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
161 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
162 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
163 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
164 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
165 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
166 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
167 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
168 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
169 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
170 | golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
171 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
172 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
173 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
174 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
175 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
176 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
177 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
178 | golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
179 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
180 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
181 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
182 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
183 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
184 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
185 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
186 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
187 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
188 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
189 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
190 | gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
191 | gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
192 | gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
193 | gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
194 |
--------------------------------------------------------------------------------
/images/create.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devmegablaster/bashform/90537fb13acb13954dafb1b97be1bb203ebf7303/images/create.gif
--------------------------------------------------------------------------------
/images/create.tape:
--------------------------------------------------------------------------------
1 | Output create.gif
2 |
3 | Set Shell "bash"
4 | Set FontSize 24
5 | Set Width 1440
6 | Set Height 1500
7 |
8 | Type@150ms "ssh -t bashform.me c 2 visits" Sleep 500ms Enter
9 |
10 | Sleep 8s
11 |
12 | Type@150ms "Form Title" Sleep 500ms Enter
13 | Sleep 500ms
14 | Type@100ms "This is the description of the form" Sleep 500ms Enter
15 |
16 | Type@150ms "" Sleep 500ms Enter
17 | Sleep 500ms
18 | Type@150ms "Name" Sleep 500ms Enter
19 | Sleep 500ms
20 | Type@150ms "" Sleep 500ms Enter
21 | Sleep 500ms
22 | Type@250ms "h" Sleep 1s Enter
23 |
24 | Sleep 2s
25 |
26 | Type@150ms "jj" Sleep 500ms Enter
27 | Sleep 500ms
28 | Type@150ms "Via" Sleep 500ms Enter
29 | Sleep 500ms
30 | Type@150ms "Website, Linkedin, Others" Sleep 500ms Enter
31 | Sleep 500ms
32 | Type@250ms "h" Sleep 500ms Enter
33 |
34 | Type@150ms "h" Sleep 1s Enter
35 |
36 | Sleep 4s
37 |
--------------------------------------------------------------------------------
/images/form.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devmegablaster/bashform/90537fb13acb13954dafb1b97be1bb203ebf7303/images/form.gif
--------------------------------------------------------------------------------
/images/form.tape:
--------------------------------------------------------------------------------
1 | Output form.gif
2 |
3 | Set Shell "bash"
4 | Set FontSize 20
5 | Set Width 1400
6 | Set Height 1000
7 |
8 | Type@50ms "ssh -t bashform.me f devmegablaster" Sleep 500ms Enter
9 |
10 | Sleep 8s
11 |
12 | Type@40ms "ojas@megablaster.dev" Sleep 500ms Enter
13 | Type@40ms "Message go brrrrrrrrrr!" Sleep 500ms Enter
14 |
15 | Sleep 4s
16 |
--------------------------------------------------------------------------------
/images/forms.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devmegablaster/bashform/90537fb13acb13954dafb1b97be1bb203ebf7303/images/forms.gif
--------------------------------------------------------------------------------
/images/forms.tape:
--------------------------------------------------------------------------------
1 | Output forms.gif
2 |
3 | Set Shell "bash"
4 | Set FontSize 24
5 | Set Width 1440
6 | Set Height 1500
7 |
8 | Type@150ms "ssh -t bashform.me forms" Sleep 500ms Enter
9 |
10 | Sleep 8s
11 |
12 | Type@800ms "jjkk" Sleep 500ms Enter
13 | Sleep 1s
14 | Type@800ms 'jjjkk' Sleep 500ms Enter
15 |
16 | Sleep 2s
17 | Escape
18 | Sleep 2s
19 | Escape
20 |
21 | Type@800ms "j" Sleep 500ms Enter
22 | Sleep 1s
23 | Type@800ms 'jjkk' Sleep 500ms Enter
24 |
25 | Sleep 2s
26 | Escape
27 |
28 | Sleep 4s
29 |
--------------------------------------------------------------------------------
/images/open.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/devmegablaster/bashform/90537fb13acb13954dafb1b97be1bb203ebf7303/images/open.gif
--------------------------------------------------------------------------------
/images/open.tape:
--------------------------------------------------------------------------------
1 | Output open.gif
2 |
3 | Set Shell "bash"
4 | Set FontSize 32
5 | Set Width 1200
6 | Set Height 600
7 |
8 | Sleep 0.5s
9 |
10 | Type@25ms "ssh -t bashform.me f devmegablaster" Sleep 500ms Enter
11 |
12 | Sleep 5.2s
13 |
--------------------------------------------------------------------------------
/internal/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "log/slog"
5 | "os"
6 |
7 | _ "github.com/joho/godotenv/autoload"
8 | "github.com/spf13/viper"
9 | )
10 |
11 | type Config struct {
12 | SSH SSHConfig `mapstructure:"ssh"`
13 | Database DatabaseConfig `mapstructure:"database"`
14 | Crypto CryptoConfig `mapstructure:"crypto"`
15 | }
16 |
17 | type SSHConfig struct {
18 | URL string `mapstructure:"url"`
19 | Host string `mapstructure:"host"`
20 | Port int `mapstructure:"port"`
21 | KeyPath string `mapstructure:"key_path"`
22 | }
23 |
24 | type DatabaseConfig struct {
25 | Host string `mapstructure:"host"`
26 | Port string `mapstructure:"port"`
27 | User string `mapstructure:"user"`
28 | Password string `mapstructure:"password"`
29 | Name string `mapstructure:"name"`
30 | }
31 |
32 | type CryptoConfig struct {
33 | AESKey string `mapstructure:"aes_key"`
34 | }
35 |
36 | func New() Config {
37 | viper.SetConfigFile(os.Getenv("CONFIG_FILE"))
38 | if err := viper.ReadInConfig(); err != nil {
39 | panic(err)
40 | }
41 |
42 | var config Config
43 | if err := viper.Unmarshal(&config); err != nil {
44 | panic(err)
45 | }
46 |
47 | config.loadEnv()
48 |
49 | slog.Info("✅ Config loaded")
50 |
51 | return config
52 | }
53 |
54 | func (c *Config) loadEnv() {
55 | // database secrets
56 | c.Database.Host = os.ExpandEnv(c.Database.Host)
57 | c.Database.Port = os.ExpandEnv(c.Database.Port)
58 | c.Database.User = os.ExpandEnv(c.Database.User)
59 | c.Database.Password = os.ExpandEnv(c.Database.Password)
60 | c.Database.Name = os.ExpandEnv(c.Database.Name)
61 |
62 | }
63 |
--------------------------------------------------------------------------------
/internal/constants/form.go:
--------------------------------------------------------------------------------
1 | package constants
2 |
3 | const (
4 | FIELD_TEXT = "text"
5 | FIELD_TEXTAREA = "textarea"
6 | FIELD_SELECT = "select"
7 | )
8 |
--------------------------------------------------------------------------------
/internal/constants/logo.go:
--------------------------------------------------------------------------------
1 | package constants
2 |
3 | import (
4 | "github.com/devmegablaster/bashform/internal/styles"
5 | )
6 |
7 | const logoArt = `
8 | ___ __ ____
9 | / _ )___ ____ / / / __/__ ______ _
10 | / _ / _ /(_- _ \/ _// _ \/ __/ ' \
11 | /____/\_,_/___/_//_/_/ \___/_/ /_/_/_/
12 | `
13 |
14 | var Logo string = styles.Logo.Render(logoArt)
15 |
--------------------------------------------------------------------------------
/internal/constants/messages.go:
--------------------------------------------------------------------------------
1 | package constants
2 |
3 | const (
4 | MessageFormNotFound = "Form not found!"
5 | MessageFormSubmitted = "Your response has been submitted!"
6 | MessageFormSubmitting = "Submitting Your response..."
7 | MessageSizeError = "Please resize your terminal to a minimum of %dx%d and run again | Current session size - %dx%d"
8 | MessageHelpExit = "q or ctrl+c to exit"
9 | MessageFormCreated = "Form created successfully!"
10 | MessageFormCreating = "Creating your form..."
11 | MessageFormCreateError = "Error creating form: %s"
12 | MessageCommandHeader = "Command to fill this form:"
13 | MessageCommand = "ssh -t %s f %s"
14 | MessageCodeNotAvailable = "Form with the same code already exists!"
15 | )
16 |
--------------------------------------------------------------------------------
/internal/database/database.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log/slog"
7 | "sync"
8 |
9 | "github.com/devmegablaster/bashform/internal/config"
10 | "github.com/devmegablaster/bashform/internal/models"
11 | "gorm.io/driver/postgres"
12 | "gorm.io/gorm"
13 | "gorm.io/gorm/logger"
14 | )
15 |
16 | type Database struct {
17 | DB *gorm.DB
18 | }
19 |
20 | func New(ctx context.Context, wg *sync.WaitGroup, cfg config.DatabaseConfig) (*Database, error) {
21 | dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s", cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.Name)
22 |
23 | conn, err := gorm.Open(postgres.Open(dsn))
24 | if err != nil {
25 | return nil, err
26 | }
27 |
28 | sqlDB, err := conn.DB()
29 | if err != nil {
30 | return nil, err
31 | }
32 |
33 | wg.Add(1)
34 | go func() {
35 | defer wg.Done()
36 |
37 | <-ctx.Done()
38 | if err := sqlDB.Close(); err != nil {
39 | slog.Error("❌ Unable to close database connection", slog.String("error", err.Error()))
40 | return
41 | }
42 |
43 | slog.Info("🔌 Database connection closed")
44 | }()
45 |
46 | conn.Logger = logger.Discard
47 | conn.TranslateError = true
48 |
49 | db := &Database{
50 | DB: conn,
51 | }
52 |
53 | if err := db.createExtension(); err != nil {
54 | return nil, err
55 | }
56 |
57 | if err := db.autoMigrate(); err != nil {
58 | return nil, err
59 | }
60 |
61 | slog.Info("✅ Connected to database")
62 |
63 | return db, nil
64 | }
65 |
66 | func (d *Database) autoMigrate() error {
67 | return d.DB.AutoMigrate(&models.User{}, &models.Form{}, &models.Question{}, &models.Option{}, &models.Response{}, &models.Answer{})
68 | }
69 |
70 | func (d *Database) createExtension() error {
71 | return d.DB.Exec("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"").Error
72 | }
73 |
--------------------------------------------------------------------------------
/internal/logger/default.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | import (
4 | "log/slog"
5 | "os"
6 | )
7 |
8 | func SetDefault() {
9 | log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{}))
10 | slog.SetDefault(log)
11 | }
12 |
--------------------------------------------------------------------------------
/internal/models/answer.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import "github.com/google/uuid"
4 |
5 | type Answer struct {
6 | ID uuid.UUID `gorm:"type:uuid;primary_key;default:uuid_generate_v4()"`
7 | QuestionID uuid.UUID `gorm:"type:uuid;not null"`
8 | Value string `gorm:"type:text"`
9 | ResponseID uuid.UUID `gorm:"type:uuid;not null"`
10 | }
11 |
12 | type AnswerRequest struct {
13 | QuestionID uuid.UUID `validate:"required"`
14 | Value string `validate:"required"`
15 | }
16 |
--------------------------------------------------------------------------------
/internal/models/form.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/charmbracelet/huh"
7 | "github.com/devmegablaster/bashform/internal/constants"
8 | "github.com/google/uuid"
9 | )
10 |
11 | type Form struct {
12 | ID uuid.UUID `gorm:"type:uuid;primary_key;default:uuid_generate_v4()"`
13 | Name string `gorm:"type:varchar(255);not null"`
14 | Description string `gorm:"type:varchar(255);"`
15 | Code string `gorm:"type:varchar(20);not null;unique"`
16 | Questions []Question `gorm:"foreignKey:FormID"`
17 | Responses []Response `gorm:"foreignKey:FormID"`
18 | UserID uuid.UUID `gorm:"type:uuid;not null"`
19 | Multiple bool `gorm:"type:boolean;not null"`
20 | CreatedAt time.Time `gorm:"autoCreateTime"`
21 | UpdatedAt time.Time `gorm:"autoUpdateTime"`
22 | }
23 |
24 | type FormRequest struct {
25 | Name string `validate:"required"`
26 | Description string
27 | Questions []QuestionRequest `validate:"required"`
28 | Code string `validate:"required"`
29 | Multiple bool
30 | }
31 |
32 | func (f *FormRequest) ToForm(userID uuid.UUID) Form {
33 | questions := make([]Question, len(f.Questions))
34 | for i, q := range f.Questions {
35 | questions[i] = q.ToQuestion()
36 | }
37 |
38 | return Form{
39 | Name: f.Name,
40 | Description: f.Description,
41 | Questions: questions,
42 | Code: f.Code,
43 | UserID: userID,
44 | Multiple: f.Multiple,
45 | }
46 | }
47 |
48 | func (f Form) ToHuhForm() *huh.Form {
49 | fields := []huh.Field{}
50 |
51 | for _, question := range f.Questions {
52 | switch question.Type {
53 | case constants.FIELD_TEXT:
54 | field := huh.NewInput().Title(question.Text).Key(question.ID.String())
55 | if question.Required {
56 | field = field.Validate(huh.ValidateNotEmpty())
57 | }
58 | fields = append(fields, field)
59 |
60 | case constants.FIELD_TEXTAREA:
61 | field := huh.NewText().Title(question.Text).Key(question.ID.String())
62 | if question.Required {
63 | field = field.Validate(huh.ValidateNotEmpty())
64 | }
65 | fields = append(fields, field)
66 |
67 | case constants.FIELD_SELECT:
68 | options := make([]string, len(question.Options))
69 | for i, option := range question.Options {
70 | options[i] = option.Text
71 | }
72 |
73 | opts := huh.NewOptions(options...)
74 |
75 | field := huh.NewSelect[string]().Title(question.Text).Options(opts...).Key(question.ID.String())
76 | if question.Required {
77 | field = field.Validate(huh.ValidateNotEmpty())
78 | }
79 | fields = append(fields, field)
80 | }
81 | }
82 |
83 | rootGroup := huh.NewGroup(fields...).WithTheme(huh.ThemeCharm())
84 |
85 | form := huh.NewForm(rootGroup).WithTheme(huh.ThemeCharm())
86 | return form
87 | }
88 |
89 | func (f *Form) ToItem() Item {
90 | return Item{
91 | ID: f.ID.String(),
92 | Name: f.Name,
93 | Desc: f.Code,
94 | }
95 | }
96 |
97 | func FormsToItems(forms []Form) []Item {
98 | items := []Item{}
99 | for _, form := range forms {
100 | items = append(items, form.ToItem())
101 | }
102 | return items
103 | }
104 |
--------------------------------------------------------------------------------
/internal/models/item.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type Item struct {
4 | ID string
5 | Name string
6 | Desc string
7 | }
8 |
9 | func (i Item) Title() string { return i.Name }
10 | func (i Item) Description() string { return i.Desc }
11 | func (i Item) FilterValue() string { return i.Name }
12 |
--------------------------------------------------------------------------------
/internal/models/option.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import "github.com/google/uuid"
4 |
5 | type Option struct {
6 | ID uuid.UUID `gorm:"type:uuid;primary_key;default:uuid_generate_v4()"`
7 | QuestionID uuid.UUID `gorm:"type:uuid;not null"`
8 | Text string `gorm:"type:varchar(255);not null"`
9 | }
10 |
11 | type OptionRequest struct {
12 | Text string
13 | }
14 |
15 | func (o *OptionRequest) ToOption() Option {
16 | return Option{
17 | Text: o.Text,
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/internal/models/question.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import "github.com/google/uuid"
4 |
5 | type Question struct {
6 | ID uuid.UUID `gorm:"type:uuid;primary_key;default:uuid_generate_v4()"`
7 | FormID uuid.UUID `gorm:"type:uuid;not null"`
8 | Text string `gorm:"type:varchar(255);not null"`
9 | Type string `gorm:"type:varchar(255);not null"`
10 | Options []Option `gorm:"foreignKey:QuestionID"`
11 | Required bool `gorm:"type:boolean;not null"`
12 | }
13 |
14 | type QuestionRequest struct {
15 | Text string `validate:"required,max=255,min=1"`
16 | Type string `validate:"required,max=255,min=1"`
17 | Options []OptionRequest
18 | Required bool `validate:"required"`
19 | }
20 |
21 | func (q *QuestionRequest) ToQuestion() Question {
22 | options := make([]Option, len(q.Options))
23 | for i, o := range q.Options {
24 | options[i] = o.ToOption()
25 | }
26 |
27 | return Question{
28 | Text: q.Text,
29 | Type: q.Type,
30 | Options: options,
31 | Required: q.Required,
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/internal/models/response.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/google/uuid"
7 | )
8 |
9 | type Response struct {
10 | ID uuid.UUID `gorm:"type:uuid;primary_key;default:uuid_generate_v4()"`
11 | FormID uuid.UUID `gorm:"type:uuid;not null"`
12 | UserID uuid.UUID `gorm:"type:uuid;not null"`
13 | Answers []Answer `gorm:"foreignKey:ResponseID"`
14 | CreatedAt time.Time `gorm:"autoCreateTime"`
15 | UpdatedAt time.Time `gorm:"autoUpdateTime"`
16 | }
17 |
18 | type ResponseRequest struct {
19 | Answers []Answer `validate:"required"`
20 | }
21 |
22 | func (r *ResponseRequest) ToResponse(formID uuid.UUID, userID uuid.UUID) Response {
23 | return Response{
24 | UserID: userID,
25 | FormID: formID,
26 | Answers: r.Answers,
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/internal/models/user.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/google/uuid"
7 | )
8 |
9 | type User struct {
10 | ID uuid.UUID `gorm:"type:uuid;primary_key;default:uuid_generate_v4()"`
11 | Email string `gorm:"type:varchar(255);"`
12 | Name string `gorm:"type:varchar(255);"`
13 | Forms []Form `gorm:"foreignKey:UserID"`
14 | PubKey string `gorm:"type:varchar(1024);not null;unique"`
15 | CreatedAt time.Time `gorm:"autoCreateTime"`
16 | UpdatedAt time.Time `gorm:"autoUpdateTime"`
17 | }
18 |
19 | type UserRequest struct {
20 | Email string
21 | Name string
22 | PubKey string
23 | }
24 |
25 | func (u *UserRequest) ToUser() User {
26 | return User{
27 | Email: u.Email,
28 | Name: u.Name,
29 | PubKey: u.PubKey,
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/internal/repository/form.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "github.com/devmegablaster/bashform/internal/database"
5 | "github.com/devmegablaster/bashform/internal/models"
6 | )
7 |
8 | type FormRepository struct {
9 | db *database.Database
10 | }
11 |
12 | func NewFormRepository(db *database.Database) *FormRepository {
13 | return &FormRepository{
14 | db: db,
15 | }
16 | }
17 |
18 | func (r *FormRepository) Create(form *models.Form) error {
19 | if err := r.db.DB.Create(&form).Error; err != nil {
20 | return err
21 | }
22 |
23 | return nil
24 | }
25 |
26 | func (r *FormRepository) GetForUser(userID string) ([]models.Form, error) {
27 | var forms []models.Form
28 |
29 | if err := r.db.DB.Find(&forms, "user_id = ?", userID).Error; err != nil {
30 | return nil, err
31 | }
32 |
33 | return forms, nil
34 | }
35 |
36 | func (r *FormRepository) GetByCode(code string) (*models.Form, error) {
37 | var form models.Form
38 |
39 | if err := r.db.DB.Preload("Questions.Options").First(&form, "code = ?", code).Error; err != nil {
40 | return nil, err
41 | }
42 |
43 | return &form, nil
44 | }
45 |
46 | func (r *FormRepository) GetByID(id string) (*models.Form, error) {
47 | var form models.Form
48 |
49 | if err := r.db.DB.Preload("Questions").First(&form, "id = ?", id).Error; err != nil {
50 | return nil, err
51 | }
52 |
53 | return &form, nil
54 | }
55 |
56 | func (r *FormRepository) GetWithResponses(userID, formID string) (*models.Form, error) {
57 | var form models.Form
58 |
59 | if err := r.db.DB.Preload("Responses.Answers").Preload("Questions").First(&form, "id = ? AND user_id = ?", formID, userID).Error; err != nil {
60 | return nil, err
61 | }
62 |
63 | return &form, nil
64 | }
65 |
--------------------------------------------------------------------------------
/internal/repository/response.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "github.com/devmegablaster/bashform/internal/database"
5 | "github.com/devmegablaster/bashform/internal/models"
6 | )
7 |
8 | type ResponseRepository struct {
9 | db *database.Database
10 | }
11 |
12 | func NewResponseRepository(db *database.Database) *ResponseRepository {
13 | return &ResponseRepository{
14 | db: db,
15 | }
16 | }
17 |
18 | func (r *ResponseRepository) Create(response *models.Response) error {
19 | if err := r.db.DB.Create(&response).Error; err != nil {
20 | return err
21 | }
22 |
23 | return nil
24 | }
25 |
26 | func (r *ResponseRepository) GetByUserAndFormID(userID, formID string) (*models.Response, error) {
27 | var response models.Response
28 |
29 | if err := r.db.DB.Preload("Answers").First(&response, "user_id = ? AND form_id = ?", userID, formID).Error; err != nil {
30 | return nil, err
31 | }
32 |
33 | return &response, nil
34 | }
35 |
--------------------------------------------------------------------------------
/internal/repository/user.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "github.com/devmegablaster/bashform/internal/database"
5 | "github.com/devmegablaster/bashform/internal/models"
6 | )
7 |
8 | type UserRepository struct {
9 | db *database.Database
10 | }
11 |
12 | func NewUserRepository(db *database.Database) *UserRepository {
13 | return &UserRepository{
14 | db: db,
15 | }
16 | }
17 |
18 | func (r *UserRepository) Create(user *models.User) error {
19 | if err := r.db.DB.Create(&user).Error; err != nil {
20 | return err
21 | }
22 |
23 | return nil
24 | }
25 |
26 | func (r *UserRepository) GetByPubKey(pubKey string) (*models.User, error) {
27 | var user models.User
28 |
29 | if err := r.db.DB.First(&user, "pub_key = ?", pubKey).Error; err != nil {
30 | return nil, err
31 | }
32 |
33 | return &user, nil
34 | }
35 |
36 | func (r *UserRepository) Update(user *models.User) error {
37 | if err := r.db.DB.Save(&user).Error; err != nil {
38 | return err
39 | }
40 |
41 | return nil
42 | }
43 |
--------------------------------------------------------------------------------
/internal/services/crypto.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "bytes"
5 | "encoding/base64"
6 |
7 | "github.com/devmegablaster/bashform/internal/config"
8 | )
9 |
10 | type CryptoService struct {
11 | cfg config.CryptoConfig
12 | }
13 |
14 | func NewCryptoService(cfg config.CryptoConfig) *CryptoService {
15 | return &CryptoService{
16 | cfg: cfg,
17 | }
18 | }
19 |
20 | // convert a byte slice to a base64 encoded string
21 | func (c *CryptoService) Base64Encode(data []byte) string {
22 | encoded := bytes.Buffer{}
23 | enc := base64.NewEncoder(base64.StdEncoding, &encoded)
24 | enc.Write(data)
25 |
26 | return encoded.String()
27 | }
28 |
29 | // TODO: Implement encryption and decryption methods
30 |
--------------------------------------------------------------------------------
/internal/services/form.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "log/slog"
7 |
8 | "github.com/devmegablaster/bashform/internal/config"
9 | "github.com/devmegablaster/bashform/internal/database"
10 | "github.com/devmegablaster/bashform/internal/models"
11 | "github.com/devmegablaster/bashform/internal/repository"
12 | "gorm.io/gorm"
13 | )
14 |
15 | type FormService struct {
16 | cfg config.Config
17 | db *database.Database
18 | fr *repository.FormRepository
19 | rr *repository.ResponseRepository
20 | v *Validator
21 | logger *slog.Logger
22 | }
23 |
24 | func NewFormService(cfg config.Config, db *database.Database, logger *slog.Logger) *FormService {
25 | return &FormService{
26 | cfg: cfg,
27 | db: db,
28 | fr: repository.NewFormRepository(db),
29 | rr: repository.NewResponseRepository(db),
30 | v: NewValidator(),
31 | logger: logger,
32 | }
33 | }
34 |
35 | // create a new form using the form request for the user
36 | func (f *FormService) Create(formRequest *models.FormRequest, user *models.User) (*models.Form, error) {
37 | if err := f.v.Validate(formRequest); err != nil {
38 | f.logger.Error("Failed to validate form request", "error", err)
39 | return nil, err
40 | }
41 |
42 | form := formRequest.ToForm(user.ID)
43 |
44 | if err := f.fr.Create(&form); err != nil {
45 | if errors.Is(err, gorm.ErrDuplicatedKey) {
46 | f.logger.Warn("Form with code already exists", slog.String("code", form.Code))
47 | return nil, fmt.Errorf("Form with code %s already exists", form.Code)
48 | }
49 |
50 | f.logger.Error("Failed to create form", "error", err)
51 | return nil, fmt.Errorf("Failed to create form")
52 | }
53 |
54 | return &form, nil
55 | }
56 |
57 | // get all forms for the user
58 | func (f *FormService) GetForUser(user *models.User) ([]models.Form, error) {
59 | forms, err := f.fr.GetForUser(user.ID.String())
60 | if err != nil {
61 | f.logger.Error("Error finding forms", "error", err)
62 | return nil, fmt.Errorf("Error finding forms")
63 | }
64 |
65 | return forms, nil
66 | }
67 |
68 | // get a form by its code
69 | func (f *FormService) GetByCode(code string, user *models.User) (*models.Form, error) {
70 | form, err := f.fr.GetByCode(code)
71 | if err != nil {
72 | f.logger.Error("Error finding form", slog.String("code", code), "error", err)
73 | if errors.Is(err, gorm.ErrRecordNotFound) {
74 | return nil, fmt.Errorf("Form not found")
75 | }
76 | return nil, fmt.Errorf("Error finding form")
77 | }
78 |
79 | if !form.Multiple && form.UserID != user.ID {
80 | _, err := f.rr.GetByUserAndFormID(user.ID.String(), form.ID.String())
81 | if err != nil {
82 | if errors.Is(err, gorm.ErrRecordNotFound) {
83 | return form, nil
84 | }
85 | }
86 |
87 | f.logger.Warn("Form already submitted", slog.String("form_id", form.ID.String()))
88 | return nil, fmt.Errorf("Form already submitted")
89 | }
90 |
91 | return form, nil
92 | }
93 |
94 | // check if the form code is available
95 | func (f *FormService) CheckCodeAvailability(code string) bool {
96 | _, err := f.fr.GetByCode(code)
97 | if err != nil {
98 | if errors.Is(err, gorm.ErrRecordNotFound) {
99 | return true
100 | }
101 | return false
102 | }
103 | return false
104 | }
105 |
106 | // get user owned form with its responses
107 | func (f *FormService) GetWithResponses(formID string, user *models.User) (*models.Form, error) {
108 | // TODO: Implement decryption of responses
109 | formWithResponses, err := f.fr.GetWithResponses(user.ID.String(), formID)
110 | if err != nil {
111 | f.logger.Error("Error finding form", slog.String("form_id", formID), "error", err)
112 | return nil, fmt.Errorf("Error finding form")
113 | }
114 |
115 | return formWithResponses, nil
116 | }
117 |
--------------------------------------------------------------------------------
/internal/services/response.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 |
7 | "github.com/devmegablaster/bashform/internal/config"
8 | "github.com/devmegablaster/bashform/internal/database"
9 | "github.com/devmegablaster/bashform/internal/models"
10 | "github.com/devmegablaster/bashform/internal/repository"
11 | "github.com/google/uuid"
12 | )
13 |
14 | type ResponseService struct {
15 | cfg config.Config
16 | db *database.Database
17 | rr *repository.ResponseRepository
18 | fr *repository.FormRepository
19 | v Validator
20 | logger *slog.Logger
21 | }
22 |
23 | func NewResponseService(cfg config.Config, db *database.Database, logger *slog.Logger) *ResponseService {
24 | return &ResponseService{
25 | cfg: cfg,
26 | db: db,
27 | rr: repository.NewResponseRepository(db),
28 | fr: repository.NewFormRepository(db),
29 | v: *NewValidator(),
30 | logger: logger,
31 | }
32 | }
33 |
34 | func (r *ResponseService) Create(responseRequest models.ResponseRequest, formID uuid.UUID, user *models.User) (models.Response, error) {
35 | // TODO: implement encryption of responses
36 | if err := r.v.Validate(responseRequest); err != nil {
37 | return models.Response{}, err
38 | }
39 | response := responseRequest.ToResponse(formID, user.ID)
40 |
41 | _, err := r.rr.GetByUserAndFormID(user.ID.String(), response.FormID.String())
42 | if err == nil {
43 | r.logger.Warn("Response already exists for form", slog.String("form_id", response.FormID.String()))
44 | return models.Response{}, fmt.Errorf("Response already exists for form")
45 | }
46 |
47 | if err := r.rr.Create(&response); err != nil {
48 | r.logger.Error("Failed to create response", "error", err)
49 | return models.Response{}, fmt.Errorf("Failed to create response")
50 | }
51 |
52 | return response, nil
53 | }
54 |
55 | func (r *ResponseService) GetByFormID(formID string, user *models.User) (*models.Form, error) {
56 | responses, err := r.fr.GetWithResponses(user.ID.String(), formID)
57 | if err != nil {
58 | r.logger.Error("Error finding form", slog.String("form_id", formID), "error", err)
59 | return nil, fmt.Errorf("Error finding form")
60 | }
61 |
62 | return responses, nil
63 | }
64 |
--------------------------------------------------------------------------------
/internal/services/user.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 |
7 | "github.com/devmegablaster/bashform/internal/config"
8 | "github.com/devmegablaster/bashform/internal/database"
9 | "github.com/devmegablaster/bashform/internal/models"
10 | "github.com/devmegablaster/bashform/internal/repository"
11 | )
12 |
13 | type UserService struct {
14 | cfg config.Config
15 | db *database.Database
16 | ur *repository.UserRepository
17 | }
18 |
19 | func NewUserService(cfg config.Config, db *database.Database) *UserService {
20 | return &UserService{
21 | cfg: cfg,
22 | db: db,
23 | ur: repository.NewUserRepository(db),
24 | }
25 | }
26 |
27 | // create a new user using the user request
28 | func (s *UserService) Create(userReq models.UserRequest) (*models.User, error) {
29 | user := userReq.ToUser()
30 | err := s.ur.Create(&user)
31 | if err != nil {
32 | slog.Error("Failed to create user", "error", err)
33 | return nil, fmt.Errorf("Failed to create user")
34 | }
35 |
36 | return &user, nil
37 | }
38 |
39 | // get a user by their public key
40 | func (s *UserService) GetByPubKey(pubKey string) (*models.User, error) {
41 | user, err := s.ur.GetByPubKey(pubKey)
42 | if err != nil {
43 | slog.Error("Failed to get user", "error", err)
44 | return nil, fmt.Errorf("Failed to get user")
45 | }
46 |
47 | return user, nil
48 | }
49 |
50 | // update a user using user strcut
51 | func (s *UserService) Update(user *models.User) error {
52 | err := s.ur.Update(user)
53 | if err != nil {
54 | slog.Error("Failed to update user", "error", err)
55 | return fmt.Errorf("Failed to update user")
56 | }
57 |
58 | return nil
59 | }
60 |
--------------------------------------------------------------------------------
/internal/services/validator.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/go-playground/validator/v10"
7 | )
8 |
9 | type Validator struct {
10 | v *validator.Validate
11 | }
12 |
13 | func NewValidator() *Validator {
14 | return &Validator{
15 | v: validator.New(),
16 | }
17 | }
18 |
19 | // Validate a struct using the validator
20 | func (v *Validator) Validate(i interface{}) error {
21 | if err := v.v.Struct(i); err != nil {
22 | errors := v.parseError(err)
23 | errStr := ""
24 | for field, err := range errors {
25 | errStr += field + ": " + err + ", "
26 | }
27 |
28 | return fmt.Errorf("Validation errors: %s", errStr)
29 | }
30 | return nil
31 | }
32 |
33 | // parseError takes a validation error and returns a map of field names to error messages
34 | func (v *Validator) parseError(err error) map[string]string {
35 | errors := make(map[string]string)
36 | for _, err := range err.(validator.ValidationErrors) {
37 | errors[err.Field()] = err.Tag()
38 | }
39 | return errors
40 | }
41 |
--------------------------------------------------------------------------------
/internal/styles/styles.go:
--------------------------------------------------------------------------------
1 | package styles
2 |
3 | import (
4 | "github.com/charmbracelet/bubbles/table"
5 | "github.com/charmbracelet/lipgloss"
6 | )
7 |
8 | var (
9 | Error = lipgloss.NewStyle().Foreground(lipgloss.Color("#ef4444"))
10 | Succes = lipgloss.NewStyle().Foreground(lipgloss.Color("#22c55e"))
11 | Heading = lipgloss.NewStyle().Foreground(lipgloss.Color("#22c55e")).Bold(true)
12 | Description = lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280"))
13 | Logo = lipgloss.NewStyle().Foreground(lipgloss.Color("#22c55e")).Bold(true)
14 | )
15 |
16 | func TableStyle() table.Styles {
17 | s := table.DefaultStyles()
18 | s.Header = s.Header.
19 | BorderStyle(lipgloss.NormalBorder()).
20 | BorderForeground(lipgloss.Color("240")).
21 | BorderBottom(true).
22 | Bold(false)
23 | s.Selected = s.Selected.
24 | Foreground(lipgloss.Color("#000000")).
25 | Background(lipgloss.Color("#16a34a")).
26 | Bold(true)
27 |
28 | return s
29 | }
30 |
31 | func Box(width int, content string) string {
32 | boxWidth := 90
33 | if width < 90 {
34 | boxWidth = width - 5
35 | }
36 |
37 | return lipgloss.NewStyle().
38 | Border(lipgloss.RoundedBorder()).
39 | BorderForeground(lipgloss.Color("#94a3b8")).
40 | Align(lipgloss.Center).
41 | Width(boxWidth).
42 | Padding(1, 2).
43 | Render(content)
44 | }
45 |
46 | func PlaceCenterVertical(width, height int, content ...string) string {
47 | return lipgloss.Place(
48 | width,
49 | height,
50 | lipgloss.Center,
51 | lipgloss.Center,
52 | lipgloss.JoinVertical(
53 | lipgloss.Center,
54 | content...,
55 | ),
56 | )
57 | }
58 |
59 | func PlaceCenter(width, height int, content string) string {
60 | return lipgloss.Place(
61 | width,
62 | height,
63 | lipgloss.Center,
64 | lipgloss.Center,
65 | content,
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/internal/types/list.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | type Item struct {
4 | ID string
5 | Name string
6 | Desc string
7 | }
8 |
9 | func (i Item) Title() string { return i.Name }
10 | func (i Item) Description() string { return i.Desc }
11 | func (i Item) FilterValue() string { return i.Name }
12 |
--------------------------------------------------------------------------------
/internal/ui/create/create.go:
--------------------------------------------------------------------------------
1 | package create
2 |
3 | import (
4 | "fmt"
5 | "time"
6 |
7 | tea "github.com/charmbracelet/bubbletea"
8 | "github.com/charmbracelet/huh"
9 | "github.com/charmbracelet/ssh"
10 | "github.com/devmegablaster/bashform/internal/config"
11 | "github.com/devmegablaster/bashform/internal/constants"
12 | "github.com/devmegablaster/bashform/internal/models"
13 | "github.com/devmegablaster/bashform/internal/services"
14 | "github.com/devmegablaster/bashform/internal/styles"
15 | )
16 |
17 | type Model struct {
18 | width, height int
19 | cfg config.Config
20 | formSvc *services.FormService
21 | user *models.User
22 | code string
23 | n int
24 | questionsForm *huh.Form
25 | form *models.Form
26 |
27 | isCreating bool
28 | isCreated bool
29 | err error
30 | sizeError bool
31 | init bool
32 | initTime time.Time
33 | }
34 |
35 | func NewModel(cfg config.Config, session ssh.Session, n int, code string, formSvc *services.FormService) *Model {
36 | pty, _, _ := session.Pty()
37 |
38 | sizeErr := false
39 | if pty.Window.Width < 50 || pty.Window.Height < 30 {
40 | sizeErr = true
41 | }
42 |
43 | u := session.Context().Value("user").(*models.User)
44 |
45 | return &Model{
46 | width: pty.Window.Width,
47 | height: pty.Window.Height,
48 | cfg: cfg,
49 | code: code,
50 | n: n,
51 | formSvc: formSvc,
52 | user: u,
53 | questionsForm: starterForm(n),
54 |
55 | sizeError: sizeErr,
56 | init: true,
57 | initTime: time.Now(),
58 | }
59 | }
60 |
61 | func (m *Model) Init() tea.Cmd {
62 | return m.questionsForm.Init()
63 | }
64 |
65 | func (m *Model) View() string {
66 | var content string
67 | content = m.questionsForm.View()
68 |
69 | switch {
70 | case m.sizeError:
71 | return styles.Error.Render(fmt.Sprintf(constants.MessageSizeError, 50, 30, m.width, m.height))
72 |
73 | case m.isCreated:
74 | content = styles.Succes.Render(constants.MessageFormCreated) +
75 | "\n\n" +
76 | styles.Description.Render(constants.MessageCommandHeader) +
77 | "\n" +
78 | styles.Heading.Render(fmt.Sprintf(constants.MessageCommand, m.cfg.SSH.URL, m.form.Code)) +
79 | "\n\n" +
80 | styles.Description.Render(constants.MessageHelpExit)
81 |
82 | case m.isCreating:
83 | content = styles.Heading.Render(constants.MessageFormCreating)
84 |
85 | case m.err != nil:
86 | content = styles.Error.Render(fmt.Sprintf(constants.MessageFormCreateError, m.err.Error()))
87 |
88 | case m.init:
89 | return styles.PlaceCenter(m.width, m.height, constants.Logo)
90 | }
91 |
92 | box := styles.Box(m.width, content)
93 |
94 | return styles.PlaceCenterVertical(m.width,
95 | m.height,
96 | styles.Heading.MarginBottom(1).Render("New Form"),
97 | box,
98 | )
99 | }
100 |
101 | func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
102 |
103 | // Handle key presses
104 | switch msg := msg.(type) {
105 | case tea.KeyMsg:
106 | switch msg.String() {
107 | case "ctrl+c":
108 | return m, tea.Quit
109 | case "q":
110 | if m.questionsForm.State == huh.StateCompleted {
111 | return m, tea.Quit
112 | }
113 | }
114 | }
115 |
116 | var cmd tea.Cmd
117 |
118 | form, cmd := m.questionsForm.Update(msg)
119 | if f, ok := form.(*huh.Form); ok {
120 | m.questionsForm = f
121 | }
122 |
123 | if m.questionsForm.State == huh.StateCompleted && !m.isCreating && !m.isCreated {
124 | m.CreateRequest()
125 | }
126 |
127 | if m.init && time.Since(m.initTime) > 2*time.Second {
128 | m.init = false
129 | }
130 |
131 | return m, cmd
132 | }
133 |
134 | func (m *Model) CreateRequest() {
135 | m.isCreating = true
136 |
137 | formRequest := huhToForm(m.n, m.questionsForm)
138 | formRequest.Code = m.code
139 |
140 | form, err := m.formSvc.Create(formRequest, m.user)
141 | if err != nil {
142 | m.err = err
143 | return
144 | }
145 |
146 | m.form = form
147 |
148 | m.isCreated = true
149 | m.isCreating = false
150 | }
151 |
--------------------------------------------------------------------------------
/internal/ui/create/form.go:
--------------------------------------------------------------------------------
1 | package create
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/charmbracelet/huh"
8 | "github.com/devmegablaster/bashform/internal/constants"
9 | "github.com/devmegablaster/bashform/internal/models"
10 | )
11 |
12 | func questionForm(index int) *huh.Group {
13 | return huh.NewGroup(
14 | huh.NewSelect[string]().
15 | Title("Question Type").
16 | Options(
17 | huh.NewOption("Text", constants.FIELD_TEXT),
18 | huh.NewOption("Textarea", constants.FIELD_TEXTAREA),
19 | huh.NewOption("Select", constants.FIELD_SELECT),
20 | ).Key(fmt.Sprintf("type%d", index)).Validate(huh.ValidateNotEmpty()),
21 | huh.NewInput().Title("Question").Key(fmt.Sprintf("question%d", index)).Validate(huh.ValidateNotEmpty()),
22 | huh.NewInput().Title("Options (comma separated) [For select questions only]").Key(fmt.Sprintf("options%d", index)),
23 | huh.NewConfirm().Title("Required?").Key(fmt.Sprintf("required%d", index)),
24 | )
25 | }
26 |
27 | func starterForm(n int) *huh.Form {
28 | questions := make([]*huh.Group, n)
29 | for i := 0; i < n; i++ {
30 | questions[i] = questionForm(i)
31 | }
32 |
33 | questions = append(questions, huh.NewGroup(
34 | huh.NewConfirm().Title("Allow multiple submissions by a user?").Key("multiple"),
35 | ))
36 |
37 | allGroups := append([]*huh.Group{
38 | huh.NewGroup(
39 | huh.NewInput().Title("Form Name").Key("name").Validate(huh.ValidateNotEmpty()),
40 | huh.NewInput().Title("Form Description").Key("description").Validate(huh.ValidateNotEmpty()),
41 | ),
42 | }, questions...)
43 |
44 | return huh.NewForm(
45 | allGroups...,
46 | )
47 | }
48 |
49 | func huhToForm(n int, huhForm *huh.Form) *models.FormRequest {
50 | questions := []models.QuestionRequest{}
51 |
52 | for i := 0; i < n; i++ {
53 | question := models.QuestionRequest{
54 | Text: huhForm.GetString(fmt.Sprintf("question%d", i)),
55 | Type: huhForm.GetString(fmt.Sprintf("type%d", i)),
56 | Required: huhForm.GetBool(fmt.Sprintf("required%d", i)),
57 | }
58 |
59 | if question.Type == constants.FIELD_SELECT {
60 | optsStr := huhForm.GetString(fmt.Sprintf("options%d", i))
61 | optionRequests := []models.OptionRequest{}
62 |
63 | opts := strings.Split(optsStr, ",")
64 | for _, opt := range opts {
65 | optionRequests = append(optionRequests, models.OptionRequest{Text: strings.TrimSpace(opt)})
66 | }
67 |
68 | question.Options = optionRequests
69 | }
70 |
71 | questions = append(questions, question)
72 | }
73 |
74 | formRequest := models.FormRequest{
75 | Name: huhForm.GetString("name"),
76 | Description: huhForm.GetString("description"),
77 | Questions: questions,
78 | Multiple: huhForm.GetBool("multiple"),
79 | }
80 |
81 | return &formRequest
82 | }
83 |
--------------------------------------------------------------------------------
/internal/ui/form/form.go:
--------------------------------------------------------------------------------
1 | package form
2 |
3 | import (
4 | "fmt"
5 | "time"
6 |
7 | "github.com/charmbracelet/bubbles/spinner"
8 | tea "github.com/charmbracelet/bubbletea"
9 | "github.com/charmbracelet/huh"
10 | "github.com/charmbracelet/ssh"
11 | "github.com/devmegablaster/bashform/internal/constants"
12 | "github.com/devmegablaster/bashform/internal/models"
13 | "github.com/devmegablaster/bashform/internal/services"
14 | "github.com/devmegablaster/bashform/internal/styles"
15 | )
16 |
17 | type Model struct {
18 | width, height int
19 | Form *models.Form
20 | responseSvc *services.ResponseService
21 | user *models.User
22 | session ssh.Session
23 | huhForm *huh.Form
24 | spinner spinner.Model
25 |
26 | isSubmitting bool
27 | sizeError bool
28 | submitError error
29 | submitSuccess bool
30 | init bool
31 | initTime time.Time
32 | }
33 |
34 | func NewModel(form *models.Form, responseSvc *services.ResponseService, session ssh.Session) *Model {
35 | s := spinner.New()
36 | s.Spinner = spinner.Dot
37 | s.Style = styles.Succes
38 |
39 | pty, _, _ := session.Pty()
40 |
41 | sizeErr := false
42 | if pty.Window.Width < 50 || pty.Window.Height < 35 {
43 | sizeErr = true
44 | }
45 |
46 | u := session.Context().Value("user").(*models.User)
47 |
48 | return &Model{
49 | Form: form,
50 | responseSvc: responseSvc,
51 | user: u,
52 | huhForm: form.ToHuhForm(),
53 | spinner: s,
54 | width: pty.Window.Width,
55 | height: pty.Window.Height,
56 | session: session,
57 | init: true,
58 | sizeError: sizeErr,
59 | initTime: time.Now(),
60 | }
61 | }
62 |
63 | func (m *Model) Init() tea.Cmd {
64 | return tea.Batch(m.spinner.Tick, m.huhForm.Init())
65 | }
66 |
67 | func (m *Model) View() string {
68 | if m.sizeError {
69 | return styles.Error.Render(fmt.Sprintf(constants.MessageSizeError, 50, 35, m.width, m.height))
70 | }
71 |
72 | var content string
73 |
74 | content = m.huhForm.View()
75 |
76 | switch {
77 | case m.submitError != nil:
78 | content = styles.Error.Render(m.submitError.Error())
79 |
80 | case m.init:
81 | return styles.PlaceCenter(m.width, m.height, constants.Logo)
82 |
83 | case m.submitSuccess:
84 | content = styles.Succes.Render(constants.MessageFormSubmitted) + "\n\n" + styles.Description.Render(constants.MessageHelpExit)
85 |
86 | case m.isSubmitting:
87 | content = m.spinner.View() + "\n" + styles.Description.Render(constants.MessageFormSubmitting)
88 | }
89 |
90 | box := styles.Box(m.width, content)
91 |
92 | return styles.PlaceCenterVertical(m.width,
93 | m.height,
94 | styles.Heading.Render(m.Form.Name),
95 | styles.Description.MarginBottom(1).Render(m.Form.Description),
96 | box,
97 | )
98 | }
99 |
100 | func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
101 | // Handle key presses for exit
102 | switch msg := msg.(type) {
103 | case tea.KeyMsg:
104 | if msg.String() == "ctrl+c" {
105 | return m, tea.Quit
106 | }
107 | if msg.String() == "q" {
108 | if m.huhForm.State == huh.StateCompleted {
109 | return m, tea.Quit
110 | }
111 | }
112 | }
113 |
114 | cmds := []tea.Cmd{}
115 |
116 | var cmd tea.Cmd
117 | m.spinner, cmd = m.spinner.Update(msg)
118 | cmds = append(cmds, cmd)
119 |
120 | form, cmd := m.huhForm.Update(msg)
121 | if f, ok := form.(*huh.Form); ok {
122 | m.huhForm = f
123 | }
124 |
125 | if m.huhForm.State == huh.StateCompleted && !m.isSubmitting && !m.submitSuccess {
126 | m.Submit()
127 | }
128 |
129 | if time.Since(m.initTime) > 2*time.Second {
130 | m.init = false
131 | }
132 |
133 | cmds = append(cmds, cmd)
134 |
135 | return m, tea.Batch(cmds...)
136 | }
137 |
138 | func (m *Model) Submit() {
139 | m.isSubmitting = true
140 | answer := []models.Answer{}
141 |
142 | for _, question := range m.Form.Questions {
143 | answer = append(answer, models.Answer{
144 | QuestionID: question.ID,
145 | Value: m.huhForm.GetString(question.ID.String()),
146 | })
147 | }
148 |
149 | respReq := models.ResponseRequest{
150 | Answers: answer,
151 | }
152 |
153 | _, err := m.responseSvc.Create(respReq, m.Form.ID, m.user)
154 | if err != nil {
155 | m.submitError = err
156 | m.isSubmitting = false
157 | }
158 |
159 | m.isSubmitting = false
160 | m.submitSuccess = true
161 | }
162 |
--------------------------------------------------------------------------------
/internal/ui/forms/forms.go:
--------------------------------------------------------------------------------
1 | package forms
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/charmbracelet/bubbles/list"
7 | tea "github.com/charmbracelet/bubbletea"
8 | "github.com/charmbracelet/ssh"
9 | "github.com/devmegablaster/bashform/internal/constants"
10 | "github.com/devmegablaster/bashform/internal/models"
11 | "github.com/devmegablaster/bashform/internal/styles"
12 | )
13 |
14 | type formsModel struct {
15 | width, height int
16 | Items []models.Item
17 | list list.Model
18 | session ssh.Session
19 |
20 | isSubmitting bool
21 | sizeError bool
22 | submitError error
23 | submitSuccess bool
24 | init bool
25 | initTime time.Time
26 | }
27 |
28 | func newFormsModel(items []models.Item, session ssh.Session) *formsModel {
29 | pty, _, _ := session.Pty()
30 |
31 | sizeErr := false
32 | if pty.Window.Width < 50 || pty.Window.Height < 30 {
33 | sizeErr = true
34 | }
35 |
36 | listItems := make([]list.Item, len(items))
37 | for i, item := range items {
38 | listItems[i] = item
39 | }
40 |
41 | l := list.New(listItems, list.NewDefaultDelegate(), 0, 0)
42 |
43 | l.SetSize(pty.Window.Width, 25)
44 | l.SetShowTitle(false)
45 |
46 | return &formsModel{
47 | Items: items,
48 | list: l,
49 | width: pty.Window.Width,
50 | height: pty.Window.Height,
51 | session: session,
52 | init: true,
53 | sizeError: sizeErr,
54 | initTime: time.Now(),
55 | }
56 | }
57 |
58 | func (m *formsModel) Init() tea.Cmd {
59 | return nil
60 | }
61 |
62 | func (m *formsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
63 | switch msg := msg.(type) {
64 | case tea.KeyMsg:
65 | if msg.String() == "ctrl+c" {
66 | return m, tea.Quit
67 | }
68 | if msg.Type == tea.KeyEnter {
69 | return m, toResponses(m.list.SelectedItem().(models.Item).ID)
70 | }
71 | }
72 |
73 | if m.init && time.Since(m.initTime) > 2*time.Second {
74 | m.init = false
75 | }
76 |
77 | var cmd tea.Cmd
78 | m.list, cmd = m.list.Update(msg)
79 | return m, cmd
80 | }
81 |
82 | func (m *formsModel) View() string {
83 | content := m.list.View()
84 |
85 | if m.init {
86 | return styles.PlaceCenter(m.width, m.height, constants.Logo)
87 | }
88 |
89 | box := styles.Box(m.width, content)
90 |
91 | return styles.PlaceCenterVertical(m.width, m.height, styles.Heading.Render("Your Forms"), box)
92 | }
93 |
--------------------------------------------------------------------------------
/internal/ui/forms/main.go:
--------------------------------------------------------------------------------
1 | package forms
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/charmbracelet/bubbles/spinner"
7 | tea "github.com/charmbracelet/bubbletea"
8 | "github.com/charmbracelet/ssh"
9 | "github.com/devmegablaster/bashform/internal/constants"
10 | "github.com/devmegablaster/bashform/internal/models"
11 | "github.com/devmegablaster/bashform/internal/services"
12 | "github.com/devmegablaster/bashform/internal/styles"
13 | )
14 |
15 | type sessionState int
16 |
17 | const (
18 | formsView sessionState = iota
19 | responsesView
20 | responseView
21 | )
22 |
23 | type responseMsg struct {
24 | questions, answers []string
25 | formID string
26 | }
27 |
28 | type responsesMsg struct {
29 | formID string
30 | }
31 |
32 | type formsMsg struct{}
33 |
34 | func toResponses(formID string) tea.Cmd {
35 | return func() tea.Msg {
36 | return responsesMsg{formID}
37 | }
38 | }
39 |
40 | func toForms() tea.Cmd {
41 | return func() tea.Msg {
42 | return formsMsg{}
43 | }
44 | }
45 |
46 | func toResponse(questions, answers []string, formID string) tea.Cmd {
47 | return func() tea.Msg {
48 | return responseMsg{
49 | questions: questions,
50 | answers: answers,
51 | formID: formID,
52 | }
53 | }
54 | }
55 |
56 | type Model struct {
57 | width, height int
58 | state sessionState
59 | formsModel *formsModel
60 | rsm *responsesModel
61 | rm *ResponseModel
62 | spinner spinner.Model
63 | sizeError bool
64 | }
65 |
66 | func NewModel(items []models.Item, formSvc *services.FormService, session ssh.Session) *Model {
67 | p, _, _ := session.Pty()
68 |
69 | sizeErr := false
70 | if p.Window.Width < 50 || p.Window.Height < 30 {
71 | sizeErr = true
72 | }
73 |
74 | return &Model{
75 | width: p.Window.Width,
76 | height: p.Window.Height,
77 | state: formsView,
78 | formsModel: newFormsModel(items, session),
79 | rsm: newResponsesModel(formSvc, session),
80 | rm: NewResponseModel(session),
81 | sizeError: sizeErr,
82 | }
83 | }
84 |
85 | func (m *Model) Init() tea.Cmd {
86 |
87 | return tea.Batch(m.formsModel.Init(), m.spinner.Tick, m.rsm.Init())
88 | }
89 |
90 | func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
91 |
92 | var cmd tea.Cmd
93 | var cmds []tea.Cmd
94 |
95 | switch msg := msg.(type) {
96 | case responsesMsg:
97 | m.rsm.formID = msg.formID
98 | m.rsm.GetResponses()
99 | m.state = responsesView
100 | case formsMsg:
101 | m.state = formsView
102 | case responseMsg:
103 | m.rm.questions = msg.questions
104 | m.rm.answers = msg.answers
105 | m.rm.formID = msg.formID
106 | m.state = responseView
107 | }
108 |
109 | m.spinner, cmd = m.spinner.Update(msg)
110 | cmds = append(cmds, cmd)
111 |
112 | switch m.state {
113 | case formsView:
114 | newModel, cmd := m.formsModel.Update(msg)
115 | formsModel, ok := newModel.(*formsModel)
116 | if !ok {
117 | return m, cmd
118 | }
119 | m.formsModel = formsModel
120 | cmds = append(cmds, cmd)
121 |
122 | case responsesView:
123 | newModel, cmd := m.rsm.Update(msg)
124 | rsm, ok := newModel.(*responsesModel)
125 | if !ok {
126 | return m, cmd
127 | }
128 | m.rsm = rsm
129 | cmds = append(cmds, cmd)
130 |
131 | case responseView:
132 | newModel, cmd := m.rm.Update(msg)
133 | rm, ok := newModel.(*ResponseModel)
134 | if !ok {
135 | return m, cmd
136 | }
137 | m.rm = rm
138 | cmds = append(cmds, cmd)
139 | }
140 |
141 | return m, tea.Batch(cmds...)
142 | }
143 |
144 | func (m *Model) View() string {
145 | if m.sizeError {
146 | return styles.Error.Render(fmt.Sprintf(constants.MessageSizeError, 50, 30, m.width, m.height))
147 | }
148 |
149 | switch m.state {
150 | case formsView:
151 | return m.formsModel.View()
152 | case responsesView:
153 | return m.rsm.View()
154 | case responseView:
155 | return m.rm.View()
156 | }
157 | return ""
158 | }
159 |
--------------------------------------------------------------------------------
/internal/ui/forms/response.go:
--------------------------------------------------------------------------------
1 | package forms
2 |
3 | import (
4 | tea "github.com/charmbracelet/bubbletea"
5 | "github.com/charmbracelet/ssh"
6 | "github.com/devmegablaster/bashform/internal/styles"
7 | )
8 |
9 | type ResponseModel struct {
10 | width, height int
11 | formID string
12 | session ssh.Session
13 | questions, answers []string
14 | }
15 |
16 | func NewResponseModel(session ssh.Session) *ResponseModel {
17 | p, _, _ := session.Pty()
18 |
19 | return &ResponseModel{
20 | width: p.Window.Width,
21 | height: p.Window.Height,
22 | session: session,
23 | questions: []string{},
24 | answers: []string{},
25 | formID: "",
26 | }
27 | }
28 |
29 | func (m *ResponseModel) Init() tea.Cmd {
30 | return nil
31 | }
32 |
33 | func (m *ResponseModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
34 | switch msg := msg.(type) {
35 | case tea.KeyMsg:
36 | if msg.Type == tea.KeyEsc {
37 | return nil, toResponses(m.formID)
38 | }
39 | }
40 |
41 | return m, nil
42 | }
43 |
44 | func (m *ResponseModel) View() string {
45 | var content string
46 | for i, question := range m.questions {
47 | content += styles.Heading.Render(question)
48 | content += "\n"
49 | content += styles.Description.Render(m.answers[i])
50 | content += "\n\n"
51 | }
52 |
53 | content += styles.Description.Render("\n Press ESC to go back")
54 |
55 | return styles.PlaceCenterVertical(m.width, m.height, styles.Heading.Render("Response\n"), styles.Box(m.width, content))
56 | }
57 |
--------------------------------------------------------------------------------
/internal/ui/forms/responses.go:
--------------------------------------------------------------------------------
1 | package forms
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/charmbracelet/bubbles/table"
7 | tea "github.com/charmbracelet/bubbletea"
8 | "github.com/charmbracelet/lipgloss"
9 | "github.com/charmbracelet/ssh"
10 | "github.com/devmegablaster/bashform/internal/models"
11 | "github.com/devmegablaster/bashform/internal/services"
12 | "github.com/devmegablaster/bashform/internal/styles"
13 | )
14 |
15 | var baseStyle = lipgloss.NewStyle().
16 | BorderStyle(lipgloss.NormalBorder()).
17 | BorderForeground(lipgloss.Color("240"))
18 |
19 | type responsesModel struct {
20 | width, height int
21 | formID string
22 | Form models.Form
23 | session ssh.Session
24 | user *models.User
25 | formSvc *services.FormService
26 | table table.Model
27 |
28 | sizeError bool
29 | responseError error
30 | init bool
31 | initTime time.Time
32 | }
33 |
34 | func newResponsesModel(formSvc *services.FormService, session ssh.Session) *responsesModel {
35 | pty, _, _ := session.Pty()
36 |
37 | sizeErr := false
38 | if pty.Window.Width < 50 || pty.Window.Height < 35 {
39 | sizeErr = true
40 | }
41 |
42 | t := table.New(
43 | table.WithFocused(true),
44 | table.WithHeight(20),
45 | )
46 |
47 | t.SetStyles(styles.TableStyle())
48 |
49 | u := session.Context().Value("user").(*models.User)
50 |
51 | return &responsesModel{
52 | Form: models.Form{},
53 | formSvc: formSvc,
54 | table: t,
55 | width: pty.Window.Width,
56 | height: pty.Window.Height,
57 | session: session,
58 | user: u,
59 | init: true,
60 | sizeError: sizeErr,
61 | initTime: time.Now(),
62 | }
63 | }
64 |
65 | func (m *responsesModel) Init() tea.Cmd {
66 | return nil
67 | }
68 |
69 | func (m *responsesModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
70 | var cmd tea.Cmd
71 |
72 | switch msg := msg.(type) {
73 | case tea.KeyMsg:
74 | if msg.Type == tea.KeyEsc {
75 | return nil, toForms()
76 | }
77 | if msg.Type == tea.KeyEnter {
78 | questions := []string{}
79 | q := m.table.Columns()
80 | for _, column := range q {
81 | questions = append(questions, column.Title)
82 | }
83 | answers := m.table.SelectedRow()
84 | return nil, toResponse(questions, answers, m.formID)
85 | }
86 | }
87 |
88 | m.table, cmd = m.table.Update(msg)
89 | return m, cmd
90 | }
91 |
92 | func (m *responsesModel) View() string {
93 | content :=
94 | styles.Heading.Render("Responses") +
95 | "\n" +
96 | baseStyle.Render(m.table.View()) +
97 | "\n\n" +
98 | styles.Description.Render("Enter - View Response | Press ESC to go back")
99 |
100 | return styles.PlaceCenter(m.width, m.height, content)
101 | }
102 |
103 | func (m *responsesModel) GetResponses() {
104 | formWithResponses, err := m.formSvc.GetWithResponses(m.formID, m.user)
105 | if err != nil {
106 | m.responseError = err
107 | }
108 |
109 | m.Form = *formWithResponses
110 |
111 | var order []string
112 | var columns []table.Column
113 | for _, question := range m.Form.Questions {
114 | columns = append(columns, table.Column{
115 | Title: question.Text,
116 | Width: m.width / (len(m.Form.Questions) + 2),
117 | })
118 | order = append(order, question.ID.String())
119 | }
120 |
121 | // TODO: This is a hack, need to fix this
122 | var rows []table.Row
123 | for _, response := range m.Form.Responses {
124 | var row table.Row
125 | for _, id := range order {
126 | for _, answer := range response.Answers {
127 | if answer.QuestionID.String() == id {
128 | row = append(row, answer.Value)
129 | break
130 | }
131 | }
132 | }
133 |
134 | rows = append(rows, row)
135 | }
136 |
137 | m.table.SetRows([]table.Row{})
138 | m.table.SetColumns([]table.Column{})
139 |
140 | m.table.SetColumns(columns)
141 | m.table.SetRows(rows)
142 | }
143 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "log/slog"
6 | "os"
7 | "os/signal"
8 | "sync"
9 | "syscall"
10 |
11 | "github.com/devmegablaster/bashform/internal/config"
12 | "github.com/devmegablaster/bashform/internal/database"
13 | "github.com/devmegablaster/bashform/internal/logger"
14 | "github.com/devmegablaster/bashform/server"
15 | )
16 |
17 | func main() {
18 |
19 | logger.SetDefault()
20 |
21 | cfg := config.New()
22 |
23 | ctx, cancel := context.WithCancel(context.Background())
24 | defer cancel()
25 |
26 | wg := &sync.WaitGroup{}
27 |
28 | db, err := database.New(ctx, wg, cfg.Database)
29 | if err != nil {
30 | slog.Error("❌ Could not connect to database", "error", err)
31 | os.Exit(1)
32 | }
33 |
34 | s, err := server.NewSSHServer(wg, cfg, db)
35 | if err != nil {
36 | slog.Error("❌ Could not create server", "error", err)
37 | os.Exit(1)
38 | }
39 |
40 | sigChan := make(chan os.Signal, 1)
41 | signal.Notify(sigChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
42 |
43 | errChan := make(chan error, 1)
44 | go func() {
45 | if err := s.ListenAndServe(ctx); err != nil {
46 | errChan <- err
47 | }
48 | }()
49 |
50 | select {
51 | case err := <-errChan:
52 | slog.Error("❌ Could not start server", "error", err)
53 | os.Exit(1)
54 |
55 | case sig := <-sigChan:
56 | slog.Info("🚨 Shutting down gracefully", slog.String("signal", sig.String()))
57 | cancel()
58 | wg.Wait()
59 | slog.Info("✅ Graceful shutdown complete")
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/server/auth.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "log/slog"
5 |
6 | "github.com/charmbracelet/ssh"
7 | "github.com/devmegablaster/bashform/internal/models"
8 | )
9 |
10 | func (s *SSHServer) handleAuth(ctx ssh.Context, key ssh.PublicKey) bool {
11 | encodedKey := s.cryptoSvc.Base64Encode(key.Marshal())
12 | user, err := s.userSvc.GetByPubKey(encodedKey)
13 | if err != nil {
14 | userReq := models.UserRequest{
15 | PubKey: encodedKey,
16 | }
17 | user, err = s.userSvc.Create(userReq)
18 | if err != nil {
19 | return false
20 | }
21 |
22 | slog.Info("🔑 Created new user", slog.String("user", user.ID.String()))
23 | }
24 |
25 | ctx.SetValue("user", user)
26 | return true
27 | }
28 |
29 | func (s *SSHServer) addUsername(next ssh.Handler) ssh.Handler {
30 | return func(sess ssh.Session) {
31 | user := sess.Context().Value("user").(*models.User)
32 | if user.Name == "" && sess.User() != "" {
33 | user.Name = sess.User()
34 | if err := s.userSvc.Update(user); err != nil {
35 | slog.Error("Failed to update user", "error", err)
36 | return
37 | }
38 |
39 | slog.Info("🔑 Updated user name", slog.String("user", user.ID.String()), slog.String("name", user.Name))
40 | }
41 |
42 | next(sess)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/server/handler.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "log/slog"
5 |
6 | "github.com/charmbracelet/ssh"
7 | "github.com/devmegablaster/bashform/cmd"
8 | )
9 |
10 | func (s *SSHServer) handleCmd(next ssh.Handler) ssh.Handler {
11 | return func(sess ssh.Session) {
12 | if err := s.executeCommand(sess); err != nil {
13 | slog.Error("❌ Command execution failed", "error", err)
14 | return
15 | }
16 | next(sess)
17 | }
18 | }
19 |
20 | func (s *SSHServer) executeCommand(sess ssh.Session) error {
21 | cli := cmd.NewCLI(s.cfg, s.db, sess)
22 | return cli.Run()
23 | }
24 |
--------------------------------------------------------------------------------
/server/logger.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "log/slog"
5 | "time"
6 |
7 | "github.com/charmbracelet/ssh"
8 | )
9 |
10 | func (s *SSHServer) logger(next ssh.Handler) ssh.Handler {
11 | return func(sess ssh.Session) {
12 | init := time.Now()
13 | slog.Info("New Connection", "remote_addr", sess.RemoteAddr(), "username", sess.User(), "command", sess.Command())
14 | next(sess)
15 | duration := time.Since(init)
16 | slog.Info("Connection Closed", "remote_addr", sess.RemoteAddr(), "username", sess.User(), "duration", duration.Round(time.Second).String())
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/server/server.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "log/slog"
7 | "net"
8 | "strconv"
9 | "sync"
10 | "time"
11 |
12 | "github.com/charmbracelet/ssh"
13 | "github.com/charmbracelet/wish"
14 | "github.com/charmbracelet/wish/activeterm"
15 | "github.com/devmegablaster/bashform/internal/config"
16 | "github.com/devmegablaster/bashform/internal/database"
17 | "github.com/devmegablaster/bashform/internal/services"
18 | )
19 |
20 | type SSHServer struct {
21 | cfg config.Config
22 | db *database.Database
23 | userSvc *services.UserService
24 | cryptoSvc *services.CryptoService
25 | s *ssh.Server
26 | wg *sync.WaitGroup
27 | }
28 |
29 | func (s *SSHServer) initSSHServer() error {
30 | server, err := wish.NewServer(
31 | wish.WithAddress(net.JoinHostPort(s.cfg.SSH.Host, strconv.Itoa(s.cfg.SSH.Port))),
32 | wish.WithHostKeyPath(s.cfg.SSH.KeyPath),
33 | wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
34 | return s.handleAuth(ctx, key)
35 | }),
36 | wish.WithMiddleware(
37 | s.handleCmd,
38 | activeterm.Middleware(),
39 | s.logger,
40 | s.addUsername,
41 | ),
42 | )
43 |
44 | if err != nil {
45 | return err
46 | }
47 |
48 | s.s = server
49 | return nil
50 | }
51 |
52 | func NewSSHServer(wg *sync.WaitGroup, cfg config.Config, db *database.Database) (*SSHServer, error) {
53 | s := &SSHServer{
54 | cfg: cfg,
55 | db: db,
56 | userSvc: services.NewUserService(cfg, db),
57 | cryptoSvc: services.NewCryptoService(cfg.Crypto),
58 | wg: wg,
59 | }
60 |
61 | if err := s.initSSHServer(); err != nil {
62 | return nil, err
63 | }
64 |
65 | return s, nil
66 | }
67 |
68 | func (s *SSHServer) ListenAndServe(ctx context.Context) error {
69 | errChan := make(chan error, 1)
70 | s.wg.Add(1)
71 | go func() {
72 | defer s.wg.Done()
73 |
74 | slog.Info("✅ Starting SSH server", slog.String("address", s.s.Addr))
75 | if err := s.s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
76 | errChan <- err
77 | }
78 | }()
79 |
80 | select {
81 | case err := <-errChan:
82 | slog.Error("❌ Could not start server", "error", err)
83 | return err
84 | case <-ctx.Done():
85 | slog.Info("🔌 Shutting down SSH Server")
86 | return s.shutdown()
87 | }
88 | }
89 |
90 | func (s *SSHServer) shutdown() error {
91 | timeCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
92 | defer cancel()
93 | s.wg.Add(1)
94 | defer s.wg.Done()
95 |
96 | // wait for all connections to close or timeout
97 | return s.s.Shutdown(timeCtx)
98 | }
99 |
--------------------------------------------------------------------------------