diff --git a/.docker/Fedora.dockerfile b/.docker/Fedora.dockerfile
new file mode 100644
index 00000000..1891af84
--- /dev/null
+++ b/.docker/Fedora.dockerfile
@@ -0,0 +1,43 @@
+FROM golang:1.23-bookworm as BUILDER
+
+WORKDIR /app
+
+ADD . .
+
+RUN CGO_ENABLED=0 go build -cover -o ./bin/algorun *.go
+
+
+FROM fedora:39 as legacy
+
+ADD playbook.yaml /root/playbook.yaml
+COPY --from=BUILDER /app/bin/algorun /usr/bin/algorun
+RUN dnf install systemd ansible-core -y && \
+ mkdir -p /app/coverage/int/fedora/39 && \
+ echo GOCOVERDIR=/app/coverage/int/fedora/39 >> /etc/environment
+
+STOPSIGNAL SIGRTMIN+3
+CMD ["/usr/lib/systemd/systemd"]
+
+FROM fedora:40 as previous
+
+ADD playbook.yaml /root/playbook.yaml
+COPY --from=BUILDER /app/bin/algorun /usr/bin/algorun
+
+RUN dnf install systemd ansible-core -y && \
+ mkdir -p /app/coverage/int/fedora/40 && \
+ echo GOCOVERDIR=/app/coverage/int/fedora/40 >> /etc/environment
+
+STOPSIGNAL SIGRTMIN+3
+CMD ["/usr/lib/systemd/systemd"]
+
+FROM fedora:41 as latest
+
+ADD playbook.yaml /root/playbook.yaml
+COPY --from=BUILDER /app/bin/algorun /usr/bin/algorun
+
+RUN dnf install systemd ansible-core -y && \
+ mkdir -p /app/coverage/int/fedora/41 && \
+ echo GOCOVERDIR=/app/coverage/int/fedora/41 >> /etc/environment
+
+STOPSIGNAL SIGRTMIN+3
+CMD ["/usr/lib/systemd/systemd"]
diff --git a/.docker/Ubuntu.dockerfile b/.docker/Ubuntu.dockerfile
new file mode 100644
index 00000000..f5cca2f7
--- /dev/null
+++ b/.docker/Ubuntu.dockerfile
@@ -0,0 +1,47 @@
+FROM golang:1.23-bookworm as BUILDER
+
+WORKDIR /app
+
+ADD . .
+
+RUN CGO_ENABLED=0 go build -cover -o ./bin/algorun *.go
+
+FROM ubuntu:18.04 as bionic
+
+RUN apt-get update && apt-get install systemd software-properties-common -y && add-apt-repository --yes --update ppa:ansible/ansible
+
+ADD playbook.yaml /root/playbook.yaml
+COPY --from=BUILDER /app/bin/algorun /usr/bin/algorun
+RUN mkdir -p /app/coverage/int/ubuntu/18.04 && \
+ echo GOCOVERDIR=/app/coverage/int/ubuntu/18.04 >> /etc/environment && \
+ apt-get install ansible -y && \
+ chmod 0 /usr/bin/apt # Liam Neeson
+
+STOPSIGNAL SIGRTMIN+3
+CMD ["/bin/systemd"]
+
+FROM ubuntu:22.04 as jammy
+
+RUN apt-get update && apt-get install systemd software-properties-common -y && add-apt-repository --yes --update ppa:ansible/ansible
+
+ADD playbook.yaml /root/playbook.yaml
+COPY --from=BUILDER /app/bin/algorun /usr/bin/algorun
+RUN mkdir -p /app/coverage/int/ubuntu/22.04 && \
+ echo GOCOVERDIR=/app/coverage/int/ubuntu/22.04 >> /etc/environment && \
+ apt-get install ansible -y
+
+STOPSIGNAL SIGRTMIN+3
+CMD ["/usr/lib/systemd/systemd"]
+
+FROM ubuntu:24.04 as noble
+
+RUN apt-get update && apt-get install systemd software-properties-common -y && add-apt-repository --yes --update ppa:ansible/ansible
+
+ADD playbook.yaml /root/playbook.yaml
+COPY --from=BUILDER /app/bin/algorun /usr/bin/algorun
+RUN mkdir -p /app/coverage/int/ubuntu/24.04 && \
+ echo GOCOVERDIR=/app/coverage/int/ubuntu/24.04 >> /etc/environment && \
+ apt-get install ansible -y
+
+STOPSIGNAL SIGRTMIN+3
+CMD ["/usr/lib/systemd/systemd"]
\ No newline at end of file
diff --git a/.github/workflows/test.yaml b/.github/workflows/code_test.yaml
similarity index 75%
rename from .github/workflows/test.yaml
rename to .github/workflows/code_test.yaml
index 3cad3ebb..df125f2e 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/code_test.yaml
@@ -1,4 +1,4 @@
-name: Tests
+name: Code Tests
on:
pull_request:
@@ -55,8 +55,23 @@ jobs:
- name: Build
run: go build -o bin/algorun *.go
- - name: Test with the Go CLI
- run: go test ./... -coverprofile=./coverage.txt -covermode=atomic -coverpkg=./...
+ - name: Unit Tests
+ run: make unit
+
+ - name: Kill docker
+ run: docker compose down
+
+ - name: Start Integration
+ run: docker compose -f docker-compose.integration.yaml up -d
+
+ - name: Wait for mount
+ run: npx wait-on ./coverage/int/fedora/40 ./coverage/int/ubuntu/24.04/
+
+ - name: Integration tests
+ run: make integration
+
+ - name: Combine coverage
+ run: make combine-coverage
- name: Upload results to Codecov
uses: codecov/codecov-action@v4
diff --git a/.github/workflows/node_test.yaml b/.github/workflows/node_test.yaml
new file mode 100644
index 00000000..cacf280c
--- /dev/null
+++ b/.github/workflows/node_test.yaml
@@ -0,0 +1,56 @@
+## This is a temporary flow, until we have our custom docker images that work with systemd for linux.
+## Once we have that, we can remove this and use docker containers in parallel, covering the various OS:es.
+
+name: Node Command OS-Matrix Test
+
+on:
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - "cmd/**"
+
+jobs:
+ ubuntu:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout Repo
+ uses: actions/checkout@v4
+
+ - name: Setup Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.22
+ - name: Run Ubuntu commands
+ run: |
+ go build .
+ ./algorun-tui node install
+ systemctl status algorand.service
+ export TOKEN=$(cat /var/lib/algorand/algod.admin.token)
+ curl http://localhost:8080/v2/participation -H "X-Algo-API-Token: $TOKEN" | grep "null"
+ ./algorun-tui node stop
+ ./algorun-tui node upgrade
+ ./algorun-tui node stop
+ ./algorun-tui node uninstall
+
+ macos:
+ runs-on: macos-latest
+ steps:
+ - name: Checkout Repo
+ uses: actions/checkout@v4
+
+ - name: Setup Go
+ run: brew install go
+
+ - name: Run MacOs commands
+ run: |
+ go build .
+ ./algorun-tui node install
+ sudo launchctl print system/com.algorand.algod
+ sleep 5
+ export TOKEN=$(cat ~/.algorand/algod.admin.token)
+ curl http://localhost:8080/v2/participation -H "X-Algo-API-Token: $TOKEN" | grep "null"
+ ./algorun-tui node stop
+ ./algorun-tui node upgrade
+ ./algorun-tui node stop
+ ./algorun-tui node uninstall
diff --git a/.gitignore b/.gitignore
index 3b4a04bd..c7a3058c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
algorun-tui
+coverage
bin
.data
@@ -15,6 +16,9 @@ bin
# Test binary, built with `go test -c`
*.test
+# coverage.txt, in case you run tests locally
+coverage.txt
+
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
diff --git a/Makefile b/Makefile
index fc3c4d38..195d9e3a 100644
--- a/Makefile
+++ b/Makefile
@@ -4,3 +4,11 @@ test:
go test -coverpkg=./... -covermode=atomic ./...
generate:
oapi-codegen -config generate.yaml https://raw.githubusercontent.com/algorand/go-algorand/v3.26.0-stable/daemon/algod/api/algod.oas3.yml
+unit:
+ mkdir -p $(CURDIR)/coverage/unit && go test -cover ./... -args -test.gocoverdir=$(CURDIR)/coverage/unit
+integration:
+ for service in $(shell docker compose -f docker-compose.integration.yaml ps --services) ; do \
+ docker compose exec -it "$$service" ansible-playbook --connection=local /root/playbook.yaml ; \
+ done
+combine-coverage:
+ go tool covdata textfmt -i=./coverage/unit,./coverage/int/ubuntu/24.04,./coverage/int/fedora/40 -o coverage.txt && sed -i 2,3d coverage.txt
\ No newline at end of file
diff --git a/cmd/configure/configure.go b/cmd/configure/configure.go
new file mode 100644
index 00000000..3669e7b6
--- /dev/null
+++ b/cmd/configure/configure.go
@@ -0,0 +1,332 @@
+package configure
+
+import (
+ "bytes"
+ "fmt"
+ "github.com/algorandfoundation/algorun-tui/cmd/node"
+ "github.com/algorandfoundation/algorun-tui/internal/algod"
+ "github.com/algorandfoundation/algorun-tui/internal/algod/utils"
+ "os"
+ "os/exec"
+ "runtime"
+ "strings"
+ "text/template"
+
+ "github.com/spf13/cobra"
+)
+
+var Cmd = &cobra.Command{
+ Use: "configure",
+ Short: "Configure Algod",
+ Long: "Configure Algod settings",
+ SilenceUsage: true,
+ PersistentPreRun: node.NeedsToBeStopped,
+ //RunE: func(cmd *cobra.Command, args []string) error {
+ // return configureNode()
+ //},
+}
+
+func init() {
+ Cmd.AddCommand(serviceCmd)
+}
+
+const ConfigureRunningErrorMsg = "algorand is currently running. Please stop the node with *node stop* before configuring"
+
+// TODO: configure not just data directory but algod path
+func configureNode() error {
+ var systemServiceConfigure bool
+
+ if algod.IsRunning() {
+ return fmt.Errorf(ConfigureRunningErrorMsg)
+ }
+
+ // Check systemctl first
+ if algod.IsService() {
+ if promptWrapperYes("Algorand is installed as a service. Do you wish to edit the service file to change the data directory? (y/n)") {
+ // Edit the service file with the user's new data directory
+ systemServiceConfigure = true
+ } else {
+ fmt.Println("Exiting...")
+ os.Exit(0)
+ }
+ }
+
+ // At the end, instead of affectALGORAND_DATA, we'll edit the systemctl algorand.service file
+ // i.e., overwrite /etc/systemd/system/algorand.service.d/override.conf
+ // ExecStart and Description will be changed to reflect the new data directory
+ //
+
+ if !systemServiceConfigure {
+ fmt.Println("Configuring Data directory for algod started through Algorun...")
+ }
+
+ algorandData := os.Getenv("ALGORAND_DATA")
+
+ // Check if ALGORAND_DATA environment variable is set
+ if algorandData != "" {
+ fmt.Println("ALGORAND_DATA environment variable is set to: " + algorandData)
+ fmt.Println("Inspecting the set data directory...")
+
+ if validateAlgorandDataDir(algorandData) {
+ fmt.Println("Found valid Algorand Data Directory: " + algorandData)
+
+ if systemServiceConfigure {
+ if promptWrapperYes("Would you like to set the ALGORAND_DATA env variable as the data directory for the systemd Algorand service? (y/n)") {
+ editAlgorandServiceFile(algorandData)
+ os.Exit(0)
+ }
+ }
+
+ if promptWrapperNo("Do you want to set a completely new data directory? (y/n)") {
+ fmt.Println("User chose not to set a completely new data directory.")
+ os.Exit(0)
+ }
+
+ if promptWrapperYes("Do you want to manually input the new data directory? (y/n)") {
+ newPath := promptWrapperInput("Enter the new data directory path")
+
+ if !validateAlgorandDataDir(newPath) {
+ fmt.Println("Path at ALGORAND_DATA: " + newPath + " is not recognizable as an Algorand Data directory.")
+ os.Exit(1)
+ }
+
+ if systemServiceConfigure {
+ // Edit the service file
+ editAlgorandServiceFile(newPath)
+ } else {
+ // Affect the ALGORAND_DATA environment variable
+ affectALGORAND_DATA(newPath)
+ }
+ os.Exit(0)
+ }
+ } else {
+ fmt.Println("Path at ALGORAND_DATA: " + algorandData + " is not recognizable as an Algorand Data directory.")
+ }
+ } else {
+ fmt.Println("ALGORAND_DATA environment variable not set.")
+ }
+
+ // Do quick "lazy" check for existing Algorand Data directories
+ paths := utils.GetKnownDataPaths()
+
+ if len(paths) != 0 {
+
+ fmt.Println("Quick check found the following potential data directories:")
+ for _, path := range paths {
+ fmt.Println("✔ " + path)
+ }
+
+ if len(paths) == 1 {
+ if promptWrapperYes("Do you want to set this directory as the new data directory? (y/n)") {
+ if systemServiceConfigure {
+ // Edit the service file
+ editAlgorandServiceFile(paths[0])
+ } else {
+ affectALGORAND_DATA(paths[0])
+ }
+ os.Exit(0)
+ }
+
+ } else {
+
+ if promptWrapperYes("Do you want to set one of these directories as the new data directory? (y/n)") {
+
+ selectedPath := promptWrapperSelection("Select an Algorand data directory", paths)
+
+ if systemServiceConfigure {
+ // Edit the service file
+ editAlgorandServiceFile(selectedPath)
+ } else {
+ affectALGORAND_DATA(selectedPath)
+ }
+ os.Exit(0)
+ }
+ }
+ }
+
+ // Deep search
+ if promptWrapperNo("Do you want Algorun to do a deep search for pre-existing Algorand Data directories? (y/n)") {
+ fmt.Println("User chose not to search for more pre-existing Algorand Data directories. Exiting...")
+ os.Exit(0)
+ }
+
+ fmt.Println("Searching for pre-existing Algorand Data directories in HOME directory...")
+ paths = deepSearchAlgorandDataDirs()
+
+ if len(paths) == 0 {
+ fmt.Println("No Algorand data directories could be found in HOME directory. Are you sure Algorand node has been setup? Please run install command.")
+ os.Exit(1)
+ }
+
+ fmt.Println("Found Algorand data directories:")
+ for _, path := range paths {
+ fmt.Println(path)
+ }
+
+ // Prompt user to select a directory
+ selectedPath := promptWrapperSelection("Select an Algorand data directory", paths)
+
+ if systemServiceConfigure {
+ editAlgorandServiceFile(selectedPath)
+ } else {
+ affectALGORAND_DATA(selectedPath)
+ }
+ return nil
+}
+
+func editAlgorandServiceFile(dataDirectoryPath string) {
+ switch runtime.GOOS {
+ case "linux":
+ editSystemdAlgorandServiceFile(dataDirectoryPath)
+ case "darwin":
+ editLaunchdAlgorandServiceFile(dataDirectoryPath)
+ default:
+ fmt.Println("Unsupported operating system.")
+ }
+}
+
+func editLaunchdAlgorandServiceFile(dataDirectoryPath string) {
+
+ algodPath, err := exec.LookPath("algod")
+ if err != nil {
+ fmt.Printf("Failed to find algod binary: %v\n", err)
+ os.Exit(1)
+ }
+
+ overwriteFilePath := "/Library/LaunchDaemons/com.algorand.algod.plist"
+
+ overwriteTemplate := `
+
+
+
+ Label
+ com.algorand.algod
+ ProgramArguments
+
+ {{.AlgodPath}}
+ -d
+ {{.DataDirectoryPath}}
+
+ RunAtLoad
+
+ KeepAlive
+
+ StandardOutPath
+ /tmp/algod.out
+ StandardErrorPath
+ /tmp/algod.err
+
+ `
+
+ // Data to fill the template
+ data := map[string]string{
+ "AlgodPath": algodPath,
+ "DataDirectoryPath": dataDirectoryPath,
+ }
+
+ // Parse and execute the template
+ tmpl, err := template.New("override").Parse(overwriteTemplate)
+ if err != nil {
+ fmt.Printf("Failed to parse template: %v\n", err)
+ os.Exit(1)
+ }
+
+ var overwriteContent bytes.Buffer
+ err = tmpl.Execute(&overwriteContent, data)
+ if err != nil {
+ fmt.Printf("Failed to execute template: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Write the override content to the file
+ err = os.WriteFile(overwriteFilePath, overwriteContent.Bytes(), 0644)
+ if err != nil {
+ fmt.Printf("Failed to write override file: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Boot out the launchd service (just in case - it should be off)
+ cmd := exec.Command("launchctl", "bootout", "system", overwriteFilePath)
+ err = cmd.Run()
+ if err != nil {
+ if !strings.Contains(err.Error(), "No such process") {
+ fmt.Printf("Failed to bootout launchd service: %v\n", err)
+ os.Exit(1)
+ }
+ }
+
+ // Load the launchd service
+ cmd = exec.Command("launchctl", "load", overwriteFilePath)
+ err = cmd.Run()
+ if err != nil {
+ fmt.Printf("Failed to load launchd service: %v\n", err)
+ os.Exit(1)
+ }
+
+ fmt.Println("Launchd service updated and reloaded successfully.")
+}
+
+// Update the algorand.service file
+func editSystemdAlgorandServiceFile(dataDirectoryPath string) {
+
+ algodPath, err := exec.LookPath("algod")
+ if err != nil {
+ fmt.Printf("Failed to find algod binary: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Path to the systemd service override file
+ // Assuming that this is the same everywhere systemd is used
+ overrideFilePath := "/etc/systemd/system/algorand.service.d/override.conf"
+
+ // Create the override directory if it doesn't exist
+ err = os.MkdirAll("/etc/systemd/system/algorand.service.d", 0755)
+ if err != nil {
+ fmt.Printf("Failed to create override directory: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Content of the override file
+ const overrideTemplate = `[Unit]
+Description=Algorand daemon {{.AlgodPath}} in {{.DataDirectoryPath}}
+[Service]
+ExecStart=
+ExecStart={{.AlgodPath}} -d {{.DataDirectoryPath}}`
+
+ // Data to fill the template
+ data := map[string]string{
+ "AlgodPath": algodPath,
+ "DataDirectoryPath": dataDirectoryPath,
+ }
+
+ // Parse and execute the template
+ tmpl, err := template.New("override").Parse(overrideTemplate)
+ if err != nil {
+ fmt.Printf("Failed to parse template: %v\n", err)
+ os.Exit(1)
+ }
+
+ var overrideContent bytes.Buffer
+ err = tmpl.Execute(&overrideContent, data)
+ if err != nil {
+ fmt.Printf("Failed to execute template: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Write the override content to the file
+ err = os.WriteFile(overrideFilePath, overrideContent.Bytes(), 0644)
+ if err != nil {
+ fmt.Printf("Failed to write override file: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Reload systemd manager configuration
+ cmd := exec.Command("systemctl", "daemon-reload")
+ err = cmd.Run()
+ if err != nil {
+ fmt.Printf("Failed to reload systemd daemon: %v\n", err)
+ os.Exit(1)
+ }
+
+ fmt.Println("Algorand service file updated successfully.")
+}
diff --git a/cmd/configure/service.go b/cmd/configure/service.go
new file mode 100644
index 00000000..8a84ae75
--- /dev/null
+++ b/cmd/configure/service.go
@@ -0,0 +1,26 @@
+package configure
+
+import (
+ "errors"
+ "github.com/algorandfoundation/algorun-tui/internal/algod"
+ "github.com/algorandfoundation/algorun-tui/internal/system"
+ "github.com/algorandfoundation/algorun-tui/ui/style"
+ "github.com/spf13/cobra"
+)
+
+var serviceCmd = &cobra.Command{
+ Use: "service",
+ Short: "Configure the node service",
+ Long: style.Purple(style.BANNER) + "\n" + style.LightBlue("Configure the service that runs the node."),
+ PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+ if !system.IsSudo() {
+ return errors.New(
+ "you need to be root to run this command. Please run this command with sudo")
+ }
+ return nil
+ },
+ RunE: func(cmd *cobra.Command, args []string) error {
+ // TODO: Combine this with algod.UpdateService and algod.SetNetwork
+ return algod.EnsureService()
+ },
+}
diff --git a/cmd/configure/utils.go b/cmd/configure/utils.go
new file mode 100644
index 00000000..16926921
--- /dev/null
+++ b/cmd/configure/utils.go
@@ -0,0 +1,97 @@
+package configure
+
+import (
+ "fmt"
+ "github.com/algorandfoundation/algorun-tui/internal/system"
+ "github.com/manifoldco/promptui"
+ "github.com/spf13/cobra"
+ "os"
+)
+
+type Release struct {
+ Name string `json:"name"`
+ ZipballURL string `json:"zipball_url"`
+ TarballURL string `json:"tarball_url"`
+ Commit struct {
+ Sha string `json:"sha"`
+ URL string `json:"url"`
+ } `json:"commit"`
+ NodeID string `json:"node_id"`
+}
+
+// Queries user on the provided prompt and returns the user input
+func promptWrapperInput(promptLabel string) string {
+ prompt := promptui.Prompt{
+ Label: promptLabel,
+ }
+
+ result, err := prompt.Run()
+ cobra.CheckErr(err)
+
+ return result
+}
+
+// Queries user on the provided prompt and returns true if user inputs "y"
+func promptWrapperYes(promptLabel string) bool {
+ return promptWrapperInput(promptLabel) == "y"
+}
+
+// Queries user on the provided prompt and returns true if user does not input "y"
+// Included for improved readability of decision tree, despite being redundant.
+func promptWrapperNo(promptLabel string) bool {
+ return promptWrapperInput(promptLabel) != "y"
+}
+
+// Queries user on the provided prompt and returns the selected item
+func promptWrapperSelection(promptLabel string, items []string) string {
+ prompt := promptui.Select{
+ Label: promptLabel,
+ Items: items,
+ }
+
+ _, result, err := prompt.Run()
+ cobra.CheckErr(err)
+
+ fmt.Printf("You selected: %s\n", result)
+
+ return result
+}
+
+// TODO: consider replacing with a method that does more for the user
+func affectALGORAND_DATA(path string) {
+ fmt.Println("Please execute the following in your terminal to set the environment variable:")
+ fmt.Println("")
+ fmt.Println("export ALGORAND_DATA=" + path)
+ fmt.Println("")
+}
+
+func validateAlgorandDataDir(path string) bool {
+ info, err := os.Stat(path)
+
+ // Check if the path exists
+ if os.IsNotExist(err) {
+ return false
+ }
+
+ // Check if the path is a directory
+ if !info.IsDir() {
+ return false
+ }
+
+ paths := system.FindPathToFile(path, "algod.token")
+ if len(paths) == 1 {
+ return true
+ }
+ return false
+}
+
+// Checks if Algorand data directories exist, based off of existence of the "algod.token" file
+func deepSearchAlgorandDataDirs() []string {
+ home, err := os.UserHomeDir()
+ cobra.CheckErr(err)
+
+ // TODO: consider a better way to identify an Algorand data directory
+ paths := system.FindPathToFile(home, "algod.token")
+
+ return paths
+}
diff --git a/cmd/node/debug.go b/cmd/node/debug.go
new file mode 100644
index 00000000..eeb77754
--- /dev/null
+++ b/cmd/node/debug.go
@@ -0,0 +1,54 @@
+package node
+
+import (
+ "encoding/json"
+ "fmt"
+ "github.com/algorandfoundation/algorun-tui/internal/algod"
+ "github.com/algorandfoundation/algorun-tui/internal/algod/utils"
+ "github.com/algorandfoundation/algorun-tui/internal/system"
+ "github.com/algorandfoundation/algorun-tui/ui/style"
+ "github.com/charmbracelet/log"
+ "github.com/spf13/cobra"
+ "os/exec"
+)
+
+type DebugInfo struct {
+ InPath bool `json:"inPath"`
+ IsRunning bool `json:"isRunning"`
+ IsService bool `json:"isService"`
+ IsInstalled bool `json:"isInstalled"`
+ Algod string `json:"algod"`
+ Data []string `json:"data"`
+}
+
+var debugCmd = &cobra.Command{
+ Use: "debug",
+ Short: "Display debug information for developers",
+ Long: "Prints debug data to be copy and pasted to a bug report.",
+ SilenceUsage: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ log.Info("Collecting debug information...")
+
+ // Warn user for prompt
+ log.Warn(style.Yellow.Render(SudoWarningMsg))
+
+ paths := utils.GetKnownDataPaths()
+ path, _ := exec.LookPath("algod")
+ info := DebugInfo{
+ InPath: system.CmdExists("algod"),
+ IsRunning: algod.IsRunning(),
+ IsService: algod.IsService(),
+ IsInstalled: algod.IsInstalled(),
+ Algod: path,
+ Data: paths,
+ }
+ data, err := json.MarshalIndent(info, "", " ")
+ if err != nil {
+ return err
+ }
+
+ log.Info(style.Blue.Render("Copy and paste the following to a bug report:"))
+ fmt.Println(style.Bold(string(data)))
+ return nil
+ },
+}
diff --git a/cmd/node/install.go b/cmd/node/install.go
new file mode 100644
index 00000000..e2a6d516
--- /dev/null
+++ b/cmd/node/install.go
@@ -0,0 +1,58 @@
+package node
+
+import (
+ "github.com/algorandfoundation/algorun-tui/internal/algod"
+ "github.com/algorandfoundation/algorun-tui/ui/style"
+ "github.com/charmbracelet/log"
+ "github.com/spf13/cobra"
+ "os"
+ "time"
+)
+
+const InstallMsg = "Installing Algorand"
+const InstallExistsMsg = "algod is already installed"
+
+var installCmd = &cobra.Command{
+ Use: "install",
+ Short: "Install the algorand daemon",
+ Long: style.Purple(style.BANNER) + "\n" + style.LightBlue("Install the algorand daemon on your local machine"),
+ SilenceUsage: true,
+ Run: func(cmd *cobra.Command, args []string) {
+ // TODO: yes flag
+
+ // TODO: get expected version
+ log.Info(style.Green.Render(InstallMsg + " vX.X.X"))
+ // Warn user for prompt
+ log.Warn(style.Yellow.Render(SudoWarningMsg))
+
+ // TODO: compare expected version to existing version
+ if algod.IsInstalled() && !force {
+ log.Error(InstallExistsMsg)
+ os.Exit(1)
+ }
+
+ // Run the installation
+ err := algod.Install()
+ if err != nil {
+ log.Error(err)
+ os.Exit(1)
+ }
+
+ time.Sleep(5 * time.Second)
+
+ // If it's not running, start the daemon (can happen)
+ if !algod.IsRunning() {
+ err = algod.Start()
+ if err != nil {
+ log.Error(err)
+ os.Exit(1)
+ }
+ }
+
+ log.Info(style.Green.Render("Algorand installed successfully 🎉"))
+ },
+}
+
+func init() {
+ installCmd.Flags().BoolVarP(&force, "force", "f", false, style.Yellow.Render("forcefully install the node"))
+}
diff --git a/cmd/node/node.go b/cmd/node/node.go
new file mode 100644
index 00000000..1fca2ecd
--- /dev/null
+++ b/cmd/node/node.go
@@ -0,0 +1,56 @@
+package node
+
+import (
+ "github.com/algorandfoundation/algorun-tui/internal/algod"
+ "github.com/algorandfoundation/algorun-tui/ui/style"
+ "github.com/charmbracelet/log"
+ "github.com/spf13/cobra"
+)
+
+const SudoWarningMsg = "(You may be prompted for your password)"
+const PermissionErrorMsg = "this command must be run with super-user privileges (sudo)"
+const NotInstalledErrorMsg = "algod is not installed. please run the *node install* command"
+const RunningErrorMsg = "algod is running, please run the *node stop* command"
+const NotRunningErrorMsg = "algod is not running"
+
+var (
+ force bool = false
+)
+var Cmd = &cobra.Command{
+ Use: "node",
+ Short: "Node Management",
+ Long: style.Purple(style.BANNER) + "\n" + style.LightBlue("Manage your Algorand node"),
+}
+
+func NeedsToBeRunning(cmd *cobra.Command, args []string) {
+ if force {
+ return
+ }
+ if !algod.IsInstalled() {
+ log.Fatal(NotInstalledErrorMsg)
+ }
+ if !algod.IsRunning() {
+ log.Fatal(NotRunningErrorMsg)
+ }
+}
+
+func NeedsToBeStopped(cmd *cobra.Command, args []string) {
+ if force {
+ return
+ }
+ if !algod.IsInstalled() {
+ log.Fatal(NotInstalledErrorMsg)
+ }
+ if algod.IsRunning() {
+ log.Fatal(RunningErrorMsg)
+ }
+}
+
+func init() {
+ Cmd.AddCommand(installCmd)
+ Cmd.AddCommand(startCmd)
+ Cmd.AddCommand(stopCmd)
+ Cmd.AddCommand(uninstallCmd)
+ Cmd.AddCommand(upgradeCmd)
+ Cmd.AddCommand(debugCmd)
+}
diff --git a/cmd/node/start.go b/cmd/node/start.go
new file mode 100644
index 00000000..43cfb444
--- /dev/null
+++ b/cmd/node/start.go
@@ -0,0 +1,30 @@
+package node
+
+import (
+ "github.com/algorandfoundation/algorun-tui/internal/algod"
+ "github.com/algorandfoundation/algorun-tui/ui/style"
+ "github.com/charmbracelet/log"
+ "github.com/spf13/cobra"
+)
+
+var startCmd = &cobra.Command{
+ Use: "start",
+ Short: "Start Algod",
+ Long: "Start Algod on your system (the one on your PATH).",
+ SilenceUsage: true,
+ PersistentPreRun: NeedsToBeStopped,
+ Run: func(cmd *cobra.Command, args []string) {
+ log.Info(style.Green.Render("Starting Algod 🚀"))
+ // Warn user for prompt
+ log.Warn(style.Yellow.Render(SudoWarningMsg))
+ err := algod.Start()
+ if err != nil {
+ log.Fatal(err)
+ }
+ log.Info(style.Green.Render("Algorand started successfully 🎉"))
+ },
+}
+
+func init() {
+ startCmd.Flags().BoolVarP(&force, "force", "f", false, style.Yellow.Render("forcefully start the node"))
+}
diff --git a/cmd/node/stop.go b/cmd/node/stop.go
new file mode 100644
index 00000000..878c3c37
--- /dev/null
+++ b/cmd/node/stop.go
@@ -0,0 +1,45 @@
+package node
+
+import (
+ "fmt"
+ "github.com/algorandfoundation/algorun-tui/internal/algod"
+ "github.com/algorandfoundation/algorun-tui/ui/style"
+ "github.com/charmbracelet/log"
+ "time"
+
+ "github.com/spf13/cobra"
+)
+
+const StopTimeout = 5 * time.Second
+const StopSuccessMsg = "Algod stopped successfully"
+const StopFailureMsg = "failed to stop Algod"
+
+var stopCmd = &cobra.Command{
+ Use: "stop",
+ Short: "Stop Algod",
+ Long: "Stop the Algod process on your system.",
+ SilenceUsage: true,
+ PersistentPreRun: NeedsToBeRunning,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ log.Info(style.Green.Render("Stopping Algod 😢"))
+ // Warn user for prompt
+ log.Warn(style.Yellow.Render(SudoWarningMsg))
+
+ err := algod.Stop()
+ if err != nil {
+ return fmt.Errorf(StopFailureMsg)
+ }
+ time.Sleep(StopTimeout)
+
+ if algod.IsRunning() {
+ return fmt.Errorf(StopFailureMsg)
+ }
+
+ log.Info(style.Green.Render("Algorand stopped successfully 🎉"))
+ return nil
+ },
+}
+
+func init() {
+ stopCmd.Flags().BoolVarP(&force, "force", "f", false, style.Yellow.Render("forcefully stop the node"))
+}
diff --git a/cmd/node/uninstall.go b/cmd/node/uninstall.go
new file mode 100644
index 00000000..350c5ee3
--- /dev/null
+++ b/cmd/node/uninstall.go
@@ -0,0 +1,34 @@
+package node
+
+import (
+ "github.com/algorandfoundation/algorun-tui/internal/algod"
+ "github.com/algorandfoundation/algorun-tui/ui/style"
+ "github.com/charmbracelet/log"
+ "github.com/spf13/cobra"
+)
+
+const UninstallWarningMsg = "(You may be prompted for your password to uninstall)"
+
+var uninstallCmd = &cobra.Command{
+ Use: "uninstall",
+ Short: "Uninstall Algorand node (Algod)",
+ Long: "Uninstall Algorand node (Algod) and other binaries on your system installed by this tool.",
+ SilenceUsage: true,
+ PersistentPreRun: NeedsToBeStopped,
+ Run: func(cmd *cobra.Command, args []string) {
+ if force {
+ log.Warn(style.Red.Render("Uninstalling Algorand (forcefully)"))
+ }
+ // Warn user for prompt
+ log.Warn(style.Yellow.Render(UninstallWarningMsg))
+
+ err := algod.Uninstall(force)
+ if err != nil {
+ log.Fatal(err)
+ }
+ },
+}
+
+func init() {
+ uninstallCmd.Flags().BoolVarP(&force, "force", "f", false, style.Yellow.Render("forcefully uninstall the node"))
+}
diff --git a/cmd/node/upgrade.go b/cmd/node/upgrade.go
new file mode 100644
index 00000000..b464f899
--- /dev/null
+++ b/cmd/node/upgrade.go
@@ -0,0 +1,42 @@
+package node
+
+import (
+ "github.com/algorandfoundation/algorun-tui/internal/algod"
+ "github.com/algorandfoundation/algorun-tui/ui/style"
+ "github.com/charmbracelet/log"
+ "github.com/spf13/cobra"
+ "os"
+ "time"
+)
+
+const UpgradeMsg = "Upgrading Algod"
+
+var upgradeCmd = &cobra.Command{
+ Use: "upgrade",
+ Short: "Upgrade Algod",
+ Long: "Upgrade Algod (if installed with package manager).",
+ SilenceUsage: true,
+ PersistentPreRun: NeedsToBeStopped,
+ Run: func(cmd *cobra.Command, args []string) {
+ // TODO: get expected version and check if update is required
+ log.Info(style.Green.Render(UpgradeMsg + " vX.X.X"))
+ // Warn user for prompt
+ log.Warn(style.Yellow.Render(SudoWarningMsg))
+ // TODO: Check Version from S3 against the local binary
+ err := algod.Update()
+ if err != nil {
+ log.Error(err)
+ }
+
+ time.Sleep(5 * time.Second)
+
+ // If it's not running, start the daemon (can happen)
+ if !algod.IsRunning() {
+ err = algod.Start()
+ if err != nil {
+ log.Error(err)
+ os.Exit(1)
+ }
+ }
+ },
+}
diff --git a/cmd/root.go b/cmd/root.go
index 3e84283d..2e03a8d9 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"github.com/algorandfoundation/algorun-tui/api"
+ "github.com/algorandfoundation/algorun-tui/cmd/configure"
+ "github.com/algorandfoundation/algorun-tui/cmd/node"
"github.com/algorandfoundation/algorun-tui/internal"
"github.com/algorandfoundation/algorun-tui/ui"
"github.com/algorandfoundation/algorun-tui/ui/explanations"
@@ -17,18 +19,10 @@ import (
"github.com/spf13/viper"
"io"
"os"
+ "runtime"
"strings"
)
-const BANNER = `
- _____ .__ __________
- / _ \ | | ____ ____\______ \__ __ ____
- / /_\ \| | / ___\ / _ \| _/ | \/ \
-/ | \ |__/ /_/ > <_> ) | \ | / | \
-\____|__ /____/\___ / \____/|____|_ /____/|___| /
- \/ /_____/ \/ \/
-`
-
var (
algod string
token = strings.Repeat("a", 64)
@@ -36,7 +30,7 @@ var (
rootCmd = &cobra.Command{
Use: "algorun",
Short: "Manage Algorand nodes",
- Long: style.Purple(BANNER) + "\n",
+ Long: style.Purple(style.BANNER) + "\n",
CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: true,
},
@@ -145,15 +139,15 @@ func init() {
rootCmd.Version = Version
// Bindings
- rootCmd.PersistentFlags().StringVarP(&algod, "algod-endpoint", "a", "", style.LightBlue("algod endpoint address URI, including http[s]"))
- rootCmd.PersistentFlags().StringVarP(&token, "algod-token", "t", "", lipgloss.JoinHorizontal(
+ rootCmd.Flags().StringVarP(&algod, "algod-endpoint", "a", "", style.LightBlue("algod endpoint address URI, including http[s]"))
+ rootCmd.Flags().StringVarP(&token, "algod-token", "t", "", lipgloss.JoinHorizontal(
lipgloss.Left,
style.LightBlue("algod "),
style.BoldUnderline("admin"),
style.LightBlue(" token"),
))
- _ = viper.BindPFlag("algod-endpoint", rootCmd.PersistentFlags().Lookup("algod-endpoint"))
- _ = viper.BindPFlag("algod-token", rootCmd.PersistentFlags().Lookup("algod-token"))
+ _ = viper.BindPFlag("algod-endpoint", rootCmd.Flags().Lookup("algod-endpoint"))
+ _ = viper.BindPFlag("algod-token", rootCmd.Flags().Lookup("algod-token"))
// Update Long Text
rootCmd.Long +=
@@ -167,6 +161,10 @@ func init() {
// Add Commands
rootCmd.AddCommand(statusCmd)
+ if runtime.GOOS != "windows" {
+ rootCmd.AddCommand(node.Cmd)
+ rootCmd.AddCommand(configure.Cmd)
+ }
}
// Execute executes the root command.
diff --git a/cmd/status.go b/cmd/status.go
index 5ab65081..62bd010f 100644
--- a/cmd/status.go
+++ b/cmd/status.go
@@ -17,7 +17,7 @@ import (
var statusCmd = &cobra.Command{
Use: "status",
Short: "Get the node status",
- Long: style.Purple(BANNER) + "\n" + style.LightBlue("View the node status"),
+ Long: style.Purple(style.BANNER) + "\n" + style.LightBlue("View the node status"),
RunE: func(cmd *cobra.Command, args []string) error {
initConfig()
if viper.GetString("algod-endpoint") == "" {
diff --git a/cmd/status_test.go b/cmd/status_test.go
index d6ff576d..87a36873 100644
--- a/cmd/status_test.go
+++ b/cmd/status_test.go
@@ -2,8 +2,9 @@ package cmd
import (
"context"
- "github.com/spf13/viper"
"testing"
+
+ "github.com/spf13/viper"
)
func Test_ExecuteInvalidStatusCommand(t *testing.T) {
diff --git a/codecov.yaml b/codecov.yaml
index 2c7de746..eaf149d2 100644
--- a/codecov.yaml
+++ b/codecov.yaml
@@ -6,4 +6,4 @@ coverage:
threshold: 10%
patch:
default:
- target: 60%
\ No newline at end of file
+ target: 10%
\ No newline at end of file
diff --git a/docker-compose.integration.yaml b/docker-compose.integration.yaml
new file mode 100644
index 00000000..9a89539d
--- /dev/null
+++ b/docker-compose.integration.yaml
@@ -0,0 +1,58 @@
+services:
+ # Legacy with apt disabled
+ ubuntu.18.04:
+ deploy:
+ replicas: 0
+ privileged: true
+ environment:
+ - GOCOVERDIR=/app/coverage/int/ubuntu/18.04
+ build:
+ context: .
+ target: bionic
+ dockerfile: .docker/Ubuntu.dockerfile
+ volumes:
+ - "./coverage/int/ubuntu/18.04:/app/coverage/int/ubuntu/18.04"
+ ubuntu.22.04:
+ deploy:
+ replicas: 0
+ privileged: true
+ environment:
+ - GOCOVERDIR=/app/coverage/int/ubuntu/22.04
+ build:
+ context: .
+ target: jammy
+ dockerfile: .docker/Ubuntu.dockerfile
+ volumes:
+ - "./coverage/int/ubuntu/22.04:/app/coverage/int/ubuntu/22.04"
+ ubuntu.24.04:
+ privileged: true
+ environment:
+ - GOCOVERDIR=/app/coverage/int/ubuntu/24.04
+ build:
+ context: .
+ target: noble
+ dockerfile: .docker/Ubuntu.dockerfile
+ volumes:
+ - "./coverage/int/ubuntu/24.04:/app/coverage/int/ubuntu/24.04"
+ fedora.39:
+ deploy:
+ replicas: 0
+ privileged: true
+ environment:
+ - GOCOVERDIR=/app/coverage/int/fedora/39
+ build:
+ context: .
+ target: legacy
+ dockerfile: .docker/Fedora.dockerfile
+ volumes:
+ - "./coverage/int/fedora/39:/app/coverage/int/fedora/39"
+ fedora.40:
+ privileged: true
+ environment:
+ - GOCOVERDIR=/app/coverage/int/fedora/40
+ build:
+ context: .
+ target: previous
+ dockerfile: .docker/Fedora.dockerfile
+ volumes:
+ - "./coverage/int/fedora/40:/app/coverage/int/fedora/40"
\ No newline at end of file
diff --git a/go.mod b/go.mod
index a04b4a7c..7134e0ea 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,7 @@ require (
github.com/charmbracelet/lipgloss v0.13.1
github.com/charmbracelet/log v0.4.0
github.com/charmbracelet/x/exp/teatest v0.0.0-20241022174419-46d9bb99a691
+ github.com/manifoldco/promptui v0.9.0
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1
github.com/oapi-codegen/runtime v1.1.1
github.com/spf13/cobra v1.8.1
@@ -19,6 +20,7 @@ require (
)
require (
+ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
diff --git a/go.sum b/go.sum
index acaa1e37..0b57cc4a 100644
--- a/go.sum
+++ b/go.sum
@@ -32,6 +32,12 @@ github.com/charmbracelet/x/exp/teatest v0.0.0-20241022174419-46d9bb99a691 h1:xiY
github.com/charmbracelet/x/exp/teatest v0.0.0-20241022174419-46d9bb99a691/go.mod h1:ektxP4TiEONm1mTGILRfo8F0a4rZMwsT1fEkXslQKtU=
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
+github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
+github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -68,6 +74,8 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
+github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
@@ -137,6 +145,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
diff --git a/internal/algod/algod.go b/internal/algod/algod.go
new file mode 100644
index 00000000..34ad1537
--- /dev/null
+++ b/internal/algod/algod.go
@@ -0,0 +1,139 @@
+package algod
+
+import (
+ "fmt"
+ "github.com/algorandfoundation/algorun-tui/internal/algod/linux"
+ "github.com/algorandfoundation/algorun-tui/internal/algod/mac"
+ "github.com/algorandfoundation/algorun-tui/internal/system"
+ "runtime"
+)
+
+const UnsupportedOSError = "unsupported operating system"
+
+// IsInstalled checks if the Algod software is installed on the system
+// by verifying its presence and service setup.
+func IsInstalled() bool {
+ return system.CmdExists("algod")
+}
+
+// IsRunning checks if the algod is currently running on the host operating system.
+// It returns true if the application is running, or false if it is not or if an error occurs.
+// This function supports Linux and macOS platforms. It returns an error for unsupported operating systems.
+func IsRunning() bool {
+ switch runtime.GOOS {
+ case "linux", "darwin":
+ return system.IsCmdRunning("algod")
+
+ default:
+ return false
+ }
+}
+
+// IsService determines if the Algorand service is configured as
+// a system service on the current operating system.
+func IsService() bool {
+ switch runtime.GOOS {
+ case "linux":
+ return linux.IsService()
+ case "darwin":
+ return mac.IsService()
+ default:
+ return false
+ }
+}
+
+// SetNetwork configures the network to the specified setting
+// or returns an error on unsupported operating systems.
+func SetNetwork(network string) error {
+ return fmt.Errorf(UnsupportedOSError)
+}
+
+// Install installs Algorand software based on the host OS
+// and returns an error if the installation fails or is unsupported.
+func Install() error {
+ switch runtime.GOOS {
+ case "linux":
+ return linux.Install()
+ case "darwin":
+ return mac.Install()
+ default:
+ return fmt.Errorf(UnsupportedOSError)
+ }
+}
+
+// Update checks the operating system and performs an
+// upgrade using OS-specific package managers, if supported.
+func Update() error {
+ switch runtime.GOOS {
+ case "linux":
+ return linux.Upgrade()
+ case "darwin":
+ return mac.Upgrade(false)
+ default:
+ return fmt.Errorf(UnsupportedOSError)
+ }
+}
+
+// Uninstall removes the Algorand software from the system based
+// on the host operating system using appropriate methods.
+func Uninstall(force bool) error {
+ switch runtime.GOOS {
+ case "linux":
+ return linux.Uninstall()
+ case "darwin":
+ return mac.Uninstall(force)
+ default:
+ return fmt.Errorf(UnsupportedOSError)
+ }
+}
+
+// UpdateService updates the service configuration for the
+// Algorand daemon based on the OS and reloads the service.
+func UpdateService(dataDirectoryPath string) error {
+ switch runtime.GOOS {
+ case "linux":
+ return linux.UpdateService(dataDirectoryPath)
+ case "darwin":
+ return mac.UpdateService(dataDirectoryPath)
+ default:
+ return fmt.Errorf(UnsupportedOSError)
+ }
+}
+
+// EnsureService ensures the `algod` service is configured and running
+// as a service based on the OS;
+// Returns an error for unsupported systems.
+func EnsureService() error {
+ switch runtime.GOOS {
+ case "darwin":
+ return mac.EnsureService()
+ default:
+ return fmt.Errorf(UnsupportedOSError)
+ }
+}
+
+// Start attempts to initiate the Algod service based on the
+// host operating system. Returns an error for unsupported OS.
+func Start() error {
+ switch runtime.GOOS {
+ case "linux":
+ return linux.Start()
+ case "darwin":
+ return mac.Start(false)
+ default: // Unsupported OS
+ return fmt.Errorf(UnsupportedOSError)
+ }
+}
+
+// Stop shuts down the Algorand algod system process based on the current operating system.
+// Returns an error if the operation fails or the operating system is unsupported.
+func Stop() error {
+ switch runtime.GOOS {
+ case "linux":
+ return linux.Stop()
+ case "darwin":
+ return mac.Stop(false)
+ default:
+ return fmt.Errorf(UnsupportedOSError)
+ }
+}
diff --git a/internal/algod/fallback/algod.go b/internal/algod/fallback/algod.go
new file mode 100644
index 00000000..c83c4454
--- /dev/null
+++ b/internal/algod/fallback/algod.go
@@ -0,0 +1,90 @@
+package fallback
+
+import (
+ "errors"
+ "fmt"
+ "github.com/algorandfoundation/algorun-tui/internal/algod/msgs"
+ "github.com/algorandfoundation/algorun-tui/internal/algod/utils"
+ "github.com/algorandfoundation/algorun-tui/internal/system"
+ "github.com/charmbracelet/log"
+ "os"
+ "os/exec"
+ "syscall"
+)
+
+// Install executes a series of commands to set up the Algorand node and development tools on a Unix environment.
+// TODO: Allow for changing of the paths
+func Install() error {
+ return system.RunAll(system.CmdsList{
+ {"mkdir", "~/node"},
+ {"sh", "-c", "cd ~/node"},
+ {"wget", "https://raw.githubusercontent.com/algorand/go-algorand/rel/stable/cmd/updater/update.sh"},
+ {"chmod", "744", "update.sh"},
+ {"sh", "-c", "./update.sh -i -c stable -p ~/node -d ~/node/data -n"},
+ })
+
+}
+
+func Start() error {
+ path, err := exec.LookPath("algod")
+ log.Debug("Starting algod", "path", path)
+
+ // Check if ALGORAND_DATA environment variable is set
+ log.Info("Checking if ALGORAND_DATA env var is set...")
+ algorandData := os.Getenv("ALGORAND_DATA")
+
+ if !utils.IsDataDir(algorandData) {
+ return errors.New(msgs.InvalidDataDirectory)
+ }
+
+ log.Info("ALGORAND_DATA env var set to valid directory: " + algorandData)
+
+ cmd := exec.Command("algod")
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ Setsid: true,
+ }
+ err = cmd.Start()
+ if err != nil {
+ return fmt.Errorf("Failed to start algod: %v\n", err)
+ }
+ return nil
+}
+
+func Stop() error {
+ log.Debug("Manually shutting down algod")
+ // Find the process ID of algod
+ pid, err := findAlgodPID()
+ if err != nil {
+ return err
+ }
+
+ // Send SIGTERM to the process
+ process, err := os.FindProcess(pid)
+ if err != nil {
+ return err
+ }
+
+ err = process.Signal(syscall.SIGTERM)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func findAlgodPID() (int, error) {
+ log.Debug("Scanning for algod process")
+ cmd := exec.Command("pgrep", "algod")
+ output, err := cmd.Output()
+ if err != nil {
+ return 0, err
+ }
+
+ var pid int
+ _, err = fmt.Sscanf(string(output), "%d", &pid)
+ if err != nil {
+ return 0, err
+ }
+
+ return pid, nil
+}
diff --git a/internal/algod/linux/linux.go b/internal/algod/linux/linux.go
new file mode 100644
index 00000000..4820a42b
--- /dev/null
+++ b/internal/algod/linux/linux.go
@@ -0,0 +1,220 @@
+package linux
+
+import (
+ "bytes"
+ "fmt"
+ "github.com/algorandfoundation/algorun-tui/internal/algod/fallback"
+ "github.com/algorandfoundation/algorun-tui/internal/system"
+ "github.com/charmbracelet/log"
+ "os"
+ "os/exec"
+ "runtime"
+ "strings"
+ "text/template"
+)
+
+const PackageManagerNotFoundMsg = "could not find a package manager to uninstall Algorand"
+
+type Algod struct {
+ system.Interface
+ Path string
+ DataDirectoryPath string
+}
+
+// InstallRequirements generates installation commands for "sudo" based on the detected package manager and system state.
+func InstallRequirements() system.CmdsList {
+ var cmds system.CmdsList
+ if (system.CmdExists("sudo") && system.CmdExists("prep")) || os.Geteuid() != 0 {
+ return cmds
+ }
+ if system.CmdExists("apt-get") {
+ return system.CmdsList{
+ {"apt-get", "update"},
+ {"apt-get", "install", "-y", "sudo", "procps"},
+ }
+ }
+
+ if system.CmdExists("dnf") {
+ return system.CmdsList{
+ {"dnf", "install", "-y", "sudo", "procps-ng"},
+ }
+ }
+ return cmds
+}
+
+// Install installs Algorand development tools or node software depending on the package manager.
+func Install() error {
+ log.Info("Installing Algod on Linux")
+ // Based off of https://developer.algorand.org/docs/run-a-node/setup/install/#installation-with-a-package-manager
+ if system.CmdExists("apt-get") { // On some Debian systems we use apt-get
+ log.Info("Installing with apt-get")
+ return system.RunAll(append(InstallRequirements(), system.CmdsList{
+ {"sudo", "apt-get", "update"},
+ {"sudo", "apt-get", "install", "-y", "gnupg2", "curl", "software-properties-common"},
+ {"sh", "-c", "curl -o - https://releases.algorand.com/key.pub | sudo tee /etc/apt/trusted.gpg.d/algorand.asc"},
+ {"sudo", "add-apt-repository", "-y", fmt.Sprintf("deb [arch=%s] https://releases.algorand.com/deb/ stable main", runtime.GOARCH)},
+ {"sudo", "apt-get", "update"},
+ {"sudo", "apt-get", "install", "-y", "algorand-devtools"},
+ }...))
+ }
+
+ if system.CmdExists("dnf") { // On Fedora and CentOs8 there's the dnf package manager
+ log.Printf("Installing with dnf")
+ return system.RunAll(append(InstallRequirements(), system.CmdsList{
+ {"curl", "-O", "https://releases.algorand.com/rpm/rpm_algorand.pub"},
+ {"sudo", "rpmkeys", "--import", "rpm_algorand.pub"},
+ {"sudo", "dnf", "install", "-y", "dnf-command(config-manager)"},
+ {"sudo", "dnf", "config-manager", "--add-repo=https://releases.algorand.com/rpm/stable/algorand.repo"},
+ {"sudo", "dnf", "install", "-y", "algorand-devtools"},
+ {"sudo", "systemctl", "enable", "algorand.service"},
+ {"sudo", "systemctl", "start", "algorand.service"},
+ {"rm", "-f", "rpm_algorand.pub"},
+ }...))
+
+ }
+
+ // TODO: watch this method to see if it is ever used
+ return fallback.Install()
+}
+
+// Uninstall removes the Algorand software using a supported package manager or clears related system files if necessary.
+// Returns an error if a supported package manager is not found or if any command fails during execution.
+func Uninstall() error {
+ log.Info("Uninstalling Algorand")
+ var unInstallCmds system.CmdsList
+ // On Ubuntu and Debian there's the apt package manager
+ if system.CmdExists("apt-get") {
+ log.Info("Using apt-get package manager")
+ unInstallCmds = [][]string{
+ {"sudo", "apt-get", "autoremove", "algorand-devtools", "algorand", "-y"},
+ }
+ }
+ // On Fedora and CentOs8 there's the dnf package manager
+ if system.CmdExists("dnf") {
+ log.Info("Using dnf package manager")
+ unInstallCmds = [][]string{
+ {"sudo", "dnf", "remove", "algorand-devtools", "algorand", "-y"},
+ }
+ }
+ // Error on unsupported package managers
+ if len(unInstallCmds) == 0 {
+ return fmt.Errorf(PackageManagerNotFoundMsg)
+ }
+
+ // Commands to clear systemd algorand.service and any other files, like the configuration override
+ unInstallCmds = append(unInstallCmds, []string{"sudo", "bash", "-c", "rm -rf /etc/systemd/system/algorand*"})
+ unInstallCmds = append(unInstallCmds, []string{"sudo", "systemctl", "daemon-reload"})
+
+ return system.RunAll(unInstallCmds)
+}
+
+// Upgrade updates Algorand and its dev tools using an approved package
+// manager if available, otherwise returns an error.
+func Upgrade() error {
+ if system.CmdExists("apt-get") {
+ return system.RunAll(system.CmdsList{
+ {"sudo", "apt-get", "update"},
+ {"sudo", "apt-get", "install", "--only-upgrade", "-y", "algorand-devtools", "algorand"},
+ })
+ }
+ if system.CmdExists("dnf") {
+ return system.RunAll(system.CmdsList{
+ {"sudo", "dnf", "update", "-y", "--refresh", "algorand-devtools", "algorand"},
+ })
+ }
+ return fmt.Errorf("the *node upgrade* command is currently only available for installations done with an approved package manager. Please use a different method to upgrade")
+}
+
+// Start attempts to start the Algorand service using the system's service manager.
+// It executes the appropriate command for systemd on Linux-based systems.
+// Returns an error if the command fails.
+// TODO: Replace with D-Bus integration
+func Start() error {
+ return exec.Command("sudo", "systemctl", "start", "algorand").Run()
+}
+
+// Stop shuts down the Algorand algod system process on Linux using the systemctl stop command.
+// Returns an error if the operation fails.
+// TODO: Replace with D-Bus integration
+func Stop() error {
+ return exec.Command("sudo", "systemctl", "stop", "algorand").Run()
+}
+
+// IsService checks if the "algorand.service" is listed as a systemd unit file on Linux.
+// Returns true if it exists.
+// TODO: Replace with D-Bus integration
+func IsService() bool {
+ out, err := system.Run([]string{"sudo", "systemctl", "list-unit-files", "algorand.service"})
+ if err != nil {
+ return false
+ }
+ return strings.Contains(out, "algorand.service")
+}
+
+// UpdateService updates the systemd service file for the Algorand daemon
+// with a new data directory path and reloads the daemon.
+func UpdateService(dataDirectoryPath string) error {
+
+ algodPath, err := exec.LookPath("algod")
+ if err != nil {
+ fmt.Printf("Failed to find algod binary: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Path to the systemd service override file
+ // Assuming that this is the same everywhere systemd is used
+ overrideFilePath := "/etc/systemd/system/algorand.service.d/override.conf"
+
+ // Create the override directory if it doesn't exist
+ err = os.MkdirAll("/etc/systemd/system/algorand.service.d", 0755)
+ if err != nil {
+ fmt.Printf("Failed to create override directory: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Content of the override file
+ const overrideTemplate = `[Unit]
+Description=Algorand daemon {{.AlgodPath}} in {{.DataDirectoryPath}}
+[Service]
+ExecStart=
+ExecStart={{.AlgodPath}} -d {{.DataDirectoryPath}}`
+
+ // Data to fill the template
+ data := map[string]string{
+ "AlgodPath": algodPath,
+ "DataDirectoryPath": dataDirectoryPath,
+ }
+
+ // Parse and execute the template
+ tmpl, err := template.New("override").Parse(overrideTemplate)
+ if err != nil {
+ fmt.Printf("Failed to parse template: %v\n", err)
+ os.Exit(1)
+ }
+
+ var overrideContent bytes.Buffer
+ err = tmpl.Execute(&overrideContent, data)
+ if err != nil {
+ fmt.Printf("Failed to execute template: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Write the override content to the file
+ err = os.WriteFile(overrideFilePath, overrideContent.Bytes(), 0644)
+ if err != nil {
+ fmt.Printf("Failed to write override file: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Reload systemd manager configuration
+ cmd := exec.Command("systemctl", "daemon-reload")
+ err = cmd.Run()
+ if err != nil {
+ fmt.Printf("Failed to reload systemd daemon: %v\n", err)
+ os.Exit(1)
+ }
+
+ log.Info("Algorand service file updated successfully.")
+
+ return nil
+}
diff --git a/internal/algod/mac/mac.go b/internal/algod/mac/mac.go
new file mode 100644
index 00000000..8afeeeb7
--- /dev/null
+++ b/internal/algod/mac/mac.go
@@ -0,0 +1,316 @@
+package mac
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "github.com/algorandfoundation/algorun-tui/internal/algod/utils"
+ "github.com/algorandfoundation/algorun-tui/internal/system"
+ "github.com/charmbracelet/log"
+ "github.com/spf13/cobra"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "text/template"
+)
+
+const MustBeServiceMsg = "service must be installed to be able to manage it"
+const HomeBrewNotFoundMsg = "homebrew is not installed. please install Homebrew and try again"
+
+// IsService check if Algorand service has been created with launchd (macOS)
+// Note that it needs to be run in super-user privilege mode to
+// be able to view the root level services.
+func IsService() bool {
+ _, err := system.Run([]string{"sudo", "launchctl", "list", "com.algorand.algod"})
+ return err == nil
+}
+
+// Install sets up Algod on macOS using Homebrew,
+// configures necessary directories, and ensures it
+// runs as a background service.
+func Install() error {
+ log.Info("Installing Algod on macOS...")
+
+ // Homebrew is our package manager of choice
+ if !system.CmdExists("brew") {
+ return errors.New(HomeBrewNotFoundMsg)
+ }
+
+ err := system.RunAll(system.CmdsList{
+ {"brew", "tap", "algorandfoundation/homebrew-node"},
+ {"brew", "install", "algorand"},
+ {"brew", "--prefix", "algorand", "--installed"},
+ })
+ if err != nil {
+ return err
+ }
+
+ // Handle data directory and genesis.json file
+ err = handleDataDirMac()
+ if err != nil {
+ return err
+ }
+
+ path, err := os.Executable()
+ if err != nil {
+ return err
+ }
+
+ // Create and load the launchd service
+ // TODO: find a clever way to avoid this or make sudo persist for the second call
+ err = system.RunAll(system.CmdsList{{"sudo", path, "configure", "service"}})
+ if err != nil {
+ return err
+ }
+
+ if !IsService() {
+ return fmt.Errorf("algod is not a service")
+ }
+
+ log.Info("Installed Algorand (Algod) with Homebrew ")
+
+ return nil
+}
+
+// Uninstall removes the Algorand application from the system using Homebrew if it is installed.
+func Uninstall(force bool) error {
+ if force {
+ if system.IsCmdRunning("algod") {
+ err := Stop(force)
+ if err != nil {
+ return err
+ }
+ }
+ }
+
+ cmds := system.CmdsList{}
+ if IsService() {
+ cmds = append(cmds, []string{"sudo", "launchctl", "unload", "/Library/LaunchDaemons/com.algorand.algod.plist"})
+ }
+
+ if !system.CmdExists("brew") && !force {
+ return errors.New("homebrew is not installed")
+ } else {
+ cmds = append(cmds, []string{"brew", "uninstall", "algorand"})
+ }
+
+ if force {
+ cmds = append(cmds, []string{"sudo", "rm", "-rf", strings.Join(utils.GetKnownDataPaths(), " ")})
+ cmds = append(cmds, []string{"sudo", "rm", "-rf", "/Library/LaunchDaemons/com.algorand.algod.plist"})
+ }
+
+ return system.RunAll(cmds)
+}
+
+// Upgrade updates the installed Algorand package using Homebrew if it's available and properly configured.
+func Upgrade(force bool) error {
+ if !system.CmdExists("brew") {
+ return errors.New("homebrew is not installed")
+ }
+
+ return system.RunAll(system.CmdsList{
+ {"brew", "--prefix", "algorand", "--installed"},
+ {"brew", "upgrade", "algorand", "--formula"},
+ })
+}
+
+// Start algorand with launchd
+func Start(force bool) error {
+ log.Debug("Attempting to start algorand with launchd")
+ //if !IsService() && !force {
+ // return fmt.Errorf(MustBeServiceMsg)
+ //}
+ return system.RunAll(system.CmdsList{
+ {"sudo", "launchctl", "start", "com.algorand.algod"},
+ })
+}
+
+// Stop shuts down the Algorand algod system process using the launchctl bootout command.
+// Returns an error if the operation fails.
+func Stop(force bool) error {
+ if !IsService() && !force {
+ return fmt.Errorf(MustBeServiceMsg)
+ }
+
+ return system.RunAll(system.CmdsList{
+ {"sudo", "launchctl", "stop", "com.algorand.algod"},
+ })
+}
+
+// UpdateService updates the Algorand launchd service with
+// a new data directory path and reloads the service configuration.
+// TODO: Deduplicate this method, redundant version of EnsureService.
+func UpdateService(dataDirectoryPath string) error {
+
+ algodPath, err := exec.LookPath("algod")
+ if err != nil {
+ log.Info("Failed to find algod binary: %v\n", err)
+ os.Exit(1)
+ }
+
+ overwriteFilePath := "/Library/LaunchDaemons/com.algorand.algod.plist"
+
+ overwriteTemplate := `
+
+
+
+ Label
+ com.algorand.algod
+ ProgramArguments
+
+ {{.AlgodPath}}
+ -d
+ {{.DataDirectoryPath}}
+
+ RunAtLoad
+
+ StandardOutPath
+ /tmp/algod.out
+ StandardErrorPath
+ /tmp/algod.err
+
+ `
+
+ // Data to fill the template
+ data := map[string]string{
+ "AlgodPath": algodPath,
+ "DataDirectoryPath": dataDirectoryPath,
+ }
+
+ // Parse and execute the template
+ tmpl, err := template.New("override").Parse(overwriteTemplate)
+ if err != nil {
+ log.Info("Failed to parse template: %v\n", err)
+ os.Exit(1)
+ }
+
+ var overwriteContent bytes.Buffer
+ err = tmpl.Execute(&overwriteContent, data)
+ if err != nil {
+ log.Info("Failed to execute template: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Write the override content to the file
+ err = os.WriteFile(overwriteFilePath, overwriteContent.Bytes(), 0644)
+ if err != nil {
+ log.Info("Failed to write override file: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Boot out the launchd service (just in case - it should be off)
+ cmd := exec.Command("launchctl", "bootout", "system", overwriteFilePath)
+ err = cmd.Run()
+ if err != nil {
+ if !strings.Contains(err.Error(), "No such process") {
+ log.Info("Failed to bootout launchd service: %v\n", err)
+ os.Exit(1)
+ }
+ }
+
+ // Load the launchd service
+ cmd = exec.Command("launchctl", "load", overwriteFilePath)
+ err = cmd.Run()
+ if err != nil {
+ log.Info("Failed to load launchd service: %v\n", err)
+ os.Exit(1)
+ }
+
+ fmt.Println("Launchd service updated and reloaded successfully.")
+ return nil
+}
+
+// TODO move to configure as a generic
+func handleDataDirMac() error {
+ // Ensure the ~/.algorand directory exists
+ algorandDir := filepath.Join(os.Getenv("HOME"), ".algorand")
+ if err := os.MkdirAll(algorandDir, 0755); err != nil {
+ return err
+ }
+
+ // Check if genesis.json file exists in ~/.algorand
+ // TODO: replace with algocfg or goal templates
+ genesisFilePath := filepath.Join(os.Getenv("HOME"), ".algorand", "genesis.json")
+ _, err := os.Stat(genesisFilePath)
+ if !os.IsNotExist(err) {
+ return nil
+ }
+
+ log.Info("Downloading mainnet genesis.json file to ~/.algorand/genesis.json")
+
+ // Download the genesis.json file
+ resp, err := http.Get("https://raw.githubusercontent.com/algorand/go-algorand/db7f1627e4919b05aef5392504e48b93a90a0146/installer/genesis/mainnet/genesis.json")
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ // Create the file
+ out, err := os.Create(genesisFilePath)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ // Write the content to the file
+ _, err = io.Copy(out, resp.Body)
+ if err != nil {
+ return err
+ }
+
+ log.Info("mainnet genesis.json file downloaded successfully.")
+ return nil
+}
+
+func EnsureService() error {
+ log.Debug("Ensuring Algorand service is running")
+ path, err := exec.LookPath("algod")
+ if err != nil {
+ log.Error("algod does not exist in path")
+ return err
+ }
+ // Define the launchd plist content
+ plistContent := fmt.Sprintf(`
+
+
+
+ Label
+ com.algorand.algod
+ ProgramArguments
+
+ %s
+ -d
+ %s/.algorand
+
+ RunAtLoad
+
+ Debug
+
+ StandardOutPath
+ /tmp/algod.out
+ StandardErrorPath
+ /tmp/algod.err
+
+`, path, os.Getenv("HOME"))
+
+ // Write the plist content to a file
+ plistPath := "/Library/LaunchDaemons/com.algorand.algod.plist"
+ err = os.MkdirAll(filepath.Dir(plistPath), 0755)
+ if err != nil {
+ log.Info("Failed to create LaunchDaemons directory: %v\n", err)
+ cobra.CheckErr(err)
+ }
+
+ err = os.WriteFile(plistPath, []byte(plistContent), 0644)
+ if err != nil {
+ log.Info("Failed to write plist file: %v\n", err)
+ cobra.CheckErr(err)
+ }
+ return system.RunAll(system.CmdsList{
+ {"launchctl", "load", plistPath},
+ {"launchctl", "list", "com.algorand.algod"},
+ })
+}
diff --git a/internal/algod/msgs/errors.go b/internal/algod/msgs/errors.go
new file mode 100644
index 00000000..d68b0c52
--- /dev/null
+++ b/internal/algod/msgs/errors.go
@@ -0,0 +1,5 @@
+package msgs
+
+const (
+ InvalidDataDirectory = "algorand data directory is invalid"
+)
diff --git a/internal/algod/utils/utils.go b/internal/algod/utils/utils.go
new file mode 100644
index 00000000..a32de05d
--- /dev/null
+++ b/internal/algod/utils/utils.go
@@ -0,0 +1,69 @@
+package utils
+
+import (
+ "github.com/algorandfoundation/algorun-tui/internal/system"
+ "github.com/spf13/cobra"
+ "os"
+ "path/filepath"
+)
+
+func IsDataDir(path string) bool {
+ info, err := os.Stat(path)
+
+ // Check if the path exists
+ if os.IsNotExist(err) {
+ return false
+ }
+
+ // Check if the path is a directory
+ if !info.IsDir() {
+ return false
+ }
+
+ paths := system.FindPathToFile(path, "algod.token")
+ if len(paths) == 1 {
+ return true
+ }
+ return false
+}
+
+func GetKnownPaths() []string {
+ // Hardcoded paths known to be common Algorand data directories
+ binPaths := []string{
+ "/opt/homebrew/bin/algod",
+ "/opt/homebrew/bin/algod",
+ }
+
+ var paths []string
+
+ for _, path := range binPaths {
+ if IsDataDir(path) {
+ paths = append(paths, path)
+ }
+ }
+
+ return paths
+}
+
+// GetKnownDataPaths Does a lazy check for Algorand data directories, based off of known common paths
+func GetKnownDataPaths() []string {
+ home, err := os.UserHomeDir()
+ cobra.CheckErr(err)
+
+ // Hardcoded paths known to be common Algorand data directories
+ commonAlgorandDataDirPaths := []string{
+ "/var/lib/algorand",
+ filepath.Join(home, "node", "data"),
+ filepath.Join(home, ".algorand"),
+ }
+
+ var paths []string
+
+ for _, path := range commonAlgorandDataDirPaths {
+ if IsDataDir(path) {
+ paths = append(paths, path)
+ }
+ }
+
+ return paths
+}
diff --git a/internal/system/cmds.go b/internal/system/cmds.go
new file mode 100644
index 00000000..45b9ab48
--- /dev/null
+++ b/internal/system/cmds.go
@@ -0,0 +1,115 @@
+package system
+
+import (
+ "fmt"
+ "github.com/algorandfoundation/algorun-tui/ui/style"
+ "github.com/charmbracelet/log"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+)
+
+const CmdFailedErrorMsg = "command failed: %s output: %s error: %v"
+
+func IsSudo() bool {
+ return os.Geteuid() == 0
+}
+
+func IsCmdRunning(name string) bool {
+ err := exec.Command("pgrep", name).Run()
+ return err == nil
+}
+
+// CmdExists checks that a bash cli/cmd tool exists
+func CmdExists(tool string) bool {
+ _, err := exec.LookPath(tool)
+ return err == nil
+}
+
+type CmdsList [][]string
+
+func (l CmdsList) Su(user string) CmdsList {
+ for i, args := range l {
+ if !strings.HasPrefix(args[0], "sudo") {
+ l[i] = append([]string{"sudo", "-u", user}, args...)
+ }
+ }
+ return l
+}
+
+func Run(args []string) (string, error) {
+ cmd := exec.Command(args[0], args[1:]...)
+ output, err := cmd.CombinedOutput()
+ return string(output), err
+}
+
+func RunAll(list CmdsList) error {
+ // Run each installation command
+ for _, args := range list {
+ cmd := exec.Command(args[0], args[1:]...)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ log.Error(fmt.Sprintf("%s: %s", style.Red.Render("Failed"), strings.Join(args, " ")))
+ return fmt.Errorf(CmdFailedErrorMsg, strings.Join(args, " "), output, err)
+ }
+ log.Debug(fmt.Sprintf("%s: %s", style.Green.Render("Running"), strings.Join(args, " ")))
+ }
+ return nil
+}
+
+// FindPathToFile finds path(s) to a file in a directory and its subdirectories using parallel processing
+func FindPathToFile(startDir string, targetFileName string) []string {
+ var dirPaths []string
+ var mu sync.Mutex
+ var wg sync.WaitGroup
+
+ fileChan := make(chan string)
+
+ // Worker function to process files
+ worker := func() {
+ defer wg.Done()
+ for path := range fileChan {
+ info, err := os.Stat(path)
+ if err != nil {
+ continue
+ }
+ if !info.IsDir() && info.Name() == targetFileName {
+ dirPath := filepath.Dir(path)
+ mu.Lock()
+ dirPaths = append(dirPaths, dirPath)
+ mu.Unlock()
+ }
+ }
+ }
+
+ // Start worker goroutines
+ numWorkers := 4 // Adjust the number of workers based on your system's capabilities
+ for i := 0; i < numWorkers; i++ {
+ wg.Add(1)
+ go worker()
+ }
+
+ // Walk the directory tree and send file paths to the channel
+ err := filepath.Walk(startDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ // Ignore permission msgs
+ if os.IsPermission(err) {
+ return nil
+ }
+ return err
+ }
+ fileChan <- path
+ return nil
+ })
+
+ close(fileChan)
+ wg.Wait()
+
+ if err != nil {
+ panic(err)
+ }
+
+ return dirPaths
+}
diff --git a/internal/system/service.go b/internal/system/service.go
new file mode 100644
index 00000000..7a573f02
--- /dev/null
+++ b/internal/system/service.go
@@ -0,0 +1,16 @@
+package system
+
+type Interface interface {
+ IsInstalled() bool
+ IsRunning() bool
+ IsService() bool
+ SetNetwork(network string) error
+ Install() error
+ Update() error
+ Uninstall() error
+ Start() error
+ Stop() error
+ Restart() error
+ UpdateService(dataDirectoryPath string) error
+ EnsureService() error
+}
diff --git a/main.go b/main.go
index f282015a..5dc55870 100644
--- a/main.go
+++ b/main.go
@@ -2,8 +2,21 @@ package main
import (
"github.com/algorandfoundation/algorun-tui/cmd"
+ "github.com/charmbracelet/log"
+ "os"
)
+func init() {
+ // Log as JSON instead of the default ASCII formatter.
+ //log.SetFormatter(log.JSONFormatter)
+
+ // Output to stdout instead of the default stderr
+ // Can be any io.Writer, see below for File example
+ log.SetOutput(os.Stdout)
+
+ // Only log the warning severity or above.
+ log.SetLevel(log.DebugLevel)
+}
func main() {
err := cmd.Execute()
if err != nil {
diff --git a/main_test.go b/main_test.go
index 207c8325..ce07c8fb 100644
--- a/main_test.go
+++ b/main_test.go
@@ -1,8 +1,9 @@
package main
import (
- "github.com/spf13/viper"
"testing"
+
+ "github.com/spf13/viper"
)
func Test_Main(t *testing.T) {
diff --git a/playbook.yaml b/playbook.yaml
new file mode 100644
index 00000000..097634f7
--- /dev/null
+++ b/playbook.yaml
@@ -0,0 +1,22 @@
+- name: Test Instance
+ hosts: localhost
+ tasks:
+ - name: Ensure algorun exists
+ stat:
+ path: /usr/bin/algorun
+ register: binpath
+ - name: Fail missing binary
+ fail:
+ msg: "Must have algorun installed!"
+ when: not binpath.stat.exists
+ - name: Run installer
+ command: algorun node install
+ - name: Run stop
+ command: algorun node stop
+ - name: Run upgrade
+ command: algorun node upgrade
+ - name: Run stop
+ command: algorun node stop
+ - name: Run Start
+ command: algorun node start
+ # TODO: start a private network, fund TUI account and run TUI integration
\ No newline at end of file
diff --git a/ui/README.md b/ui/README.md
index a50a9d1e..4d528a8f 100644
--- a/ui/README.md
+++ b/ui/README.md
@@ -1,13 +1,13 @@
# Overview
-The ui package contains bubbletea interfaces.
+The ui package contains bubbletea interfaces.
## Common practices
A `style.go` file holds lipgloss predefined styles for the package.
All components are instances of a `tea.Model` which is composed of models
-from the `internal` package.
+from the `internal` package.
Components can either be single file or independent packages.
Example for `status.go` single file component:
@@ -26,12 +26,13 @@ func (m StatusViewModel) Int(){}
//other tea.Model interfaces ...
```
-Once the component is sufficiently complex or needs to be reused, it can be moved
+Once the component is sufficiently complex or needs to be reused, it can be moved
to its own package
Example refactor for `status.go` to a package:
#### ui/status/model.go
+
```go
package status
import "github.com/algorandfoundation/algorun-tui/internal"
@@ -43,6 +44,7 @@ type ViewModel struct {
```
#### ui/status/controller.go
+
```go
package status
@@ -84,4 +86,4 @@ package status
import "github.com/charmbracelet/lipgloss"
var someStyle = lipgloss.NewStyle()
-```
\ No newline at end of file
+```
diff --git a/ui/app/app_test.go b/ui/app/app_test.go
index 311ffdb4..90ce2deb 100644
--- a/ui/app/app_test.go
+++ b/ui/app/app_test.go
@@ -46,7 +46,7 @@ func Test_EmitDeleteKey(t *testing.T) {
t.Error("Expected ABC")
}
if evt.Err != nil {
- t.Error("Expected no errors")
+ t.Error("Expected no msgs")
}
client = test.GetClient(true)
@@ -60,7 +60,7 @@ func Test_EmitDeleteKey(t *testing.T) {
t.Error("Expected no response")
}
if evt.Err == nil {
- t.Error("Expected errors")
+ t.Error("Expected msgs")
}
}
diff --git a/ui/style/style.go b/ui/style/style.go
index 94c1089c..ef192ff4 100644
--- a/ui/style/style.go
+++ b/ui/style/style.go
@@ -178,3 +178,12 @@ func TruncateLeft(line string, padding int) string {
return ansiStyle + strings.Join(wrapped[1:], "")
}
+
+const BANNER = `
+ _____ .__ __________
+ / _ \ | | ____ ____\______ \__ __ ____
+ / /_\ \| | / ___\ / _ \| _/ | \/ \
+/ | \ |__/ /_/ > <_> ) | \ | / | \
+\____|__ /____/\___ / \____/|____|_ /____/|___| /
+ \/ /_____/ \/ \/
+`