Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions cmd/dvb/completion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// cmd/dvb/completion.go
package main

import (
"fmt"
"os"

"github.com/spf13/cobra"
)

func newCompletionCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion scripts",
Long: `Generate shell completion scripts for dvb.

To load completions:

Bash:
$ source <(dvb completion bash)
# To load for each session:
$ echo 'source <(dvb completion bash)' >> ~/.bashrc

Zsh:
$ source <(dvb completion zsh)
# To load for each session:
$ echo 'source <(dvb completion zsh)' >> ~/.zshrc

Fish:
$ dvb completion fish | source
# To load for each session:
$ dvb completion fish > ~/.config/fish/completions/dvb.fish

PowerShell:
PS> dvb completion powershell | Out-String | Invoke-Expression
# To load for each session, add to your profile:
PS> dvb completion powershell >> $PROFILE`,
Args: cobra.ExactArgs(1),
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
return cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
return cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
return cmd.Root().GenPowerShellCompletion(os.Stdout)
default:
return fmt.Errorf("unsupported shell: %s (supported: bash, zsh, fish, powershell)", args[0])
}
},
}

return cmd
}
2 changes: 1 addition & 1 deletion cmd/dvb/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ Examples:

// Local connection
if !client.IsDaemonRunning() {
return fmt.Errorf("daemon not running - start with: devnetd")
return errDaemonNotRunning
}

// Create local client and call WhoAmI
Expand Down
4 changes: 2 additions & 2 deletions cmd/dvb/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ func runDeleteFromFile(cmd *cobra.Command, namespace, filePath string, force, dr
}

