diff --git a/internal/cli/app.go b/internal/cli/app.go index c1da377..745755d 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -44,9 +44,11 @@ func setupGlobals(lc fx.Lifecycle, g *globals.Globals, v *vaultik.Vaultik, opts lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { g.StartTime = time.Now().UTC() + if opts.Cron || opts.Quiet { v.UI.SetQuiet(true) } + return nil }, }) @@ -105,6 +107,7 @@ func cleanStartupError(err error) error { if idx := strings.LastIndex(msg, "): "); idx >= 0 { msg = msg[idx+3:] } + return errors.New(msg) } @@ -122,7 +125,8 @@ func RunApp(ctx context.Context, app *fx.App) error { defer cancel() // Start the app - if err := app.Start(ctx); err != nil { + err := app.Start(ctx) + if err != nil { return cleanStartupError(err) } @@ -130,6 +134,7 @@ func RunApp(ctx context.Context, app *fx.App) error { shutdownComplete := make(chan struct{}) go func() { defer close(shutdownComplete) + <-sigChan log.Notice("Received interrupt signal, shutting down gracefully...") @@ -137,7 +142,8 @@ func RunApp(ctx context.Context, app *fx.App) error { shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) defer shutdownCancel() - if err := app.Stop(shutdownCtx); err != nil { + err := app.Stop(shutdownCtx) + if err != nil { log.Error("Error during shutdown", "error", err) } }() @@ -149,9 +155,11 @@ func RunApp(ctx context.Context, app *fx.App) error { return nil case <-ctx.Done(): // Context cancelled (shouldn't happen in normal operation) - if err := app.Stop(context.Background()); err != nil { + err := app.Stop(context.Background()) + if err != nil { log.Error("Error stopping app", "error", err) } + return ctx.Err() case <-app.Done(): // App finished running (e.g., backup completed) @@ -166,19 +174,24 @@ func RunApp(ctx context.Context, app *fx.App) error { func RunWithApp(ctx context.Context, opts AppOptions) error { // Acquire PID lock to prevent concurrent instances lockDir := filepath.Join(xdg.DataHome, "vaultik") + lock, err := pidlock.Acquire(lockDir) if err != nil { if errors.Is(err, pidlock.ErrAlreadyRunning) { return fmt.Errorf("cannot start: %w", err) } + return fmt.Errorf("failed to acquire lock: %w", err) } + defer func() { - if err := lock.Release(); err != nil { + err := lock.Release() + if err != nil { log.Warn("Failed to release PID lock", "error", err) } }() app := NewApp(opts) + return RunApp(ctx, app) } diff --git a/internal/cli/config.go b/internal/cli/config.go index 1dc2c4f..c1bc9c4 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "fmt" "os" "os/exec" @@ -240,16 +241,20 @@ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`, } dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o755); err != nil { + + err := os.MkdirAll(dir, 0o755) + if err != nil { return fmt.Errorf("creating config directory %s: %w", dir, err) } - if err := os.WriteFile(path, []byte(defaultConfigTemplate), 0o600); err != nil { + err = os.WriteFile(path, []byte(defaultConfigTemplate), 0o600) + if err != nil { return fmt.Errorf("writing config file: %w", err) } fmt.Printf("Config written to %s\n", path) fmt.Println("Edit it to set your age_recipients, snapshots, and storage_url.") + return nil }, } @@ -276,6 +281,7 @@ func newConfigEditCommand() *cobra.Command { ed.Stdin = os.Stdin ed.Stdout = os.Stdout ed.Stderr = os.Stderr + return ed.Run() }, } @@ -305,6 +311,7 @@ func newConfigGetCommand() *cobra.Command { if node.Kind == yaml.ScalarNode { fmt.Println(node.Value) + return nil } @@ -312,7 +319,9 @@ func newConfigGetCommand() *cobra.Command { if err != nil { return fmt.Errorf("marshaling value: %w", err) } + fmt.Print(string(out)) + return nil }, } @@ -363,6 +372,7 @@ Examples: } fmt.Printf("%s = %s\n", args[0], args[1]) + return nil }, } @@ -399,8 +409,9 @@ func yamlPathGet(root *yaml.Node, keys []string) (*yaml.Node, error) { node := root if node.Kind == yaml.DocumentNode { if len(node.Content) == 0 { - return nil, fmt.Errorf("empty config file") + return nil, errors.New("empty config file") } + node = node.Content[0] } @@ -408,13 +419,16 @@ func yamlPathGet(root *yaml.Node, keys []string) (*yaml.Node, error) { switch node.Kind { case yaml.MappingNode: found := false + for j := 0; j+1 < len(node.Content); j += 2 { if node.Content[j].Value == key { node = node.Content[j+1] found = true + break } } + if !found { return nil, fmt.Errorf("key not found: %s", strings.Join(keys[:i+1], ".")) } @@ -423,9 +437,11 @@ func yamlPathGet(root *yaml.Node, keys []string) (*yaml.Node, error) { if err != nil { return nil, fmt.Errorf("key %q is a list; use a numeric index", strings.Join(keys[:i], ".")) } + if idx < 0 || idx >= len(node.Content) { return nil, fmt.Errorf("index %d out of range for %s (len %d)", idx, strings.Join(keys[:i], "."), len(node.Content)) } + node = node.Content[idx] default: return nil, fmt.Errorf("key %q is not a map or list", strings.Join(keys[:i], ".")) @@ -445,6 +461,7 @@ func yamlPathSet(root *yaml.Node, keys []string, value string) error { if len(node.Content) == 0 { node.Content = []*yaml.Node{{Kind: yaml.MappingNode}} } + node = node.Content[0] } @@ -454,19 +471,23 @@ func yamlPathSet(root *yaml.Node, keys []string, value string) error { switch node.Kind { case yaml.MappingNode: var valueNode *yaml.Node + for j := 0; j+1 < len(node.Content); j += 2 { if node.Content[j].Value == key { valueNode = node.Content[j+1] + break } } if valueNode == nil { keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key} + valueNode = &yaml.Node{Kind: yaml.MappingNode} if last { valueNode = &yaml.Node{Kind: yaml.ScalarNode, Value: value} } + node.Content = append(node.Content, keyNode, valueNode) } else if last { setScalar(valueNode, value) @@ -479,18 +500,22 @@ func yamlPathSet(root *yaml.Node, keys []string, value string) error { if err != nil { return fmt.Errorf("key %q is a list; use a numeric index", strings.Join(keys[:i], ".")) } + if idx < 0 || idx > len(node.Content) { return fmt.Errorf("index %d out of range for %s (len %d)", idx, strings.Join(keys[:i], "."), len(node.Content)) } + if idx == len(node.Content) { newNode := &yaml.Node{Kind: yaml.MappingNode} if last { newNode = &yaml.Node{Kind: yaml.ScalarNode, Value: value} } + node.Content = append(node.Content, newNode) } else if last { setScalar(node.Content[idx], value) } + node = node.Content[idx] default: @@ -516,8 +541,10 @@ func configPathForInit() string { if rootFlags.ConfigPath != "" { return rootFlags.ConfigPath } + if envPath := os.Getenv("VAULTIK_CONFIG"); envPath != "" { return envPath } + return DefaultConfigPath() } diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index cf45f04..24ad8ab 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -12,7 +12,9 @@ import ( // that unmarshals into the Config struct with the expected snapshots. func TestDefaultConfigTemplateParses(t *testing.T) { var cfg config.Config - if err := yaml.Unmarshal([]byte(defaultConfigTemplate), &cfg); err != nil { + + err := yaml.Unmarshal([]byte(defaultConfigTemplate), &cfg) + if err != nil { t.Fatalf("default config template is not valid YAML: %v", err) } @@ -24,9 +26,11 @@ func TestDefaultConfigTemplateParses(t *testing.T) { if !ok { t.Fatal("expected 'home' snapshot in default config") } + if len(home.Paths) == 0 { t.Error("home snapshot should have at least one path") } + if len(home.Exclude) == 0 { t.Error("home snapshot should have exclude patterns") } @@ -35,9 +39,11 @@ func TestDefaultConfigTemplateParses(t *testing.T) { if !ok { t.Fatal("expected 'apps' snapshot in default config") } + if len(apps.Paths) != 1 || apps.Paths[0] != "/Applications" { t.Errorf("apps snapshot should back up /Applications, got %v", apps.Paths) } + if len(apps.Exclude) == 0 { t.Error("apps snapshot should have exclude patterns") } @@ -58,10 +64,14 @@ snapshots: func parseTestYAML(t *testing.T) *yaml.Node { t.Helper() + var root yaml.Node - if err := yaml.Unmarshal([]byte(testYAML), &root); err != nil { + + err := yaml.Unmarshal([]byte(testYAML), &root) + if err != nil { t.Fatalf("parsing test yaml: %v", err) } + return &root } @@ -91,11 +101,14 @@ func TestYAMLPathGet(t *testing.T) { if err == nil { t.Fatalf("expected error for %q", tt.path) } + return } + if err != nil { t.Fatalf("unexpected error: %v", err) } + if node.Value != tt.want { t.Errorf("get %q = %q, want %q", tt.path, node.Value, tt.want) } @@ -115,6 +128,7 @@ func TestYAMLPathSet(t *testing.T) { if err := yamlPathSet(root, splitPath("s3.endpoint"), "s3.example.com"); err != nil { t.Fatalf("set s3.endpoint: %v", err) } + if err := yamlPathSet(root, splitPath("newmap.newkey"), "val"); err != nil { t.Fatalf("set newmap.newkey: %v", err) } @@ -123,9 +137,11 @@ func TestYAMLPathSet(t *testing.T) { if err := yamlPathSet(root, splitPath("age_recipients.0"), "age1bbb"); err != nil { t.Fatalf("set age_recipients.0: %v", err) } + if err := yamlPathSet(root, splitPath("age_recipients.1"), "age1ccc"); err != nil { t.Fatalf("append age_recipients.1: %v", err) } + if err := yamlPathSet(root, splitPath("age_recipients.5"), "age1ddd"); err == nil { t.Error("expected out-of-range append to fail") } @@ -135,6 +151,7 @@ func TestYAMLPathSet(t *testing.T) { if err != nil { t.Fatalf("marshal: %v", err) } + text := string(out) for _, want := range []string{"newbucket", "s3.example.com", "newkey: val", "# top comment", "# inline comment", "age1bbb", "age1ccc"} { @@ -147,6 +164,7 @@ func TestYAMLPathSet(t *testing.T) { if err != nil { t.Fatalf("get after set: %v", err) } + if got.Value != "newbucket" { t.Errorf("s3.bucket = %q after set, want newbucket", got.Value) } diff --git a/internal/cli/database.go b/internal/cli/database.go index cf83da0..a6532fb 100644 --- a/internal/cli/database.go +++ b/internal/cli/database.go @@ -66,6 +66,7 @@ Use --force to skip the confirmation prompt.`, // Check if database exists if _, err := os.Stat(dbPath); os.IsNotExist(err) { fmt.Printf("Database does not exist: %s\n", dbPath) + return nil } @@ -73,9 +74,11 @@ Use --force to skip the confirmation prompt.`, if !force { fmt.Printf("This will delete the local state database at:\n %s\n\n", dbPath) fmt.Print("Are you sure? Type 'yes' to confirm: ") + var confirm string if _, err := fmt.Scanln(&confirm); err != nil || confirm != "yes" { fmt.Println("Aborted.") + return nil } } @@ -97,6 +100,7 @@ Use --force to skip the confirmation prompt.`, } log.Info("Local state database deleted", "path", dbPath) + return nil }, } diff --git a/internal/cli/duration.go b/internal/cli/duration.go index d9a92ab..8fd2350 100644 --- a/internal/cli/duration.go +++ b/internal/cli/duration.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "fmt" "regexp" "strconv" @@ -25,7 +26,7 @@ func parseDuration(s string) (time.Duration, error) { // Extended duration parsing // Check for negative values if strings.HasPrefix(strings.TrimSpace(s), "-") { - return 0, fmt.Errorf("negative durations are not supported") + return 0, errors.New("negative durations are not supported") } // Pattern matches: number + unit, repeated @@ -48,6 +49,7 @@ func parseDuration(s string) (time.Duration, error) { } var d time.Duration + switch unit { // Standard time units case "ns", "nanosecond", "nanoseconds": @@ -75,7 +77,7 @@ func parseDuration(s string) (time.Duration, error) { d = time.Duration(value * float64(365*24*time.Hour)) default: // Try parsing as standard Go duration unit - testStr := fmt.Sprintf("1%s", unit) + testStr := "1" + unit if _, err := time.ParseDuration(testStr); err == nil { // It's a valid Go duration unit, parse the full value fullStr := fmt.Sprintf("%g%s", value, unit) diff --git a/internal/cli/duration_test.go b/internal/cli/duration_test.go index 88cd67e..3edb414 100644 --- a/internal/cli/duration_test.go +++ b/internal/cli/duration_test.go @@ -185,6 +185,7 @@ func TestParseDuration(t *testing.T) { if tt.wantErr { assert.Error(t, err, "expected error for input %q", tt.input) + return } diff --git a/internal/cli/entry.go b/internal/cli/entry.go index 4b9fed8..1818e8c 100644 --- a/internal/cli/entry.go +++ b/internal/cli/entry.go @@ -19,13 +19,15 @@ func CLIEntry() { if len(short) > 12 { short = short[:12] } + writeStartupBanner(ui.New(os.Stdout), time.Now().UTC(), short) } rootCmd := NewRootCommand() rootCmd.SilenceErrors = true - if err := rootCmd.Execute(); err != nil { + err := rootCmd.Execute() + if err != nil { ReportError("%s", err.Error()) os.Exit(1) } @@ -48,10 +50,12 @@ func bannerSuppressedInArgs(args []string) bool { if a == "--" { return false } + switch a { case "--quiet", "-q", "--cron": return true } + if strings.HasPrefix(a, "--quiet=") || strings.HasPrefix(a, "--cron=") { return true } @@ -64,5 +68,6 @@ func bannerSuppressedInArgs(args []string) bool { } } } + return false } diff --git a/internal/cli/entry_test.go b/internal/cli/entry_test.go index cc7e623..f652399 100644 --- a/internal/cli/entry_test.go +++ b/internal/cli/entry_test.go @@ -21,12 +21,15 @@ func TestCLIEntry(t *testing.T) { expectedCommands := []string{"config", "snapshot", "prune", "info", "version", "remote", "database"} for _, expected := range expectedCommands { found := false + for _, cmd := range cmd.Commands() { if cmd.Use == expected || cmd.Name() == expected { found = true + break } } + if !found { t.Errorf("Expected command '%s' not found", expected) } @@ -41,12 +44,15 @@ func TestCLIEntry(t *testing.T) { expectedSubCommands := []string{"create", "list", "purge", "verify", "remove", "restore"} for _, expected := range expectedSubCommands { found := false + for _, subcmd := range snapshotCmd.Commands() { if subcmd.Use == expected || subcmd.Name() == expected { found = true + break } } + if !found { t.Errorf("Expected snapshot subcommand '%s' not found", expected) } diff --git a/internal/cli/info.go b/internal/cli/info.go index e4aca7e..88c2e2a 100644 --- a/internal/cli/info.go +++ b/internal/cli/info.go @@ -2,6 +2,7 @@ package cli import ( "context" + "errors" "os" "github.com/spf13/cobra" @@ -31,6 +32,7 @@ func NewInfoCommand() *cobra.Command { // Use the app framework rootFlags := GetRootFlags() + return RunWithApp(cmd.Context(), AppOptions{ ConfigPath: configPath, LogOptions: log.LogOptions{ @@ -44,21 +46,26 @@ func NewInfoCommand() *cobra.Command { lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { go func() { - if err := v.ShowInfo(); err != nil { - if err != context.Canceled { + err := v.ShowInfo() + if err != nil { + if !errors.Is(err, context.Canceled) { log.Error("Failed to show info", "error", err) ReportError("Failed to show info: %v", err) os.Exit(1) } } - if err := v.Shutdowner.Shutdown(); err != nil { + + err = v.Shutdowner.Shutdown() + if err != nil { log.Error("Failed to shutdown", "error", err) } }() + return nil }, OnStop: func(ctx context.Context) error { v.Cancel() + return nil }, }) diff --git a/internal/cli/prune.go b/internal/cli/prune.go index b367e9c..2524580 100644 --- a/internal/cli/prune.go +++ b/internal/cli/prune.go @@ -2,6 +2,7 @@ package cli import ( "context" + "errors" "os" "github.com/spf13/cobra" @@ -39,6 +40,7 @@ work (e.g. after a crashed backup or to reclaim storage).`, // Use the app framework like other commands rootFlags := GetRootFlags() + return RunWithApp(cmd.Context(), AppOptions{ ConfigPath: configPath, LogOptions: log.LogOptions{ @@ -54,26 +56,31 @@ work (e.g. after a crashed backup or to reclaim storage).`, // Start the prune operation in a goroutine go func() { // Run the prune operation - if err := v.Prune(opts); err != nil { - if err != context.Canceled { + err := v.Prune(opts) + if err != nil { + if !errors.Is(err, context.Canceled) { if !opts.JSON { log.Error("Prune operation failed", "error", err) ReportError("Prune failed: %v", err) } + os.Exit(1) } } // Shutdown the app when prune completes - if err := v.Shutdowner.Shutdown(); err != nil { + err = v.Shutdowner.Shutdown() + if err != nil { log.Error("Failed to shutdown", "error", err) } }() + return nil }, OnStop: func(ctx context.Context) error { log.Debug("Stopping prune operation") v.Cancel() + return nil }, }) diff --git a/internal/cli/remote.go b/internal/cli/remote.go index cde873e..8ccf499 100644 --- a/internal/cli/remote.go +++ b/internal/cli/remote.go @@ -2,7 +2,7 @@ package cli import ( "context" - "fmt" + "errors" "os" "github.com/spf13/cobra" @@ -41,7 +41,7 @@ This is destructive and irreversible. Requires --force.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { if !force { - return fmt.Errorf("remote nuke requires --force (this deletes ALL remote snapshots and blobs)") + return errors.New("remote nuke requires --force (this deletes ALL remote snapshots and blobs)") } configPath, err := ResolveConfigPath() @@ -50,6 +50,7 @@ This is destructive and irreversible. Requires --force.`, } rootFlags := GetRootFlags() + return RunWithApp(cmd.Context(), AppOptions{ ConfigPath: configPath, LogOptions: log.LogOptions{ @@ -63,21 +64,26 @@ This is destructive and irreversible. Requires --force.`, lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { go func() { - if err := v.NukeRemote(true); err != nil { - if err != context.Canceled { + err := v.NukeRemote(true) + if err != nil { + if !errors.Is(err, context.Canceled) { log.Error("Remote nuke failed", "error", err) ReportError("Remote nuke failed: %v", err) os.Exit(1) } } - if err := v.Shutdowner.Shutdown(); err != nil { + + err = v.Shutdowner.Shutdown() + if err != nil { log.Error("Failed to shutdown", "error", err) } }() + return nil }, OnStop: func(ctx context.Context) error { v.Cancel() + return nil }, }) @@ -113,6 +119,7 @@ func newRemoteInfoCommand() *cobra.Command { } rootFlags := GetRootFlags() + return RunWithApp(cmd.Context(), AppOptions{ ConfigPath: configPath, LogOptions: log.LogOptions{ @@ -126,23 +133,29 @@ func newRemoteInfoCommand() *cobra.Command { lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { go func() { - if err := v.RemoteInfo(jsonOutput); err != nil { - if err != context.Canceled { + err := v.RemoteInfo(jsonOutput) + if err != nil { + if !errors.Is(err, context.Canceled) { if !jsonOutput { log.Error("Failed to get remote info", "error", err) ReportError("Failed to get remote info: %v", err) } + os.Exit(1) } } - if err := v.Shutdowner.Shutdown(); err != nil { + + err = v.Shutdowner.Shutdown() + if err != nil { log.Error("Failed to shutdown", "error", err) } }() + return nil }, OnStop: func(ctx context.Context) error { v.Cancel() + return nil }, }) diff --git a/internal/cli/root.go b/internal/cli/root.go index 5c29b14..2e53dad 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -78,6 +78,7 @@ func ResolveConfigPath() (string, error) { if _, err := os.Stat(path); err != nil { return "", fmt.Errorf("config file from --config not found: %s (run 'vaultik config init --config %s' to create it)", path, path) } + return path, nil } @@ -85,6 +86,7 @@ func ResolveConfigPath() (string, error) { if _, err := os.Stat(path); err != nil { return "", fmt.Errorf("config file from $VAULTIK_CONFIG not found: %s (unset VAULTIK_CONFIG, point it at an existing file, or run 'vaultik config init')", path) } + return path, nil } @@ -114,5 +116,6 @@ func DefaultConfigPath() string { if os.Getuid() == 0 { return "/etc/vaultik/config.yml" } + return filepath.Join(xdg.ConfigHome, "vaultik", "config.yml") } diff --git a/internal/cli/snapshot.go b/internal/cli/snapshot.go index ef01eaa..b8b6793 100644 --- a/internal/cli/snapshot.go +++ b/internal/cli/snapshot.go @@ -2,6 +2,7 @@ package cli import ( "context" + "errors" "fmt" "os" @@ -58,6 +59,7 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, // Use the backup functionality from cli package rootFlags := GetRootFlags() + return RunWithApp(cmd.Context(), AppOptions{ ConfigPath: configPath, LogOptions: log.LogOptions{ @@ -74,8 +76,9 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, // Start the snapshot creation in a goroutine go func() { // --cron suppression is wired through v.UI by setupGlobals. - if err := v.CreateSnapshot(opts); err != nil { - if err != context.Canceled { + err := v.CreateSnapshot(opts) + if err != nil { + if !errors.Is(err, context.Canceled) { log.Error("Snapshot creation failed", "error", err) ReportError("Snapshot creation failed: %v", err) os.Exit(1) @@ -83,16 +86,19 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, } // Shutdown the app when snapshot completes - if err := v.Shutdowner.Shutdown(); err != nil { + err = v.Shutdowner.Shutdown() + if err != nil { log.Error("Failed to shutdown", "error", err) } }() + return nil }, OnStop: func(ctx context.Context) error { log.Debug("Stopping snapshot creation") // Cancel the Vaultik context v.Cancel() + return nil }, }) @@ -127,6 +133,7 @@ func newSnapshotListCommand() *cobra.Command { } rootFlags := GetRootFlags() + return RunWithApp(cmd.Context(), AppOptions{ ConfigPath: configPath, LogOptions: log.LogOptions{ @@ -140,21 +147,26 @@ func newSnapshotListCommand() *cobra.Command { lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { go func() { - if err := v.ListSnapshots(jsonOutput); err != nil { - if err != context.Canceled { + err := v.ListSnapshots(jsonOutput) + if err != nil { + if !errors.Is(err, context.Canceled) { log.Error("Failed to list snapshots", "error", err) ReportError("Failed to list snapshots: %v", err) os.Exit(1) } } - if err := v.Shutdowner.Shutdown(); err != nil { + + err = v.Shutdowner.Shutdown() + if err != nil { log.Error("Failed to shutdown", "error", err) } }() + return nil }, OnStop: func(ctx context.Context) error { v.Cancel() + return nil }, }) @@ -185,10 +197,11 @@ restrict the operation to specific snapshot names.`, RunE: func(cmd *cobra.Command, args []string) error { // Validate flags if !opts.KeepLatest && opts.OlderThan == "" { - return fmt.Errorf("must specify either --keep-latest or --older-than") + return errors.New("must specify either --keep-latest or --older-than") } + if opts.KeepLatest && opts.OlderThan != "" { - return fmt.Errorf("cannot specify both --keep-latest and --older-than") + return errors.New("cannot specify both --keep-latest and --older-than") } // Use unified config resolution @@ -198,6 +211,7 @@ restrict the operation to specific snapshot names.`, } rootFlags := GetRootFlags() + return RunWithApp(cmd.Context(), AppOptions{ ConfigPath: configPath, LogOptions: log.LogOptions{ @@ -211,21 +225,26 @@ restrict the operation to specific snapshot names.`, lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { go func() { - if err := v.PurgeSnapshotsWithOptions(opts); err != nil { - if err != context.Canceled { + err := v.PurgeSnapshotsWithOptions(opts) + if err != nil { + if !errors.Is(err, context.Canceled) { log.Error("Failed to purge snapshots", "error", err) ReportError("Failed to purge snapshots: %v", err) os.Exit(1) } } - if err := v.Shutdowner.Shutdown(); err != nil { + + err = v.Shutdowner.Shutdown() + if err != nil { log.Error("Failed to shutdown", "error", err) } }() + return nil }, OnStop: func(ctx context.Context) error { v.Cancel() + return nil }, }) @@ -254,11 +273,14 @@ func newSnapshotVerifyCommand() *cobra.Command { Args: func(cmd *cobra.Command, args []string) error { if len(args) != 1 { _ = cmd.Help() + if len(args) == 0 { - return fmt.Errorf("snapshot ID required") + return errors.New("snapshot ID required") } + return fmt.Errorf("expected 1 argument, got %d", len(args)) } + return nil }, RunE: func(cmd *cobra.Command, args []string) error { @@ -271,6 +293,7 @@ func newSnapshotVerifyCommand() *cobra.Command { } rootFlags := GetRootFlags() + return RunWithApp(cmd.Context(), AppOptions{ ConfigPath: configPath, LogOptions: log.LogOptions{ @@ -284,23 +307,29 @@ func newSnapshotVerifyCommand() *cobra.Command { lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { go func() { - if err := v.VerifySnapshotWithOptions(snapshotID, opts); err != nil { - if err != context.Canceled { + err := v.VerifySnapshotWithOptions(snapshotID, opts) + if err != nil { + if !errors.Is(err, context.Canceled) { if !opts.JSON { log.Error("Verification failed", "error", err) ReportError("Verification failed: %v", err) } + os.Exit(1) } } - if err := v.Shutdowner.Shutdown(); err != nil { + + err = v.Shutdowner.Shutdown() + if err != nil { log.Error("Failed to shutdown", "error", err) } }() + return nil }, OnStop: func(ctx context.Context) error { v.Cancel() + return nil }, }) @@ -345,11 +374,14 @@ nuke --force' — it is the single supported entry point for that.`, Args: func(cmd *cobra.Command, args []string) error { if len(args) != 1 { _ = cmd.Help() + if len(args) == 0 { - return fmt.Errorf("snapshot ID required") + return errors.New("snapshot ID required") } + return fmt.Errorf("expected 1 argument, got %d", len(args)) } + return nil }, RunE: func(cmd *cobra.Command, args []string) error { @@ -360,6 +392,7 @@ nuke --force' — it is the single supported entry point for that.`, } rootFlags := GetRootFlags() + return RunWithApp(cmd.Context(), AppOptions{ ConfigPath: configPath, LogOptions: log.LogOptions{ @@ -375,22 +408,26 @@ nuke --force' — it is the single supported entry point for that.`, go func() { _, err := v.RemoveSnapshot(args[0], opts) if err != nil { - if err != context.Canceled { + if !errors.Is(err, context.Canceled) { if !opts.JSON { log.Error("Failed to remove snapshot", "error", err) ReportError("Failed to remove snapshot: %v", err) } + os.Exit(1) } } + if err := v.Shutdowner.Shutdown(); err != nil { log.Error("Failed to shutdown", "error", err) } }() + return nil }, OnStop: func(ctx context.Context) error { v.Cancel() + return nil }, }) diff --git a/internal/cli/snapshot_restore.go b/internal/cli/snapshot_restore.go index 9f60b4a..47881a3 100644 --- a/internal/cli/snapshot_restore.go +++ b/internal/cli/snapshot_restore.go @@ -2,6 +2,7 @@ package cli import ( "context" + "errors" "os" "github.com/spf13/cobra" @@ -70,6 +71,7 @@ Examples: // runRestore parses arguments and runs the restore operation through the app framework func runRestore(cmd *cobra.Command, args []string, opts *RestoreOptions) error { snapshotID := args[0] + opts.TargetDir = args[1] if len(args) > 2 { opts.Paths = args[2:] @@ -83,6 +85,7 @@ func runRestore(cmd *cobra.Command, args []string, opts *RestoreOptions) error { // Use the app framework like other commands rootFlags := GetRootFlags() + return RunWithApp(cmd.Context(), AppOptions{ ConfigPath: configPath, LogOptions: log.LogOptions{ @@ -129,8 +132,10 @@ func buildRestoreInvokes(snapshotID string, opts *RestoreOptions) []fx.Option { Verify: opts.Verify, SkipErrors: GetRootFlags().SkipErrors, } - if err := app.Vaultik.Restore(restoreOpts); err != nil { - if err != context.Canceled { + + err := app.Vaultik.Restore(restoreOpts) + if err != nil { + if !errors.Is(err, context.Canceled) { log.Error("Restore operation failed", "error", err) ReportError("Restore failed: %v", err) os.Exit(1) @@ -138,15 +143,18 @@ func buildRestoreInvokes(snapshotID string, opts *RestoreOptions) []fx.Option { } // Shutdown the app when restore completes - if err := app.Shutdowner.Shutdown(); err != nil { + err = app.Shutdowner.Shutdown() + if err != nil { log.Error("Failed to shutdown", "error", err) } }() + return nil }, OnStop: func(ctx context.Context) error { log.Debug("Stopping restore operation") app.Vaultik.Cancel() + return nil }, }) diff --git a/internal/cli/version.go b/internal/cli/version.go index cbe3344..7c6d14c 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -24,6 +24,7 @@ func NewVersionCommand() *cobra.Command { fmt.Printf(" author: %s\n", globals.Author) fmt.Printf(" homepage: %s\n", globals.Homepage) fmt.Printf(" license: %s\n", globals.License) + if globals.Version == "dev" { fmt.Println() fmt.Println("This is a development build (no version information embedded).")