diff --git a/.goreleaser.yml b/.goreleaser.yml index 64f4c01..53db4f2 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -12,6 +12,8 @@ builds: - arm64 env: - CGO_ENABLED=0 + ldflags: + - -X github.com/shpoont/dotfiles-manager/internal/app.buildVersion={{ .Version }} - id: darwin main: ./cmd/dotfiles-manager binary: dotfiles-manager @@ -22,6 +24,8 @@ builds: - arm64 env: - CGO_ENABLED=0 + ldflags: + - -X github.com/shpoont/dotfiles-manager/internal/app.buildVersion={{ .Version }} archives: - id: default ids: diff --git a/README.md b/README.md index 91a30f1..4fdbb82 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Core workflows: - `status` — preview drift and candidate operations - `deploy` — apply source -> target - `import` — apply target -> source (managed updates + optional unmanaged/missing rules) +- `version` / `--version` — print CLI version and exit --- @@ -124,6 +125,7 @@ syncs: 2) Run commands (using default config discovery in current directory): ```bash +dotfiles-manager --version dotfiles-manager status dotfiles-manager deploy --dry-run ~/.config/nvim dotfiles-manager deploy ~/.config/nvim @@ -131,6 +133,8 @@ dotfiles-manager import --dry-run ~/.config/nvim dotfiles-manager import ~/.config/nvim ``` +`--version`/`version` prints `dotfiles-manager version ` and exits (`dev` on local non-release builds). + 3) Optional explicit override: ```bash diff --git a/cmd/dotfiles-manager/main_test.go b/cmd/dotfiles-manager/main_test.go index ae11bab..e4020c4 100644 --- a/cmd/dotfiles-manager/main_test.go +++ b/cmd/dotfiles-manager/main_test.go @@ -15,6 +15,14 @@ func TestRunReturnsZeroForHelp(t *testing.T) { require.Equal(t, 0, run()) } +func TestRunReturnsZeroForVersion(t *testing.T) { + oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + os.Args = []string{"dotfiles-manager", "--version"} + + require.Equal(t, 0, run()) +} + func TestMainUsesExitHook(t *testing.T) { oldArgs := os.Args oldExit := osExit diff --git a/docs/internal/contracts/validation-errors.md b/docs/internal/contracts/validation-errors.md index 2bb7348..71c4c4a 100644 --- a/docs/internal/contracts/validation-errors.md +++ b/docs/internal/contracts/validation-errors.md @@ -11,6 +11,7 @@ This document defines stable error codes and when they are raised. All runtime errors are fail-fast and return non-zero exit. When `--json` is set, errors are emitted via JSON envelope (`ok=false`, `error.code`, `error.message`, optional `error.details`). +`version`/`--version` bypass config loading and normally do not traverse this validation pipeline. ## 1) Exit codes @@ -23,7 +24,7 @@ When `--json` is set, errors are emitted via JSON envelope (`ok=false`, `error.c | Code | Trigger | Message template | |---|---|---| -| `DFM_CONFIG_REQUIRED` | no config source resolved from `--config`, `DOTFILES_MANAGER_CONFIG`, or `./.dotfiles-manager.yaml` in cwd | `Config not found: pass --config, set DOTFILES_MANAGER_CONFIG, or create ./.dotfiles-manager.yaml` | +| `DFM_CONFIG_REQUIRED` | no config source resolved for config-dependent commands (`status`/`deploy`/`import`) from `--config`, `DOTFILES_MANAGER_CONFIG`, or `./.dotfiles-manager.yaml` in cwd | `Config not found: pass --config, set DOTFILES_MANAGER_CONFIG, or create ./.dotfiles-manager.yaml` | | `DFM_CONFIG_NOT_FOUND` | config path does not exist | `Config file not found: {config_path}` | | `DFM_CONFIG_NOT_FILE` | config path exists but is not a regular file | `Config path is not a file: {config_path}` | | `DFM_CONFIG_PARSE` | YAML parse failure | `Failed to parse YAML config: {config_path}` | @@ -82,3 +83,6 @@ To keep errors deterministic: 5. runtime filesystem operations Stop at first failure. + +Exception: +- `version` and `--version` short-circuit before config/path/runtime validation. diff --git a/docs/internal/engineering/acceptance-checklist.md b/docs/internal/engineering/acceptance-checklist.md index 22dd862..f5a94d1 100644 --- a/docs/internal/engineering/acceptance-checklist.md +++ b/docs/internal/engineering/acceptance-checklist.md @@ -91,6 +91,9 @@ Use this checklist before calling implementation complete. - [ ] any validation/runtime error exits `1`. - [ ] runtime failures are fail-fast. - [ ] `status --dry-run` fails with `DFM_FLAG_UNSUPPORTED`. +- [ ] `dotfiles-manager version` prints `dotfiles-manager version ` and exits `0`. +- [ ] `dotfiles-manager --version` prints `dotfiles-manager version ` and exits `0`. +- [ ] `version`/`--version` work without config present. ## I) Logging and observability diff --git a/docs/internal/engineering/ci-cd.md b/docs/internal/engineering/ci-cd.md index 1ff131f..8969e54 100644 --- a/docs/internal/engineering/ci-cd.md +++ b/docs/internal/engineering/ci-cd.md @@ -138,6 +138,7 @@ Manual procedure: 2. Verify artifact checksum. 3. Run binary in isolated temp repo/temp HOME: - `dotfiles-manager --help` + - `dotfiles-manager --version` - `dotfiles-manager status` - `dotfiles-manager deploy --dry-run` - `dotfiles-manager import --dry-run` diff --git a/docs/internal/engineering/testing-strategy.md b/docs/internal/engineering/testing-strategy.md index c8c6ef7..a4f168f 100644 --- a/docs/internal/engineering/testing-strategy.md +++ b/docs/internal/engineering/testing-strategy.md @@ -19,6 +19,7 @@ This document defines the test structure that validates the specification. - operation planning and ordering 2. **Integration tests** + - CLI behavior for version/status/deploy/import - real filesystem scenarios for deploy/import/status - overlapping sync behavior (config order; later sync wins) - metadata behavior by contract @@ -106,6 +107,7 @@ internal/testkit/ - redaction/masking paths: full branch coverage - error logging branches (including `DFM_*` codes): full branch coverage - per-command integration assertions (`status`/`deploy`/`import`): + - `version`/`--version` return expected format, do not require config, and do not perform sync filesystem operations - logs are written to platform-default log file path - `--log-file` overrides destination path - default logging level is `info` diff --git a/docs/internal/scope/architecture.md b/docs/internal/scope/architecture.md index a2d56c2..6b93302 100644 --- a/docs/internal/scope/architecture.md +++ b/docs/internal/scope/architecture.md @@ -20,8 +20,9 @@ This document defines the runtime architecture that implements the approved spec ## 2) Runtime module boundaries 1. **CLI / command layer** - - parses global flags (`--config`, `--log-file`, `--log-level`) and command flags - - dispatches `status`, `deploy`, `import` + - parses global flags (`--version`, `--config`, `--log-file`, `--log-level`) and command flags + - dispatches `version`, `status`, `deploy`, `import` + - handles global `--version` short-circuit - maps unsupported/invalid flags to stable error codes 2. **Config resolver + validator** @@ -79,6 +80,13 @@ This document defines the runtime architecture that implements the approved spec - no executor write phase - status-only candidate sets are reported +### version + +`CLI -> version reporter` + +- no config resolver, scope resolver, planner, or executor path +- outputs `dotfiles-manager version ` and exits + ### deploy `CLI -> config resolver -> scope resolver -> planner -> executor -> reporter` diff --git a/docs/internal/scope/product-scope.md b/docs/internal/scope/product-scope.md index 557698d..352a7c1 100644 --- a/docs/internal/scope/product-scope.md +++ b/docs/internal/scope/product-scope.md @@ -1,7 +1,7 @@ --- owner: Product + Core Engineering status: Implementation-ready -last-updated: 2026-02-16 +last-updated: 2026-02-17 canonical-source: docs/internal/scope/product-scope.md --- @@ -15,6 +15,7 @@ canonical-source: docs/internal/scope/product-scope.md ## Core commands (scope) +- `version` / `--version` — report CLI version - `status` — preview drift and candidate operations - `deploy` — apply source -> target - `import` — apply target -> source within configured rules @@ -22,6 +23,7 @@ canonical-source: docs/internal/scope/product-scope.md ## In-scope behavior - Config-driven sync definitions (`syncs`) +- Version reporting command (`version` / `--version`) - Path-scoped execution with optional `[path]` - Pattern-driven unmanaged import and unmanaged removal behavior - Missing-path import deletion behavior by include/exclude patterns diff --git a/docs/internal/specs/cli-and-config-spec.md b/docs/internal/specs/cli-and-config-spec.md index 52d37d8..940f7e7 100644 --- a/docs/internal/specs/cli-and-config-spec.md +++ b/docs/internal/specs/cli-and-config-spec.md @@ -15,6 +15,8 @@ Contract-level details are in `../contracts/*`. ## Command surface ```text +dotfiles-manager --version +dotfiles-manager version dotfiles-manager [--config ] [--log-file ] [--log-level ] status [--json] [path] dotfiles-manager [--config ] [--log-file ] [--log-level ] deploy [--dry-run] [--json] [path] dotfiles-manager [--config ] [--log-file ] [--log-level ] import [--dry-run] [--json] [path] @@ -24,6 +26,8 @@ Rules: - config resolution order: `--config ` → `DOTFILES_MANAGER_CONFIG` → `./.dotfiles-manager.yaml` (cwd) - default lookup is cwd-only (no parent search) - default config filename is `.dotfiles-manager.yaml` +- `version` / `--version` bypass config resolution and print version immediately +- `version` does not accept `[path]`, `--json`, or `--dry-run` - logs are written to platform-default log file path unless overridden with `--log-file` - no log format flag is supported; logs are human-readable text only - log level defaults to `info`; supported levels: `debug`, `info`, `warn`, `error` @@ -66,6 +70,7 @@ Machine-readable schema: ## Behavior summary +- `version`/`--version`: print `dotfiles-manager version ` and exit (`dev` for non-release local builds) - `status`: report drift and candidate sets - `deploy`: source -> target; optional unmanaged removal by patterns - `import`: target -> source; optional unmanaged adds + optional missing deletes by patterns diff --git a/docs/internal/specs/decision-matrix.md b/docs/internal/specs/decision-matrix.md index 725915e..0daad2f 100644 --- a/docs/internal/specs/decision-matrix.md +++ b/docs/internal/specs/decision-matrix.md @@ -23,6 +23,7 @@ Canonical rules and rationale live in **`decisions.md`**. | Command | Direction | Base scope | Pattern sets used | Outcome focus | |---|---|---|---|---| +| `version` / `--version` | info | n/a | none | Reports CLI version and exits. | | `status [--json] [path]` | compare | Manifest + candidates | add-unmanaged include/exclude, remove-unmanaged, remove-missing include/exclude | Reports drift + candidate sets. | | `deploy [--dry-run] [--json] [path]` | S → T | Manifest paths | remove-unmanaged | Applies copy/remove behavior (or plans only with `--dry-run`). | | `import [--dry-run] [--json] [path]` | T → S | Manifest paths | add-unmanaged include/exclude, remove-missing include/exclude | Applies import behavior (or plans only with `--dry-run`). | diff --git a/docs/internal/specs/decisions.md b/docs/internal/specs/decisions.md index 3fe4c7c..0d67234 100644 --- a/docs/internal/specs/decisions.md +++ b/docs/internal/specs/decisions.md @@ -63,6 +63,14 @@ Pattern-based behavior is config-driven only. ## 4) Command semantics +### `version` and `--version` + +- `dotfiles-manager version` and `dotfiles-manager --version` are equivalent. +- Output format is one line: `dotfiles-manager version `. +- These paths bypass config resolution and sync planning/execution. +- Release builds print semantic version; local non-release builds print `dev`. +- `version` does not support `[path]`, `--json`, or `--dry-run`. + ### `status [--json] [path]` Reports: @@ -113,6 +121,7 @@ Metadata guarantees and best-effort behavior are defined in `../contracts/metada |---|---| | Commands are fail-fast on runtime errors | Prevents partial hidden failures. | | Exit `0` on success (including `status` with drift), non-zero on errors | Conventional CLI semantics. | +| `version` and `--version` print version and exit without loading config | Keeps version checks lightweight and robust. | | `--json` supported on `status`, `deploy`, and `import` | Machine-readable automation support. | | `--dry-run` supported on `deploy` and `import`, not `status` | Keeps preview explicit for mutating commands; `status` is already preview-only. | | Text output suppresses empty phase blocks | Reduces noise and surfaces only actionable work. | diff --git a/docs/user/README.md b/docs/user/README.md index b56ffc2..b35d3e0 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -18,10 +18,11 @@ Main commands: - `status` (preview) - `deploy` (source → target) - `import` (target → source) +- `version` / `--version` (print version and exit) ## Important baseline -- Config is required for every run, resolved in this order: +- Config is required for `status`/`deploy`/`import`, resolved in this order: 1. `--config ` 2. `DOTFILES_MANAGER_CONFIG` 3. `./.dotfiles-manager.yaml` in the current working directory @@ -35,5 +36,8 @@ Main commands: - logs are always human-readable text (no log format option) - Log level defaults to `info`; use `--log-level` to change verbosity. - Warnings/errors are emitted as human-readable diagnostics on stderr. +- `dotfiles-manager version` and `dotfiles-manager --version` print version and exit without loading config. + - release builds print semantic version + - local non-release builds print `dev` For deeper implementation/spec details, see `../internal/README.md`. diff --git a/docs/user/commands.md b/docs/user/commands.md index 9a81611..3c41f23 100644 --- a/docs/user/commands.md +++ b/docs/user/commands.md @@ -3,6 +3,8 @@ ## Command format ```text +dotfiles-manager --version +dotfiles-manager version dotfiles-manager [--config ] [--log-file ] [--log-level ] status [--json] [path] dotfiles-manager [--config ] [--log-file ] [--log-level ] deploy [--dry-run] [--json] [path] dotfiles-manager [--config ] [--log-file ] [--log-level ] import [--dry-run] [--json] [path] @@ -14,6 +16,7 @@ Config is resolved in this order: 3. `./.dotfiles-manager.yaml` in the current working directory No parent-directory config search is performed. +`version`/`--version` do not require config resolution. Log file destination: - default paths: @@ -32,6 +35,21 @@ stderr diagnostics: - warnings and errors are emitted as human-readable text on stderr - stdout remains command output (including `--json`) +## `version` and `--version` + +Both commands print a single line and exit: + +```text +dotfiles-manager version 0.1.4 +``` + +Behavior: +- `dotfiles-manager version` and `dotfiles-manager --version` are equivalent +- they do not load config +- they do not accept `[path]`, `--json`, or `--dry-run` +- release builds print semantic version +- local non-release builds print `dev` + ## `status [--json] [path]` Reports: @@ -113,6 +131,8 @@ If `[path]` matches no syncs, command fails. ## Output and exit codes +- `version`/`--version` output one line: `dotfiles-manager version `. +- `version`/`--version` exit `0` and do not require config. - text mode prints per-sync sections with exact file operations. - every sync header uses: - `sync[idx] target=~/ source=./` diff --git a/docs/user/faq.md b/docs/user/faq.md index fd0ef94..c2e746b 100644 --- a/docs/user/faq.md +++ b/docs/user/faq.md @@ -67,3 +67,17 @@ Yes. Use `--log-level `. - default log level is `info` - applies to all commands + +## How do I check CLI version? + +Use either: + +```bash +dotfiles-manager --version +# or +dotfiles-manager version +``` + +Both print `dotfiles-manager version ` and exit. +They do not require config. +Release builds print semantic version; local non-release builds print `dev`. diff --git a/docs/user/getting-started.md b/docs/user/getting-started.md index abe1bec..6306dc0 100644 --- a/docs/user/getting-started.md +++ b/docs/user/getting-started.md @@ -12,6 +12,17 @@ Or install with Go: go install github.com/shpoont/dotfiles-manager/cmd/dotfiles-manager@latest ``` +## 1.1) Check installed version + +```bash +dotfiles-manager --version +# or +dotfiles-manager version +``` + +Release builds show semantic version. +Local non-release builds show `dev`. + ## 2) Prepare your repo Create or choose a repo where your managed dotfiles live. diff --git a/internal/app/cli.go b/internal/app/cli.go index d90ba15..dd9a53c 100644 --- a/internal/app/cli.go +++ b/internal/app/cli.go @@ -32,11 +32,15 @@ func NewRootCmd() *cobra.Command { SilenceErrors: true, SilenceUsage: true, } + rootCmd.Version = currentVersion() + rootCmd.SetVersionTemplate("dotfiles-manager version {{.Version}}\n") + rootCmd.Flags().Bool("version", false, "Print version and exit") rootCmd.PersistentFlags().StringVar(&opts.configPath, "config", "", "Path to config file") rootCmd.PersistentFlags().StringVar(&opts.logFile, "log-file", "", "Path to log file") rootCmd.PersistentFlags().StringVar(&opts.logLevel, "log-level", "info", "Log level: debug|info|warn|error") + rootCmd.AddCommand(newVersionCmd()) rootCmd.AddCommand(newStatusCmd(opts)) rootCmd.AddCommand(newDeployCmd(opts)) rootCmd.AddCommand(newImportCmd(opts)) @@ -44,6 +48,18 @@ func NewRootCmd() *cobra.Command { return rootCmd } +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print version information", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), versionLine()) + return nil + }, + } +} + func newStatusCmd(opts *rootOptions) *cobra.Command { var jsonOutput bool var dryRun bool diff --git a/internal/app/version.go b/internal/app/version.go new file mode 100644 index 0000000..5c3a3a9 --- /dev/null +++ b/internal/app/version.go @@ -0,0 +1,20 @@ +package app + +import ( + "fmt" + "strings" +) + +var buildVersion = "dev" + +func currentVersion() string { + version := strings.TrimSpace(buildVersion) + if version == "" { + return "dev" + } + return version +} + +func versionLine() string { + return fmt.Sprintf("dotfiles-manager version %s", currentVersion()) +} diff --git a/internal/app/version_test.go b/internal/app/version_test.go new file mode 100644 index 0000000..836a72a --- /dev/null +++ b/internal/app/version_test.go @@ -0,0 +1,97 @@ +package app + +import ( + "bytes" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestVersionCommandAndFlagPrintVersionWithoutConfig(t *testing.T) { + projectDir := t.TempDir() + oldWD, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + require.NoError(t, os.Chdir(projectDir)) + + oldVersion := buildVersion + buildVersion = "1.2.3" + t.Cleanup(func() { buildVersion = oldVersion }) + + testCases := [][]string{ + {"version"}, + {"--version"}, + } + + for _, args := range testCases { + cmd := NewRootCmd() + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs(args) + + require.NoError(t, cmd.Execute()) + require.Equal(t, "dotfiles-manager version 1.2.3\n", stdout.String()) + require.Empty(t, stderr.String()) + } +} + +func TestVersionCommandFallsBackToDevWhenUnset(t *testing.T) { + projectDir := t.TempDir() + oldWD, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + require.NoError(t, os.Chdir(projectDir)) + + oldVersion := buildVersion + buildVersion = "" + t.Cleanup(func() { buildVersion = oldVersion }) + + cmd := NewRootCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"version"}) + + require.NoError(t, cmd.Execute()) + require.Equal(t, "dotfiles-manager version dev\n", stdout.String()) +} + +func TestVersionCommandRejectsUnsupportedInputs(t *testing.T) { + projectDir := t.TempDir() + oldWD, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + require.NoError(t, os.Chdir(projectDir)) + + testCases := []struct { + args []string + errContains string + }{ + { + args: []string{"version", "--json"}, + errContains: "unknown flag: --json", + }, + { + args: []string{"version", "--dry-run"}, + errContains: "unknown flag: --dry-run", + }, + { + args: []string{"version", "/tmp/path"}, + errContains: "unknown command \"/tmp/path\"", + }, + } + + for _, tc := range testCases { + cmd := NewRootCmd() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs(tc.args) + + err := cmd.Execute() + require.Error(t, err) + require.Contains(t, err.Error(), tc.errContains) + } +}