summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authors <[email protected]>2025-11-04 11:06:35 -0500
committers <[email protected]>2025-11-04 11:06:35 -0500
commit4486b6659640102dd542fea007f4c33ac02511ff (patch)
tree3e991f3722e3b0062a6078078ff6aa1478c3ab00
parent3c06eede8ac8cb79272601aad3b2d3359657443a (diff)
downloaddborg-4486b6659640102dd542fea007f4c33ac02511ff.tar.gz
dborg-4486b6659640102dd542fea007f4c33ac02511ff.zip
feat: add version checking and auto-update functionality
-rw-r--r--.gitignore1
-rw-r--r--AUTOUPDATE.md92
-rw-r--r--Makefile11
-rw-r--r--README.md19
-rw-r--r--cmd/reddit.go237
-rw-r--r--cmd/root.go4
-rw-r--r--cmd/version.go21
-rw-r--r--internal/client/reddit.go83
-rw-r--r--internal/models/reddit.go21
-rw-r--r--internal/utils/version.go115
-rw-r--r--internal/utils/version_test.go52
11 files changed, 650 insertions, 6 deletions
diff --git a/.gitignore b/.gitignore
index 5f377f7..773e012 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/Makefile b/Makefile
index 04e7e39..acb2961 100644
--- a/Makefile
+++ b/Makefile
@@ -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)" .
diff --git a/README.md b/README.md
index a835822..4f27264 100644
--- a/README.md
+++ b/README.md
@@ -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)
+ }
+ })
+ }
+}