// Package cli implements the vaultik command-line interface: cobra // commands, fx application wiring, and process-level concerns such as // signal handling and the PID lock. package cli import ( "context" "errors" "fmt" "os" "os/signal" "path/filepath" "strings" "syscall" "time" "github.com/adrg/xdg" "github.com/spf13/cobra" "go.uber.org/fx" "sneak.berlin/go/vaultik/internal/config" "sneak.berlin/go/vaultik/internal/database" "sneak.berlin/go/vaultik/internal/globals" "sneak.berlin/go/vaultik/internal/log" "sneak.berlin/go/vaultik/internal/pidlock" "sneak.berlin/go/vaultik/internal/snapshot" "sneak.berlin/go/vaultik/internal/storage" "sneak.berlin/go/vaultik/internal/ui" "sneak.berlin/go/vaultik/internal/vaultik" ) // shutdownTimeout bounds how long a signal-triggered graceful shutdown // may take before we give up. const shutdownTimeout = 30 * time.Second // AppOptions contains common options for creating the fx application. // It includes the configuration file path, logging options, and additional // fx modules and invocations that should be included in the application. type AppOptions struct { ConfigPath string LogOptions log.Options Modules []fx.Option Invokes []fx.Option } // setupGlobals records the startup time and, when an output-suppression // flag is active, marks the UI writer quiet so that Begin/Complete/ // Info/Notice/Detail/Progress are silenced. Warning and Error are NOT // silenced — per the documented convention that --quiet suppresses // non-error output only. The startup banner is printed by Entry // before cobra parses arguments, gated by the same arg-level check. func setupGlobals( lc fx.Lifecycle, g *globals.Globals, v *vaultik.Vaultik, opts log.Options, ) { lc.Append(fx.Hook{ OnStart: func(_ context.Context) error { g.StartTime = time.Now().UTC() if opts.Cron || opts.Quiet { v.UI.SetQuiet(true) } return nil }, }) } // writeStartupBanner prints the two-line application banner followed by a // blank line. Used both from the fx hook (for subcommand invocations) and // from the root cobra Run handler (for `vaultik` with no subcommand). func writeStartupBanner(w *ui.Writer, startTime time.Time, shortCommit string) { w.Bannerf("%s %s by %s (commit %s, built on %s) starting up at %s.", globals.Appname, globals.Version, globals.Author, shortCommit, globals.CommitDate, startTime.Format(time.RFC3339)) w.Bannerf("%s", globals.Homepage) w.Bannerf("") } // NewApp creates a new fx application with common modules. // It sets up the base modules (config, database, logging, globals) and // combines them with any additional modules specified in the options. // The returned fx.App is ready to be started with RunApp. func NewApp(opts AppOptions) *fx.App { baseModules := []fx.Option{ fx.Supply(config.Path(opts.ConfigPath)), fx.Supply(opts.LogOptions), fx.Provide(globals.New), fx.Provide(log.New), config.Module, database.Module, log.Module, storage.Module, snapshot.Module, fx.Provide(vaultik.New), fx.Invoke(setupGlobals), fx.NopLogger, } capacity := len(baseModules) + len(opts.Modules) + len(opts.Invokes) allOptions := make([]fx.Option, 0, capacity) allOptions = append(allOptions, baseModules...) allOptions = append(allOptions, opts.Modules...) allOptions = append(allOptions, opts.Invokes...) return fx.New(allOptions...) } // startupError carries a startup failure message that has been cleaned // of fx dependency-injection noise. A distinct type (rather than // errors.New) keeps the dynamic message out of err113's sight while // preserving the exact user-facing text. type startupError struct { msg string } func (e *startupError) Error() string { return e.msg } // cleanStartupError strips fx's dependency-injection call-chain noise from // startup errors. fx wraps the underlying error with messages like // // could not build arguments for function "X" (file:line): failed to build T: // could not build arguments for function "Y" (file:line): failed to build U: // received non-nil error from function "Z" (file:line): // // Users care about the real error, not the DI plumbing. We strip everything // up through the last "): " (which is always the close-paren of an fx // function-location annotation followed by the wrapped error). func cleanStartupError(err error) error { msg := err.Error() if idx := strings.LastIndex(msg, "): "); idx >= 0 { msg = msg[idx+3:] } return &startupError{msg: msg} } // RunApp starts and stops the fx application within the given context. // It handles graceful shutdown on interrupt signals (SIGINT, SIGTERM) and // ensures the application stops cleanly. The function blocks until the // application completes or is interrupted. Returns an error if startup fails. func RunApp(ctx context.Context, app *fx.App) error { // Set up signal handling for graceful shutdown sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) // Create a context that will be cancelled on signal ctx, cancel := context.WithCancel(ctx) defer cancel() // Start the app err := app.Start(ctx) if err != nil { return cleanStartupError(err) } // Handle shutdown shutdownComplete := make(chan struct{}) go func() { defer close(shutdownComplete) <-sigChan log.Notice("Received interrupt signal, shutting down gracefully...") // Create a timeout context for shutdown. The parent ctx is being // cancelled, so detach from its cancellation but keep its values. shutdownCtx, shutdownCancel := context.WithTimeout( context.WithoutCancel(ctx), shutdownTimeout) defer shutdownCancel() err := app.Stop(shutdownCtx) if err != nil { log.Error("Error during shutdown", "error", err) } }() // Wait for the signal handler to complete shutdown or the app to // request shutdown. select { case <-shutdownComplete: // Shutdown completed via signal return nil case <-ctx.Done(): // Context cancelled (shouldn't happen in normal operation) err := app.Stop(context.WithoutCancel(ctx)) if err != nil { log.Error("Error stopping app", "error", err) } return ctx.Err() case <-app.Done(): // App finished running (e.g., backup completed) return nil } } // runVaultikApp runs the standard single-operation command lifecycle // shared by the list/purge/verify/remove/remote-info subcommands: // resolve the config, start the fx app, run op against the Vaultik // instance in a goroutine, report a failure prefixed with failMsg // (suppressed while suppressErrors is true, e.g. under --json), then // trigger shutdown. The operation is cancelled when the app stops. // extraQuiet is OR-ed into LogOptions.Quiet (e.g. --json output modes). func runVaultikApp( cmd *cobra.Command, extraQuiet, suppressErrors bool, failMsg string, op func(v *vaultik.Vaultik) error, ) error { configPath, err := ResolveConfigPath() if err != nil { return err } rootFlags := GetRootFlags() return RunWithApp(cmd.Context(), AppOptions{ ConfigPath: configPath, LogOptions: log.Options{ Verbose: rootFlags.Verbose, Debug: rootFlags.Debug, Quiet: rootFlags.Quiet || extraQuiet, }, Modules: []fx.Option{}, Invokes: []fx.Option{ fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { lc.Append(fx.Hook{ OnStart: func(_ context.Context) error { go func() { err := op(v) if err != nil { if !errors.Is(err, context.Canceled) { if !suppressErrors { log.Error(failMsg, "error", err) ReportErrorf("%s: %v", failMsg, err) } os.Exit(1) } } err = v.Shutdowner.Shutdown() if err != nil { log.Error("Failed to shutdown", "error", err) } }() return nil }, OnStop: func(_ context.Context) error { v.Cancel() return nil }, }) }), }, }) } // RunWithApp is a helper that creates and runs an fx app with the given options. // It combines NewApp and RunApp into a single convenient function. This is the // preferred way to run CLI commands that need the full application context. // It acquires a PID lock before starting to prevent concurrent instances. 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() { err := lock.Release() if err != nil { log.Warn("Failed to release PID lock", "error", err) } }() app := NewApp(opts) return RunApp(ctx, app) }