├── assets ├── violet.gif └── violet.png ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── cmd └── violet │ └── main.go ├── demo.tape ├── Dockerfile ├── .goreleaser.yaml ├── test ├── single_env.sh ├── many_env.sh ├── bashrc └── multi_env.sh ├── internal └── app │ ├── spinner.go │ ├── view.go │ ├── app.go │ ├── button.go │ ├── styles.go │ ├── vagrant.go │ ├── ecosystem.go │ └── update.go ├── LICENSE ├── go.mod ├── Vagrantfile ├── Makefile ├── README.md ├── go.sum └── pkg └── vagrant ├── vagrant.go └── vagrant_test.go /assets/violet.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braheezy/violet/HEAD/assets/violet.gif -------------------------------------------------------------------------------- /assets/violet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braheezy/violet/HEAD/assets/violet.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | notes* 3 | *.log 4 | bin/ 5 | dist/ 6 | .vagrant 7 | test/*-env 8 | __debug_bin -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /cmd/violet/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/braheezy/violet/internal/app" 5 | ) 6 | 7 | func main() { 8 | app.Run() 9 | } 10 | -------------------------------------------------------------------------------- /demo.tape: -------------------------------------------------------------------------------- 1 | Output demo.gif 2 | 3 | Set FontSize 20 4 | Set Width 1200 5 | Set Height 950 6 | 7 | Type "violet" 8 | Sleep 500ms 9 | 10 | Enter 11 | 12 | Sleep 5s 13 | 14 | Tab 15 | Sleep 2s 16 | 17 | Tab 18 | Sleep 500ms 19 | 20 | Right 21 | Sleep 1s 22 | 23 | Left 24 | Sleep 500ms 25 | 26 | Enter 27 | Sleep 7s 28 | 29 | 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.19 2 | 3 | ENV TERM=xterm-256color 4 | 5 | SHELL ["/bin/bash", "-c"] 6 | 7 | RUN apt-get update && apt-get install -y vagrant make 8 | 9 | RUN wget -qO- \ 10 | https://github.com/charmbracelet/gum/releases/download/v0.10.0/gum_0.10.0_Linux_x86_64.tar.gz \ 11 | | tar -xz -C /usr/bin/ gum 12 | 13 | RUN curl -sSL https://get.docker.com | bash 14 | 15 | WORKDIR /app 16 | 17 | COPY go.mod go.sum ./ 18 | RUN go mod download && go mod verify 19 | 20 | COPY ./files/bashrc /root/.bashrc 21 | COPY . . 22 | RUN make build 23 | RUN make install 24 | 25 | CMD ["violet"] 26 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: violet 2 | before: 3 | hooks: 4 | - go mod tidy 5 | builds: 6 | - main: ./cmd/violet 7 | env: 8 | - CGO_ENABLED=0 9 | goos: 10 | - linux 11 | - windows 12 | - darwin 13 | goarch: 14 | - amd64 15 | archives: 16 | - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 17 | checksum: 18 | name_template: "checksums.txt" 19 | snapshot: 20 | name_template: "{{ incpatch .Version }}-next" 21 | changelog: 22 | sort: asc 23 | filters: 24 | exclude: 25 | - "^docs:" 26 | - "^test:" 27 | include: 28 | - "^feat:" 29 | - "^fix:" 30 | - "^perf:" 31 | -------------------------------------------------------------------------------- /test/single_env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eou pipefail 4 | 5 | VAGRANTFILE=$(cat </dev/null 27 | vagrant destroy -f &>/dev/null || true 28 | popd 29 | rm -rf "$TEST_DIR" 30 | fi 31 | 32 | mkdir "$TEST_DIR" 33 | pushd "$TEST_DIR" 2>/dev/null 34 | echo "$VAGRANTFILE" > Vagrantfile 35 | vagrant up 36 | popd 37 | -------------------------------------------------------------------------------- /test/many_env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eou pipefail 4 | 5 | VAGRANTFILE=$(cat </dev/null 27 | vagrant destroy -f &>/dev/null || true 28 | popd 29 | rm -rf "$TEST_DIR" 30 | fi 31 | 32 | mkdir -p "$TEST_DIR" 33 | pushd "$TEST_DIR" 2>/dev/null 34 | echo "$VAGRANTFILE" > Vagrantfile 35 | vagrant up 36 | popd 37 | done 38 | -------------------------------------------------------------------------------- /internal/app/spinner.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "math/rand" 5 | 6 | "github.com/charmbracelet/bubbles/spinner" 7 | "github.com/charmbracelet/lipgloss" 8 | ) 9 | 10 | var ( 11 | // Available spinners 12 | spinners = []spinner.Spinner{ 13 | spinner.MiniDot, 14 | spinner.Dot, 15 | spinner.Line, 16 | spinner.Jump, 17 | spinner.Pulse, 18 | spinner.Points, 19 | spinner.Globe, 20 | spinner.Moon, 21 | spinner.Monkey, 22 | } 23 | 24 | spinnerStyle = lipgloss.NewStyle(). 25 | Foreground(secondaryColor). 26 | Italic(true) 27 | spinnerCommandStyle = spinnerStyle. 28 | Bold(true). 29 | Foreground(secondaryColor) 30 | ) 31 | 32 | type currentSpinner struct { 33 | spinner spinner.Model 34 | show bool 35 | verb string 36 | } 37 | 38 | func newSpinner() currentSpinner { 39 | s := spinner.New() 40 | s.Spinner = spinners[rand.Intn(len(spinners))] 41 | return currentSpinner{ 42 | spinner: s, 43 | verb: verbs[rand.Intn(len(verbs))], 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test/bashrc: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export LS_OPTIONS='--color=auto' 3 | alias ls='ls $LS_OPTIONS' 4 | alias ll='ls $LS_OPTIONS -l' 5 | alias l='ls $LS_OPTIONS -lA' 6 | 7 | alias vd='vagrant destroy' 8 | alias vgs='vagrant global-status' 9 | alias vh='vagrant halt' 10 | alias vr='vagrant reload' 11 | alias vssh='vagrant ssh' 12 | alias vs='vagrant status' 13 | alias vu='vagrant up' 14 | alias vnuke='vagrant global-status --prune | awk '\''/docker/{print $1}'\'' | xargs -r vagrant destroy -f' 15 | 16 | export PATH=$PATH:$HOME/go/bin/ 17 | 18 | GREEN=$(tput setaf 114) 19 | BLUE=$(tput setaf 111) 20 | PURPLE=$(tput setaf 183) 21 | DARK_PURPLE=$(tput setaf 93) 22 | WHITE=$(tput setaf 189) 23 | END='\e[0m' 24 | 25 | # Set PS1 prompt 26 | export PS1="${BLUE}\u${WHITE} at ${PURPLE}\h${WHITE} in ${GREEN}\W" 27 | PS1+='\n' 28 | PS1+="${DARK_PURPLE}>${END} " 29 | export PS1 30 | 31 | rebuild() { 32 | pushd /vagrant &>/dev/null || exit 33 | make install 34 | popd &>/dev/null || exit 35 | } 36 | 37 | alias single="bash /vagrant/test/single_env.sh" 38 | alias multi="bash /vagrant/test/multi_env.sh" 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 braheezy 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 | -------------------------------------------------------------------------------- /test/multi_env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eou pipefail 4 | 5 | # Get the absolute path of the script's directory 6 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 7 | 8 | # Change the working directory to the script's directory 9 | cd "$SCRIPT_DIR" 10 | 11 | VAGRANTFILE=$(cat </dev/null 37 | vagrant destroy -f &>/dev/null || true 38 | popd 39 | rm -rf "$TEST_DIR" 40 | fi 41 | 42 | mkdir "$TEST_DIR" 43 | pushd "$TEST_DIR" 2>/dev/null 44 | echo "$VAGRANTFILE" > Vagrantfile 45 | vagrant up 46 | popd 47 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | concurrency: 9 | group: ${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | build: 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest, macos-latest, windows-latest] 17 | 18 | runs-on: ${{ matrix.os }} 19 | env: 20 | GO111MODULE: "on" 21 | CGO_ENABLED: 0 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v3 25 | 26 | - name: Install Go 27 | uses: actions/setup-go@v4 28 | with: 29 | go-version: "1.23" 30 | 31 | - name: Install dependencies 32 | run: go get ./... 33 | 34 | - name: Build 35 | run: go build -v ./... 36 | 37 | release: 38 | if: startsWith(github.ref, 'refs/tags/') 39 | needs: build 40 | permissions: 41 | contents: write 42 | 43 | runs-on: ubuntu-latest 44 | env: 45 | GO111MODULE: "on" 46 | steps: 47 | - name: Checkout code 48 | uses: actions/checkout@v3 49 | with: 50 | fetch-depth: 0 51 | 52 | - name: Install Go 53 | uses: actions/setup-go@v4 54 | with: 55 | go-version: "1.23" 56 | 57 | - name: Release Go binaries on tags 58 | uses: goreleaser/goreleaser-action@v4 59 | with: 60 | version: latest 61 | args: release --clean 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/braheezy/violet 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.4 6 | 7 | require ( 8 | github.com/charmbracelet/bubbles v0.21.0 9 | github.com/charmbracelet/bubbletea v1.3.10 10 | github.com/charmbracelet/lipgloss v1.1.0 11 | github.com/lrstanley/bubbletint v1.0.0 12 | github.com/lrstanley/bubblezone v1.0.0 13 | github.com/stretchr/testify v1.11.1 14 | ) 15 | 16 | require ( 17 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 18 | github.com/charmbracelet/colorprofile v0.3.1 // indirect 19 | github.com/charmbracelet/x/ansi v0.10.1 // indirect 20 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 21 | github.com/charmbracelet/x/term v0.2.1 // indirect 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 24 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 25 | github.com/mattn/go-isatty v0.0.20 // indirect 26 | github.com/mattn/go-localereader v0.0.1 // indirect 27 | github.com/mattn/go-runewidth v0.0.16 // indirect 28 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 29 | github.com/muesli/cancelreader v0.2.2 // indirect 30 | github.com/muesli/termenv v0.16.0 // indirect 31 | github.com/pmezard/go-difflib v1.0.0 // indirect 32 | github.com/rivo/uniseg v0.4.7 // indirect 33 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 34 | golang.org/x/sys v0.36.0 // indirect 35 | golang.org/x/text v0.26.0 // indirect 36 | gopkg.in/yaml.v3 v3.0.1 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /internal/app/view.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | zone "github.com/lrstanley/bubblezone" 8 | ) 9 | 10 | func (v Violet) View() (view string) { 11 | // Title view area 12 | title := titleStyle.Render("Violet:") 13 | greeter := greeterStyle.Render("Pretty manager for Vagrant") 14 | titleGreeter := lipgloss.NewStyle().Margin(marginVertical, marginHorizontal).Render(title + greeter) 15 | view += lipgloss.PlaceHorizontal(v.terminalWidth, lipgloss.Center, titleGreeter) 16 | 17 | help := v.help.View(v.keys) 18 | helpText := lipgloss.NewStyle(). 19 | Margin(marginVertical, marginHorizontal). 20 | Render(help) 21 | view += lipgloss.PlaceHorizontal(v.terminalWidth, lipgloss.Center, helpText) 22 | view += "\n" 23 | 24 | // Show the current environments 25 | ecosystemView := v.ecosystem.View() 26 | view += lipgloss.PlaceHorizontal(v.terminalWidth, lipgloss.Center, ecosystemView) 27 | view += "\n\n" 28 | 29 | if len(v.errorMessage) > 0 { 30 | view += errorTitleStyle.Render("Violet ran into an error: ") 31 | view += "\n" 32 | view += errorStyle.Render(v.errorMessage) 33 | } else if v.spinner.show { 34 | currentMachine, _ := v.ecosystem.currentMachine() 35 | commandIndex := currentMachine.selectedCommand 36 | targetName := currentMachine.name 37 | if v.ecosystem.currentEnv().hasFocus { 38 | commandIndex = v.ecosystem.currentEnv().selectedCommand 39 | targetName = v.ecosystem.currentEnv().name 40 | } 41 | command := spinnerCommandStyle.Render(supportedMachineCommands[commandIndex]) 42 | 43 | title := spinnerStyle.Render(fmt.Sprintf( 44 | "%v: %v command %v", 45 | targetName, 46 | v.spinner.verb, 47 | command, 48 | )) 49 | 50 | progressView := fmt.Sprintf("%v %v %v\n\n", v.spinner.spinner.View(), title, v.spinner.spinner.View()) 51 | view += lipgloss.NewStyle().Margin(marginVertical, marginHorizontal).Render(progressView) 52 | } 53 | 54 | // Monitor mouse zones and strip injected ANSI sequences 55 | view = zone.Scan(view) 56 | 57 | return view 58 | } 59 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # The resources to give the VM. 2 | CPUS = 2 3 | MEMORY = 4096 4 | 5 | # The name of the VM. 6 | NAME = "violet-test" 7 | 8 | Vagrant.configure("2") do |config| 9 | config.vm.box = "generic/alpine317" 10 | 11 | config.vm.define NAME 12 | config.vm.hostname = NAME 13 | 14 | config.vm.synced_folder ".", "/vagrant", disabled: false, type: "virtiofs" 15 | 16 | config.vm.provision "setup", type: "shell", keep_color: true, inline: <<-SCRIPT 17 | # Set up shell profile 18 | cp /vagrant/test/bashrc /etc/profile.d/bashrc.sh 19 | 20 | # Update packages 21 | apk update 22 | 23 | # Fetch Gum 24 | wget -qO- \ 25 | https://github.com/charmbracelet/gum/releases/download/v0.10.0/gum_0.10.0_Linux_x86_64.tar.gz \ 26 | | tar -xz -C /usr/bin/ gum 27 | 28 | # Install and start Docker 29 | apk add --no-cache docker 30 | rc-update add docker default 31 | rc-service docker start 32 | addgroup vagrant docker 33 | 34 | # Install Vagrant from source 35 | apk add --no-cache ruby ruby-dev ruby-bundler git gcc make libc-dev ncurses go 36 | git clone https://github.com/hashicorp/vagrant.git 37 | cd vagrant 38 | bundle install 39 | bundle --binstubs exec 40 | ln -sf $(pwd)/exec/vagrant /usr/bin/vagrant 41 | 42 | vagrant version 43 | SCRIPT 44 | config.vm.provision "install", type: "shell", keep_color: true, privileged: false, inline: <<-SCRIPT 45 | cd /vagrant 46 | make install 47 | SCRIPT 48 | config.vm.provider :libvirt do |l, override| 49 | l.driver = "kvm" 50 | l.cpus = CPUS 51 | l.memory = MEMORY 52 | l.disk_bus = "virtio" 53 | l.qemu_use_session = false 54 | 55 | l.default_prefix = "" 56 | 57 | l.nested = true 58 | 59 | l.memorybacking :access, :mode => "shared" 60 | 61 | # Enable Hyper-V enlightments: https://blog.wikichoon.com/2014/07/enabling-hyper-v-enlightenments-with-kvm.html 62 | l.hyperv_feature :name => 'relaxed', :state => 'on' 63 | l.hyperv_feature :name => 'synic', :state => 'on' 64 | l.hyperv_feature :name => 'vapic', :state => 'on' 65 | l.hyperv_feature :name => 'vpindex', :state => 'on' 66 | end 67 | end -------------------------------------------------------------------------------- /internal/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | /* 4 | The main entry point for the application. 5 | */ 6 | 7 | import ( 8 | "fmt" 9 | "io" 10 | "log" 11 | "os" 12 | 13 | "github.com/braheezy/violet/pkg/vagrant" 14 | "github.com/charmbracelet/bubbles/help" 15 | tea "github.com/charmbracelet/bubbletea" 16 | "github.com/charmbracelet/lipgloss" 17 | zone "github.com/lrstanley/bubblezone" 18 | ) 19 | 20 | func Run() { 21 | if os.Getenv("VIOLET_DEBUG") != "" { 22 | if f, err := tea.LogToFile("violet-debug.log", "debug"); err != nil { 23 | fmt.Println("Couldn't open a file for logging:", err) 24 | os.Exit(1) 25 | } else { 26 | defer f.Close() 27 | } 28 | } else { 29 | // Set up a dummy logger that discards log output 30 | log.SetOutput(io.Discard) 31 | } 32 | // Set the color palette for the application. 33 | if lipgloss.HasDarkBackground() { 34 | theme = defaultDarkTheme 35 | } else { 36 | theme = defaultLightTheme 37 | } 38 | 39 | // Setup mouse tracking 40 | zone.NewGlobal() 41 | 42 | p := tea.NewProgram(newViolet(), tea.WithAltScreen(), tea.WithMouseAllMotion()) 43 | p.SetWindowTitle("♡♡ violet ♡♡") 44 | if _, err := p.Run(); err != nil { 45 | log.Fatalf("Could not start program :(\n%v\n", err) 46 | } 47 | } 48 | 49 | // Complete app state (i.e. the BubbleTea model) 50 | type Violet struct { 51 | // Reference to the Ecosystem 52 | ecosystem Ecosystem 53 | // Fancy help bubble 54 | help help.Model 55 | // To support help 56 | keys helpKeyMap 57 | // Spinner to show while commands are running 58 | spinner currentSpinner 59 | // Current terminal size 60 | terminalWidth int 61 | terminalHeight int 62 | errorMessage string 63 | } 64 | 65 | func (v *Violet) setErrorMessage(message string) { 66 | v.errorMessage = message 67 | } 68 | 69 | // Return the default Violet model 70 | func newViolet() Violet { 71 | client, err := vagrant.NewVagrantClient() 72 | if err != nil { 73 | log.Fatal(err) 74 | } 75 | 76 | help := help.New() 77 | help.ShowAll = true 78 | 79 | return Violet{ 80 | ecosystem: Ecosystem{ 81 | environments: nil, 82 | client: client, 83 | }, 84 | keys: keys, 85 | help: help, 86 | spinner: newSpinner(), 87 | } 88 | } 89 | 90 | func (v Violet) Init() tea.Cmd { 91 | return getInitialGlobalStatus 92 | 93 | } 94 | 95 | // Runs on boot to get current Vagrant status on host. 96 | func getInitialGlobalStatus() tea.Msg { 97 | client, err := vagrant.NewVagrantClient() 98 | if err != nil { 99 | log.Fatal(err) 100 | } 101 | ecosystem, err := createEcosystem(client) 102 | if err != nil { 103 | return ecosystemErrMsg{err} 104 | } 105 | return ecosystemMsg(ecosystem) 106 | } 107 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PACKAGE := violet 2 | 3 | # Go defintions 4 | GOCMD ?= go 5 | GOBUILD := $(GOCMD) build 6 | GOINSTALL := $(GOCMD) install 7 | GOARCH := amd64 8 | 9 | # Build definitions 10 | BUILD_ENTRY := $(PWD)/cmd/violet 11 | BIN_DIR := $(PWD)/bin 12 | 13 | # Determine the file extension based on the platform 14 | ifeq ($(OS),Windows_NT) 15 | EXTENSION := .exe 16 | else 17 | EXTENSION := 18 | endif 19 | # Different platform support 20 | PLATFORMS := linux windows darwin 21 | BINARIES := $(addprefix $(BIN_DIR)/,$(addsuffix /$(PACKAGE)$(EXTENSION),$(PLATFORMS))) 22 | 23 | # Fancy colors 24 | BOLD := $(shell tput bold) 25 | ITALIC := \e[3m 26 | YELLOW := $(shell tput setaf 222) 27 | GREEN := $(shell tput setaf 114) 28 | BLUE := $(shell tput setaf 111) 29 | PURPLE := $(shell tput setaf 183) 30 | END := $(shell tput sgr0) 31 | 32 | # Function to colorize a command help string 33 | command-style = $(GREEN)$(BOLD)$1$(END) $(ITALIC)$(BLUE)$2$(END) 34 | 35 | define help_text 36 | $(PURPLE)$(BOLD)Targets:$(END) 37 | - $(call command-style,all, Build Violet for all targets (Linux, Windows, Mac, 64-bit)) 38 | - $(call command-style,build, Build Violet for current host architecture) 39 | - $(call command-style,run, Build and run Violet for current host) 40 | - $(call command-style,install,Build and install Violet for current host) 41 | - $(call command-style,debug, Run a dlv debug headless session) 42 | - $(call command-style,test, Run all tests) 43 | - $(call command-style,clean, Delete built artifacts) 44 | - $(call command-style,[help], Print this help) 45 | endef 46 | export help_text 47 | 48 | .PHONY: test clean help build all install run debug 49 | 50 | help: 51 | @echo -e "$$help_text" 52 | 53 | # Select the right binary for the current host 54 | ifeq ($(OS),Windows_NT) 55 | BIN := $(BIN_DIR)/windows/$(PACKAGE)$(EXTENSION) 56 | else 57 | UNAME := $(shell uname -s) 58 | ifeq ($(UNAME),Linux) 59 | BIN := $(BIN_DIR)/linux/$(PACKAGE) 60 | endif 61 | ifeq ($(UNAME),Darwin) 62 | BIN := $(BIN_DIR)/darwin/$(PACKAGE) 63 | endif 64 | endif 65 | 66 | SOURCES := $(shell find . -name "*.go") 67 | SOURCES += go.mod go.sum 68 | 69 | all: $(BINARIES) 70 | @echo -e "$(GREEN)📦️ Builds are complete: $(END)$(PURPLE)$(BIN_DIR)$(END)" 71 | 72 | $(BIN_DIR)/%/$(PACKAGE)$(EXTENSION): $(SOURCES) 73 | @echo -e "$(YELLOW)🚧 Building $@...$(END)" 74 | @CGO_ENABLED=0 GOARCH=$(GOARCH) GOOS=$* $(GOBUILD) -o $@ $(BUILD_ENTRY) 75 | 76 | build: $(BIN) 77 | @echo -e "$(GREEN)📦️ Build is complete: $(END)$(PURPLE)$(BIN)$(END)" 78 | 79 | clean: 80 | @rm -rf $(BIN_DIR) 81 | @echo -e "$(GREEN)Cleaned!$(END)" 82 | 83 | TEST_FILES = $(PWD)/internal/app/ $(PWD)/pkg/vagrant/ 84 | test: 85 | @echo -e "$(YELLOW)Testing...$(END)" 86 | @go test $(TEST_FILES) 87 | @echo -e "$(GREEN)✅ Test is complete!$(END)" 88 | 89 | run: $(BIN) 90 | @exec $? 91 | 92 | debug: 93 | @dlv debug --listen ":2345" --headless $(BUILD_ENTRY) 94 | 95 | install: $(BIN) 96 | @echo -e "$(YELLOW)🚀 Installing $(BIN) to appropriate location...$(END)" 97 | @$(GOINSTALL) $(BUILD_ENTRY) 98 | @echo -e "$(GREEN)✅ Installation complete!$(END)" 99 | -------------------------------------------------------------------------------- /internal/app/button.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | ) 6 | 7 | var ( 8 | defaultLargeButtonStyle = lipgloss.NewStyle(). 9 | Foreground(primaryColor). 10 | Padding(1) 11 | activeLargeButtonStyle = defaultLargeButtonStyle. 12 | Foreground(secondaryColor). 13 | Bold(true) 14 | buttonLargeGroupStyle = lipgloss.NewStyle(). 15 | Border(lipgloss.RoundedBorder(), true). 16 | BorderForeground(primaryColor). 17 | Margin(0) 18 | 19 | defaultSmallButtonStyle = lipgloss.NewStyle(). 20 | Foreground(primaryColor). 21 | Margin(0, 1) 22 | activeSmallButtonStyle = defaultSmallButtonStyle. 23 | Foreground(secondaryColor). 24 | Bold(true) 25 | buttonSmallGroupStyle = lipgloss.NewStyle(). 26 | Margin(marginVertical, marginHorizontal, 0). 27 | Border(lipgloss.RoundedBorder(), true). 28 | BorderForeground(primaryColor) 29 | ) 30 | 31 | type button struct { 32 | content string 33 | style lipgloss.Style 34 | } 35 | 36 | func (b *button) View() string { 37 | return b.style.Render(b.content) 38 | } 39 | 40 | type buttonGroup struct { 41 | buttons []button 42 | } 43 | 44 | type machineCommandButtons buttonGroup 45 | 46 | func newMachineCommandButtons(supportedVagrantCommands []string) machineCommandButtons { 47 | var buttons []button 48 | 49 | for _, command := range supportedVagrantCommands { 50 | buttons = append(buttons, button{ 51 | content: symbols[command], 52 | style: defaultSmallButtonStyle, 53 | }) 54 | } 55 | 56 | return machineCommandButtons{ 57 | buttons: buttons, 58 | } 59 | } 60 | 61 | func (bg *machineCommandButtons) View(selectedCommand int, hasFocus bool) string { 62 | for i := range bg.buttons { 63 | if i == selectedCommand && hasFocus { 64 | bg.buttons[i].style = activeSmallButtonStyle.Padding(0) 65 | } else { 66 | bg.buttons[i].style = defaultSmallButtonStyle.Padding(0) 67 | } 68 | } 69 | 70 | var row []string 71 | for _, button := range bg.buttons { 72 | row = append(row, button.View()) 73 | } 74 | 75 | grid := lipgloss.JoinHorizontal(lipgloss.Center, row...) 76 | 77 | return buttonSmallGroupStyle.Render(grid) 78 | } 79 | 80 | type envCommandButtons buttonGroup 81 | 82 | func newEnvCommandButtons(supportedVagrantCommands []string) envCommandButtons { 83 | var buttons []button 84 | // Create buttons based on supported commands 85 | for _, command := range supportedVagrantCommands { 86 | cont := symbols[command] + " " + command 87 | buttons = append(buttons, button{ 88 | content: cont, 89 | style: defaultLargeButtonStyle, 90 | }) 91 | } 92 | 93 | return envCommandButtons{ 94 | buttons: buttons, 95 | } 96 | } 97 | 98 | func (bg *envCommandButtons) View(selectedCommand int, hasFocus bool) string { 99 | for i := range bg.buttons { 100 | if i == selectedCommand && hasFocus { 101 | bg.buttons[i].style = activeLargeButtonStyle 102 | } else { 103 | bg.buttons[i].style = defaultLargeButtonStyle 104 | } 105 | } 106 | 107 | var row []string 108 | for _, button := range bg.buttons { 109 | row = append(row, button.View()) 110 | } 111 | 112 | grid := lipgloss.JoinHorizontal(lipgloss.Center, row...) 113 | 114 | return buttonLargeGroupStyle.Render(grid) 115 | } 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Violet 2 | Give [Vagrant](https://developer.hashicorp.com/vagrant) a splash of color :art: 3 | 4 | Violet is a colorful TUI frontend to manage Vagrant virtual machines. Quickly view the state of all VMs and issue commands against them! 5 | 6 | ![Violet Gif](./assets/violet.png) 7 | 8 | ## Getting Started 9 | 10 | Violet is delivered as a single binary for various platforms. See the [Releases](https://github.com/braheezy/violet/releases) page for the latest builds. 11 | 12 | ### Prerequisites 13 | 14 | Violet does absolutely nothing without Vagrant installed. See the [Vagrant docs](https://developer.hashicorp.com/vagrant/downloads) to install it for your platform. 15 | 16 | Vagrant itself does absolutely nothing unless you have a Hypervisor installed and configured. Here's a few popular ones: 17 | - [VirtualBox](https://www.virtualbox.org/) 18 | - [Libvirt/QEMU](https://libvirt.org/) 19 | 20 | For best results, it helps to have existing Vagrant VMs. 21 | 22 | ### Usage 23 | Open a terminal and run the program: 24 | 25 | violet 26 | 27 | See the following table for how to interact with Violet: 28 | | Action | Key | Description | 29 | |-------------------------|------------|-----------------------------------------------------------| 30 | | Switch Environment Tab | Tab/Shift+Tab | Cycle through found Vagrant environments | 31 | | Select Command | Left/Right | Cycle through the supported Vagrant commands | 32 | | Run command | Enter | Run the highlighted command on the selected entity | 33 | | Toggle Environments/VM control | Space bar | Operate on the environment as a whole or individual machines | 34 | 35 | 36 | Note that Violet does not aim to support all Vagrant commands and will provide a poor interface for troubleshooting issues with Vagrant, VMs, hypervisors, etc. 37 | 38 | ## Development 39 | 40 | The `Makefile` contains the most common developer actions to perform. See `make help` for everything, or build and run for your machine: 41 | 42 | make run 43 | 44 | Using the `Vagrantfile`, a Libvirt VM can be created and inside that, scripts from `test/` can create quick dummy Vagrant projects. This can be a safe sandbox environment to experiment with builds of `violet`. 45 | 46 | ```bash 47 | # Bring VM up and SSH in 48 | vagrant up && vagrant ssh 49 | # Get into the working directory 50 | cd /vagrant 51 | # Setup test environments 52 | bash test/multi_env.sh 53 | # Make edits, then run 54 | make run 55 | ``` 56 | 57 | | Test Script | Purpose | 58 | | --- | --- | 59 | | `single_env.sh` | Create one env with one machine 60 | | `multi_env.sh` | Create one env with multiple machines 61 | | `many_env.sh` | Create multiple envs, each with one machine 62 | 63 | ## Acknowledgements 64 | 65 | * [bubbletea](https://github.com/charmbracelet/bubbletea) - Main TUI framework 66 | * [lipgloss](https://github.com/charmbracelet/lipgloss) - Styling and colors 67 | * [bubbletint](https://github.com/lrstanley/bubbletint) - Pre-made lipgloss colors 68 | * [bubblezone](https://github.com/lrstanley/bubblezone) - Rich mouse tracking 69 | 70 | ## Contributing 71 | 72 | Please do! Issues and PRs are very welcome. 73 | 74 | ## Inspiration 75 | My interest in TUI applications was growing and I wanted to build something complicated and useful (more than a [game](https://github.com/braheezy/hangman)). 76 | -------------------------------------------------------------------------------- /internal/app/styles.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | tint "github.com/lrstanley/bubbletint" 6 | ) 7 | 8 | // Tweak these to quickly change app feels 9 | var defaultDarkTheme = tint.TintKonsolas 10 | var defaultLightTheme = tint.TintCatppuccinLatte 11 | var theme = defaultDarkTheme 12 | 13 | // Tweak these for a different palette 14 | var ( 15 | primaryColor = theme.Purple() 16 | secondaryColor = theme.Cyan() 17 | textColor = theme.Fg() 18 | 19 | inactiveTabBorder = tabBorderWithBottom("┴", "─", "┴") 20 | activeTabBorder = tabBorderWithBottom("┘", " ", "└") 21 | gapBorder = lipgloss.Border{ 22 | Top: " ", 23 | Bottom: "─", 24 | Left: " ", 25 | Right: " ", 26 | TopLeft: " ", 27 | TopRight: " ", 28 | BottomLeft: "─", 29 | BottomRight: "╮", 30 | } 31 | 32 | marginVertical = 1 33 | marginHorizontal = 2 34 | textWrap = 12 35 | 36 | titleStyle = lipgloss.NewStyle(). 37 | Bold(true). 38 | Italic(true). 39 | Foreground(primaryColor). 40 | Padding(0, 1) 41 | greeterStyle = lipgloss.NewStyle(). 42 | Foreground(secondaryColor) 43 | 44 | highlightColor = primaryColor 45 | inactiveTabStyle = lipgloss.NewStyle(). 46 | Border(inactiveTabBorder, true). 47 | BorderForeground(highlightColor). 48 | Padding(0, 1). 49 | Foreground(textColor) 50 | activeTabStyle = inactiveTabStyle. 51 | Border(activeTabBorder, true). 52 | Foreground(secondaryColor) 53 | tabGapStyle = inactiveTabStyle. 54 | Border(gapBorder) 55 | tabWindowStyle = lipgloss.NewStyle(). 56 | BorderForeground(highlightColor). 57 | Padding(1, 1). 58 | Border(lipgloss.RoundedBorder()). 59 | UnsetBorderTop() 60 | errorTitleStyle = lipgloss.NewStyle(). 61 | Foreground(theme.Red()). 62 | MarginLeft(marginHorizontal) 63 | errorStyle = lipgloss.NewStyle(). 64 | MarginLeft(marginHorizontal). 65 | Foreground(theme.BrightRed()). 66 | Bold(true) 67 | ) 68 | 69 | var verbs = []string{"Running", "Executing", "Performing", "Invoking", "Launching", "Casting"} 70 | 71 | func tabBorderWithBottom(left, middle, right string) lipgloss.Border { 72 | border := lipgloss.RoundedBorder() 73 | border.BottomLeft = left 74 | border.Bottom = middle 75 | border.BottomRight = right 76 | return border 77 | } 78 | 79 | // ------ Card Style --------- 80 | var ( 81 | cardTitleStyle = lipgloss.NewStyle(). 82 | Bold(true). 83 | Foreground(primaryColor). 84 | Width(textWrap) 85 | cardStatusStyle = lipgloss.NewStyle() 86 | statusColors = map[string]lipgloss.TerminalColor{ 87 | "running": theme.Green(), 88 | "shutoff": theme.Red(), 89 | "stopped": theme.Red(), 90 | "not started": theme.Black(), 91 | } 92 | cardProviderStyle = lipgloss.NewStyle(). 93 | Faint(true). 94 | Italic(true). 95 | Foreground(textColor) 96 | defaultCardStyle = lipgloss.NewStyle(). 97 | Border(lipgloss.RoundedBorder(), false, false, false, false). 98 | PaddingLeft(2) 99 | selectedCardStyle = defaultCardStyle. 100 | BorderLeft(true). 101 | Border(lipgloss.RoundedBorder(), false, false, false, true). 102 | BorderForeground(secondaryColor). 103 | PaddingLeft(1) 104 | 105 | envCardTitleStyle = cardTitleStyle. 106 | MarginLeft(1). 107 | Width(textWrap) 108 | selectedEnvCardStyle = envCardTitleStyle. 109 | Bold(true). 110 | Foreground(secondaryColor) 111 | ) 112 | -------------------------------------------------------------------------------- /internal/app/vagrant.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | "github.com/braheezy/violet/pkg/vagrant" 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | // Order matters here. 13 | var supportedMachineCommands = []string{"up", "halt", "ssh", "reload", "provision"} 14 | var supportedEnvCommands = []string{"up", "halt", "reload", "provision"} 15 | var symbols = map[string]string{ 16 | "up": "▶", 17 | "halt": "■", 18 | "ssh": ">_ssh", 19 | "reload": "↺", 20 | "provision": "🛠", 21 | } 22 | 23 | // runMsg is emitted after a command is run. 24 | type runMsg struct { 25 | content string 26 | } 27 | type runErrMsg string 28 | 29 | // Create the tea.Cmd that will run command on the machine specified by identifier. 30 | func (v *Violet) createMachineRunCmd(command string, identifier string) tea.Cmd { 31 | return func() tea.Msg { 32 | log.Printf("Running %v on %v", command, identifier) 33 | content, err := v.ecosystem.client.RunCommand(fmt.Sprintf("%v %v", command, identifier)) 34 | 35 | if err != nil { 36 | return runErrMsg(vagrant.ParseVagrantError(err.Error())) 37 | } 38 | 39 | return runMsg{content: content} 40 | } 41 | } 42 | 43 | // Create the tea.Cmd that will run command in the directory. 44 | func (v *Violet) createEnvRunCmd(command string, dir string) tea.Cmd { 45 | return func() tea.Msg { 46 | log.Printf("Running %v in %v", command, dir) 47 | content, err := v.ecosystem.client.RunCommandInDirectory(command, dir) 48 | 49 | if err != nil { 50 | return runErrMsg(vagrant.ParseVagrantError(err.Error())) 51 | } 52 | 53 | return runMsg{content: content} 54 | } 55 | } 56 | 57 | // machineStatusMsg is emitted when status on a machine is received. 58 | type machineStatusMsg struct { 59 | // identifier is the name or machine-id for this status info 60 | identifier string 61 | // Resultant status about machine 62 | status vagrant.MachineInfo 63 | } 64 | 65 | type statusErrMsg struct{ err error } 66 | 67 | func (e statusErrMsg) Error() string { return e.err.Error() } 68 | 69 | // Create the tea.Cmd that will get status on a machine. 70 | func (v *Violet) createMachineStatusCmd(identifier string) tea.Cmd { 71 | return func() tea.Msg { 72 | log.Printf("Getting status for %v", identifier) 73 | result, err := v.ecosystem.client.GetStatusForID(identifier) 74 | 75 | if err != nil { 76 | return statusErrMsg{err} 77 | } 78 | 79 | machineStatus := vagrant.ParseVagrantOutput(result)[0] 80 | machineStatus.Fields["state"] = strings.Replace(machineStatus.Fields["state"], "_", " ", -1) 81 | 82 | return machineStatusMsg{ 83 | identifier: identifier, 84 | status: machineStatus, 85 | } 86 | } 87 | } 88 | 89 | type nameStatusMsg struct { 90 | machineID string 91 | name string 92 | } 93 | 94 | type nameStatusErrMsg struct{ err error } 95 | 96 | func (e nameStatusErrMsg) Error() string { return e.err.Error() } 97 | 98 | // Create the tea.Cmd that will get name of a machine. 99 | func (v *Violet) createNameStatusCmd(identifier string) tea.Cmd { 100 | return func() tea.Msg { 101 | log.Printf("Getting status for %v", identifier) 102 | result, err := v.ecosystem.client.GetStatusForID(identifier) 103 | 104 | if err != nil { 105 | return nameStatusErrMsg{err} 106 | } 107 | 108 | machineStatus := vagrant.ParseVagrantOutput(result)[0] 109 | 110 | return nameStatusMsg{ 111 | machineID: identifier, 112 | name: machineStatus.Name, 113 | } 114 | } 115 | } 116 | 117 | // envStatusMsg is emitted when status on an environment is received. 118 | type envStatusMsg struct { 119 | name string 120 | status []vagrant.MachineInfo 121 | } 122 | 123 | // Create the tea.Cmd that will get status on an environment. 124 | func (v *Violet) createEnvStatusCmd(env *Environment) tea.Cmd { 125 | return func() tea.Msg { 126 | log.Printf("Getting status in %v", env.home) 127 | result, err := v.ecosystem.client.RunCommandInDirectory("status --machine-readable", env.home) 128 | 129 | if err != nil { 130 | return statusErrMsg{err} 131 | } 132 | 133 | newStatus := vagrant.ParseVagrantOutput(result) 134 | return envStatusMsg{ 135 | name: env.name, 136 | status: newStatus, 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 2 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 3 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 4 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 5 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 6 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 7 | github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 8 | github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 9 | github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= 10 | github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= 11 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 12 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 13 | github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= 14 | github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 15 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 16 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 17 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 18 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 19 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 20 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 21 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 22 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 23 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 24 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 25 | github.com/lrstanley/bubbletint v1.0.0 h1:SQkt6FmXYkvXRfCU6K9MArlTeJGBerW6emX4Ko9B4Ww= 26 | github.com/lrstanley/bubbletint v1.0.0/go.mod h1:vGVizd1HXa1DLy4N7PAii5gwGvJhSQpvtJTR6Pr8r2o= 27 | github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA= 28 | github.com/lrstanley/bubblezone v1.0.0/go.mod h1:kcTekA8HE/0Ll2bWzqHlhA2c513KDNLW7uDfDP4Mly8= 29 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 30 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 31 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 32 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 33 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 34 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 35 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 36 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 37 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 38 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 39 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 40 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 41 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 42 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 43 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 44 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 45 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 46 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 47 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 48 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 49 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 50 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 51 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 52 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 53 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 54 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 55 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 57 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 58 | golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 59 | golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 60 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 61 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 62 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 63 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 64 | -------------------------------------------------------------------------------- /pkg/vagrant/vagrant.go: -------------------------------------------------------------------------------- 1 | // Package vagrant provides 2 | package vagrant 3 | 4 | import ( 5 | "bufio" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | "regexp" 11 | "strings" 12 | ) 13 | 14 | // VagrantClient know how to runs Vagrant commands 15 | type VagrantClient struct { 16 | // The path to the Vagrant executable. 17 | ExecPath string 18 | // Environment variables used when running Vagrant commands 19 | Env []string 20 | // The working directory for Vagrant commands 21 | workingDir string 22 | } 23 | 24 | // NewVagrantClient returns a new VagrantClient ready to run commands. 25 | // Return error if there's issues getting Vagrant binary. 26 | func NewVagrantClient() (*VagrantClient, error) { 27 | execPath, err := exec.LookPath("vagrant") 28 | if err != nil { 29 | return nil, errors.New("vagrant binary not found in PATH") 30 | } 31 | 32 | return &VagrantClient{ 33 | ExecPath: execPath, 34 | Env: os.Environ(), 35 | }, nil 36 | } 37 | 38 | // Return the version of Vagrant. 39 | // NB: Good way to check VagrantClient is working 40 | func (c *VagrantClient) GetVersion() (string, error) { 41 | cmd := exec.Command(c.ExecPath, "--version") 42 | result, err := cmd.CombinedOutput() 43 | if err != nil { 44 | return "", errors.New("unable to run vagrant binary") 45 | } 46 | // Parse out version string 47 | version := string(result) 48 | r := regexp.MustCompile(`Vagrant (\d+.\d+.\d+)`) 49 | matches := r.FindStringSubmatch(version) 50 | 51 | if len(matches) > 0 { 52 | version = matches[1] 53 | } else { 54 | // Did they change the format of the version? 55 | version = "N/A" 56 | } 57 | return version, nil 58 | } 59 | 60 | func (c *VagrantClient) GetGlobalStatus() (result string, err error) { 61 | return c.RunCommand("global-status --prune --machine-readable") 62 | } 63 | 64 | func (c *VagrantClient) GetStatusForID(machineID string) (result string, err error) { 65 | return c.RunCommand(fmt.Sprintf("status %v --machine-readable", machineID)) 66 | } 67 | 68 | // Run a Vagrant command and return the result as a string with newlines. 69 | func (c *VagrantClient) RunCommand(command string) (output string, err error) { 70 | cmd := exec.Command(c.ExecPath, strings.Split(command, " ")...) 71 | cmd.Env = c.Env 72 | cmd.Dir = c.workingDir 73 | 74 | stdout, err := cmd.StdoutPipe() 75 | if err != nil { 76 | return "", errors.New("Error creating stdout pipe: " + err.Error()) 77 | } 78 | cmd.Stderr = cmd.Stdout 79 | 80 | scanner := bufio.NewScanner(stdout) 81 | 82 | err = cmd.Start() 83 | if err != nil { 84 | return "", errors.New("Error executing: " + err.Error()) 85 | } 86 | 87 | go func() { 88 | for scanner.Scan() { 89 | output += scanner.Text() + "\n" 90 | } 91 | }() 92 | 93 | err = cmd.Wait() 94 | if err != nil { 95 | return "", errors.New("Error waiting for the command to complete: " + err.Error()) 96 | } 97 | return output, nil 98 | } 99 | 100 | func (c *VagrantClient) RunCommandInDirectory(command string, dir string) (output string, err error) { 101 | c.workingDir = dir 102 | result, err := c.RunCommand(command) 103 | c.workingDir = "" 104 | 105 | if err != nil { 106 | return "", err 107 | } 108 | return result, nil 109 | } 110 | 111 | // Represents the result of a Vagrant command under the context of a single machine. 112 | type MachineInfo struct { 113 | // Name is the name of the machine. 114 | Name string 115 | // MachineID is the unique ID of the machine. 116 | MachineID string 117 | // Fields is a map of field names to field values. 118 | Fields map[string]string 119 | } 120 | 121 | // Returns true if item is in slice. 122 | func Contains(slice []MachineInfo, item MachineInfo) bool { 123 | for _, s := range slice { 124 | if len(s.MachineID) > 0 && s.MachineID == item.MachineID { 125 | return true 126 | } 127 | if len(s.Name) > 0 && s.Name == item.Name { 128 | return true 129 | } 130 | } 131 | return false 132 | } 133 | 134 | func ParseVagrantError(output string) string { 135 | errRegex := regexp.MustCompile(`^\s*\d+,(.*),error-exit,(.+)$`) 136 | for _, line := range strings.Split(output, "\n") { 137 | if m := errRegex.FindStringSubmatch(line); m != nil { 138 | return m[2] 139 | } 140 | } 141 | return "" 142 | } 143 | 144 | // Generically parses the output from a Vagrant command and returns the result. 145 | // Multi-machine environments may result in a list of Result objects 146 | func ParseVagrantOutput(output string) []MachineInfo { 147 | /* 148 | This function operators on the output of --machine-readable Vagrant commands: https://developer.hashicorp.com/vagrant/docs/cli/machine-readable. 149 | 150 | The format is: 151 | timestamp,target,type,data... 152 | 153 | timestamp is a Unix timestamp in UTC of when the message was printed. 154 | 155 | target is the target of the following output. This is empty if the message is related to Vagrant globally. Otherwise, this is generally a machine name so you can relate output to a specific machine when multi-machine is in use. 156 | 157 | type is the type of machine-readable message being outputted. There are a set of standard types which are covered later. 158 | 159 | data is zero or more comma-separated values associated with the prior type. The exact amount and meaning of this data is type-dependent, so you must read the documentation associated with the type to understand fully. 160 | */ 161 | // Compile regular expressions for each field. 162 | fields := make(map[string]*regexp.Regexp) 163 | // DEV: Add more fields here as needed. 164 | supportedFields := []string{"metadata", "machine-id", "provider-name", "state", "state-human-long", "machine-home"} 165 | for _, field := range supportedFields { 166 | fields[field] = regexp.MustCompile(`^\s*\d+,(.*),` + field + `,(.+)$`) 167 | } 168 | var results []MachineInfo 169 | var result MachineInfo 170 | result.Fields = make(map[string]string) 171 | for _, line := range strings.Split(output, "\n") { 172 | // Check if the line matches any of the fields. 173 | matched := false 174 | for field, re := range fields { 175 | if m := re.FindStringSubmatch(line); m != nil { 176 | target := m[1] 177 | // This loops manages findings for machines we might have already seen. 178 | for _, r := range results { 179 | // When the name matches (and isn't empty), it's a machine seen before. 180 | if r.Name == target && r.Name != "" { 181 | // We're about to switch the result to a different machine so make sure 182 | // it's in the results list before we "drop" it. 183 | if !Contains(results, result) { 184 | results = append(results, result) 185 | } 186 | // Update this machine instead of whatever we we're tracking previously. 187 | result = r 188 | } 189 | } 190 | // Now, fill in machine data, keeping an eye out for new Names or MachineIDs, the clear indication a new machine has been found. 191 | // metadata lines de-lineate machines and are a good place to grab the name. 192 | if field == "metadata" { 193 | // Save name if it's the first we've seen 194 | if result.Name == "" { 195 | // NB: target might be "" too, that's okay. 196 | result.Name = target 197 | } else if result.Name != target { 198 | // New machine found. Create new Result 199 | results = append(results, result) 200 | result = MachineInfo{Name: target, Fields: make(map[string]string)} 201 | } 202 | } else if field == "machine-id" { 203 | if result.MachineID != "" { 204 | // New machine found. Create new Result 205 | results = append(results, result) 206 | result = MachineInfo{Name: target, MachineID: m[2], Fields: make(map[string]string)} 207 | } else { 208 | result.MachineID = m[2] 209 | } 210 | } else { 211 | // Update the result with the field value. 212 | result.Fields[field] = m[2] 213 | } 214 | // Found a field for this line, move on to next line 215 | matched = true 216 | break 217 | } 218 | } 219 | if !matched { 220 | // No field was matched, move on to the next line. 221 | continue 222 | } 223 | } 224 | // Add the last result 225 | if len(result.Fields) != 0 && !Contains(results, result) { 226 | results = append(results, result) 227 | } 228 | return results 229 | } 230 | -------------------------------------------------------------------------------- /pkg/vagrant/vagrant_test.go: -------------------------------------------------------------------------------- 1 | package vagrant 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | // Confirm the Vagrant Client can be successfully created 12 | func TestNewVagrantClient(t *testing.T) { 13 | t.Run("Verify client when binary is available and accessible", func(t *testing.T) { 14 | client, err := NewVagrantClient() 15 | require.NoError(t, err) 16 | require.NotNil(t, client) 17 | // Confirm the client can run 18 | version, err := client.GetVersion() 19 | require.NoError(t, err) 20 | require.NotNil(t, version) 21 | }) 22 | t.Run("Verify client when binary is not installed", func(t *testing.T) { 23 | // Save the current PATH so we can restore it after 24 | originalPathEnv := os.Getenv("PATH") 25 | // Destroy PATH for next call 26 | modifiedPathEnv := "/nothing" 27 | os.Setenv("PATH", modifiedPathEnv) 28 | client, err := NewVagrantClient() 29 | 30 | assert.Nil(t, client) 31 | require.ErrorContains(t, err, "vagrant binary not found") 32 | 33 | // Restore PATH 34 | os.Setenv("PATH", originalPathEnv) 35 | }) 36 | t.Run("Verify client when binary is not available", func(t *testing.T) { 37 | client, _ := NewVagrantClient() 38 | client.ExecPath = "/fake/path/to/vagrant" 39 | 40 | _, err := client.GetVersion() 41 | 42 | require.ErrorContains(t, err, "unable to run vagrant binary") 43 | }) 44 | 45 | } 46 | 47 | func TestRunCommand(t *testing.T) { 48 | client, _ := NewVagrantClient() 49 | 50 | t.Run("Verify a valid Vagrant command", func(t *testing.T) { 51 | result, err := client.RunCommand("global-status") 52 | 53 | require.Nil(t, err) 54 | require.Greater(t, len(result), 40) 55 | }) 56 | } 57 | 58 | func TestGetGlobalStatus(t *testing.T) { 59 | client, _ := NewVagrantClient() 60 | 61 | result, err := client.GetGlobalStatus() 62 | 63 | require.Nil(t, err) 64 | require.NotEmpty(t, result) 65 | } 66 | 67 | func TestGetStatusForID(t *testing.T) { 68 | client, _ := NewVagrantClient() 69 | 70 | tests := []struct { 71 | name string 72 | input string 73 | expected string 74 | wantError bool 75 | }{ 76 | { 77 | name: "Test bad ID", 78 | input: "fake", 79 | expected: "", 80 | wantError: true, 81 | }, 82 | } 83 | for _, test := range tests { 84 | result, err := client.GetStatusForID(test.input) 85 | require.Contains(t, result, test.expected) 86 | if test.wantError { 87 | require.Error(t, err) 88 | } 89 | } 90 | } 91 | 92 | func TestRunCommandInDirectory(t *testing.T) { 93 | client, _ := NewVagrantClient() 94 | tests := []struct { 95 | name string 96 | input map[string]string 97 | expected string 98 | wantError bool 99 | }{ 100 | { 101 | name: "Non-existent vagrant project", 102 | input: map[string]string{"command": "status", "directory": "/tmp"}, 103 | expected: "", 104 | wantError: true, 105 | }, 106 | } 107 | for _, test := range tests { 108 | result, err := client.RunCommandInDirectory(test.input["command"], test.input["directory"]) 109 | require.Contains(t, result, test.expected) 110 | require.Empty(t, client.workingDir) 111 | if test.wantError { 112 | require.Error(t, err) 113 | } 114 | } 115 | 116 | } 117 | func TestParseVagrantOutput_StatusSingleEnv(t *testing.T) { 118 | tests := []struct { 119 | name string 120 | input string 121 | expected []MachineInfo 122 | }{ 123 | { 124 | name: "Test output from the 'vagrant status' command.", 125 | input: `1,builder-f35,metadata,provider,libvirt 126 | 2,builder-f35,provider-name,libvirt 127 | 3,builder-f35,state,shutoff 128 | 4,builder-f35,state-human-short,shutoff 129 | 5,builder-f35,state-human-long,The Libvirt domain is not running. Run 'vagrant up' to start it. 130 | 5,,ui,info,Current machine states:\n\nbuilder-f35 shutoff (libvirt)\n\nThe Libvirt domain is not running. Run 'vagrant up' to start it. 131 | 6,$spe_Cat4,metadata,provider,virtualbox 132 | 7,$spe_Cat4,provider-name,virtualbox 133 | 8,$spe_Cat4,state,running 134 | 9,$spe_Cat4,state-human-short,running 135 | 10,$spe_Cat4,state-human-long,`, 136 | expected: []MachineInfo{ 137 | { 138 | Name: "builder-f35", 139 | MachineID: "", 140 | Fields: map[string]string{ 141 | "provider-name": "libvirt", 142 | "state": "shutoff", 143 | "state-human-long": "The Libvirt domain is not running. Run 'vagrant up' to start it.", 144 | }, 145 | }, 146 | { 147 | Name: "$spe_Cat4", 148 | MachineID: "", 149 | Fields: map[string]string{ 150 | "provider-name": "virtualbox", 151 | "state": "running", 152 | }, 153 | }, 154 | }, 155 | }, 156 | { 157 | name: "Test error status", 158 | input: `1671329290,,ui,error,A Vagrant environment or target machine is required to run this\ncommand. Run 'vagrant init' to create a new Vagrant environment. Or%!(VAGRANT_COMMA)\nget an ID of a target machine from 'vagrant global-status' to run\nthis command on. A final option is to change to a directory with a\nVagrantfile and to try again. 159 | 1671329290,,error-exit,Vagrant::Errors::NoEnvironmentError,A Vagrant environment or target machine is required to run this\ncommand. Run 'vagrant init' to create a new Vagrant environment. Or%!(VAGRANT_COMMA)\nget an ID of a target machine from 'vagrant global-status' to run\nthis command on. A final option is to change to a directory with a\nVagrantfile and to try again.`, 160 | expected: nil, 161 | }, 162 | { 163 | name: "Test empty status", 164 | input: ``, 165 | expected: nil, 166 | }, 167 | } 168 | 169 | for _, test := range tests { 170 | results := ParseVagrantOutput(test.input) 171 | assert.EqualValues(t, test.expected, results) 172 | } 173 | 174 | } 175 | 176 | func TestParseVagrantOutput_StatusMultiEnv(t *testing.T) { 177 | tests := []struct { 178 | name string 179 | input string 180 | expected []MachineInfo 181 | }{ 182 | { 183 | name: "Test output from the 'vagrant status' command.", 184 | input: `1688688549,node1,metadata,provider,docker 185 | 1688688549,node2,metadata,provider,docker 186 | 1688688549,node1,provider-name,docker 187 | 1688688549,node1,state,not_created 188 | 1688688549,node2,provider-name,docker 189 | 1688688549,node2,state,not_created`, 190 | expected: []MachineInfo{ 191 | { 192 | Name: "node1", 193 | MachineID: "", 194 | Fields: map[string]string{ 195 | "provider-name": "docker", 196 | "state": "not_created", 197 | }, 198 | }, 199 | { 200 | Name: "node2", 201 | MachineID: "", 202 | Fields: map[string]string{ 203 | "provider-name": "docker", 204 | "state": "not_created", 205 | }, 206 | }, 207 | }, 208 | }, 209 | } 210 | 211 | for _, test := range tests { 212 | results := ParseVagrantOutput(test.input) 213 | assert.EqualValues(t, test.expected, results) 214 | } 215 | 216 | } 217 | func TestParseVagrantOutput_GlobalStatus(t *testing.T) { 218 | tests := []struct { 219 | name string 220 | input string 221 | expected []MachineInfo 222 | }{ 223 | { 224 | name: "Verify empty status", 225 | input: `1,,metadata,machine-count,0 226 | 2,,ui,info,id 227 | 3,,ui,info,name 228 | 4,,ui,info,provider 229 | 4,,ui,info,state 230 | 5,,ui,info,directory 231 | 6,,ui,info, 232 | 7,,ui,info,-------------------------------------------------------------------- 233 | 8,,ui,info,There are no active Vagrant environments on this computer! Or%!(VAGRANT_COMMA)\nyou haven't destroyed and recreated Vagrant environments that were\nstarted with an older version of Vagrant.`, 234 | expected: nil, 235 | }, 236 | { 237 | name: "Verify single status", 238 | input: `1671330325,,metadata,machine-count,1 239 | 1671330325,,machine-id,c03b277 240 | 1671330325,,provider-name,libvirt 241 | 1671330325,,machine-home,/home/braheezy/prettybox/runners 242 | 1671330325,,state,shutoff 243 | 1671330325,,ui,info,id 244 | 1671330325,,ui,info,name 245 | 1671330325,,ui,info,provider 246 | 1671330325,,ui,info,state 247 | 1671330325,,ui,info,directory 248 | 1671330325,,ui,info, 249 | 1671330325,,ui,info,-------------------------------------------------------------------------- 250 | 1671330325,,ui,info,c03b277 251 | 1671330325,,ui,info,builder-f35 252 | 1671330325,,ui,info,libvirt 253 | 1671330325,,ui,info,shutoff 254 | 1671330325,,ui,info,/home/braheezy/prettybox/runners 255 | 1671330325,,ui,info, 256 | 1671330325,,ui,info, \nThe above shows information about all known Vagrant environments\non this machine. This data is cached and may not be completely\nup-to-date (use "vagrant global-status --prune" to prune invalid\nentries). To interact with any of the machines%!(VAGRANT_COMMA) you can go to that\ndirectory and run Vagrant%!(VAGRANT_COMMA) or you can use the ID directly with\nVagrant commands from any directory. For example:\n"vagrant destroy 1a2b3c4d"`, 257 | expected: []MachineInfo{ 258 | { 259 | Name: "", 260 | MachineID: "c03b277", 261 | Fields: map[string]string{ 262 | "provider-name": "libvirt", 263 | "state": "shutoff", 264 | "machine-home": "/home/braheezy/prettybox/runners", 265 | }, 266 | }, 267 | }, 268 | }, 269 | { 270 | name: "Verify multi status", 271 | input: `1672263560,,metadata,machine-count,3 272 | 1672263560,,machine-id,12deee0 273 | 1672263560,,provider-name,libvirt 274 | 1672263560,,machine-home,/home/braheezy/vagrant-envs/violet-test/env1 275 | 1672263560,,state,running 276 | 1672263560,,machine-id,15b6a07 277 | 1672263560,,provider-name,libvirt 278 | 1672263560,,machine-home,/home/braheezy/vagrant-envs/violet-test/env1 279 | 1672263560,,state,running 280 | 1672263560,,machine-id,200d64a 281 | 1672263560,,provider-name,libvirt 282 | 1672263560,,machine-home,/home/braheezy/vagrant-envs/violet-test/env2 283 | 1672263560,,state,running 284 | 1672263560,,ui,info,id 285 | 1672263560,,ui,info,name 286 | 1672263560,,ui,info,provider 287 | 1672263560,,ui,info,state 288 | 1672263560,,ui,info,directory 289 | 1672263560,,ui,info, 290 | 1672263560,,ui,info,-------------------------------------------------------------------------------- 291 | 1672263560,,ui,info,12deee0`, 292 | expected: []MachineInfo{ 293 | { 294 | Name: "", 295 | MachineID: "12deee0", 296 | Fields: map[string]string{ 297 | "provider-name": "libvirt", 298 | "state": "running", 299 | "machine-home": "/home/braheezy/vagrant-envs/violet-test/env1", 300 | }, 301 | }, 302 | { 303 | Name: "", 304 | MachineID: "15b6a07", 305 | Fields: map[string]string{ 306 | "provider-name": "libvirt", 307 | "state": "running", 308 | "machine-home": "/home/braheezy/vagrant-envs/violet-test/env1", 309 | }, 310 | }, 311 | { 312 | Name: "", 313 | MachineID: "200d64a", 314 | Fields: map[string]string{ 315 | "provider-name": "libvirt", 316 | "state": "running", 317 | "machine-home": "/home/braheezy/vagrant-envs/violet-test/env2", 318 | }, 319 | }, 320 | }, 321 | }, 322 | } 323 | 324 | for _, test := range tests { 325 | results := ParseVagrantOutput(test.input) 326 | assert.EqualValues(t, test.expected, results, test.name) 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /internal/app/ecosystem.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "errors" 5 | "path" 6 | "strings" 7 | 8 | "github.com/braheezy/violet/pkg/vagrant" 9 | "github.com/charmbracelet/bubbles/paginator" 10 | "github.com/charmbracelet/lipgloss" 11 | zone "github.com/lrstanley/bubblezone" 12 | ) 13 | 14 | // Ecosystem contains the total Vagrant world information 15 | type Ecosystem struct { 16 | // Collection of all Vagrant environments 17 | environments []Environment 18 | // Reference to a Vagrant client to run commands with 19 | client *vagrant.VagrantClient 20 | // Buttons to allow the user to run commands 21 | machineCommands machineCommandButtons 22 | envCommands envCommandButtons 23 | // Indexes of the respective lists that are currently selected. 24 | selectedEnv int 25 | selectedMachine int 26 | // Helper to paginate the list of environments 27 | envPager environmentPager 28 | } 29 | 30 | // Updates for the entire ecosystem. Usually with results from `global-status` 31 | type ecosystemMsg Ecosystem 32 | 33 | type ecosystemErrMsg struct{ err error } 34 | 35 | func (e ecosystemErrMsg) Error() string { return e.err.Error() } 36 | 37 | type environmentPager struct { 38 | pg paginator.Model 39 | moreIsSelected bool 40 | backIsSelected bool 41 | } 42 | 43 | func (ep *environmentPager) hasMultiplePages() bool { 44 | return ep.pg.TotalPages > 1 45 | } 46 | 47 | // Call `global-status` and translate result into a new Ecosystem 48 | func createEcosystem(client *vagrant.VagrantClient) (Ecosystem, error) { 49 | // Fetch (not stream) the current global status 50 | result, err := client.GetGlobalStatus() 51 | var nilEcosystem Ecosystem 52 | 53 | if err != nil { 54 | return nilEcosystem, ecosystemErrMsg{err} 55 | } 56 | 57 | results := vagrant.ParseVagrantOutput(result) 58 | if results == nil { 59 | return nilEcosystem, nil 60 | } 61 | 62 | var machines []Machine 63 | for _, machineInfo := range results { 64 | machine := Machine{ 65 | machineID: machineInfo.MachineID, 66 | provider: machineInfo.Fields["provider-name"], 67 | state: strings.Replace(machineInfo.Fields["state"], "_", " ", -1), 68 | home: machineInfo.Fields["machine-home"], 69 | } 70 | machines = append(machines, machine) 71 | } 72 | // Create different envs by grouping machines based on machine-home 73 | type EnvironmentGroup struct { 74 | Name string 75 | Machines []Machine 76 | } 77 | var envGroups []EnvironmentGroup 78 | for _, machine := range machines { 79 | found := false 80 | for i, env := range envGroups { 81 | // TODO: Bug if two different paths have the same folder name e.g. /foo/env1 and /bar/env1 will incorrectly be treated the same 82 | if env.Name == path.Base(machine.home) { 83 | envGroups[i].Machines = append(envGroups[i].Machines, machine) 84 | found = true 85 | break 86 | } 87 | } 88 | if !found { 89 | env := EnvironmentGroup{ 90 | Name: path.Base(machine.home), 91 | Machines: []Machine{machine}, 92 | } 93 | envGroups = append(envGroups, env) 94 | } 95 | } 96 | 97 | var environments []Environment 98 | for _, envGroup := range envGroups { 99 | if len(envGroup.Machines) > 0 { 100 | env := Environment{ 101 | name: envGroup.Name, 102 | machines: envGroup.Machines, 103 | home: envGroup.Machines[0].home, 104 | hasFocus: true, 105 | } 106 | environments = append(environments, env) 107 | } 108 | } 109 | 110 | pager := paginator.New() 111 | pager.PerPage = 5 112 | pager.SetTotalPages(len(environments)) 113 | 114 | return Ecosystem{ 115 | environments: environments, 116 | client: client, 117 | machineCommands: newMachineCommandButtons(supportedMachineCommands), 118 | envCommands: newEnvCommandButtons(supportedEnvCommands), 119 | envPager: environmentPager{pg: pager}, 120 | }, nil 121 | } 122 | 123 | // Simple helper to get the specific machine the user is interacting with 124 | func (e *Ecosystem) currentMachine() (*Machine, error) { 125 | if e.selectedEnv >= len(e.environments) { 126 | return nil, errors.New("tried to access environment outside of ecosystem") 127 | } else if e.selectedMachine >= len(e.environments[e.selectedEnv].machines) { 128 | return nil, errors.New("tried to access machine outside of ecosystem") 129 | } else { 130 | return &e.environments[e.selectedEnv].machines[e.selectedMachine], nil 131 | } 132 | } 133 | 134 | func (e *Ecosystem) currentEnv() *Environment { 135 | return &e.environments[e.selectedEnv] 136 | } 137 | 138 | func (e *Ecosystem) incrementEnv() { 139 | start, end := e.envPager.pg.GetSliceBounds(len(e.environments)) 140 | if e.envPager.moreIsSelected { 141 | if e.envPager.pg.Page > 0 { 142 | // There's a Back button to worry about 143 | e.envPager.backIsSelected = true 144 | e.selectedEnv = -1 145 | } else { 146 | // Wrap around to start of tabs 147 | e.selectedEnv = start 148 | e.envPager.moreIsSelected = false 149 | } 150 | } else if e.envPager.backIsSelected { 151 | e.selectedEnv = start 152 | e.envPager.backIsSelected = false 153 | } else { 154 | if e.selectedEnv == end-1 && e.envPager.hasMultiplePages() && e.envPager.pg.OnLastPage() { 155 | // At the end, no More tab, so wrap around to Back tab 156 | e.selectedEnv = -1 157 | e.envPager.backIsSelected = true 158 | } else if e.selectedEnv == len(e.environments[start:end])-1 && e.envPager.hasMultiplePages() { 159 | // User selected the More tab 160 | e.envPager.moreIsSelected = true 161 | e.selectedEnv = -1 162 | } else { 163 | if e.selectedEnv == end-1 { 164 | e.selectedEnv = 0 165 | } else { 166 | e.selectedEnv += 1 167 | } 168 | } 169 | } 170 | } 171 | 172 | func (e *Ecosystem) decrementEnv() { 173 | start, end := e.envPager.pg.GetSliceBounds(len(e.environments)) 174 | if e.selectedEnv == start { 175 | if e.envPager.hasMultiplePages() { 176 | if e.envPager.pg.Page > 0 { 177 | // User selected the Back tab 178 | e.envPager.backIsSelected = true 179 | e.selectedEnv = -1 180 | } else { 181 | // User has wrapped around and selected the More tab 182 | e.envPager.moreIsSelected = true 183 | e.selectedEnv = -1 184 | } 185 | } else { 186 | e.selectedEnv = end - 1 187 | } 188 | } else { 189 | if e.envPager.moreIsSelected { 190 | e.envPager.moreIsSelected = false 191 | e.selectedEnv = end - 1 192 | } else if e.envPager.backIsSelected { 193 | // Wrap around to end of env tabs (there is no More tab) 194 | e.envPager.backIsSelected = false 195 | e.selectedEnv = end - 1 196 | } else { 197 | e.selectedEnv -= 1 198 | } 199 | } 200 | } 201 | 202 | func (e *Ecosystem) View() (result string) { 203 | if e.environments == nil { 204 | return lipgloss.NewStyle().Foreground(textColor).Italic(true).Faint(true).Render("Still looking for environments...") 205 | } 206 | 207 | // Create the tab headers, one for each environment. 208 | var tabs []string 209 | start, end := e.envPager.pg.GetSliceBounds(len(e.environments)) 210 | for i, env := range e.environments[start:end] { 211 | // Figure out which "tab" is selected and stylize accordingly 212 | var style lipgloss.Style 213 | idx := i 214 | if e.envPager.pg.Page > 0 { 215 | idx = i + e.envPager.pg.PerPage 216 | } 217 | isFirst, _, isActive := idx == start, idx == len(e.environments)-1, idx == e.selectedEnv 218 | if isActive { 219 | style = activeTabStyle 220 | } else { 221 | style = inactiveTabStyle 222 | } 223 | border, _, _, _, _ := style.GetBorder() 224 | // Override border edges for these edge cases 225 | if e.envPager.pg.Page == 0 { 226 | if isFirst && isActive { 227 | border.BottomLeft = "│" 228 | } else if isFirst && !isActive { 229 | border.BottomLeft = "├" 230 | } 231 | } 232 | 233 | style = style.Border(border) 234 | tabs = append(tabs, zone.Mark(env.name, style.Render(env.name))) 235 | } 236 | 237 | var tabContent string 238 | 239 | // If there's paged environments, show a tab with a paged indicator 240 | if e.envPager.hasMultiplePages() { 241 | // Show a More button if there's additional pages 242 | var moreTab string 243 | var backTab string 244 | if e.envPager.pg.Page < e.envPager.pg.TotalPages-1 { 245 | moreTab = zone.Mark("more", "⮕ ") 246 | if e.envPager.moreIsSelected { 247 | moreTab = activeTabStyle.Render(moreTab) 248 | } else { 249 | moreTab = inactiveTabStyle.Render(moreTab) 250 | } 251 | tabs = append(tabs, moreTab) 252 | } 253 | // Show a Back button if there's previous pages 254 | if e.envPager.pg.Page > 0 { 255 | backTab = zone.Mark("back", "⬅ ") 256 | if e.envPager.backIsSelected { 257 | border, _, _, _, _ := activeTabStyle.GetBorder() 258 | border.BottomLeft = "│" 259 | style := activeTabStyle.Border(border) 260 | backTab = style.Render(backTab) 261 | } else { 262 | border, _, _, _, _ := inactiveTabStyle.GetBorder() 263 | border.BottomLeft = "├" 264 | style := inactiveTabStyle.Border(border) 265 | backTab = style.Render(backTab) 266 | } 267 | tabs = append([]string{backTab}, tabs...) 268 | } 269 | } 270 | 271 | if e.envPager.moreIsSelected { 272 | // Show More tab content 273 | tabContent = "There's more stuff ova there ->\nHit ENTER" 274 | } else if e.envPager.backIsSelected { 275 | tabContent = "<- There's stuff bak there\nHit ENTER" 276 | } else { 277 | // machineCards will be the set of machines to show for the selected env. 278 | // They are dealt with first so we know the size of content we need to 279 | // wrap in "tabs" 280 | machineCards := []string{} 281 | selectedEnv := e.environments[e.selectedEnv] 282 | for i, machine := range selectedEnv.machines { 283 | // "Viewing" a machine will get it's specific info 284 | machineView := machine.View() 285 | commands := e.machineCommands.View(machine.selectedCommand, !selectedEnv.hasFocus) 286 | cardInfo := lipgloss.JoinHorizontal(lipgloss.Center, machineView, commands) 287 | if !selectedEnv.hasFocus && i == e.selectedMachine { 288 | cardInfo = selectedCardStyle.Render(cardInfo) 289 | } else { 290 | cardInfo = defaultCardStyle.Render(cardInfo) 291 | } 292 | machineCards = append(machineCards, cardInfo) 293 | } 294 | 295 | // This card always exists and controls the top-level environment 296 | envTitle := envCardTitleStyle.Render(selectedEnv.name) 297 | envCommands := newEnvCommandButtons(supportedEnvCommands) 298 | if selectedEnv.hasFocus { 299 | envTitle = selectedEnvCardStyle.Render(selectedEnv.name) 300 | } 301 | envCard := lipgloss.JoinHorizontal(lipgloss.Center, envTitle, envCommands.View(selectedEnv.selectedCommand, selectedEnv.hasFocus)) 302 | 303 | tabContent = envCard + "\n" + strings.Join(machineCards, "\n") 304 | } 305 | 306 | tabHeader := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) 307 | // Create the window effect by creating a blank tab to fill the rest of the width. 308 | gapWidth := lipgloss.Width(tabContent) - lipgloss.Width(tabHeader) 309 | if gapWidth < 0 { 310 | // There's more tabs than the standard width of a tab, so add padding 311 | tabContent = lipgloss.NewStyle().MarginRight(gapWidth * -1).Render(tabContent) 312 | gapWidth = 0 313 | } 314 | gap := tabGapStyle.Render(strings.Repeat(" ", gapWidth)) 315 | tabHeader = lipgloss.JoinHorizontal(lipgloss.Top, tabHeader, gap) 316 | 317 | result = lipgloss.JoinVertical(lipgloss.Center, tabHeader, tabWindowStyle.Render(tabContent)) 318 | 319 | return result 320 | } 321 | 322 | // Environment represents a single Vagrant project 323 | type Environment struct { 324 | // Friendly name for the Environment 325 | name string 326 | // Environments have 0 or more machines 327 | machines []Machine 328 | // The currently selected command to run on the machine. 329 | selectedCommand int 330 | home string 331 | hasFocus bool 332 | } 333 | 334 | // Machine contains all the data and actions associated with a specific Machine 335 | type Machine struct { 336 | name string 337 | provider string 338 | state string 339 | home string 340 | machineID string 341 | // The currently selected command to run on the machine. 342 | selectedCommand int 343 | } 344 | 345 | func (m *Machine) View() string { 346 | displayName := m.name 347 | // If there's no name yet, at least show the machineID 348 | if displayName == "" { 349 | displayName = m.machineID 350 | } 351 | 352 | // Join the machine info for the card view 353 | content := lipgloss.JoinVertical( 354 | lipgloss.Right, 355 | cardTitleStyle.Render(displayName), 356 | cardStatusStyle.Foreground(statusColors[m.state]).Render(m.state), 357 | cardProviderStyle.Render(m.provider), 358 | ) 359 | 360 | return content 361 | } 362 | -------------------------------------------------------------------------------- /internal/app/update.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "math/rand" 5 | "os/exec" 6 | 7 | "github.com/charmbracelet/bubbles/key" 8 | tea "github.com/charmbracelet/bubbletea" 9 | zone "github.com/lrstanley/bubblezone" 10 | ) 11 | 12 | // helpKeyMap defines a set of keybindings. 13 | type helpKeyMap struct { 14 | Up key.Binding 15 | Down key.Binding 16 | Left key.Binding 17 | Right key.Binding 18 | Tab key.Binding 19 | ShiftTab key.Binding 20 | Execute key.Binding 21 | SelectCommand key.Binding 22 | Space key.Binding 23 | Help key.Binding 24 | Quit key.Binding 25 | // These are defined to assist with help text. 26 | SelectMachine key.Binding 27 | } 28 | 29 | // Setup the keybinding and help text for each key 30 | var keys = helpKeyMap{ 31 | SelectCommand: key.NewBinding( 32 | key.WithKeys("left", "h", "right", "l"), 33 | key.WithHelp("←/h →/l", "pick command"), 34 | ), 35 | Up: key.NewBinding( 36 | key.WithKeys("up", "k"), 37 | ), 38 | Down: key.NewBinding( 39 | key.WithKeys("down", "j"), 40 | ), 41 | Left: key.NewBinding( 42 | key.WithKeys("left", "h"), 43 | ), 44 | Right: key.NewBinding( 45 | key.WithKeys("right", "l"), 46 | ), 47 | Tab: key.NewBinding( 48 | key.WithKeys("tab"), 49 | key.WithHelp("⭾/⇧+⭾", "switch env tab"), 50 | ), 51 | ShiftTab: key.NewBinding( 52 | key.WithKeys("shift+tab"), 53 | ), 54 | Execute: key.NewBinding( 55 | key.WithKeys("enter"), 56 | key.WithHelp("⏎", "run"), 57 | ), 58 | Space: key.NewBinding( 59 | key.WithKeys(" "), 60 | key.WithHelp("space", "toggle env/vm"), 61 | ), 62 | Help: key.NewBinding( 63 | key.WithKeys("?"), 64 | key.WithHelp("?", "toggle help"), 65 | ), 66 | Quit: key.NewBinding( 67 | key.WithKeys("q", "esc", "ctrl+c"), 68 | key.WithHelp("q", "quit"), 69 | ), 70 | SelectMachine: key.NewBinding( 71 | key.WithKeys("up", "k", "down", "j"), 72 | key.WithHelp("↑/k ↓/j", "pick vm"), 73 | ), 74 | } 75 | 76 | // ShortHelp returns keybindings to be shown in the mini help view. It's part 77 | // of the key.Map interface. 78 | func (k helpKeyMap) ShortHelp() []key.Binding { 79 | return []key.Binding{k.Help, k.Quit} 80 | } 81 | 82 | // FullHelp returns keybindings for the expanded help view. It's part of the 83 | // key.Map interface. 84 | func (k helpKeyMap) FullHelp() [][]key.Binding { 85 | return [][]key.Binding{ 86 | {k.SelectMachine, k.SelectCommand, k.Tab}, // first column 87 | {k.Space, k.Execute, k.Help, k.Quit}, // second column 88 | } 89 | } 90 | 91 | func (v Violet) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 92 | switch msg := msg.(type) { 93 | // Window was resized 94 | case tea.WindowSizeMsg: 95 | // During development, there were horrific UI bugs when the screen was resized. Things would wrap 96 | // to the next line. This approach repaints the screen when the happens and it seems to handle all 97 | // cases. Hopefully this check is good enough to not spam ClearScreen commands. 98 | needsRepaint := false 99 | 100 | if msg.Width < v.terminalWidth { 101 | needsRepaint = true 102 | } 103 | 104 | // If we set a width on the help menu it can it can gracefully truncate 105 | // its view as needed. 106 | v.help.Width = msg.Width 107 | v.terminalWidth = msg.Width 108 | v.terminalHeight = msg.Height 109 | 110 | if needsRepaint { 111 | return v, tea.ClearScreen 112 | } 113 | 114 | // User moved the mouse 115 | case tea.MouseMsg: 116 | if tea.MouseEvent(msg).Action == tea.MouseActionRelease { 117 | // Check if clicked More or Back tab 118 | if zone.Get("more").InBounds(msg) { 119 | v.ecosystem.envPager.moreIsSelected = true 120 | v.ecosystem.selectedEnv = -1 121 | } else if zone.Get("back").InBounds(msg) { 122 | v.ecosystem.envPager.backIsSelected = true 123 | v.ecosystem.selectedEnv = -1 124 | } else { 125 | // Iterate over environment names 126 | for i, env := range v.ecosystem.environments { 127 | if zone.Get(env.name).InBounds(msg) { 128 | v.ecosystem.selectedEnv = i 129 | v.ecosystem.envPager.moreIsSelected = false 130 | v.ecosystem.envPager.backIsSelected = false 131 | break 132 | } 133 | } 134 | } 135 | } 136 | return v, nil 137 | 138 | // User pressed a key 139 | case tea.KeyMsg: 140 | switch { 141 | case key.Matches(msg, v.keys.Left): 142 | currentEnv := v.ecosystem.currentEnv() 143 | currentMachine, err := v.ecosystem.currentMachine() 144 | if err != nil { 145 | v.setErrorMessage(err.Error()) 146 | return v, nil 147 | } 148 | if currentEnv.hasFocus { 149 | if currentEnv.selectedCommand == 0 { 150 | currentEnv.selectedCommand = len(supportedEnvCommands) - 1 151 | } else { 152 | currentEnv.selectedCommand-- 153 | } 154 | } else { 155 | if currentMachine.selectedCommand == 0 { 156 | currentMachine.selectedCommand = len(supportedMachineCommands) - 1 157 | } else { 158 | currentMachine.selectedCommand-- 159 | } 160 | } 161 | case key.Matches(msg, v.keys.Right): 162 | currentEnv := v.ecosystem.currentEnv() 163 | currentMachine, err := v.ecosystem.currentMachine() 164 | if err != nil { 165 | v.setErrorMessage(err.Error()) 166 | return v, nil 167 | } 168 | if currentEnv.hasFocus { 169 | if currentEnv.selectedCommand == len(supportedEnvCommands)-1 { 170 | currentEnv.selectedCommand = 0 171 | } else { 172 | currentEnv.selectedCommand++ 173 | } 174 | } else { 175 | if currentMachine.selectedCommand == len(supportedMachineCommands)-1 { 176 | currentMachine.selectedCommand = 0 177 | } else { 178 | currentMachine.selectedCommand++ 179 | } 180 | } 181 | case key.Matches(msg, v.keys.Up): 182 | if v.ecosystem.currentEnv().hasFocus { 183 | break 184 | } 185 | if v.ecosystem.selectedMachine == 0 { 186 | v.ecosystem.selectedMachine = len(v.ecosystem.currentEnv().machines) - 1 187 | } else { 188 | v.ecosystem.selectedMachine -= 1 189 | } 190 | case key.Matches(msg, v.keys.Down): 191 | if v.ecosystem.currentEnv().hasFocus { 192 | break 193 | } 194 | if v.ecosystem.selectedMachine == len(v.ecosystem.currentEnv().machines)-1 { 195 | v.ecosystem.selectedMachine = 0 196 | } else { 197 | v.ecosystem.selectedMachine += 1 198 | } 199 | case key.Matches(msg, v.keys.Tab): 200 | v.ecosystem.incrementEnv() 201 | return v, nil 202 | case key.Matches(msg, v.keys.ShiftTab): 203 | v.ecosystem.decrementEnv() 204 | return v, nil 205 | case key.Matches(msg, v.keys.Space): 206 | v.ecosystem.currentEnv().hasFocus = !v.ecosystem.currentEnv().hasFocus 207 | return v, nil 208 | case key.Matches(msg, v.keys.Execute): 209 | if v.ecosystem.envPager.moreIsSelected { 210 | // User wants to see new env page 211 | v.ecosystem.envPager.pg.NextPage() 212 | start, _ := v.ecosystem.envPager.pg.GetSliceBounds(len(v.ecosystem.environments)) 213 | v.ecosystem.selectedEnv = start 214 | v.ecosystem.envPager.moreIsSelected = false 215 | } else if v.ecosystem.envPager.backIsSelected { 216 | // User wants to go back to the previous env page 217 | v.ecosystem.envPager.pg.PrevPage() 218 | _, end := v.ecosystem.envPager.pg.GetSliceBounds(len(v.ecosystem.environments)) 219 | v.ecosystem.selectedEnv = end - 1 220 | v.ecosystem.envPager.backIsSelected = false 221 | } else { 222 | if v.ecosystem.currentEnv().hasFocus { 223 | vagrantCommand := supportedMachineCommands[v.ecosystem.currentEnv().selectedCommand] 224 | runCommand := v.createEnvRunCmd(vagrantCommand, v.ecosystem.currentEnv().home) 225 | v.spinner.show = true 226 | // This must be sent for the spinner to spin 227 | tickCmd := v.spinner.spinner.Tick 228 | return v, tea.Batch(runCommand, tickCmd) 229 | } else { 230 | currentMachine, _ := v.ecosystem.currentMachine() 231 | vagrantCommand := supportedMachineCommands[currentMachine.selectedCommand] 232 | /* 233 | TODO: This doesn't support running commands in a desktop-less environment that doesn't have an external terminal to put commands on. One approach is to use `screen` to create virtual screen. 234 | 235 | Create a virtual screen: 236 | screen -dmS 237 | Connect to it: 238 | screen -r 239 | */ 240 | 241 | if vagrantCommand == "ssh" { 242 | c := exec.Command("vagrant", "ssh", currentMachine.machineID) 243 | if currentMachine.provider == "docker" { 244 | c = exec.Command("vagrant", "docker-exec", currentMachine.name, "-it", "--", "/bin/sh") 245 | c.Dir = currentMachine.home 246 | } 247 | runCommand := tea.ExecProcess(c, func(err error) tea.Msg { 248 | if err != nil { 249 | return runErrMsg(err.Error()) 250 | } 251 | return nil 252 | }) 253 | return v, runCommand 254 | } else { 255 | // Run the command async and stream result back 256 | runCommand := v.createMachineRunCmd( 257 | vagrantCommand, 258 | currentMachine.machineID, 259 | ) 260 | v.spinner.show = true 261 | // This must be sent for the spinner to spin 262 | tickCmd := v.spinner.spinner.Tick 263 | return v, tea.Batch(runCommand, tickCmd) 264 | } 265 | } 266 | } 267 | case key.Matches(msg, v.keys.Help): 268 | v.help.ShowAll = !v.help.ShowAll 269 | case key.Matches(msg, v.keys.Quit): 270 | return v, tea.Quit 271 | } 272 | 273 | // New data from `global-status` has come in 274 | case ecosystemMsg: 275 | eco := Ecosystem(msg) 276 | var statusCmds []tea.Cmd 277 | // Don't have the machine names yet, just machineIDs. 278 | // Queue up a bunch of async calls to go get those names. 279 | for _, env := range eco.environments { 280 | for _, machine := range env.machines { 281 | statusCmds = append(statusCmds, v.createNameStatusCmd(machine.machineID)) 282 | } 283 | } 284 | // Set the new ecosystem 285 | v.ecosystem = eco 286 | 287 | return v, tea.Batch(statusCmds...) 288 | 289 | case nameStatusMsg: 290 | // Find the machine this message is about 291 | for i, env := range v.ecosystem.environments { 292 | for j, machine := range env.machines { 293 | if msg.machineID == machine.machineID { 294 | v.ecosystem.environments[i].machines[j].name = msg.name 295 | } 296 | } 297 | } 298 | 299 | // New data about a specific machine has come in 300 | case machineStatusMsg: 301 | v.spinner.show = false 302 | v.spinner.verb = verbs[rand.Intn(len(verbs))] 303 | v.spinner.spinner.Spinner = spinners[rand.Intn(len(spinners))] 304 | // Find the machine this message is about 305 | for i, env := range v.ecosystem.environments { 306 | for j, machine := range env.machines { 307 | if msg.identifier == machine.machineID || msg.identifier == machine.name { 308 | // Found the machine this status message is about. 309 | // Status msgs don't return some info so retain existing info 310 | updateMachine := Machine{ 311 | machineID: machine.machineID, 312 | provider: msg.status.Fields["provider-name"], 313 | state: msg.status.Fields["state"], 314 | home: machine.home, 315 | name: msg.status.Name, 316 | selectedCommand: machine.selectedCommand, 317 | } 318 | v.ecosystem.environments[i].machines[j] = updateMachine 319 | } 320 | } 321 | } 322 | 323 | case envStatusMsg: 324 | v.spinner.show = false 325 | v.spinner.verb = verbs[rand.Intn(len(verbs))] 326 | v.spinner.spinner.Spinner = spinners[rand.Intn(len(spinners))] 327 | 328 | // Find the env this message is about 329 | for i, env := range v.ecosystem.environments { 330 | if msg.name == env.name { 331 | selectedEnv := &v.ecosystem.environments[i] 332 | newMachines := make([]Machine, 0) 333 | for _, machineStatus := range msg.status { 334 | newMachine := Machine{ 335 | provider: machineStatus.Fields["provider-name"], 336 | state: machineStatus.Fields["state"], 337 | home: selectedEnv.home, 338 | name: machineStatus.Name, 339 | // Reset the selected command 340 | selectedCommand: 0, 341 | } 342 | newMachines = append(newMachines, newMachine) 343 | } 344 | selectedEnv.machines = newMachines 345 | break 346 | } 347 | } 348 | return v, nil 349 | 350 | // Result from a command has been streamed in 351 | case runMsg: 352 | if v.ecosystem.currentEnv().hasFocus { 353 | return v, v.createEnvStatusCmd(v.ecosystem.currentEnv()) 354 | } else { 355 | // Getting a runMsg means something happened so run async task to get 356 | // new status on the machine the command was just run on. 357 | currentMachine, _ := v.ecosystem.currentMachine() 358 | return v, v.createMachineStatusCmd(currentMachine.machineID) 359 | } 360 | 361 | case ecosystemErrMsg: 362 | v.setErrorMessage(msg.Error()) 363 | case statusErrMsg: 364 | v.setErrorMessage(msg.Error()) 365 | case runErrMsg: 366 | v.setErrorMessage(string(msg)) 367 | case nameStatusErrMsg: 368 | v.setErrorMessage(msg.Error()) 369 | } 370 | 371 | if v.spinner.show { 372 | var spinCmd tea.Cmd 373 | v.spinner.spinner, spinCmd = v.spinner.spinner.Update(msg) 374 | return v, spinCmd 375 | } 376 | 377 | return v, nil 378 | } 379 | --------------------------------------------------------------------------------