diff --git a/.gitignore b/.gitignore index 4bb421f..305f423 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,10 @@ go.work .DS_Store # Project specific -journal +/journal + +/bin + +# Backup files +*.bak +*~ diff --git a/CHANGELOG.md b/CHANGELOG.md index a6e2dfa..2dfc599 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve - Initial development items tracked here. +## [0.2.02] - 2026-01-06 + +### Added + +- `self-update` command to update the CLI directly from GitHub Releases. +- `make bump-version` command to automate version updates across files. +- Documentation for the update command in `README.md` and standard help output. + ## [0.2.01] - 2026-01-06 ### Added diff --git a/Makefile b/Makefile index d2b53b4..91dc90f 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # Variables BINARY_NAME=journal -VERSION?=0.2.01 +VERSION?=0.2.02 BUILD_DIR=bin MAIN_PATH=cmd/journal/main.go COVERAGE_FILE=coverage.out @@ -103,3 +103,11 @@ vet: ## Run go vet @echo "Running go vet..." go vet ./... @echo "Vet complete!" + +bump-version: ## Bump version (usage: make bump-version v=1.0.0) + @if [ -z "$(v)" ]; then echo "Usage: make bump-version v=x.y.z"; exit 1; fi + @echo "Bumping version to $(v)..." + @sed -i.bak 's/^VERSION?=.*/VERSION?=$(v)/' Makefile && rm Makefile.bak + @sed -i.bak 's/const Version = .*/const Version = "$(v)"/' $(MAIN_PATH) && rm $(MAIN_PATH).bak + @sed -i.bak 's/version: .*/version: $(v)/' internal/help/help.yaml && rm internal/help/help.yaml.bak + @echo "Version bumped to $(v)" diff --git a/README.md b/README.md index fdd7f94..c349ee1 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,22 @@ Behavior of `--todos` mode: Note: shells treat flags-without-values differently. Using `--todos ""` explicitly is reliable across shells to mean "today." If you prefer, I can add a separate boolean flag `--todo-mode` that always updates today's todos. +## Updates + +To update `journal-cli` to the latest version, run: + +```bash +sudo ./journal self-update +``` + +This will: +1. Validate the latest GitHub release. +2. Downloads the binary for your OS/Arch. +3. Replaces the current binary in-place. +4. Preserves the old binary as `.bak` in case of failure. + +*Note: You may need `sudo` if the binary is installed in a protected directory.* + ## Keywords - journaling @@ -140,4 +156,11 @@ Note: shells treat flags-without-values differently. Using `--todos ""` explicit We welcome contributions! See `CONTRIBUTING.md` for guidelines on filing issues and submitting pull requests. Maintainers will review incoming PRs — the repository uses a review-first workflow and code owners to ensure one or more reviews are required before merging. -If you'd like to help, open an issue or submit a draft PR and we will guide you through the process. \ No newline at end of file +If you'd like to help, open an issue or submit a draft PR and we will guide you through the process. + +## Documentation + +- [Architecture Overview](docs/ARCHITECTURE.md) +- [ADR 001: Pragmatic Clean Architecture](docs/adr/001-pragmatic-clean-architecture.md) +- [Feature: Self-Update](docs/features/self-update.md) +- [Feature: Templates](docs/features/templates.md) \ No newline at end of file diff --git a/architecture.md b/architecture.md deleted file mode 100644 index f201ba5..0000000 --- a/architecture.md +++ /dev/null @@ -1,50 +0,0 @@ -# Clean Architecture in Journal CLI - -This project follows the principles of **Clean Architecture** to ensure separation of concerns, testability, and maintainability. The code is organized into concentric layers, with dependencies pointing inwards. - -## Architectural Layers - -### 1. Entities (Domain Layer) -**Location**: `internal/domain` - -This is the innermost layer. It contains the core business objects of the application. These entities are plain Go structs and have **no dependencies** on outer layers (no filesystem, no TUI, no config). - -- **`JournalEntry`**: Represents a daily journal entry with mood, energy, todos, and answers. -- **`Todo`**: Represents a single task. - -### 2. Use Cases (Application Layer) -**Location**: `internal/todo`, `internal/markdown`, `internal/app` - -This layer contains application-specific business rules. It orchestrates the flow of data to and from the entities. - -- **`internal/todo`**: Contains the logic for calculating the backlog (business rule: "unchecked todos from yesterday become today's backlog"). -- **`internal/markdown`**: Handles the conversion between Domain Entities and the persistence format (Markdown). This acts as a data mapper. -- **`internal/app`**: The **Application Orchestrator**. It acts as the main entry point for the business logic, wiring together the config, templates, and TUI. - -### 3. Interface Adapters (Presentation Layer) -**Location**: `internal/tui`, `cmd` - -This layer converts data from the format most convenient for the use cases and entities, to the format most convenient for some external agency (in this case, the Terminal). - -- **`internal/tui`**: The **Bubble Tea** model. It handles user input (keyboard events) and renders the UI. It interacts with the `JournalEntry` domain object but delegates persistence to the outer layers (via the App Orchestrator). -- **`cmd/journal`**: The entry point. It initializes the application and dependencies. - -### 4. Frameworks & Drivers (Infrastructure Layer) -**Location**: `internal/fs`, `internal/config`, `internal/template` - -This is the outermost layer. It contains details such as the filesystem, configuration files, and external tools. - -- **`internal/fs`**: Low-level wrappers around `os` and `filepath` to handle file I/O. -- **`internal/config`**: Knows how to find and parse the `config.yaml` file from the OS-specific user config directory. -- **`internal/template`**: Knows how to scan the `templates` directory and parse YAML files into template structures. - -## Dependency Rule -The source code dependencies only point inwards. -- `internal/domain` knows nothing about `internal/tui` or `internal/fs`. -- `internal/tui` imports `internal/domain` but doesn't know about the specific filesystem implementation details (ideally, though in this simple CLI, `app` mediates this). -- `internal/app` (Application Layer) orchestrates the interaction between the Infrastructure (`config`, `fs`) and the Presentation (`tui`). - -## Benefits -1. **Independent of Frameworks**: The TUI library (Bubble Tea) is isolated in `internal/tui`. We could swap it for a web server or a GUI without changing the `domain` or `todo` logic. -2. **Testable**: The `internal/domain` and `internal/todo` logic can be unit tested without any filesystem or UI. -3. **Independent of Database**: The persistence mechanism (Markdown files) is isolated. We could switch to SQLite by changing the persistence adapter without affecting the TUI or Domain. diff --git a/cmd/journal/main.go b/cmd/journal/main.go index 052f2ba..d54561c 100644 --- a/cmd/journal/main.go +++ b/cmd/journal/main.go @@ -7,11 +7,21 @@ import ( "journal-cli/internal/app" "journal-cli/internal/help" + "journal-cli/internal/updater" ) -const Version = "0.2.01" +const Version = "0.2.02" func main() { + // Check for "self-update" subcommand + if len(os.Args) > 1 && os.Args[1] == "self-update" { + if err := updater.Update(); err != nil { + fmt.Fprintf(os.Stderr, "Error updating: %v\n", err) + os.Exit(1) + } + return + } + helpFlag := flag.Bool("help", false, "Show help message") version := flag.Bool("version", false, "Show version") todos := flag.String("todos", "", "Update todos for a date (YYYY-MM-DD). Empty = today") diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..6bc1258 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,41 @@ +# Architecture Overview + +`journal-cli` follows a **Pragmatic Clean Architecture**. The codebase is organized to separate domain logic from infrastructure concerns, though it favors simplicity over strict interface abstraction where appropriate for a CLI. + +## Directory Structure + +### `cmd/` +Entry points for the application. +- `cmd/journal/main.go`: The main entry point. Reads flags and hands control to the `app` package. + +### `internal/` +Private application code. + +- **`domain/`**: **(Inner Layer)** + - Contains pure data structures like `JournalEntry` and `Todo`. + - Has no dependencies on other internal packages. + +- **`app/`**: **(Application Layer)** + - Contains the "glue" code. + - Orchestrates the program flow: Load Config -> Load Data -> Run TUI -> Save Data. + - Currently implements the primary "Use Cases" implicitly. + +- **Infrastructure & Adapters**: + - `config/`: Handles loading and parsing `config.yaml`. + - `fs/`: File system helpers (wrappers for `os` and `io` with error handling). + - `markdown/`: Parsing and generation of Keep-a-Changelog style markdown journals. + - `tui/`: The Bubble Tea model and view logic. Handles the UI rendering and state machine. + - `updater/`: Handles the `self-update` mechanics (GitHub Releases API, binary replacement). + - `help/`: Structured help documentation logic. + +## Data Flow + +1. **Startup**: `main.go` parses flags. +2. **Orchestration**: `app.Run()` initializes config, template loader, and checks for existing files. +3. **Interaction**: Control is handed to `tui.NewModel()`. The Bubble Tea runtime manages the event loop (keystrokes -> update model -> view). +4. **Completion**: On exit, `app` retrieves the final state from the TUI model. +5. **Persistence**: `app` calls `markdown.GenerateMarkdown()` and then `fs.WriteFile()` to save logic to disk. + +## Design Decisions + +See [ADR 001: Pragmatic Clean Architecture](adr/001-pragmatic-clean-architecture.md). diff --git a/docs/adr/001-pragmatic-clean-architecture.md b/docs/adr/001-pragmatic-clean-architecture.md new file mode 100644 index 0000000..a7fc206 --- /dev/null +++ b/docs/adr/001-pragmatic-clean-architecture.md @@ -0,0 +1,45 @@ +# 1. Adoption of Pragmatic Clean Architecture + +Date: 2026-01-06 + +## Status + +Accepted + +## Context + +The `journal-cli` project aims to be a robust, maintainable CLI application. We want to structure the code in a way that separates concerns, making it testable and easy to extend. However, strict adherence to Clean Architecture (with full interface abstraction for every layer) can introduce unnecessary boilerplate for a CLI tool of this size. + +## Decision + +We will adopt a **Pragmatic Clean Architecture** style. + +### Layers + +1. **Domain (`internal/domain`)**: + - Contains pure business entities (e.g., `JournalEntry`, `Todo`). + - No external dependencies. + +2. **Application (`internal/app`)**: + - Orchestrates the application flow (e.g., loading config, checking files, running the loop). + - Currently acts as both "Use Case" and "Controller". + - *Constraint*: Should prioritize business logic over UI specifics where possible. + +3. **Adapters/Infrastructure (`internal/fs`, `internal/markdown`, `internal/tui`, `internal/updater`)**: + - Implement specific details (FileSystem, Markdown parsing, Terminal UI, GitHub Updates). + - The Application layer calls these helpers directly. + +## Consequences + +### Positive +- **Simplicity**: Less boilerplate than defining interfaces for every file operation or markdown parser. +- **Speed**: Faster development iteration. +- **Clarity**: Directory structure clearly indicates what each package does. + +### Negative +- **Coupling**: `internal/app` is coupled to concrete implementations of `fs` and `tui`. +- **Testing**: We cannot easily mock the FileSystem or TUI in integration tests without refactoring `app` to use interfaces. + +### Mitigation +- We accept this coupling for now. +- If a component (like `fs`) becomes complex or needs interchangeable backends (e.g., S3 support), we will refactor it behind an interface at that time. diff --git a/docs/features/self-update.md b/docs/features/self-update.md new file mode 100644 index 0000000..9025cde --- /dev/null +++ b/docs/features/self-update.md @@ -0,0 +1,30 @@ +# Feature: Self-Update + +The `self-update` feature allows users to update their CLI binary to the latest version available on GitHub Releases without needing an external package manager. + +## Usage + +```bash +journal self-update +``` + +If installed in a protected directory (like `/usr/local/bin`): +```bash +sudo journal self-update +``` + +## Implementation Details + +Located in: `internal/updater` + +1. **Check**: Queries `https://api.github.com/repos/ops295/journal-cli/releases/latest`. +2. **Match**: Looks for an asset matching the pattern `journal-{GOOS}-{GOARCH}` (e.g., `journal-darwin-arm64`). +3. **Download**: Streams the binary to a temporary file in `os.TempDir`. +4. **Replace**: + - Moves the current binary to `{binary}.bak`. + - Moves the new binary to the current location. + - Restores from backup if the move fails. + +## Constraints +- The binary must have write permissions to its own location. +- Access to GitHub API is required. diff --git a/docs/features/templates.md b/docs/features/templates.md new file mode 100644 index 0000000..ed7831c --- /dev/null +++ b/docs/features/templates.md @@ -0,0 +1,40 @@ +# Feature: Templates + +Templates allow users to define custom prompts for their daily journal entries. + +## Configuration + +Templates are YAML files stored in the `templates/` subdirectory of the config folder. + +### Location +- **macOS**: `~/Library/Application Support/journal-cli/templates/` +- **Linux**: `~/.config/journal-cli/templates/` +- **Windows**: `%APPDATA%\journal-cli\templates\` + +## File Format + +Example `daily-reflection.yaml`: + +```yaml +name: daily-reflection +description: A simple daily reflection +questions: + - id: gratitude + title: "What are you grateful for?" + - id: improvement + title: "What could have gone better?" +``` + +## Management Commands + +- **List**: `journal --list-templates` +- **Set Default**: `journal --set-template ` + - Example: `journal --set-template daily-reflection` + - Logic: Sets `default_template` in `config.yaml`. The app will skip the template selection screen and auto-load this template. + +## Implementation Details + +Located in: `internal/template` (loading) and `internal/app` (management). + +- `template.LoadTemplates()`: Scans the directory and parses valid YAML files. +- `app.SetDefaultTemplate()`: Updates the main `config.yaml` file. diff --git a/internal/help/help.go b/internal/help/help.go index 4aee021..01de7d4 100644 --- a/internal/help/help.go +++ b/internal/help/help.go @@ -133,6 +133,15 @@ func (h *HelpDoc) Render(programName string) string { } } } + } else if section.Title == "Updates" { + sb.WriteString(" Examples:\n") + for _, cmd := range h.Commands { + if strings.Contains(cmd.Name, "update") && len(cmd.Examples) > 0 { + for _, ex := range cmd.Examples { + sb.WriteString(fmt.Sprintf(" %s\n", ex)) + } + } + } } } diff --git a/internal/help/help.yaml b/internal/help/help.yaml index 16139bd..845bd9d 100644 --- a/internal/help/help.yaml +++ b/internal/help/help.yaml @@ -1,7 +1,7 @@ app: name: journal-cli description: A cross-platform terminal-based daily journaling application - version: 0.2.01 + version: 0.2.02 commands: - name: --help @@ -13,26 +13,31 @@ commands: - name: --list-templates description: List available templates examples: - - ./journal --list-templates + - journal --list-templates - name: --set-template args: description: Set default template examples: - - ./journal --set-template daily-human-dev - - ./journal --set-template gentle-day + - journal --set-template daily-human-dev + - journal --set-template gentle-day - name: --todo description: Update today's todos (shorthand) examples: - - ./journal --todo + - journal --todo - name: --todos args: "[YYYY-MM-DD]" description: Update todos for a date (empty = today) examples: - - ./journal --todos "" - - ./journal --todos 2025-12-30 + - journal --todos "" + - journal --todos 2025-12-30 + + - name: self-update + description: Update journal to the latest version + examples: + - journal self-update configuration: title: Configuration @@ -73,3 +78,8 @@ sections: - "Use --todos [YYYY-MM-DD] to update todos (empty = today)" - "Mark todos as complete, partial, or move to backlog" - "Use --todo as a shorthand for updating today's todos" + + - title: Updates + description: Keep your journal CLI up to date + items: + - "Use `self-update` to download and install the latest version from GitHub." diff --git a/internal/updater/self_update.go b/internal/updater/self_update.go new file mode 100644 index 0000000..f411819 --- /dev/null +++ b/internal/updater/self_update.go @@ -0,0 +1,162 @@ +package updater + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + + "journal-cli/internal/version" +) + +const repo = "ops295/journal-cli" + +type Release struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` +} + +// Update handles the self-update process +func Update() error { + fmt.Println("🔍 Checking latest version...") + + // 1. Fetch latest release info + req, err := http.NewRequest("GET", "https://api.github.com/repos/"+repo+"/releases/latest", nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("User-Agent", "journal-cli/"+version.GetVersion()) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch latest release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // Provide clearer guidance about common GitHub API errors and include the response body. + msg := fmt.Sprintf("unexpected status code from GitHub API: %d", resp.StatusCode) + + switch resp.StatusCode { + case http.StatusNotFound: + msg += " (latest release not found; check that the repository has a published release)" + case http.StatusForbidden: + msg += " (access forbidden or rate limited; you may have hit GitHub's API rate limit)" + } + + if body, readErr := io.ReadAll(resp.Body); readErr == nil && len(body) > 0 { + msg += fmt.Sprintf(" - response body: %s", string(body)) + } + + return fmt.Errorf("%s", msg) + } + + var release Release + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return fmt.Errorf("failed to decode release info: %w", err) + } + + // 2. Determine target binary name + // Naming convention assumed: journal-darwin-arm64, journal-linux-amd64, etc. + target := fmt.Sprintf( + "journal-%s-%s", + runtime.GOOS, + runtime.GOARCH, + ) + + // Windows binaries usually have .exe extension + if runtime.GOOS == "windows" { + target += ".exe" + } + + var url string + for _, asset := range release.Assets { + if asset.Name == target { + url = asset.BrowserDownloadURL + break + } + } + + if url == "" { + return fmt.Errorf("no compatible binary found for %s/%s (looking for %s)", runtime.GOOS, runtime.GOARCH, target) + } + + fmt.Printf("⬇️ Downloading %s (%s)...\n", release.TagName, target) + + // 3. Download the new binary to a temporary file + tmpFile := filepath.Join(os.TempDir(), "journal-new") + // Ensure we don't conflict if multiple runs or stale files + _ = os.Remove(tmpFile) + + out, err := os.Create(tmpFile) + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer out.Close() + + reqBin, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.Errorf("failed to create download request: %w", err) + } + reqBin.Header.Set("User-Agent", "journal-cli/"+version.GetVersion()) + + respBin, err := http.DefaultClient.Do(reqBin) + if err != nil { + return fmt.Errorf("failed to download binary: %w", err) + } + defer respBin.Body.Close() + + if respBin.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code during download: %d", respBin.StatusCode) + } + + if _, err := io.Copy(out, respBin.Body); err != nil { + return fmt.Errorf("failed to save binary: %w", err) + } + + // Make the temp file executable + if err := out.Chmod(0755); err != nil { + return fmt.Errorf("failed to make binary executable: %w", err) + } + + // 4. Replace the current binary + current, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to locate current executable: %w", err) + } + + // Resolve symlinks if any (common in some installs), though os.Executable usually handles this. + // We'll stick to what os.Executable returns for now. + + backup := current + ".bak" + + fmt.Println("🔄 Replacing binary...") + + // First move the current binary to .bak + if err := os.Rename(current, backup); err != nil { + // If permission denied, give a helpful hint + if os.IsPermission(err) { + return fmt.Errorf("permission denied: try running with sudo/admin privileges") + } + return fmt.Errorf("failed to backup current binary: %w", err) + } + + // Then move the new binary to the current location + if err := os.Rename(tmpFile, current); err != nil { + // Rollback: try to restore the backup + _ = os.Rename(backup, current) + return fmt.Errorf("failed to install new binary: %w", err) + } + + // Cleanup backup + _ = os.Remove(backup) + + fmt.Println("✅ Update successful! Please run 'journal --version' to verify.") + return nil +} diff --git a/internal/updater/self_update_test.go b/internal/updater/self_update_test.go new file mode 100644 index 0000000..89beec3 --- /dev/null +++ b/internal/updater/self_update_test.go @@ -0,0 +1,425 @@ +package updater + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// TestParseReleaseJSON tests parsing of GitHub release JSON +func TestParseReleaseJSON(t *testing.T) { + tests := []struct { + name string + jsonData string + wantTag string + wantAsset string + wantErr bool + }{ + { + name: "valid release with assets", + jsonData: `{ + "tag_name": "v1.0.0", + "assets": [ + { + "name": "journal-linux-amd64", + "browser_download_url": "https://example.com/journal-linux-amd64" + }, + { + "name": "journal-darwin-arm64", + "browser_download_url": "https://example.com/journal-darwin-arm64" + } + ] + }`, + wantTag: "v1.0.0", + wantAsset: "journal-linux-amd64", + wantErr: false, + }, + { + name: "release with no assets", + jsonData: `{ + "tag_name": "v2.0.0", + "assets": [] + }`, + wantTag: "v2.0.0", + wantAsset: "", + wantErr: false, + }, + { + name: "invalid json", + jsonData: `{invalid json}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var release Release + err := json.Unmarshal([]byte(tt.jsonData), &release) + + if (err != nil) != tt.wantErr { + t.Errorf("json.Unmarshal() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if err == nil { + if release.TagName != tt.wantTag { + t.Errorf("TagName = %v, want %v", release.TagName, tt.wantTag) + } + + if len(release.Assets) > 0 && tt.wantAsset != "" { + if release.Assets[0].Name != tt.wantAsset { + t.Errorf("Asset name = %v, want %v", release.Assets[0].Name, tt.wantAsset) + } + } + } + }) + } +} + +// TestDetermineBinaryName tests binary name generation for different OS/arch combinations +func TestDetermineBinaryName(t *testing.T) { + tests := []struct { + name string + goos string + goarch string + expected string + }{ + { + name: "linux amd64", + goos: "linux", + goarch: "amd64", + expected: "journal-linux-amd64", + }, + { + name: "linux arm64", + goos: "linux", + goarch: "arm64", + expected: "journal-linux-arm64", + }, + { + name: "darwin amd64", + goos: "darwin", + goarch: "amd64", + expected: "journal-darwin-amd64", + }, + { + name: "darwin arm64", + goos: "darwin", + goarch: "arm64", + expected: "journal-darwin-arm64", + }, + { + name: "windows amd64", + goos: "windows", + goarch: "amd64", + expected: "journal-windows-amd64.exe", + }, + { + name: "windows arm64", + goos: "windows", + goarch: "arm64", + expected: "journal-windows-arm64.exe", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Simulate the binary name generation logic from Update() + target := fmt.Sprintf("journal-%s-%s", tt.goos, tt.goarch) + if tt.goos == "windows" { + target += ".exe" + } + + if target != tt.expected { + t.Errorf("Binary name = %v, want %v", target, tt.expected) + } + }) + } +} + +// TestFindAssetForPlatform tests finding the correct asset for a platform +func TestFindAssetForPlatform(t *testing.T) { + release := Release{ + TagName: "v1.0.0", + Assets: []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + }{ + {Name: "journal-linux-amd64", BrowserDownloadURL: "https://example.com/journal-linux-amd64"}, + {Name: "journal-darwin-arm64", BrowserDownloadURL: "https://example.com/journal-darwin-arm64"}, + {Name: "journal-windows-amd64.exe", BrowserDownloadURL: "https://example.com/journal-windows-amd64.exe"}, + }, + } + + tests := []struct { + name string + targetName string + wantURL string + wantFound bool + }{ + { + name: "linux amd64 exists", + targetName: "journal-linux-amd64", + wantURL: "https://example.com/journal-linux-amd64", + wantFound: true, + }, + { + name: "darwin arm64 exists", + targetName: "journal-darwin-arm64", + wantURL: "https://example.com/journal-darwin-arm64", + wantFound: true, + }, + { + name: "windows amd64 exists", + targetName: "journal-windows-amd64.exe", + wantURL: "https://example.com/journal-windows-amd64.exe", + wantFound: true, + }, + { + name: "linux arm64 not found", + targetName: "journal-linux-arm64", + wantURL: "", + wantFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var url string + for _, asset := range release.Assets { + if asset.Name == tt.targetName { + url = asset.BrowserDownloadURL + break + } + } + + if (url != "") != tt.wantFound { + t.Errorf("Found asset = %v, want found %v", url != "", tt.wantFound) + } + + if url != tt.wantURL { + t.Errorf("Asset URL = %v, want %v", url, tt.wantURL) + } + }) + } +} + +// TestUpdateWithHTTPErrors tests error handling for various HTTP error conditions +func TestUpdateWithHTTPErrors(t *testing.T) { + tests := []struct { + name string + statusCode int + responseBody string + wantErrContain string + }{ + { + name: "404 not found", + statusCode: http.StatusNotFound, + responseBody: `{"message": "Not Found"}`, + wantErrContain: "latest release not found", + }, + { + name: "403 forbidden", + statusCode: http.StatusForbidden, + responseBody: `{"message": "API rate limit exceeded"}`, + wantErrContain: "access forbidden or rate limited", + }, + { + name: "500 internal server error", + statusCode: http.StatusInternalServerError, + responseBody: `{"message": "Internal Server Error"}`, + wantErrContain: "unexpected status code", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.responseBody)) + })) + defer server.Close() + + // Mock the GitHub API by temporarily changing the repo variable + // Since we can't change the const, we'll test the logic directly + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + msg := fmt.Sprintf("unexpected status code from GitHub API: %d", resp.StatusCode) + + switch resp.StatusCode { + case http.StatusNotFound: + msg += " (latest release not found; check that the repository has a published release)" + case http.StatusForbidden: + msg += " (access forbidden or rate limited; you may have hit GitHub's API rate limit)" + } + + if body, readErr := io.ReadAll(resp.Body); readErr == nil && len(body) > 0 { + msg += fmt.Sprintf(" - response body: %s", string(body)) + } + + // Verify the error message contains expected text + if tt.wantErrContain != "" { + if !strings.Contains(msg, tt.wantErrContain) { + t.Errorf("Error message %q does not contain %q", msg, tt.wantErrContain) + } + } + } + }) + } +} + +// TestUpdateWithInvalidJSON tests handling of invalid JSON response +func TestUpdateWithInvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{invalid json`)) + })) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + var release Release + err = json.NewDecoder(resp.Body).Decode(&release) + if err == nil { + t.Error("Expected error decoding invalid JSON, got nil") + } +} + +// TestUpdateWithMissingAsset tests handling when target platform binary is not in assets +func TestUpdateWithMissingAsset(t *testing.T) { + // Create a server that returns a valid release but without the current platform's binary + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + release := Release{ + TagName: "v1.0.0", + Assets: []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + }{ + {Name: "journal-different-platform", BrowserDownloadURL: "https://example.com/binary"}, + }, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(release) + })) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + var release Release + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + t.Fatalf("Failed to decode release: %v", err) + } + + // Simulate looking for the current platform's binary + target := fmt.Sprintf("journal-%s-%s", runtime.GOOS, runtime.GOARCH) + if runtime.GOOS == "windows" { + target += ".exe" + } + + var url string + for _, asset := range release.Assets { + if asset.Name == target { + url = asset.BrowserDownloadURL + break + } + } + + if url != "" { + t.Errorf("Expected no URL for missing asset, got %s", url) + } +} + +// TestUpdateBinaryDownload tests downloading a binary +func TestUpdateBinaryDownload(t *testing.T) { + binaryContent := []byte("fake binary content") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write(binaryContent) + })) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("Failed to download: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test-binary") + + out, err := os.Create(tmpFile) + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer out.Close() + + if _, err := io.Copy(out, resp.Body); err != nil { + t.Fatalf("Failed to save binary: %v", err) + } + + // Verify the downloaded content + content, err := os.ReadFile(tmpFile) + if err != nil { + t.Fatalf("Failed to read temp file: %v", err) + } + + if string(content) != string(binaryContent) { + t.Errorf("Downloaded content = %v, want %v", string(content), string(binaryContent)) + } +} + +// TestUpdateBinaryPermissions tests setting executable permissions on downloaded binary +func TestUpdateBinaryPermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping permission test on Windows") + } + + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test-binary") + + // Create a test file + if err := os.WriteFile(tmpFile, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Change permissions to executable + if err := os.Chmod(tmpFile, 0755); err != nil { + t.Fatalf("Failed to chmod: %v", err) + } + + // Verify permissions + info, err := os.Stat(tmpFile) + if err != nil { + t.Fatalf("Failed to stat file: %v", err) + } + + mode := info.Mode() + if mode.Perm() != 0755 { + t.Errorf("File permissions = %o, want 0755", mode.Perm()) + } +}