All checks were successful
check / check (push) Successful in 5s
Updates golangci-lint to v2.12.2 everywhere it is pinned and installs the canonical `.golangci.yml`, then remediates every finding the new linter/config surfaces so `make check` is green. ## Version bump - `Dockerfile` lint stage: `golangci/golangci-lint:v2.11.3-alpine` -> `v2.12.2-alpine` (digest-pinned, date comment updated) - `Makefile` `deps` target: `go install` moved from the old v1 module path at `@latest` to the pinned `github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2` - `.golangci.yml` replaced with the canonical config (v2 schema; settings under `linters.settings` so the thresholds actually apply; `default: all` with the standard six disables) - `script/bootstrap` installs golangci-lint via the system package manager and carries no version pin, so it is unchanged - CI (`.gitea/workflows/check.yml`) only runs `script/cibuild`, so it needed no change ## Lint remediation The canonical config surfaced ~3,300 findings across 56k lines. All are fixed, behavior-preserving; incorporates and supersedes the per-package mechanical passes already merged to `main` (refs #61). Highlights: - `err113`: dynamic errors replaced with package sentinels + `%w` wrapping; comparisons via `errors.Is` - `goprintffuncname`: printf-style helpers renamed with an `f` suffix (`ui.Writer` message methods, `cli.ReportErrorf`, `database.Fatalf`) and all call sites updated - `revive` stutter renames: `blob.Handler`, `blob.WithReader`, `blob.ChunkPosition`, `storage.URL`, `storage.Info`; missing doc comments added - `contextcheck`/`noctx`: `context.Context` threaded through `blob.Packer` and the scanner call sites; context-aware `exec`/`sql` variants - `funlen`/`cyclop`/`gocognit`/`dupl`: oversized and duplicated functions split into focused helpers (production and test code) - tests: `t.Parallel()` added where safe (global logger init kept in the serial phase for `-race`), `t.TempDir()`/`t.Helper()` adopted, several suites converted to external test packages - `gosec`: bounded integer conversions, `ReadHeaderTimeout` on the test HTTP server; remaining warnings suppressed per-site with justifications - remaining `nolint` directives are rare, targeted, and each carries a reason (e.g. `nilnil` not-found contract in the repository layer, fx module globals, on-disk snake_case struct tags) - removed the deprecated `log.LogOptions` alias (callers migrated to `log.Options`) `make check` (tests with `-race`, lint, fmt-check) passes. Co-authored-by: sneak <sneak@sneak.berlin> Reviewed-on: #62 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
614 lines
18 KiB
Go
614 lines
18 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// configFileMode is the permission set for freshly written config files;
|
|
// configs may hold S3 credentials, so keep them owner-only.
|
|
const configFileMode = 0o600
|
|
|
|
// configSetArgs is the argument count of `config set <key> <value>`.
|
|
const configSetArgs = 2
|
|
|
|
// configDirMode is the permission set for created config directories;
|
|
// parent config dirs (e.g. ~/.config) are conventionally traversable.
|
|
const configDirMode = 0o755
|
|
|
|
var (
|
|
errConfigExists = errors.New("config file already exists")
|
|
errEmptyConfig = errors.New("empty config file")
|
|
errKeyNotFound = errors.New("key not found")
|
|
errNeedNumericIndex = errors.New("key is a list; use a numeric index")
|
|
errIndexOutOfRange = errors.New("index out of range")
|
|
errNotMapOrList = errors.New("key is not a map or list")
|
|
)
|
|
|
|
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 vaultik_backup_private_key.txt
|
|
# grep 'public key' vaultik_backup_private_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(_ *cobra.Command, _ []string) error {
|
|
path := configPathForInit()
|
|
|
|
_, err := os.Stat(path)
|
|
if err == nil {
|
|
return fmt.Errorf("%w: %s", errConfigExists, path)
|
|
}
|
|
|
|
dir := filepath.Dir(path)
|
|
|
|
err = os.MkdirAll(dir, configDirMode)
|
|
if err != nil {
|
|
return fmt.Errorf("creating config directory %s: %w", dir, err)
|
|
}
|
|
|
|
err = os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
|
if err != nil {
|
|
return fmt.Errorf("writing config file: %w", err)
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(os.Stdout, "Config written to %s\n", path)
|
|
_, _ = fmt.Fprintln(os.Stdout,
|
|
"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, _ []string) error {
|
|
path, err := ResolveConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
editor := os.Getenv("EDITOR")
|
|
if editor == "" {
|
|
editor = "vi"
|
|
}
|
|
|
|
//nolint:gosec // G204: launching the operator's own $EDITOR is the point
|
|
ed := exec.CommandContext(cmd.Context(), 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 <key>",
|
|
Short: "Print a config value by dotted path (e.g. storage_url, compression_level)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(_ *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.Fprintln(os.Stdout, node.Value)
|
|
|
|
return nil
|
|
}
|
|
|
|
out, err := yaml.Marshal(node)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling value: %w", err)
|
|
}
|
|
|
|
_, _ = fmt.Fprint(os.Stdout, string(out))
|
|
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// newConfigSetCommand creates the 'config set' subcommand.
|
|
func newConfigSetCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "set <key> <value>",
|
|
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 storage_url "file:///mnt/backups"
|
|
vaultik config set storage_url "s3://bucket/prefix?endpoint=host®ion=us-east-1"
|
|
vaultik config set compression_level 9
|
|
vaultik config set s3.bucket mybucket # legacy S3 fields still supported`,
|
|
Args: cobra.ExactArgs(configSetArgs),
|
|
RunE: func(_ *cobra.Command, args []string) error {
|
|
path, err := ResolveConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
root, err := loadYAMLFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = yamlPathSet(root, strings.Split(args[0], "."), args[1])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
out, err := yaml.Marshal(root)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling config: %w", err)
|
|
}
|
|
|
|
mode := os.FileMode(configFileMode)
|
|
|
|
info, statErr := os.Stat(path)
|
|
if statErr == nil {
|
|
mode = info.Mode().Perm()
|
|
}
|
|
|
|
err = os.WriteFile(path, out, mode)
|
|
if err != nil {
|
|
return fmt.Errorf("writing config file: %w", err)
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(os.Stdout, "%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) //nolint:gosec // G304: config path is operator-supplied
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading config file: %w", err)
|
|
}
|
|
|
|
var root yaml.Node
|
|
|
|
err = yaml.Unmarshal(data, &root)
|
|
if 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, errEmptyConfig
|
|
}
|
|
|
|
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("%w: %s",
|
|
errKeyNotFound, strings.Join(keys[:i+1], "."))
|
|
}
|
|
case yaml.SequenceNode:
|
|
idx, err := strconv.Atoi(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %s",
|
|
errNeedNumericIndex, strings.Join(keys[:i], "."))
|
|
}
|
|
|
|
if idx < 0 || idx >= len(node.Content) {
|
|
return nil, fmt.Errorf("%w: index %d for %s (len %d)",
|
|
errIndexOutOfRange, idx, strings.Join(keys[:i], "."),
|
|
len(node.Content))
|
|
}
|
|
|
|
node = node.Content[idx]
|
|
case yaml.DocumentNode, yaml.ScalarNode, yaml.AliasNode:
|
|
return nil, fmt.Errorf("%w: %s",
|
|
errNotMapOrList, strings.Join(keys[:i], "."))
|
|
default:
|
|
return nil, fmt.Errorf("%w: %s",
|
|
errNotMapOrList, 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:
|
|
node = yamlSetInMapping(node, key, value, last)
|
|
case yaml.SequenceNode:
|
|
next, err := yamlSetInSequence(node, keys, i, value, last)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
node = next
|
|
case yaml.DocumentNode, yaml.ScalarNode, yaml.AliasNode:
|
|
return fmt.Errorf("%w: %s",
|
|
errNotMapOrList, strings.Join(keys[:i], "."))
|
|
default:
|
|
return fmt.Errorf("%w: %s",
|
|
errNotMapOrList, strings.Join(keys[:i], "."))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// yamlSetInMapping resolves (creating if needed) the value node for key
|
|
// within a mapping node, setting it to value when it is the final path
|
|
// element, and returns the node to descend into.
|
|
func yamlSetInMapping(node *yaml.Node, key, value string, last bool) *yaml.Node {
|
|
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)
|
|
}
|
|
|
|
return valueNode
|
|
}
|
|
|
|
// yamlSetInSequence indexes (or appends to) a sequence node using the
|
|
// numeric path element keys[i], setting the element to value when it is
|
|
// the final path element, and returns the node to descend into.
|
|
func yamlSetInSequence(
|
|
node *yaml.Node, keys []string, i int, value string, last bool,
|
|
) (*yaml.Node, error) {
|
|
idx, err := strconv.Atoi(keys[i])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %s",
|
|
errNeedNumericIndex, strings.Join(keys[:i], "."))
|
|
}
|
|
|
|
if idx < 0 || idx > len(node.Content) {
|
|
return nil, fmt.Errorf("%w: index %d for %s (len %d)",
|
|
errIndexOutOfRange, 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)
|
|
}
|
|
|
|
return node.Content[idx], 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()
|
|
}
|