├── .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 |

24 | 25 | ## Installation 26 | 27 | There is no installation required for using Bashform. As long as you have SSH access, you can: 28 | 29 | - Create forms 30 | - Respond to forms 31 | - Get form responses 32 | 33 | ## Usage 34 | 35 | > [!NOTE] 36 | > You need an SSH key to use bashform. If you don't have one (WHY?), you can generate one using the following command: 37 | > 38 | > ```bash 39 | > ssh-keygen -t rsa -b 4096 -C "" 40 | > ``` 41 | 42 | ### Fill Out a Form 43 | 44 | To fill out a form, use the following command: 45 | 46 | ```bash 47 | ssh -t bashform.me form 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 | / _ / _ /(_- 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 | --------------------------------------------------------------------------------