package cli import ( "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) const defaultConfigTemplate = `# vaultik configuration # Documentation: https://sneak.berlin/go/vaultik # ─── REQUIRED ──────────────────────────────────────────────────────────────── # Age recipient public keys for encryption. # Backups are encrypted to ALL listed recipients. Any one of the corresponding # private keys can decrypt. Generate a keypair with: # age-keygen -o key.txt && grep 'public key' key.txt age_recipients: - age1REPLACE_WITH_YOUR_PUBLIC_KEY # Named snapshots. Each snapshot backs up one or more paths and can have its # own exclude patterns in addition to the global excludes below. # # Exclude pattern semantics: # - Patterns starting with / are anchored to the snapshot path root # (e.g. "/Library/Caches" matches only ~/Library/Caches in a ~ snapshot) # - Patterns without a leading / match anywhere in the tree # (e.g. ".cache" matches any directory named .cache at any depth) # - Globs are supported: *, **, ? snapshots: home: paths: - "~" exclude: # Trash, temp, and filesystem metadata - "/.Trash" - "/.Trashes" - "/.fseventsd" - "/.Spotlight-V100" - "/.TemporaryItems" - "/tmp" - "/.rnd" - ".DS_Store" # Caches and package manager state (rebuildable) - ".cache" - ".bundle" - "/.cpan/build" - "/.cpan/sources" - "/.gradle/caches" - "/.dropbox" - "/.minikube/cache" - "/.local/share/containers/podman/machine" - "/.persepolis" - "/Library/Caches" - "/Library/Logs" - "/Library/Cookies" - "/Library/Metadata" - "/Library/Suggestions" - "/Library/PubSub" - "/Library/Homebrew" - "/Library/Developer" - "/Library/Google/GoogleSoftwareUpdate" - "/Library/Preferences/Macromedia/Flash Player" - "/Library/Preferences/SDMHelpData" - "/Library/VoiceTrigger/SAT" # Language/toolchain package caches (rebuildable from registries) - "/.npm" - "/.cargo/registry" - "/.cargo/git" - "/.rustup/toolchains" - "/go/pkg/mod" - "/.m2/repository" - "/.vagrant.d/boxes" - "node_modules" - "__pycache__" - ".venv" # Virtual machine disk images (huge; remove these lines to back them up) - "/Parallels" - "/Virtual Machines.localized" - "/VirtualBox VMs" - "/.orbstack" - "/Library/Containers/com.utmapp.UTM" # Downloaded LLM models (huge, re-downloadable) - "/.ollama/models" - "/.lmstudio/models" # Cloud-synced storage. These are synced to a provider already, and on # modern macOS may contain dataless placeholder files that the backup # would force-download in full. - "/Library/CloudStorage" - "/Library/Mobile Documents" # Android SDK and emulator images (re-downloadable) - "/Library/Android/sdk" - "/.android/avd" # Cloud-synced or restorable-from-server data - "/Library/Mail" - "/Library/Mail Downloads" - "/Library/Safari" - "/Library/Application Support/Evernote" - "/Library/Application Support/MobileSync" - "/Library/Application Support/SyncServices" - "/Library/Application Support/protonmail/bridge/cache" - "/Library/Application Support/Syncthing/index-*" - "/Library/Syncthing/folders" - "/Documents/Dropbox/.dropbox.cache" # Large rebuildable app data (games, media caches, device backups) - "/Applications/Fortnite" - "/Documents/Steam Content" - "/Library/Application Support/Ableton" - "/Library/Application Support/CrossOver Games" - "/Library/Application Support/SecondLife/cache" - "/Library/Application Support/Steam/SteamApps" - "/Library/Containers/com.docker.docker" - "/Library/Group Containers/group.com.apple.secure-control-center-preferences" - "/Library/iTunes/iPad Software Updates" - "/Library/iTunes/iPhone Software Updates" - "/Movies/CacheClip" - "/Movies/ProxyMedia" - "/Music/iTunes/Album Artwork" - "/Pictures/iPod Photo Cache" # Third-party applications. OS-provided apps live in /System/Applications # on modern macOS and are never in /Applications, but Apple-installed # App Store apps (Safari, GarageBand, iWork, iMovie) are excluded since # they are re-downloadable. apps: paths: - /Applications exclude: - ".DS_Store" - "/Safari.app" - "/GarageBand.app" - "/iMovie.app" - "/Keynote.app" - "/Numbers.app" - "/Pages.app" - "/Xcode.app" - "/Spotify.app" - "/Steam.app" - "/VirtualBox.app" - "/Utilities/Adobe Installers" # Storage backend (pick ONE of the three forms below). # # S3-compatible: # storage_url: "s3://mybucket/backups?endpoint=s3.example.com®ion=us-east-1" # (also set s3.access_key_id and s3.secret_access_key below) # # Local filesystem: # storage_url: "file:///mnt/backups/vaultik" # # Rclone (requires rclone configured separately): # storage_url: "rclone://myremote/path/to/backups" storage_url: "" # ─── S3 CREDENTIALS (required for s3:// storage_url) ──────────────────────── # s3: # access_key_id: YOUR_ACCESS_KEY # secret_access_key: YOUR_SECRET_KEY # # region: us-east-1 # Default: us-east-1 # # use_ssl: true # Default: true # # part_size: 5MB # Multipart upload part size. Default: 5MB # ─── OPTIONAL ──────────────────────────────────────────────────────────────── # Global exclude patterns applied to ALL snapshots. # Snapshot-specific excludes are additive. # exclude: # - "*.log" # - "*.tmp" # - ".git" # - "node_modules" # Average chunk size for content-defined chunking (FastCDC). # Smaller = better deduplication but more metadata overhead. # Accepts: 1MB, 10M, 64KB, etc. # Default: 10MB # chunk_size: 10MB # Maximum blob size before splitting into a new blob. # Accepts: 1GB, 10G, 500MB, etc. # Default: 10GB # blob_size_limit: 10GB # Zstd compression level (1-19). Higher = better ratio but slower. # Default: 3 # compression_level: 3 # Hostname used in snapshot IDs. Default: system hostname. # hostname: myserver # Path to the local SQLite index database. # Default: the platform data directory, e.g. # macOS: ~/Library/Application Support/vaultik/index.sqlite # Linux: ~/.local/share/vaultik/index.sqlite # index_path: /path/to/index.sqlite ` // NewConfigCommand creates the config command group. func NewConfigCommand() *cobra.Command { cmd := &cobra.Command{ Use: "config", Short: "Manage the configuration file", Long: "Commands for creating, editing, and querying the vaultik config file.", } cmd.AddCommand(newConfigInitCommand()) cmd.AddCommand(newConfigEditCommand()) cmd.AddCommand(newConfigGetCommand()) cmd.AddCommand(newConfigSetCommand()) return cmd } // newConfigInitCommand creates the 'config init' subcommand. func newConfigInitCommand() *cobra.Command { return &cobra.Command{ Use: "init", Short: "Write a default config file", Long: `Creates a default configuration file with commented explanations for every setting. If a config file already exists at the target path, the command refuses to overwrite it. The config is written to the path from --config, $VAULTIK_CONFIG, or the platform default config directory (e.g. ~/Library/Application Support/ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { path := configPathForInit() if _, err := os.Stat(path); err == nil { return fmt.Errorf("config file already exists: %s", path) } dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("creating config directory %s: %w", dir, err) } if err := os.WriteFile(path, []byte(defaultConfigTemplate), 0o600); 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 }, } } // newConfigEditCommand creates the 'config edit' subcommand. func newConfigEditCommand() *cobra.Command { return &cobra.Command{ Use: "edit", Short: "Open the config file in $EDITOR", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { path, err := ResolveConfigPath() if err != nil { return err } editor := os.Getenv("EDITOR") if editor == "" { editor = "vi" } ed := exec.Command(editor, path) ed.Stdin = os.Stdin ed.Stdout = os.Stdout ed.Stderr = os.Stderr return ed.Run() }, } } // newConfigGetCommand creates the 'config get' subcommand. func newConfigGetCommand() *cobra.Command { return &cobra.Command{ Use: "get ", Short: "Print a config value by dotted path (e.g. s3.bucket)", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { path, err := ResolveConfigPath() if err != nil { return err } root, err := loadYAMLFile(path) if err != nil { return err } node, err := yamlPathGet(root, strings.Split(args[0], ".")) if err != nil { return err } if node.Kind == yaml.ScalarNode { fmt.Println(node.Value) return nil } out, err := yaml.Marshal(node) if err != nil { return fmt.Errorf("marshaling value: %w", err) } fmt.Print(string(out)) return nil }, } } // newConfigSetCommand creates the 'config set' subcommand. func newConfigSetCommand() *cobra.Command { return &cobra.Command{ Use: "set ", Short: "Set a config value by dotted path (e.g. compression_level 5)", Long: `Sets a scalar config value addressed by dotted YAML path and writes the file back, preserving comments and formatting. Intermediate maps are created as needed. Examples: vaultik config set compression_level 9 vaultik config set s3.bucket mybucket vaultik config set storage_url "file:///mnt/backups"`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { path, err := ResolveConfigPath() if err != nil { return err } root, err := loadYAMLFile(path) if err != nil { return err } if err := yamlPathSet(root, strings.Split(args[0], "."), args[1]); err != nil { return err } out, err := yaml.Marshal(root) if err != nil { return fmt.Errorf("marshaling config: %w", err) } mode := os.FileMode(0o600) if info, err := os.Stat(path); err == nil { mode = info.Mode().Perm() } if err := os.WriteFile(path, out, mode); err != nil { return fmt.Errorf("writing config file: %w", err) } fmt.Printf("%s = %s\n", args[0], args[1]) return nil }, } } // loadYAMLFile parses a YAML file into a yaml.Node document tree, // which preserves comments and ordering for round-tripping. func loadYAMLFile(path string) (*yaml.Node, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading config file: %w", err) } var root yaml.Node if err := yaml.Unmarshal(data, &root); err != nil { return nil, fmt.Errorf("parsing config file: %w", err) } // An empty file yields a zero node; normalize to an empty mapping document. if root.Kind == 0 { root = yaml.Node{ Kind: yaml.DocumentNode, Content: []*yaml.Node{{Kind: yaml.MappingNode}}, } } return &root, nil } // yamlPathGet navigates a dotted key path through mapping and sequence // nodes and returns the value node. Numeric path components index into // sequences (e.g. "age_recipients.0"). 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") } node = node.Content[0] } for i, key := range keys { 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], ".")) } case yaml.SequenceNode: idx, err := strconv.Atoi(key) 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], ".")) } } return node, nil } // yamlPathSet navigates a dotted key path, creating intermediate maps as // needed, and sets the final key to the given scalar value. Numeric path // components index into sequences; an index equal to the sequence length // appends a new element (e.g. "age_recipients.1" on a 1-element list). func yamlPathSet(root *yaml.Node, keys []string, value string) error { node := root if node.Kind == yaml.DocumentNode { if len(node.Content) == 0 { node.Content = []*yaml.Node{{Kind: yaml.MappingNode}} } node = node.Content[0] } for i, key := range keys { last := i == len(keys)-1 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) } node = valueNode case yaml.SequenceNode: idx, err := strconv.Atoi(key) 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: return fmt.Errorf("key %q is not a map or list", strings.Join(keys[:i], ".")) } } return nil } // setScalar overwrites a node in place with a plain scalar value. func setScalar(n *yaml.Node, value string) { n.Kind = yaml.ScalarNode n.Tag = "" n.Value = value n.Content = nil n.Style = 0 } // configPathForInit returns the config path to write, checking --config flag, // VAULTIK_CONFIG env, and the platform default. func configPathForInit() string { if rootFlags.ConfigPath != "" { return rootFlags.ConfigPath } if envPath := os.Getenv("VAULTIK_CONFIG"); envPath != "" { return envPath } return DefaultConfigPath() }