Skip to content

Commit 7b79691

Browse files
authored
feat(dvb): improve CLI developer experience with global flags, unified node commands, and quick provision (#96)
1 parent 248ddd0 commit 7b79691

15 files changed

Lines changed: 455 additions & 212 deletions

cmd/dvb/completion.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// cmd/dvb/completion.go
2+
package main
3+
4+
import (
5+
"fmt"
6+
"os"
7+
8+
"github.com/spf13/cobra"
9+
)
10+
11+
func newCompletionCmd() *cobra.Command {
12+
cmd := &cobra.Command{
13+
Use: "completion [bash|zsh|fish|powershell]",
14+
Short: "Generate shell completion scripts",
15+
Long: `Generate shell completion scripts for dvb.
16+
17+
To load completions:
18+
19+
Bash:
20+
$ source <(dvb completion bash)
21+
# To load for each session:
22+
$ echo 'source <(dvb completion bash)' >> ~/.bashrc
23+
24+
Zsh:
25+
$ source <(dvb completion zsh)
26+
# To load for each session:
27+
$ echo 'source <(dvb completion zsh)' >> ~/.zshrc
28+
29+
Fish:
30+
$ dvb completion fish | source
31+
# To load for each session:
32+
$ dvb completion fish > ~/.config/fish/completions/dvb.fish
33+
34+
PowerShell:
35+
PS> dvb completion powershell | Out-String | Invoke-Expression
36+
# To load for each session, add to your profile:
37+
PS> dvb completion powershell >> $PROFILE`,
38+
Args: cobra.ExactArgs(1),
39+
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
40+
RunE: func(cmd *cobra.Command, args []string) error {
41+
switch args[0] {
42+
case "bash":
43+
return cmd.Root().GenBashCompletion(os.Stdout)
44+
case "zsh":
45+
return cmd.Root().GenZshCompletion(os.Stdout)
46+
case "fish":
47+
return cmd.Root().GenFishCompletion(os.Stdout, true)
48+
case "powershell":
49+
return cmd.Root().GenPowerShellCompletion(os.Stdout)
50+
default:
51+
return fmt.Errorf("unsupported shell: %s (supported: bash, zsh, fish, powershell)", args[0])
52+
}
53+
},
54+
}
55+
56+
return cmd
57+
}

cmd/dvb/daemon.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ Examples:
241241

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

247247
// Create local client and call WhoAmI

cmd/dvb/delete.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ func runDeleteFromFile(cmd *cobra.Command, namespace, filePath string, force, dr
107107
}
108108

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

172172
// Confirm if not forced
173-
if !force {
173+
if !force && !ShouldSkipConfirm() {
174174
fmt.Printf("Are you sure you want to delete devnet %q (namespace: %s)? [y/N] ", name, ns)
175175
var response string
176176
if _, err := fmt.Scanln(&response); err != nil || (response != "y" && response != "Y") {

cmd/dvb/errors.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// cmd/dvb/errors.go
2+
package main
3+
4+
import "fmt"
5+
6+
// errDaemonNotRunning is the standard error returned when daemon connection is required but unavailable.
7+
var errDaemonNotRunning = fmt.Errorf("daemon not running - start with: devnetd")
8+
9+
// requireDaemon returns errDaemonNotRunning if the daemon client is not connected.
10+
// Usage: if err := requireDaemon(); err != nil { return err }
11+
func requireDaemon() error {
12+
if daemonClient == nil {
13+
return errDaemonNotRunning
14+
}
15+
return nil
16+
}

cmd/dvb/get.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ Examples:
4444
dvb get staging/my-devnet`,
4545
Args: cobra.MaximumNArgs(1),
4646
RunE: func(cmd *cobra.Command, args []string) error {
47-
if daemonClient == nil {
48-
return fmt.Errorf("daemon not running - start with: devnetd")
47+
if err := requireDaemon(); err != nil {
48+
return err
4949
}
5050

5151
var explicitDevnet string

cmd/dvb/interactive.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// cmd/dvb/interactive.go
2+
package main
3+
4+
import (
5+
"os"
6+
7+
"github.com/altuslabsxyz/devnet-builder/internal/tui"
8+
)
9+
10+
var (
11+
// flagYes auto-confirms all confirmation prompts.
12+
flagYes bool
13+
// flagNonInteractive disables all interactive UI elements (pickers, wizards, TUI).
14+
flagNonInteractive bool
15+
)
16+
17+
// IsNonInteractive returns true if interactive mode should be disabled.
18+
// Checks the --non-interactive flag, DVB_NON_INTERACTIVE=1 / CI=true env vars, or non-TTY.
19+
func IsNonInteractive() bool {
20+
return flagNonInteractive ||
21+
os.Getenv("DVB_NON_INTERACTIVE") == "1" ||
22+
os.Getenv("CI") == "true" ||
23+
!tui.IsInteractive()
24+
}
25+
26+
// ShouldSkipConfirm returns true if confirmation prompts should be auto-accepted.
27+
// Checks --yes flag, --non-interactive flag, env vars, or non-TTY.
28+
func ShouldSkipConfirm() bool {
29+
return flagYes || IsNonInteractive()
30+
}

cmd/dvb/main.go

Lines changed: 41 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33

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

187189
// Add commands
188190
rootCmd.AddCommand(
@@ -193,15 +195,16 @@ func main() {
193195
newGetCmd(),
194196
newDeleteCmd(),
195197
newListCmd(),
196-
newStartCmd(),
197-
newStopCmd(),
198198
newNodeCmd(),
199199
newUpgradeCmd(),
200200
newTxCmd(),
201201
newGovCmd(),
202202
newGenesisCmd(),
203203
newProvisionCmd(),
204204
newConfigCmd(),
205+
newCompletionCmd(),
206+
newDeprecatedStartCmd(),
207+
newDeprecatedStopCmd(),
205208
)
206209

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

262265
func newListCmd() *cobra.Command {
263-
var namespace string
266+
var (
267+
namespace string
268+
output string
269+
)
264270

265271
cmd := &cobra.Command{
266272
Use: "list",
267273
Short: "List all devnets",
268274
Aliases: []string{"ls"},
269275
RunE: func(cmd *cobra.Command, args []string) error {
270-
if daemonClient == nil {
271-
return fmt.Errorf("daemon not running - start with: devnetd")
276+
if err := requireDaemon(); err != nil {
277+
return err
272278
}
273279

274280
devnets, err := daemonClient.ListDevnets(cmd.Context(), namespace)
275281
if err != nil {
276282
return err
277283
}
278284

285+
if output == "json" {
286+
return printJSON(devnets)
287+
}
288+
279289
if len(devnets) == 0 {
280290
fmt.Println("No devnets found")
281291
return nil
@@ -299,143 +309,49 @@ func newListCmd() *cobra.Command {
299309
}
300310

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

303314
return cmd
304315
}
305316

306-
func newStartCmd() *cobra.Command {
307-
var (
308-
namespace string
309-
noWait bool
310-
verbose bool
311-
force bool
312-
)
313-
317+
// newDeprecatedStartCmd returns a hidden "start" command that tells users to use "dvb node start --all".
318+
func newDeprecatedStartCmd() *cobra.Command {
314319
cmd := &cobra.Command{
315-
Use: "start [devnet]",
316-
Short: "Start a stopped devnet",
317-
Long: "Start a stopped devnet. If already running, prompts to restart (use --force to skip prompt).",
318-
Args: cobra.MaximumNArgs(1),
320+
Use: "start",
321+
Short: "Deprecated: use 'dvb node start --all'",
322+
Hidden: true,
323+
Deprecated: "use 'dvb node start --all' instead",
319324
RunE: func(cmd *cobra.Command, args []string) error {
320-
if daemonClient == nil {
321-
return fmt.Errorf("daemon not running - start with: devnetd")
322-
}
323-
324-
// 1. Resolve devnet from args or context
325-
var explicitDevnet string
326-
if len(args) > 0 {
327-
explicitDevnet = args[0]
328-
}
329-
330-
ns, name, err := resolveWithSuggestions(explicitDevnet, namespace)
331-
if err != nil {
332-
return err
333-
}
334-
335-
printContextHeader(explicitDevnet, currentContext)
336-
337-
// 2. Get current status to check if already running
338-
devnet, err := daemonClient.GetDevnet(cmd.Context(), ns, name)
339-
if err != nil {
340-
return fmt.Errorf("failed to get devnet: %w", err)
341-
}
342-
343-
// 3. Check if running - prompt for restart (unless --force)
344-
if devnet.Status.Phase == types.PhaseRunning {
345-
if !force {
346-
// In non-interactive mode without --force, error out
347-
if !tui.IsInteractive() {
348-
return fmt.Errorf("devnet %q is already running; use --force to restart in non-interactive mode", name)
349-
}
350-
// Interactive mode: prompt for confirmation
351-
fmt.Fprintf(os.Stderr, "Devnet %q is already running. Restart? [y/N] ", name)
352-
var response string
353-
if _, err := fmt.Scanln(&response); err != nil ||
354-
(response != "y" && response != "Y") {
355-
fmt.Fprintf(os.Stderr, "Cancelled\n")
356-
return nil
357-
}
358-
}
359-
360-
// Stop for restart
361-
color.Yellow("Stopping devnet %q...", name)
362-
if _, err := daemonClient.StopDevnet(cmd.Context(), ns, name); err != nil {
363-
return fmt.Errorf("failed to stop: %w", err)
364-
}
365-
}
366-
367-
// 4. Start the devnet
368-
devnet, err = daemonClient.StartDevnet(cmd.Context(), ns, name)
369-
if err != nil {
370-
return fmt.Errorf("failed to start: %w", err)
371-
}
372-
373-
// 5. Handle --no-wait
374-
if noWait {
375-
color.Green("✓ Devnet %q starting", name)
376-
fmt.Fprintf(os.Stderr, " Phase: %s\n", devnet.Status.Phase)
377-
return nil
378-
}
379-
380-
// 6. Handle wait behavior (same pattern as provision.go)
381-
if tui.IsInteractive() && !verbose {
382-
// Use TUI for interactive terminals
383-
return runStartTUI(cmd.Context(), ns, name)
384-
}
385-
// Stream detailed status (verbose or non-interactive)
386-
return pollStartStatus(cmd.Context(), ns, name)
325+
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")
387326
},
388327
}
389-
390-
cmd.Flags().StringVarP(&namespace, "namespace", "n", "", "Namespace (defaults to server default)")
391-
cmd.Flags().BoolVar(&noWait, "no-wait", false, "Return immediately without waiting")
392-
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show verbose status updates")
393-
cmd.Flags().BoolVarP(&force, "force", "f", false, "Force restart without confirmation prompt")
394-
395328
return cmd
396329
}
397330

398-
func newStopCmd() *cobra.Command {
399-
var namespace string
400-
331+
// newDeprecatedStopCmd returns a hidden "stop" command that tells users to use "dvb node stop --all".
332+
func newDeprecatedStopCmd() *cobra.Command {
401333
cmd := &cobra.Command{
402-
Use: "stop [devnet]",
403-
Short: "Stop a running devnet",
404-
Args: cobra.MaximumNArgs(1),
334+
Use: "stop",
335+
Short: "Deprecated: use 'dvb node stop --all'",
336+
Hidden: true,
337+
Deprecated: "use 'dvb node stop --all' instead",
405338
RunE: func(cmd *cobra.Command, args []string) error {
406-
if daemonClient == nil {
407-
return fmt.Errorf("daemon not running - start with: devnetd")
408-
}
409-
410-
var explicitDevnet string
411-
if len(args) > 0 {
412-
explicitDevnet = args[0]
413-
}
414-
415-
ns, name, err := resolveWithSuggestions(explicitDevnet, namespace)
416-
if err != nil {
417-
return err
418-
}
419-
420-
printContextHeader(explicitDevnet, currentContext)
421-
422-
devnet, err := daemonClient.StopDevnet(cmd.Context(), ns, name)
423-
if err != nil {
424-
return err
425-
}
426-
427-
color.Green("✓ Devnet %q stopped", devnet.Metadata.Name)
428-
fmt.Printf(" Phase: %s\n", devnet.Status.Phase)
429-
430-
return nil
339+
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")
431340
},
432341
}
433-
434-
cmd.Flags().StringVarP(&namespace, "namespace", "n", "", "Namespace (defaults to server default)")
435-
436342
return cmd
437343
}
438344

345+
// printJSON marshals v to indented JSON and writes it to stdout.
346+
func printJSON(v interface{}) error {
347+
out, err := json.MarshalIndent(v, "", " ")
348+
if err != nil {
349+
return fmt.Errorf("failed to marshal json: %w", err)
350+
}
351+
fmt.Println(string(out))
352+
return nil
353+
}
354+
439355
// getBinaryNameFromPlugin returns the CLI binary name for a given plugin.
440356
// Falls back to "gaiad" if plugin is unknown.
441357
func getBinaryNameFromPlugin(plugin string) string {

0 commit comments

Comments
 (0)