// Confirm if not forced
if !force {
if !force && !ShouldSkipConfirm() {
fmt.Printf("This will delete %d devnet(s):\n", len(devnets))
for i := range devnets {
ns := devnets[i].Metadata.Namespace
Expand Down Expand Up @@ -170,7 +170,7 @@ func runDeleteDevnet(cmd *cobra.Command, namespace, explicitName string, force,
}

// Confirm if not forced
if !force {
if !force && !ShouldSkipConfirm() {
fmt.Printf("Are you sure you want to delete devnet %q (namespace: %s)? [y/N] ", name, ns)
var response string
if _, err := fmt.Scanln(&response); err != nil || (response != "y" && response != "Y") {
Expand Down
16 changes: 16 additions & 0 deletions cmd/dvb/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// cmd/dvb/errors.go
package main

import "fmt"

// errDaemonNotRunning is the standard error returned when daemon connection is required but unavailable.
var errDaemonNotRunning = fmt.Errorf("daemon not running - start with: devnetd")

// requireDaemon returns errDaemonNotRunning if the daemon client is not connected.
// Usage: if err := requireDaemon(); err != nil { return err }
func requireDaemon() error {
if daemonClient == nil {
return errDaemonNotRunning
}
return nil
}
4 changes: 2 additions & 2 deletions cmd/dvb/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ Examples:
dvb get staging/my-devnet`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if daemonClient == nil {
return fmt.Errorf("daemon not running - start with: devnetd")
if err := requireDaemon(); err != nil {
return err
}

var explicitDevnet string
Expand Down
30 changes: 30 additions & 0 deletions cmd/dvb/interactive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// cmd/dvb/interactive.go
package main

import (
"os"

"github.com/altuslabsxyz/devnet-builder/internal/tui"
)

var (
// flagYes auto-confirms all confirmation prompts.
flagYes bool
// flagNonInteractive disables all interactive UI elements (pickers, wizards, TUI).
flagNonInteractive bool
)

// IsNonInteractive returns true if interactive mode should be disabled.
// Checks the --non-interactive flag, DVB_NON_INTERACTIVE=1 / CI=true env vars, or non-TTY.
func IsNonInteractive() bool {
return flagNonInteractive ||
os.Getenv("DVB_NON_INTERACTIVE") == "1" ||
os.Getenv("CI") == "true" ||
!tui.IsInteractive()
}

// ShouldSkipConfirm returns true if confirmation prompts should be auto-accepted.
// Checks --yes flag, --non-interactive flag, env vars, or non-TTY.
func ShouldSkipConfirm() bool {
return flagYes || IsNonInteractive()
}
166 changes: 41 additions & 125 deletions cmd/dvb/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
Expand All @@ -13,7 +14,6 @@ import (
"github.com/altuslabsxyz/devnet-builder/internal/daemon/types"
"github.com/altuslabsxyz/devnet-builder/internal/dvbcontext"
"github.com/altuslabsxyz/devnet-builder/internal/output"
"github.com/altuslabsxyz/devnet-builder/internal/tui"
"github.com/altuslabsxyz/devnet-builder/internal/version"
"github.com/fatih/color"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -183,6 +183,8 @@ func main() {
rootCmd.PersistentFlags().StringVar(&flagServer, "server", "", "Remote devnetd server address (e.g., devnetd.example.com:9000)")
rootCmd.PersistentFlags().StringVar(&flagAPIKey, "api-key", "", "API key for remote server authentication")
rootCmd.PersistentFlags().BoolVar(&flagLocal, "local", false, "Force local Unix socket connection (ignore config)")
rootCmd.PersistentFlags().BoolVarP(&flagYes, "yes", "y", false, "Auto-confirm all prompts (skip confirmations)")
rootCmd.PersistentFlags().BoolVar(&flagNonInteractive, "non-interactive", false, "Disable all interactive UI elements (pickers, wizards)")

// Add commands
rootCmd.AddCommand(
Expand All @@ -193,15 +195,16 @@ func main() {
newGetCmd(),
newDeleteCmd(),
newListCmd(),
newStartCmd(),
newStopCmd(),
newNodeCmd(),
newUpgradeCmd(),
newTxCmd(),
newGovCmd(),
newGenesisCmd(),
newProvisionCmd(),
newConfigCmd(),
newCompletionCmd(),
newDeprecatedStartCmd(),
newDeprecatedStopCmd(),
)

if err := rootCmd.Execute(); err != nil {
Expand Down Expand Up @@ -260,22 +263,29 @@ func newVersionCmd() *cobra.Command {
}

func newListCmd() *cobra.Command {
var namespace string
var (
namespace string
output string
)

cmd := &cobra.Command{
Use: "list",
Short: "List all devnets",
Aliases: []string{"ls"},
RunE: func(cmd *cobra.Command, args []string) error {
if daemonClient == nil {
return fmt.Errorf("daemon not running - start with: devnetd")
if err := requireDaemon(); err != nil {
return err
}

devnets, err := daemonClient.ListDevnets(cmd.Context(), namespace)
if err != nil {
return err
}

if output == "json" {
return printJSON(devnets)
}

if len(devnets) == 0 {
fmt.Println("No devnets found")
return nil
Expand All @@ -299,143 +309,49 @@ func newListCmd() *cobra.Command {
}

cmd.Flags().StringVarP(&namespace, "namespace", "n", "", "Filter by namespace (empty = all namespaces)")
cmd.Flags().StringVarP(&output, "output", "o", "", "Output format: json")

return cmd
}

func newStartCmd() *cobra.Command {
var (
namespace string
noWait bool
verbose bool
force bool
)

// newDeprecatedStartCmd returns a hidden "start" command that tells users to use "dvb node start --all".
func newDeprecatedStartCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "start [devnet]",
Short: "Start a stopped devnet",
Long: "Start a stopped devnet. If already running, prompts to restart (use --force to skip prompt).",
Args: cobra.MaximumNArgs(1),
Use: "start",
Short: "Deprecated: use 'dvb node start --all'",
Hidden: true,
Deprecated: "use 'dvb node start --all' instead",
RunE: func(cmd *cobra.Command, args []string) error {
if daemonClient == nil {
return fmt.Errorf("daemon not running - start with: devnetd")
}

// 1. Resolve devnet from args or context
var explicitDevnet string
if len(args) > 0 {
explicitDevnet = args[0]
}

ns, name, err := resolveWithSuggestions(explicitDevnet, namespace)
if err != nil {
return err
}

printContextHeader(explicitDevnet, currentContext)

// 2. Get current status to check if already running
devnet, err := daemonClient.GetDevnet(cmd.Context(), ns, name)
if err != nil {
return fmt.Errorf("failed to get devnet: %w", err)
}

// 3. Check if running - prompt for restart (unless --force)
if devnet.Status.Phase == types.PhaseRunning {
if !force {
// In non-interactive mode without --force, error out
if !tui.IsInteractive() {
return fmt.Errorf("devnet %q is already running; use --force to restart in non-interactive mode", name)
}
// Interactive mode: prompt for confirmation
fmt.Fprintf(os.Stderr, "Devnet %q is already running. Restart? [y/N] ", name)
var response string
if _, err := fmt.Scanln(&response); err != nil ||
(response != "y" && response != "Y") {
fmt.Fprintf(os.Stderr, "Cancelled\n")
return nil
}
}

// Stop for restart
color.Yellow("Stopping devnet %q...", name)
if _, err := daemonClient.StopDevnet(cmd.Context(), ns, name); err != nil {
return fmt.Errorf("failed to stop: %w", err)
}
}

// 4. Start the devnet
devnet, err = daemonClient.StartDevnet(cmd.Context(), ns, name)
if err != nil {
return fmt.Errorf("failed to start: %w", err)
}

// 5. Handle --no-wait
if noWait {
color.Green("✓ Devnet %q starting", name)
fmt.Fprintf(os.Stderr, " Phase: %s\n", devnet.Status.Phase)
return nil
}

// 6. Handle wait behavior (same pattern as provision.go)
if tui.IsInteractive() && !verbose {
// Use TUI for interactive terminals
return runStartTUI(cmd.Context(), ns, name)
}
// Stream detailed status (verbose or non-interactive)
return pollStartStatus(cmd.Context(), ns, name)
return fmt.Errorf("'dvb start' has been replaced by 'dvb node start --all'\n\nUsage:\n dvb node start --all # start all nodes\n dvb node start validator-0 # start a single node")
},
}

cmd.Flags().StringVarP(&namespace, "namespace", "n", "", "Namespace (defaults to server default)")
cmd.Flags().BoolVar(&noWait, "no-wait", false, "Return immediately without waiting")
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show verbose status updates")
cmd.Flags().BoolVarP(&force, "force", "f", false, "Force restart without confirmation prompt")

return cmd
}

func newStopCmd() *cobra.Command {
var namespace string

// newDeprecatedStopCmd returns a hidden "stop" command that tells users to use "dvb node stop --all".
func newDeprecatedStopCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "stop [devnet]",
Short: "Stop a running devnet",
Args: cobra.MaximumNArgs(1),
Use: "stop",
Short: "Deprecated: use 'dvb node stop --all'",
Hidden: true,
Deprecated: "use 'dvb node stop --all' instead",
RunE: func(cmd *cobra.Command, args []string) error {
if daemonClient == nil {
return fmt.Errorf("daemon not running - start with: devnetd")
}

var explicitDevnet string
if len(args) > 0 {
explicitDevnet = args[0]
}

ns, name, err := resolveWithSuggestions(explicitDevnet, namespace)
if err != nil {
return err
}

printContextHeader(explicitDevnet, currentContext)

devnet, err := daemonClient.StopDevnet(cmd.Context(), ns, name)
if err != nil {
return err
}

color.Green("✓ Devnet %q stopped", devnet.Metadata.Name)
fmt.Printf(" Phase: %s\n", devnet.Status.Phase)

return nil
return fmt.Errorf("'dvb stop' has been replaced by 'dvb node stop --all'\n\nUsage:\n dvb node stop --all # stop all nodes\n dvb node stop validator-0 # stop a single node")
},
}

cmd.Flags().StringVarP(&namespace, "namespace", "n", "", "Namespace (defaults to server default)")

return cmd
}

// printJSON marshals v to indented JSON and writes it to stdout.
func printJSON(v interface{}) error {
out, err := json.MarshalIndent(v, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal json: %w", err)
}
fmt.Println(string(out))
return nil
}

// getBinaryNameFromPlugin returns the CLI binary name for a given plugin.
// Falls back to "gaiad" if plugin is unknown.
func getBinaryNameFromPlugin(plugin string) string {
Expand Down
Loading
Loading