diff options
| author | s <[email protected]> | 2025-11-04 11:06:35 -0500 |
|---|---|---|
| committer | s <[email protected]> | 2025-11-04 11:06:35 -0500 |
| commit | 4486b6659640102dd542fea007f4c33ac02511ff (patch) | |
| tree | 3e991f3722e3b0062a6078078ff6aa1478c3ab00 | |
| parent | 3c06eede8ac8cb79272601aad3b2d3359657443a (diff) | |
| download | dborg-4486b6659640102dd542fea007f4c33ac02511ff.tar.gz dborg-4486b6659640102dd542fea007f4c33ac02511ff.zip | |
feat: add version checking and auto-update functionality
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | AUTOUPDATE.md | 92 | ||||
| -rw-r--r-- | Makefile | 11 | ||||
| -rw-r--r-- | README.md | 19 | ||||
| -rw-r--r-- | cmd/reddit.go | 237 | ||||
| -rw-r--r-- | cmd/root.go | 4 | ||||
| -rw-r--r-- | cmd/version.go | 21 | ||||
| -rw-r--r-- | internal/client/reddit.go | 83 | ||||
| -rw-r--r-- | internal/models/reddit.go | 21 | ||||
| -rw-r--r-- | internal/utils/version.go | 115 | ||||
| -rw-r--r-- | internal/utils/version_test.go | 52 |
11 files changed, 650 insertions, 6 deletions
@@ -32,3 +32,4 @@ go.work.sum # .vscode/ dborg +doc.json diff --git a/AUTOUPDATE.md b/AUTOUPDATE.md new file mode 100644 index 0000000..4c72519 --- /dev/null +++ b/AUTOUPDATE.md @@ -0,0 +1,92 @@ +# Auto-Update Feature + +## Overview + +The dborg CLI includes an automatic update checker that runs on every program start. It checks the git remote for newer tagged versions and prompts the user to update if available. + +## How It Works + +1. **Version Check**: On startup, the CLI queries the git remote for the latest tag +2. **Comparison**: Compares remote version with current version (embedded at build time) +3. **User Prompt**: If newer version exists, prompts user to update +4. **Installation**: Runs `go install git.db.org.ai/dborg` to fetch latest version +5. **Auto-Restart**: Uses `syscall.Exec()` to restart with the new binary seamlessly + +## Implementation Details + +### Version Embedding + +The version is embedded at build time using ldflags: + +```bash +go build -ldflags "-X git.db.org.ai/dborg/internal/utils.Version=v0.1.0" +``` + +The Makefile automatically extracts the version from git tags: + +```makefile +VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null || echo "v0.1.0") +LDFLAGS := -X git.db.org.ai/dborg/internal/utils.Version=$(VERSION) +``` + +### Version Check Process + +Located in `internal/utils/version.go`: + +1. **CheckForUpdates()**: Main entry point called from root command's PersistentPreRunE +2. **getLatestRemoteTag()**: Queries git remote using `git ls-remote --tags` +3. **isNewerVersion()**: Simple string comparison for version numbers +4. **promptAndUpdate()**: Handles user interaction and update process +5. **restartSelf()**: Restarts the binary using `syscall.Exec()` + +### Skip Conditions + +Auto-update is skipped when: +- Running in non-interactive mode (piped output) +- Version is "dev" (development builds) +- Git remote query fails (silently continues) +- No newer version available +- User declines update + +## User Experience + +``` +$ dborg npd --firstname John + +š A new version of dborg is available: v0.2.0 (current: v0.1.0) +Would you like to update now? [Y/n]: y +Updating to v0.2.0... +ā Update successful! Restarting... + +[Binary automatically restarts and continues with original command] +``` + +## Manual Update + +Users can always update manually: + +```bash +go install git.db.org.ai/dborg@latest +``` + +Or check their current version: + +```bash +dborg version +``` + +## Testing + +Unit tests cover version comparison logic: + +```bash +go test ./internal/utils -v +``` + +## Security Considerations + +- Only queries official git remote (git.db.org.ai/dborg) +- Runs exact command: `go install git.db.org.ai/dborg` +- No automatic updates without user confirmation +- Fails safely if update errors occur +- Preserves original command arguments after restart @@ -1,16 +1,19 @@ .PHONY: build build-admin clean install install-admin +VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null || echo "v0.1.0") +LDFLAGS := -X git.db.org.ai/dborg/internal/utils.Version=$(VERSION) + build: - go build -o dborg . + go build -ldflags "$(LDFLAGS)" -o dborg . build-admin: - go build -tags admin -o dborg . + go build -tags admin -ldflags "$(LDFLAGS)" -o dborg . clean: rm -f dborg install: - go install . + go install -ldflags "$(LDFLAGS)" . install-admin: - go install -tags admin . + go install -tags admin -ldflags "$(LDFLAGS)" . @@ -32,7 +32,9 @@ dborg/ ā ā āāā usrsx.go # Username check types ā ā āāā x.go # Twitter/X types ā āāā utils/ # Utility functions -ā āāā output.go # Output formatting helpers +ā āāā output.go # Output formatting helpers +ā āāā version.go # Version checking and auto-update +ā āāā version_test.go # Version utility tests āāā go.mod āāā go.sum āāā main.go @@ -43,7 +45,7 @@ dborg/ ## Installation ```bash -go install +go install git.db.org.ai/dborg ``` To build with admin commands: @@ -58,6 +60,19 @@ Or: go build -tags admin -o dborg . ``` +### Auto-Update + +The CLI automatically checks for updates on startup. When a newer version is available: +- You'll be prompted to update +- Accepts with `Y` or `Enter` to install the latest version via `go install git.db.org.ai/dborg` +- The binary automatically restarts with the new version +- Skip with `n` to continue with your current version + +Check your current version: +```bash +dborg version +``` + ## Configuration Set your API key: diff --git a/cmd/reddit.go b/cmd/reddit.go new file mode 100644 index 0000000..2341322 --- /dev/null +++ b/cmd/reddit.go @@ -0,0 +1,237 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "git.db.org.ai/dborg/internal/client" + "git.db.org.ai/dborg/internal/config" + "git.db.org.ai/dborg/internal/models" + "github.com/spf13/cobra" +) + +var redditCmd = &cobra.Command{ + Use: "reddit", + Short: "Reddit data retrieval tools", + Long: `Retrieve posts, comments, and user information from Reddit`, +} + +var redditSubredditCmd = &cobra.Command{ + Use: "subreddit", + Short: "Get subreddit data", + Long: `Retrieve posts or comments from a subreddit`, +} + +var redditUserCmd = &cobra.Command{ + Use: "user", + Short: "Get Reddit user data", + Long: `Retrieve posts, comments, or profile information for a Reddit user`, +} + +var redditSubredditPostsCmd = &cobra.Command{ + Use: "posts [subreddit]", + Short: "Get subreddit posts", + Long: `Get up to 1000 recent posts from a subreddit`, + Args: cobra.ExactArgs(1), + RunE: runRedditSubredditPosts, +} + +var redditSubredditCommentsCmd = &cobra.Command{ + Use: "comments [subreddit]", + Short: "Get subreddit comments", + Long: `Get up to 1000 recent comments from a subreddit`, + Args: cobra.ExactArgs(1), + RunE: runRedditSubredditComments, +} + +var redditUserPostsCmd = &cobra.Command{ + Use: "posts [username]", + Short: "Get user posts", + Long: `Get up to 1000 recent posts from a Reddit user`, + Args: cobra.ExactArgs(1), + RunE: runRedditUserPosts, +} + +var redditUserCommentsCmd = &cobra.Command{ + Use: "comments [username]", + Short: "Get user comments", + Long: `Get up to 1000 recent comments from a Reddit user`, + Args: cobra.ExactArgs(1), + RunE: runRedditUserComments, +} + +var redditUserAboutCmd = &cobra.Command{ + Use: "about [username]", + Short: "Get user profile", + Long: `Get profile information for a Reddit user`, + Args: cobra.ExactArgs(1), + RunE: runRedditUserAbout, +} + +func init() { + rootCmd.AddCommand(redditCmd) + redditCmd.AddCommand(redditSubredditCmd) + redditCmd.AddCommand(redditUserCmd) + + redditSubredditCmd.AddCommand(redditSubredditPostsCmd) + redditSubredditCmd.AddCommand(redditSubredditCommentsCmd) + + redditUserCmd.AddCommand(redditUserPostsCmd) + redditUserCmd.AddCommand(redditUserCommentsCmd) + redditUserCmd.AddCommand(redditUserAboutCmd) +} + +func runRedditSubredditPosts(cmd *cobra.Command, args []string) error { + apiKey, _ := cmd.Flags().GetString("api-key") + cfg := config.New().WithAPIKey(apiKey) + + c, err := client.New(cfg) + if err != nil { + return err + } + + params := &models.RedditSubredditParams{ + Subreddit: args[0], + } + + response, err := c.GetSubredditPosts(params) + if err != nil { + return err + } + + if response.Error != "" { + return fmt.Errorf("API error: %s", response.Error) + } + + output, err := json.MarshalIndent(response, "", " ") + if err != nil { + return fmt.Errorf("failed to format response: %w", err) + } + + fmt.Println(string(output)) + return nil +} + +func runRedditSubredditComments(cmd *cobra.Command, args []string) error { + apiKey, _ := cmd.Flags().GetString("api-key") + cfg := config.New().WithAPIKey(apiKey) + + c, err := client.New(cfg) + if err != nil { + return err + } + + params := &models.RedditSubredditParams{ + Subreddit: args[0], + } + + response, err := c.GetSubredditComments(params) + if err != nil { + return err + } + + if response.Error != "" { + return fmt.Errorf("API error: %s", response.Error) + } + + output, err := json.MarshalIndent(response, "", " ") + if err != nil { + return fmt.Errorf("failed to format response: %w", err) + } + + fmt.Println(string(output)) + return nil +} + +func runRedditUserPosts(cmd *cobra.Command, args []string) error { + apiKey, _ := cmd.Flags().GetString("api-key") + cfg := config.New().WithAPIKey(apiKey) + + c, err := client.New(cfg) + if err != nil { + return err + } + + params := &models.RedditUserParams{ + Username: args[0], + } + + response, err := c.GetUserPosts(params) + if err != nil { + return err + } + + if response.Error != "" { + return fmt.Errorf("API error: %s", response.Error) + } + + output, err := json.MarshalIndent(response, "", " ") + if err != nil { + return fmt.Errorf("failed to format response: %w", err) + } + + fmt.Println(string(output)) + return nil +} + +func runRedditUserComments(cmd *cobra.Command, args []string) error { + apiKey, _ := cmd.Flags().GetString("api-key") + cfg := config.New().WithAPIKey(apiKey) + + c, err := client.New(cfg) + if err != nil { + return err + } + + params := &models.RedditUserParams{ + Username: args[0], + } + + response, err := c.GetUserComments(params) + if err != nil { + return err + } + + if response.Error != "" { + return fmt.Errorf("API error: %s", response.Error) + } + + output, err := json.MarshalIndent(response, "", " ") + if err != nil { + return fmt.Errorf("failed to format response: %w", err) + } + + fmt.Println(string(output)) + return nil +} + +func runRedditUserAbout(cmd *cobra.Command, args []string) error { + apiKey, _ := cmd.Flags().GetString("api-key") + cfg := config.New().WithAPIKey(apiKey) + + c, err := client.New(cfg) + if err != nil { + return err + } + + params := &models.RedditUserParams{ + Username: args[0], + } + + response, err := c.GetUserAbout(params) + if err != nil { + return err + } + + if response.Error != "" { + return fmt.Errorf("API error: %s", response.Error) + } + + output, err := json.MarshalIndent(response, "", " ") + if err != nil { + return fmt.Errorf("failed to format response: %w", err) + } + + fmt.Println(string(output)) + return nil +} diff --git a/cmd/root.go b/cmd/root.go index b8ab9e4..cc74436 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "git.db.org.ai/dborg/internal/utils" "github.com/spf13/cobra" ) @@ -11,6 +12,9 @@ var rootCmd = &cobra.Command{ Use: "dborg", Short: "DB.org.ai CLI client", Long: `Query db.org.ai API`, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return utils.CheckForUpdates(cmd) + }, } func Execute() { diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..bdb3331 --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "fmt" + + "git.db.org.ai/dborg/internal/utils" + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version number of dborg", + Long: `Display the current version of the dborg CLI`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("dborg version %s\n", utils.Version) + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} diff --git a/internal/client/reddit.go b/internal/client/reddit.go new file mode 100644 index 0000000..da95782 --- /dev/null +++ b/internal/client/reddit.go @@ -0,0 +1,83 @@ +package client + +import ( + "encoding/json" + "fmt" + + "git.db.org.ai/dborg/internal/models" +) + +func (c *Client) GetSubredditPosts(params *models.RedditSubredditParams) (*models.RedditResponse, error) { + path := fmt.Sprintf("/reddit/r/%s", params.Subreddit) + data, err := c.Get(path, nil) + if err != nil { + return nil, err + } + + var response models.RedditResponse + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &response, nil +} + +func (c *Client) GetSubredditComments(params *models.RedditSubredditParams) (*models.RedditResponse, error) { + path := fmt.Sprintf("/reddit/r/%s/comments", params.Subreddit) + data, err := c.Get(path, nil) + if err != nil { + return nil, err + } + + var response models.RedditResponse + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &response, nil +} + +func (c *Client) GetUserPosts(params *models.RedditUserParams) (*models.RedditResponse, error) { + path := fmt.Sprintf("/reddit/user/%s/posts", params.Username) + data, err := c.Get(path, nil) + if err != nil { + return nil, err + } + + var response models.RedditResponse + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &response, nil +} + +func (c *Client) GetUserComments(params *models.RedditUserParams) (*models.RedditResponse, error) { + path := fmt.Sprintf("/reddit/user/%s/comments", params.Username) + data, err := c.Get(path, nil) + if err != nil { + return nil, err + } + + var response models.RedditResponse + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &response, nil +} + +func (c *Client) GetUserAbout(params *models.RedditUserParams) (*models.RedditResponse, error) { + path := fmt.Sprintf("/reddit/user/%s/about", params.Username) + data, err := c.Get(path, nil) + if err != nil { + return nil, err + } + + var response models.RedditResponse + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &response, nil +} diff --git a/internal/models/reddit.go b/internal/models/reddit.go new file mode 100644 index 0000000..4d81aa8 --- /dev/null +++ b/internal/models/reddit.go @@ -0,0 +1,21 @@ +package models + +type RedditSubredditParams struct { + Subreddit string `json:"subreddit"` +} + +type RedditUserParams struct { + Username string `json:"username"` +} + +type RedditResponse struct { + Subreddit string `json:"subreddit,omitempty"` + Username string `json:"username,omitempty"` + Type string `json:"type,omitempty"` + Results interface{} `json:"results"` + Credits struct { + Remaining int `json:"remaining"` + Unlimited bool `json:"unlimited"` + } `json:"credits"` + Error string `json:"error,omitempty"` +} diff --git a/internal/utils/version.go b/internal/utils/version.go new file mode 100644 index 0000000..00e6cc2 --- /dev/null +++ b/internal/utils/version.go @@ -0,0 +1,115 @@ +package utils + +import ( + "fmt" + "os" + "os/exec" + "strings" + "syscall" + + "github.com/spf13/cobra" +) + +var Version = "dev" + +func CheckForUpdates(cmd *cobra.Command) error { + if !isTerminal() || Version == "dev" { + return nil + } + + latestVersion, err := getLatestRemoteTag() + if err != nil { + return nil + } + + if latestVersion == "" || latestVersion == Version { + return nil + } + + if isNewerVersion(latestVersion, Version) { + promptAndUpdate(latestVersion) + } + + return nil +} + +func getLatestRemoteTag() (string, error) { + cmd := exec.Command("git", "ls-remote", "--tags", "--refs", "--sort=-v:refname", "git.db.org.ai/dborg") + output, err := cmd.Output() + if err != nil { + return "", err + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) == 0 { + return "", fmt.Errorf("no tags found") + } + + parts := strings.Split(lines[0], "refs/tags/") + if len(parts) < 2 { + return "", fmt.Errorf("invalid tag format") + } + + return strings.TrimSpace(parts[1]), nil +} + +func isNewerVersion(remote, local string) bool { + remote = strings.TrimPrefix(remote, "v") + local = strings.TrimPrefix(local, "v") + + return remote != local && remote > local +} + +func promptAndUpdate(newVersion string) { + fmt.Fprintf(os.Stderr, "\nš A new version of dborg is available: %s (current: %s)\n", newVersion, Version) + fmt.Fprintf(os.Stderr, "Would you like to update now? [Y/n]: ") + + var response string + fmt.Scanln(&response) + + response = strings.ToLower(strings.TrimSpace(response)) + if response != "" && response != "y" && response != "yes" { + fmt.Fprintf(os.Stderr, "Update skipped. Run 'go install git.db.org.ai/dborg@latest' to update manually.\n\n") + return + } + + fmt.Fprintf(os.Stderr, "Updating to %s...\n", newVersion) + + installCmd := exec.Command("go", "install", "git.db.org.ai/dborg") + installCmd.Stdout = os.Stderr + installCmd.Stderr = os.Stderr + + if err := installCmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to update: %v\n", err) + fmt.Fprintf(os.Stderr, "Please update manually: go install git.db.org.ai/dborg@latest\n\n") + return + } + + fmt.Fprintf(os.Stderr, "ā Update successful! Restarting...\n\n") + + restartSelf() +} + +func restartSelf() { + executable, err := os.Executable() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get executable path: %v\n", err) + os.Exit(1) + } + + args := os.Args[1:] + + err = syscall.Exec(executable, append([]string{executable}, args...), os.Environ()) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to restart: %v\n", err) + os.Exit(1) + } +} + +func isTerminal() bool { + fileInfo, err := os.Stdout.Stat() + if err != nil { + return false + } + return (fileInfo.Mode() & os.ModeCharDevice) != 0 +} diff --git a/internal/utils/version_test.go b/internal/utils/version_test.go new file mode 100644 index 0000000..dc1627d --- /dev/null +++ b/internal/utils/version_test.go @@ -0,0 +1,52 @@ +package utils + +import "testing" + +func TestIsNewerVersion(t *testing.T) { + tests := []struct { + name string + remote string + local string + expected bool + }{ + { + name: "newer version available", + remote: "v0.2.0", + local: "v0.1.0", + expected: true, + }, + { + name: "same version", + remote: "v0.1.0", + local: "v0.1.0", + expected: false, + }, + { + name: "local is newer", + remote: "v0.1.0", + local: "v0.2.0", + expected: false, + }, + { + name: "without v prefix", + remote: "0.2.0", + local: "0.1.0", + expected: true, + }, + { + name: "mixed prefix", + remote: "v0.2.0", + local: "0.1.0", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isNewerVersion(tt.remote, tt.local) + if result != tt.expected { + t.Errorf("isNewerVersion(%s, %s) = %v, expected %v", tt.remote, tt.local, result, tt.expected) + } + }) + } +} |
