From 5a0da6da12a5c1e5971f688c80c974152b6704db Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 10 Nov 2026 21:52:03 +0100 Subject: [PATCH] Write the apps snapshot in config init only on macOS (closes #408) The default config backed up /Applications on every system. That directory exists only on macOS, so on Linux the first snapshot create stopped on the missing path. config init now writes the apps snapshot only when runtime.GOOS is darwin; the macOS file is unchanged. Model: opus-5-5 (implementation, review); opus-5-5 (merge) --- README.md | 4 ++-- TODO.md | 5 ++++ internal/cli/config.go | 31 ++++++++++++++++++++---- internal/cli/config_test.go | 48 +++++++++++++++++++++++++++++++------ 4 files changed, 75 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index f83d065..c436e22 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ mkdir /Volumes/usbstick/mybackup # (System Settings → Privacy & Security → Full Disk Access), otherwise # the backup will abort with a permission error on protected directories -# run your first backup (the default config backs up ~ and /Applications -# with sensible excludes) +# run your first backup (the default config backs up ~, and /Applications +# on macOS only, with sensible excludes) vaultik snapshot create # see what you have diff --git a/TODO.md b/TODO.md index c32824f..64f0e47 100644 --- a/TODO.md +++ b/TODO.md @@ -24,6 +24,11 @@ owner's; a `v*` tag runs the release build in # Completed Steps +- 2026-11-10: Made `config init` write the `apps` snapshot, which backs + up `/Applications`, only on macOS + ([issue #408](https://git.eeqj.de/sneak/vaultik/issues/408)). On + Linux the first `snapshot create` stopped on the missing directory. + - 2026-10-15: Stopped `snapshot create --prune` pruning after a backup in which a volume mounted below a configured path in an earlier snapshot of the same name was not mounted diff --git a/internal/cli/config.go b/internal/cli/config.go index 2a18fc1..96976aa 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" "reflect" + "runtime" "slices" "strconv" "strings" @@ -30,7 +31,7 @@ const configSetArgs = 2 // parent config dirs (e.g. ~/.config) are conventionally traversable. const configDirMode = 0o755 -// configYAMLIndent matches the 2-space indentation of defaultConfigTemplate, +// configYAMLIndent matches the 2-space indentation of the default config, // so `config set` writes the file back with the same indentation rather than // yaml.Marshal's 4-space default. const configYAMLIndent = 2 @@ -50,7 +51,22 @@ var ( "so the file was not changed") ) -const defaultConfigTemplate = `# vaultik configuration +// macOS is the value of runtime.GOOS on macOS. +const macOS = "darwin" + +// defaultConfigText returns the file `config init` writes on the operating +// system goos, named as runtime.GOOS names it. Only macOS gets the apps +// snapshot, since /Applications exists nowhere else. +func defaultConfigText(goos string) string { + if goos == macOS { + return defaultConfigHead + appsSnapshotConfig + defaultConfigTail + } + + return defaultConfigHead + defaultConfigTail +} + +// defaultConfigHead is the default config up to the end of the home snapshot. +const defaultConfigHead = `# vaultik configuration # Documentation: https://sneak.berlin/go/vaultik # ─── REQUIRED ──────────────────────────────────────────────────────────────── @@ -168,7 +184,10 @@ snapshots: - "/Movies/ProxyMedia" - "/Music/iTunes/Album Artwork" - "/Pictures/iPod Photo Cache" +` +// appsSnapshotConfig is the apps snapshot of the default config. +const appsSnapshotConfig = ` # 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 @@ -189,7 +208,10 @@ snapshots: - "/Steam.app" - "/VirtualBox.app" - "/Utilities/Adobe Installers" +` +// defaultConfigTail is the default config after the snapshots. +const defaultConfigTail = ` # Storage backend (pick ONE of the three forms below). # # S3-compatible: @@ -311,7 +333,8 @@ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`, return fmt.Errorf("creating config directory %s: %w", dir, err) } - err = os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode) + err = os.WriteFile(path, []byte(defaultConfigText(runtime.GOOS)), + configFileMode) if err != nil { return fmt.Errorf("writing config file: %w", err) } @@ -560,7 +583,7 @@ func yamlPathOnly(root *yaml.Node, keys []string) (*yaml.Node, error) { } // marshalConfigYAML renders a config document tree with 2-space indentation, -// matching defaultConfigTemplate. yaml.Marshal defaults to 4 spaces, which +// matching the default config. yaml.Marshal defaults to 4 spaces, which // would reindent the whole file on the first `config set` despite the promise // to preserve formatting. func marshalConfigYAML(root *yaml.Node) ([]byte, error) { diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index 7dccfa0..7f7d390 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -13,14 +13,15 @@ import ( "sneak.berlin/go/vaultik/internal/ui" ) -// TestDefaultConfigTemplateParses ensures the init template is valid YAML -// that unmarshals into the Config struct with the expected snapshots. -func TestDefaultConfigTemplateParses(t *testing.T) { +// TestDefaultConfigParsesOnMacOS ensures the config `config init` writes on +// macOS is valid YAML that unmarshals into the Config struct with the +// expected snapshots. +func TestDefaultConfigParsesOnMacOS(t *testing.T) { t.Parallel() var cfg config.Config - err := yaml.Unmarshal([]byte(defaultConfigTemplate), &cfg) + err := yaml.Unmarshal([]byte(defaultConfigText("darwin")), &cfg) if err != nil { t.Fatalf("default config template is not valid YAML: %v", err) } @@ -58,6 +59,39 @@ func TestDefaultConfigTemplateParses(t *testing.T) { } } +// TestDefaultConfigOnLinux checks that the config `config init` writes on +// Linux loads with its home snapshot and without the apps snapshot, whose +// /Applications exists only on macOS. +func TestDefaultConfigOnLinux(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.yml") + + err := os.WriteFile(path, []byte(defaultConfigText("linux")), configFileMode) + if err != nil { + t.Fatalf("write config: %v", err) + } + + err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false), + path, "storage_url", "file:///mnt/backups") + if err != nil { + t.Fatalf("config set storage_url: %v", err) + } + + cfg, err := config.Load(path) + if err != nil { + t.Fatalf("config.Load: %v", err) + } + + if _, ok := cfg.Snapshots["home"]; !ok { + t.Error("expected 'home' snapshot in the Linux default config") + } + + if _, ok := cfg.Snapshots["apps"]; ok { + t.Error("the Linux default config should have no 'apps' snapshot") + } +} + // testRecipient is a valid age public key. const testRecipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj" @@ -71,7 +105,7 @@ func TestConfigSetRecipientOnFreshConfig(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yml") - err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode) + err := os.WriteFile(path, []byte(defaultConfigText(macOS)), configFileMode) if err != nil { t.Fatalf("write config: %v", err) } @@ -220,7 +254,7 @@ func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) { {levelReference, "blob_size_limit", "1MB", "blob_size_limit must be at least"}, // The type check on a config that does not load yet. - {defaultConfigTemplate, "snapshots.apps.paths", "/srv", + {defaultConfigText(macOS), "snapshots.apps.paths", "/srv", "into []string"}, // The type check on a config that cannot be read here. {configWithUnsetReference, "snapshots.home.paths", "/x", @@ -380,7 +414,7 @@ func loadAfterConfigSet(t *testing.T, key, value string) *config.Config { path := filepath.Join(t.TempDir(), "config.yml") - err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode) + err := os.WriteFile(path, []byte(defaultConfigText(macOS)), configFileMode) if err != nil { t.Fatalf("write config: %v", err) }