All user-facing output now goes through a single ui.Writer with a
uniform style:
》 (white) for begin / info / notice
》 (green) for complete / success
Warning: for warnings (orange)
ERROR: for errors (red)
》 (indented) for progress heartbeats
Color is enabled when stdout is a TTY and NO_COLOR is unset.
Standards:
- Complete-sentence messages with fully qualified terms ("backup
destination store", "local index database", "snapshot source
files enumeration").
- Every Complete has a matching Begin.
- Natural verb tense conveys state ("Uploading" -> "Uploaded"). The
words "begin"/"complete" never appear in message bodies; the marker
color carries that information.
- ETA means clock time, not duration. Progress lines say "estimated
remaining time (<dur>), finish at <time>" with both labeled.
Adds globals.CommitDate (populated by Makefile/Dockerfile/goreleaser
via ldflags from `git show -s --format=%cI HEAD`) and a startup banner
printed once per invocation.
Strips fx call-chain noise from startup errors so users see the actual
underlying error (e.g. "creating base path: mkdir /Volumes/BACKUPS:
permission denied" instead of three layers of "could not build
arguments for function ...").
README documents the output style and the ui package conventions.
176 lines
5.5 KiB
Go
176 lines
5.5 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/adrg/xdg"
|
|
"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"
|
|
)
|
|
|
|
// 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.LogOptions
|
|
Modules []fx.Option
|
|
Invokes []fx.Option
|
|
}
|
|
|
|
// setupGlobals records the startup time and prints the startup banner.
|
|
// In --cron mode the banner is suppressed (LogOptions.Cron == true).
|
|
func setupGlobals(lc fx.Lifecycle, g *globals.Globals, v *vaultik.Vaultik, opts log.LogOptions) {
|
|
lc.Append(fx.Hook{
|
|
OnStart: func(ctx context.Context) error {
|
|
g.StartTime = time.Now().UTC()
|
|
if opts.Cron || opts.Quiet {
|
|
// Replace UI writer with a discarding one so all
|
|
// user-facing output is suppressed.
|
|
v.UI = ui.NewWithColor(io.Discard, false)
|
|
} else {
|
|
v.UI.Banner("%s %s (commit %s, %s) invoked at %s by %s",
|
|
g.Appname, g.Version, g.ShortCommit(), g.CommitDate,
|
|
g.StartTime.Format(time.RFC3339), globals.Author)
|
|
}
|
|
return nil
|
|
},
|
|
})
|
|
}
|
|
|
|
// 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.ConfigPath(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,
|
|
}
|
|
|
|
allOptions := append(baseModules, opts.Modules...)
|
|
allOptions = append(allOptions, opts.Invokes...)
|
|
|
|
return fx.New(allOptions...)
|
|
}
|
|
|
|
// 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): <real error>
|
|
//
|
|
// 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 errors.New(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
|
|
if err := app.Start(ctx); 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
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer shutdownCancel()
|
|
|
|
if err := app.Stop(shutdownCtx); err != nil {
|
|
log.Error("Error during shutdown", "error", err)
|
|
}
|
|
}()
|
|
|
|
// Wait for either 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)
|
|
if err := app.Stop(context.Background()); err != nil {
|
|
log.Error("Error stopping app", "error", err)
|
|
}
|
|
return ctx.Err()
|
|
case <-app.Done():
|
|
// App finished running (e.g., backup completed)
|
|
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() {
|
|
if err := lock.Release(); err != nil {
|
|
log.Warn("Failed to release PID lock", "error", err)
|
|
}
|
|
}()
|
|
|
|
app := NewApp(opts)
|
|
return RunApp(ctx, app)
|
|
}
|