feat: address TODOs and write README.md

This commit is contained in:
Stevan Freeborn
2026-08-04 18:00:54 -05:00
parent 79090b3d39
commit ecaa816f1c
4 changed files with 200 additions and 40 deletions
+75 -1
View File
@@ -1,3 +1,77 @@
# gator # gator
This is a blog aggregator that was built as part of a [boot.dev](https://boot.dev) course. A blog aggregator CLI built as part of the [boot.dev](https://boot.dev) course. Follow RSS feeds and browse their latest posts right from your terminal.
## Requirements
Before you can run gator you'll need to have the following installed:
- **Go** (1.26 or newer)
- **PostgreSQL** (a running server, local or remote)
## Installation
Install the gator CLI with `go install`:
```sh
go install github.com/StevanFreeborn/gator@latest
```
The binary is placed in `$(go env GOPATH)/bin`. Make sure that directory is on your `PATH`:
```sh
export PATH="$(go env GOPATH)/bin:$PATH"
```
## Setup
### 1. Create a database
Create a database for gator in your Postgres server:
```sh
createdb gator
```
The schema is managed with [goose](https://github.com/pressly/goose) migrations in `sql/schema`. Install goose and apply the migrations:
```sh
go install github.com/pressly/goose/v3/cmd/goose@latest
goose -dir sql/schema postgres "postgres://<user>:<password>@localhost:5432/gator?sslmode=disable" up
```
### 2. Create the config file
gator reads its configuration from `~/.gatorconfig.json`. Create that file with the connection string for your database:
```json
{
"db_url": "postgres://<user>:<password>@localhost:5432/gator?sslmode=disable",
"current_user_name": ""
}
```
## Usage
Run gator with `gator <command> <args>`. Start by registering a user, then log in:
```sh
gator register myusername
gator login myusername
```
A few commands you can run:
| Command | Description |
| ------------------------------ | --------------------------------------------------------------------------------------- |
| `gator register <name>` | Register a new user and log in as them |
| `gator login <name>` | Log in as an existing user |
| `gator users` | List all registered users |
| `gator addfeed <name> <url>` | Add an RSS feed and follow it |
| `gator feeds` | List all feeds |
| `gator follow <name-or-url>` | Follow a feed by name or url |
| `gator following` | List the feeds you're following |
| `gator unfollow <name-or-url>` | Unfollow a feed by name or url |
| `gator browse [limit]` | Browse posts from your followed feeds |
| `gator agg <duration>` | Continuously fetch posts every `<duration>` (e.g. `60s`); press `q` or `Ctrl-C` to stop |
| `gator reset` | Delete all users and their data |
+18 -31
View File
@@ -4,7 +4,6 @@ import (
"cmp" "cmp"
"context" "context"
"fmt" "fmt"
"net/url"
"os" "os"
"os/signal" "os/signal"
"slices" "slices"
@@ -17,7 +16,6 @@ import (
"github.com/StevanFreeborn/gator/internal/rss" "github.com/StevanFreeborn/gator/internal/rss"
"github.com/StevanFreeborn/gator/internal/state" "github.com/StevanFreeborn/gator/internal/state"
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/term"
) )
type Command struct { type Command struct {
@@ -192,30 +190,13 @@ func aggCommand() *Command {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel() defer cancel()
oldState, err := term.MakeRaw(int(os.Stdin.Fd())) restore, err := enableKeypressExit(cancel)
if err != nil { if err != nil {
return fmt.Errorf("Failed to enable raw mode: %v", err) return err
} }
defer term.Restore(int(os.Stdin.Fd()), oldState) defer restore()
go func() {
buf := make([]byte, 1)
for {
n, err := os.Stdin.Read(buf)
if err != nil || n == 0 {
return
}
if buf[0] == 'q' || buf[0] == 'Q' || buf[0] == 3 {
cancel()
return
}
}
}()
ticker := time.NewTicker(validDuration) ticker := time.NewTicker(validDuration)
defer ticker.Stop() defer ticker.Stop()
@@ -246,21 +227,27 @@ func addFeedCommand() *Command {
feedName := s.Arguments[0] feedName := s.Arguments[0]
feedUrl := s.Arguments[1] feedUrl := s.Arguments[1]
validatedFeedUrl, err := url.Parse(feedUrl)
if strings.TrimSpace(feedName) == "" { if strings.TrimSpace(feedName) == "" {
return fmt.Errorf("Feed name cannot be empty") return fmt.Errorf("Feed name cannot be empty")
} }
canonicalFeedUrl, err := normalizeFeedURL(feedUrl)
if err != nil { if err != nil {
return fmt.Errorf("Feed url '%s' is not a valid url", feedUrl) return fmt.Errorf("Feed url '%s' is not a valid url", feedUrl)
} }
_, err = s.Database.GetFeedByUrl(context.Background(), canonicalFeedUrl)
if err == nil {
return fmt.Errorf("Feed with url '%s' already exists; use 'follow <name>' to follow it", canonicalFeedUrl)
}
createFeedParams := database.CreateFeedParams{ createFeedParams := database.CreateFeedParams{
ID: uuid.New(), ID: uuid.New(),
UserID: currentUser.ID, UserID: currentUser.ID,
Name: feedName, Name: feedName,
Url: validatedFeedUrl.String(), Url: canonicalFeedUrl,
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
@@ -325,12 +312,12 @@ func followCommand() *Command {
return fmt.Errorf("Did not receive expected url argument") return fmt.Errorf("Did not receive expected url argument")
} }
urlForFeedToFollow := s.Arguments[0] feedIdentifier := s.Arguments[0]
feed, err := s.Database.GetFeedByUrl(context.Background(), urlForFeedToFollow) feed, err := resolveFeedByNameOrURL(s, feedIdentifier)
if err != nil { if err != nil {
return fmt.Errorf("Unable to find feed with url '%s'", urlForFeedToFollow) return err
} }
createFollowParams := database.CreateFollowParams{ createFollowParams := database.CreateFollowParams{
@@ -375,12 +362,12 @@ func unfollowCommand() *Command {
return fmt.Errorf("Did not receive expected feed url argument") return fmt.Errorf("Did not receive expected feed url argument")
} }
feedUrl := s.Arguments[0] feedIdentifier := s.Arguments[0]
feed, err := s.Database.GetFeedByUrl(context.Background(), feedUrl) feed, err := resolveFeedByNameOrURL(s, feedIdentifier)
if err != nil { if err != nil {
return fmt.Errorf("Unable to find feed with url '%s'", feedUrl) return err
} }
deleteFollowParams := database.DeleteFollowForUserParams{ deleteFollowParams := database.DeleteFollowForUserParams{
+107
View File
@@ -0,0 +1,107 @@
package command
import (
"context"
"fmt"
"net/url"
"os"
"strings"
"github.com/StevanFreeborn/gator/internal/database"
"github.com/StevanFreeborn/gator/internal/state"
"golang.org/x/term"
)
func normalizeFeedURL(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil {
return "", err
}
u.Scheme = strings.ToLower(u.Scheme)
u.Host = strings.ToLower(u.Host)
if (u.Scheme == "http" && u.Port() == "80") || (u.Scheme == "https" && u.Port() == "443") {
u.Host = u.Hostname()
}
if len(u.Path) > 1 {
u.Path = strings.TrimSuffix(u.Path, "/")
}
u.Fragment = ""
return u.String(), nil
}
func resolveFeedByNameOrURL(s *state.State, identifier string) (database.Feed, error) {
if strings.TrimSpace(identifier) == "" {
return database.Feed{}, fmt.Errorf("Feed identifier cannot be empty")
}
feeds, err := s.Database.GetAllFeeds(context.Background())
if err != nil {
return database.Feed{}, fmt.Errorf("Failed to load feeds")
}
var matches []database.Feed
for _, feed := range feeds {
if strings.EqualFold(feed.Name, identifier) {
matches = append(matches, feed)
}
}
if len(matches) == 1 {
return matches[0], nil
}
if len(matches) > 1 {
return database.Feed{}, fmt.Errorf("Multiple feeds match name '%s'; use a url instead", identifier)
}
normalizedURL, err := normalizeFeedURL(identifier)
if err != nil {
return database.Feed{}, fmt.Errorf("No feed found matching name or url '%s'", identifier)
}
feed, err := s.Database.GetFeedByUrl(context.Background(), normalizedURL)
if err != nil {
return database.Feed{}, fmt.Errorf("No feed found matching name or url '%s'", identifier)
}
return feed, nil
}
func enableKeypressExit(cancel context.CancelFunc) (func(), error) {
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
if err != nil {
return nil, fmt.Errorf("Failed to enable raw mode: %v", err)
}
go func() {
buf := make([]byte, 1)
for {
n, err := os.Stdin.Read(buf)
if err != nil || n == 0 {
return
}
if buf[0] == 'q' || buf[0] == 'Q' || buf[0] == 3 {
cancel()
return
}
}
}()
return func() {
term.Restore(int(os.Stdin.Fd()), oldState)
}, nil
}
-8
View File
@@ -12,14 +12,6 @@ import (
_ "github.com/lib/pq" _ "github.com/lib/pq"
) )
// TODO: Reconcile identifiers for commands. Should prefer referring to things by Name
// not URL
// TODO: Address what happens when user provides URL that resolves to same set of posts
// but URL slight difference than existing one. i.e. https://test.com/rss vs. https://test.com/rss/
// TODO: Extract exit handling in agg command to separate function
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {
fmt.Println("Not enough arguments provided. Usage: <command> <args>") fmt.Println("Not enough arguments provided. Usage: <command> <args>")