From 196c7cabf9173898b0539d956e26e3db9b151e42 Mon Sep 17 00:00:00 2001 From: evisdren Date: Tue, 10 Mar 2026 12:54:46 -0700 Subject: [PATCH 01/19] Add `entire search` command for semantic checkpoint search New command that calls the Entire search service to perform hybrid semantic + keyword search over checkpoints. Auth via GitHub token (GITHUB_TOKEN env var or `gh auth token`). Supports --json for agent consumption, --branch filtering, and --limit. Co-Authored-By: Claude Opus 4.6 --- README.md | 39 +++++++ cmd/entire/cli/root.go | 1 + cmd/entire/cli/search/github.go | 45 ++++++++ cmd/entire/cli/search/search.go | 117 ++++++++++++++++++++ cmd/entire/cli/search/search_test.go | 51 +++++++++ cmd/entire/cli/search_cmd.go | 154 +++++++++++++++++++++++++++ 6 files changed, 407 insertions(+) create mode 100644 cmd/entire/cli/search/github.go create mode 100644 cmd/entire/cli/search/search.go create mode 100644 cmd/entire/cli/search/search_test.go create mode 100644 cmd/entire/cli/search_cmd.go diff --git a/README.md b/README.md index 775cc6123..9eac50ed3 100644 --- a/README.md +++ b/README.md @@ -164,9 +164,48 @@ Multiple AI sessions can run on the same commit. If you start a second session w | `entire reset` | Delete the shadow branch and session state for the current HEAD commit | | `entire resume` | Switch to a branch, restore latest checkpointed session metadata, and show command(s) to continue | | `entire rewind` | Rewind to a previous checkpoint | +| `entire search` | Search checkpoints using semantic and keyword matching | | `entire status` | Show current session info | | `entire version` | Show Entire CLI version | +### `entire search` + +Search checkpoints across the current repository using hybrid search (semantic + keyword). Results are ranked using Reciprocal Rank Fusion (RRF), combining OpenAI embeddings with BM25 full-text search. + +```bash +# Search with pretty-printed output +entire search "implement login feature" + +# Filter by branch +entire search "fix auth bug" --branch main + +# JSON output (for agent/script consumption) +entire search "refactor database layer" --json + +# Limit results +entire search "add tests" --limit 10 +``` + +| Flag | Description | +| ---------- | ------------------------------------ | +| `--json` | Output results as JSON | +| `--branch` | Filter results by branch name | +| `--limit` | Maximum number of results (default: 20) | + +**Authentication:** `entire search` requires a GitHub token to verify repo access. The token is resolved automatically from: + +1. `GITHUB_TOKEN` environment variable +2. `gh auth token` (GitHub CLI, if installed) + +No other commands require a GitHub token — search is the only command that calls an external service. + +**Environment variables:** + +| Variable | Description | +| -------------------- | ---------------------------------------------------------- | +| `GITHUB_TOKEN` | GitHub personal access token or fine-grained token | +| `ENTIRE_SEARCH_URL` | Override the search service URL (default: `https://entire.io`) | + ### `entire enable` Flags | Flag | Description | diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index 87fd1c1eb..5f86d860f 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -84,6 +84,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newExplainCmd()) cmd.AddCommand(newDoctorCmd()) cmd.AddCommand(newTrailCmd()) + cmd.AddCommand(newSearchCmd()) cmd.AddCommand(newSendAnalyticsCmd()) cmd.AddCommand(newCurlBashPostInstallCmd()) diff --git a/cmd/entire/cli/search/github.go b/cmd/entire/cli/search/github.go new file mode 100644 index 000000000..3ba472ced --- /dev/null +++ b/cmd/entire/cli/search/github.go @@ -0,0 +1,45 @@ +// Package search provides search functionality via the Entire search service. +package search + +import ( + "fmt" + "net/url" + "strings" +) + +// ParseGitHubRemote extracts owner and repo from a GitHub remote URL. +// Supports SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git). +func ParseGitHubRemote(remoteURL string) (owner, repo string, err error) { + remoteURL = strings.TrimSpace(remoteURL) + if remoteURL == "" { + return "", "", fmt.Errorf("empty remote URL") + } + + var path string + + // SSH format: git@github.com:owner/repo.git + if strings.HasPrefix(remoteURL, "git@") { + idx := strings.Index(remoteURL, ":") + if idx < 0 { + return "", "", fmt.Errorf("invalid SSH remote URL: %s", remoteURL) + } + path = remoteURL[idx+1:] + } else { + // HTTPS format: https://github.com/owner/repo.git + u, parseErr := url.Parse(remoteURL) + if parseErr != nil { + return "", "", fmt.Errorf("parsing remote URL: %w", parseErr) + } + path = strings.TrimPrefix(u.Path, "/") + } + + // Remove .git suffix + path = strings.TrimSuffix(path, ".git") + + parts := strings.SplitN(path, "/", 3) + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("could not extract owner/repo from remote URL: %s", remoteURL) + } + + return parts[0], parts[1], nil +} diff --git a/cmd/entire/cli/search/search.go b/cmd/entire/cli/search/search.go new file mode 100644 index 000000000..a198568a8 --- /dev/null +++ b/cmd/entire/cli/search/search.go @@ -0,0 +1,117 @@ +package search + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const apiTimeout = 30 * time.Second + +// DefaultServiceURL is the production search service URL. +const DefaultServiceURL = "https://entire.io" + +// Result represents a single search result from the search service. +type Result struct { + CheckpointID string `json:"checkpoint_id"` + RRF float64 `json:"rrf"` + VectorRank *int `json:"vectorRank"` + BM25Rank *int `json:"bm25Rank"` + MatchType string `json:"matchType"` + Branch *string `json:"branch"` + Agent *string `json:"agent"` + Author *string `json:"author"` + CreatedAt *string `json:"created_at"` + CommitSHA *string `json:"commit_sha"` + CommitMessage *string `json:"commit_message"` + Prompt *string `json:"prompt"` + FilesTouched *string `json:"files_touched"` +} + +// Response is the search service response. +type Response struct { + Results []Result `json:"results"` + Query string `json:"query"` + Repo string `json:"repo"` + Total int `json:"total"` + Error string `json:"error,omitempty"` +} + +// Config holds the configuration for a search request. +type Config struct { + ServiceURL string // Base URL of the search service + GitHubToken string + Owner string + Repo string + Query string + Branch string + Limit int +} + +// Search calls the search service to perform a hybrid search. +func Search(ctx context.Context, cfg Config) (*Response, error) { + ctx, cancel := context.WithTimeout(ctx, apiTimeout) + defer cancel() + + serviceURL := cfg.ServiceURL + if serviceURL == "" { + serviceURL = DefaultServiceURL + } + + // Build URL: /search/v1/:owner/:repo?q=...&branch=...&limit=... + u, err := url.Parse(serviceURL) + if err != nil { + return nil, fmt.Errorf("parsing service URL: %w", err) + } + u.Path = fmt.Sprintf("/search/v1/%s/%s", url.PathEscape(cfg.Owner), url.PathEscape(cfg.Repo)) + + q := u.Query() + q.Set("q", cfg.Query) + if cfg.Branch != "" { + q.Set("branch", cfg.Branch) + } + if cfg.Limit > 0 { + q.Set("limit", fmt.Sprintf("%d", cfg.Limit)) + } + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", "token "+cfg.GitHubToken) + req.Header.Set("User-Agent", "entire-cli") + + client := &http.Client{} + resp, err := client.Do(req) //nolint:bodyclose // closed below + if err != nil { + return nil, fmt.Errorf("calling search service: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var errResp struct { + Error string `json:"error"` + } + if json.Unmarshal(body, &errResp) == nil && errResp.Error != "" { + return nil, fmt.Errorf("search service error (%d): %s", resp.StatusCode, errResp.Error) + } + return nil, fmt.Errorf("search service returned %d: %s", resp.StatusCode, string(body)) + } + + var result Response + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + + return &result, nil +} diff --git a/cmd/entire/cli/search/search_test.go b/cmd/entire/cli/search/search_test.go new file mode 100644 index 000000000..9c9ee875a --- /dev/null +++ b/cmd/entire/cli/search/search_test.go @@ -0,0 +1,51 @@ +package search + +import ( + "testing" +) + +func TestParseGitHubRemote_SSH(t *testing.T) { + t.Parallel() + owner, repo, err := ParseGitHubRemote("git@github.com:entirehq/entire.io.git") + if err != nil { + t.Fatal(err) + } + if owner != "entirehq" || repo != "entire.io" { + t.Errorf("got %s/%s, want entirehq/entire.io", owner, repo) + } +} + +func TestParseGitHubRemote_HTTPS(t *testing.T) { + t.Parallel() + owner, repo, err := ParseGitHubRemote("https://github.com/entirehq/entire.io.git") + if err != nil { + t.Fatal(err) + } + if owner != "entirehq" || repo != "entire.io" { + t.Errorf("got %s/%s, want entirehq/entire.io", owner, repo) + } +} + +func TestParseGitHubRemote_HTTPSNoGit(t *testing.T) { + t.Parallel() + owner, repo, err := ParseGitHubRemote("https://github.com/entirehq/entire.io") + if err != nil { + t.Fatal(err) + } + if owner != "entirehq" || repo != "entire.io" { + t.Errorf("got %s/%s, want entirehq/entire.io", owner, repo) + } +} + +func TestParseGitHubRemote_Invalid(t *testing.T) { + t.Parallel() + _, _, err := ParseGitHubRemote("") + if err == nil { + t.Error("expected error for empty URL") + } + + _, _, err = ParseGitHubRemote("not-a-url") + if err == nil { + t.Error("expected error for invalid URL") + } +} diff --git a/cmd/entire/cli/search_cmd.go b/cmd/entire/cli/search_cmd.go new file mode 100644 index 000000000..ac5203a33 --- /dev/null +++ b/cmd/entire/cli/search_cmd.go @@ -0,0 +1,154 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "strings" + "text/tabwriter" + + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/search" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/spf13/cobra" +) + +func newSearchCmd() *cobra.Command { + var ( + jsonFlag bool + branchFlag string + limitFlag int + ) + + cmd := &cobra.Command{ + Use: "search ", + Short: "Search checkpoints using semantic and keyword matching", + Long: `Search checkpoints using hybrid search (semantic + keyword), +powered by the Entire search service. + +Requires a GitHub token for authentication. The token is resolved from: + 1. GITHUB_TOKEN environment variable + 2. gh auth token (GitHub CLI) + +Results are ranked using Reciprocal Rank Fusion (RRF) combining +OpenAI embeddings with BM25 full-text search.`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + query := strings.Join(args, " ") + + // Resolve GitHub token + ghToken := os.Getenv("GITHUB_TOKEN") + if ghToken == "" { + // Try gh CLI + out, err := exec.CommandContext(ctx, "gh", "auth", "token").Output() + if err == nil { + ghToken = strings.TrimSpace(string(out)) + } + } + if ghToken == "" { + return fmt.Errorf("GitHub token required. Set GITHUB_TOKEN or install gh CLI (gh auth login)") + } + + // Get the repo's GitHub remote URL + repo, err := strategy.OpenRepository(ctx) + if err != nil { + cmd.SilenceUsage = true + fmt.Fprintln(cmd.ErrOrStderr(), "Not a git repository. Run this command from within a git repository.") + return NewSilentError(err) + } + + remote, err := repo.Remote("origin") + if err != nil { + return fmt.Errorf("could not find 'origin' remote: %w", err) + } + urls := remote.Config().URLs + if len(urls) == 0 { + return fmt.Errorf("origin remote has no URLs configured") + } + + owner, repoName, err := search.ParseGitHubRemote(urls[0]) + if err != nil { + return fmt.Errorf("parsing remote URL: %w", err) + } + + if !jsonFlag { + fmt.Fprintf(cmd.ErrOrStderr(), "Searching %s/%s for: %s\n", owner, repoName, query) + } + + serviceURL := os.Getenv("ENTIRE_SEARCH_URL") + if serviceURL == "" { + serviceURL = search.DefaultServiceURL + } + + resp, err := search.Search(ctx, search.Config{ + ServiceURL: serviceURL, + GitHubToken: ghToken, + Owner: owner, + Repo: repoName, + Query: query, + Branch: branchFlag, + Limit: limitFlag, + }) + if err != nil { + return fmt.Errorf("search failed: %w", err) + } + + if len(resp.Results) == 0 { + if jsonFlag { + fmt.Fprintln(cmd.OutOrStdout(), "[]") + } else { + fmt.Fprintln(cmd.OutOrStdout(), "No results found.") + } + return nil + } + + if jsonFlag { + data, err := jsonutil.MarshalIndentWithNewline(resp.Results, "", " ") + if err != nil { + return fmt.Errorf("marshaling results: %w", err) + } + fmt.Fprint(cmd.OutOrStdout(), string(data)) + return nil + } + + // Pretty print + fmt.Fprintf(cmd.OutOrStdout(), "\nFound %d results for %s:\n\n", resp.Total, resp.Repo) + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "RANK\tCHECKPOINT\tSCORE\tMATCH\tBRANCH\tAUTHOR\tPROMPT") + for i, r := range resp.Results { + branch := "-" + if r.Branch != nil { + branch = truncateStr(*r.Branch, 20) + } + author := "-" + if r.Author != nil { + author = *r.Author + } + prompt := "-" + if r.Prompt != nil { + prompt = truncateStr(*r.Prompt, 40) + } + fmt.Fprintf(w, "%d\t%s\t%.4f\t%s\t%s\t%s\t%s\n", + i+1, r.CheckpointID, r.RRF, r.MatchType, branch, author, prompt) + } + w.Flush() + fmt.Fprintln(cmd.OutOrStdout()) + + return nil + }, + } + + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output results as JSON") + cmd.Flags().StringVar(&branchFlag, "branch", "", "Filter results by branch name") + cmd.Flags().IntVar(&limitFlag, "limit", 20, "Maximum number of results") + + return cmd +} + +func truncateStr(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} From f4b35f07bd4cfe7229581f7ceced581f7a3e325a Mon Sep 17 00:00:00 2001 From: evisdren Date: Tue, 10 Mar 2026 13:19:08 -0700 Subject: [PATCH 02/19] Fix golangci-lint issues in search command - Extract repeated test string to constant (goconst) - Suppress gosec G704 SSRF warning on trusted URL (gosec) - Handle tabwriter Flush error (gosec G104) Co-Authored-By: Claude Opus 4.6 --- cmd/entire/cli/search/search.go | 2 +- cmd/entire/cli/search/search_test.go | 15 +++++++++------ cmd/entire/cli/search_cmd.go | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cmd/entire/cli/search/search.go b/cmd/entire/cli/search/search.go index a198568a8..a9556d70c 100644 --- a/cmd/entire/cli/search/search.go +++ b/cmd/entire/cli/search/search.go @@ -87,7 +87,7 @@ func Search(ctx context.Context, cfg Config) (*Response, error) { req.Header.Set("User-Agent", "entire-cli") client := &http.Client{} - resp, err := client.Do(req) //nolint:bodyclose // closed below + resp, err := client.Do(req) //nolint:bodyclose,gosec // closed below; URL is constructed from trusted config if err != nil { return nil, fmt.Errorf("calling search service: %w", err) } diff --git a/cmd/entire/cli/search/search_test.go b/cmd/entire/cli/search/search_test.go index 9c9ee875a..030650e13 100644 --- a/cmd/entire/cli/search/search_test.go +++ b/cmd/entire/cli/search/search_test.go @@ -4,14 +4,17 @@ import ( "testing" ) +const testOwner = "entirehq" +const testRepo = "entire.io" + func TestParseGitHubRemote_SSH(t *testing.T) { t.Parallel() owner, repo, err := ParseGitHubRemote("git@github.com:entirehq/entire.io.git") if err != nil { t.Fatal(err) } - if owner != "entirehq" || repo != "entire.io" { - t.Errorf("got %s/%s, want entirehq/entire.io", owner, repo) + if owner != testOwner || repo != testRepo { + t.Errorf("got %s/%s, want %s/%s", owner, repo, testOwner, testRepo) } } @@ -21,8 +24,8 @@ func TestParseGitHubRemote_HTTPS(t *testing.T) { if err != nil { t.Fatal(err) } - if owner != "entirehq" || repo != "entire.io" { - t.Errorf("got %s/%s, want entirehq/entire.io", owner, repo) + if owner != testOwner || repo != testRepo { + t.Errorf("got %s/%s, want %s/%s", owner, repo, testOwner, testRepo) } } @@ -32,8 +35,8 @@ func TestParseGitHubRemote_HTTPSNoGit(t *testing.T) { if err != nil { t.Fatal(err) } - if owner != "entirehq" || repo != "entire.io" { - t.Errorf("got %s/%s, want entirehq/entire.io", owner, repo) + if owner != testOwner || repo != testRepo { + t.Errorf("got %s/%s, want %s/%s", owner, repo, testOwner, testRepo) } } diff --git a/cmd/entire/cli/search_cmd.go b/cmd/entire/cli/search_cmd.go index ac5203a33..b5b08832e 100644 --- a/cmd/entire/cli/search_cmd.go +++ b/cmd/entire/cli/search_cmd.go @@ -132,7 +132,7 @@ OpenAI embeddings with BM25 full-text search.`, fmt.Fprintf(w, "%d\t%s\t%.4f\t%s\t%s\t%s\t%s\n", i+1, r.CheckpointID, r.RRF, r.MatchType, branch, author, prompt) } - w.Flush() + _ = w.Flush() fmt.Fprintln(cmd.OutOrStdout()) return nil From 73fbeb4eb9f27116f4be631ba6079d828eedfd9f Mon Sep 17 00:00:00 2001 From: evisdren Date: Tue, 10 Mar 2026 13:21:45 -0700 Subject: [PATCH 03/19] Remove unused bodyclose nolint directive Co-Authored-By: Claude Opus 4.6 --- cmd/entire/cli/search/search.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/entire/cli/search/search.go b/cmd/entire/cli/search/search.go index a9556d70c..9bc50afb6 100644 --- a/cmd/entire/cli/search/search.go +++ b/cmd/entire/cli/search/search.go @@ -87,7 +87,7 @@ func Search(ctx context.Context, cfg Config) (*Response, error) { req.Header.Set("User-Agent", "entire-cli") client := &http.Client{} - resp, err := client.Do(req) //nolint:bodyclose,gosec // closed below; URL is constructed from trusted config + resp, err := client.Do(req) //nolint:gosec // URL is constructed from trusted config if err != nil { return nil, fmt.Errorf("calling search service: %w", err) } From 37ea0b17cd31b24fb653ffc6c25d57b7e7df6b11 Mon Sep 17 00:00:00 2001 From: evisdren Date: Tue, 10 Mar 2026 14:02:17 -0700 Subject: [PATCH 04/19] Update CLI search to match enriched response format The search service now returns full checkpoint data (commit info, token usage, file stats, etc.) instead of just IDs and scores. Updated Result struct and display to use the new nested searchMeta and camelCase fields. Co-Authored-By: Claude Opus 4.6 --- cmd/entire/cli/search/search.go | 44 +++++++++++++++++++++++---------- cmd/entire/cli/search_cmd.go | 14 +++++------ 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/cmd/entire/cli/search/search.go b/cmd/entire/cli/search/search.go index 9bc50afb6..716b94015 100644 --- a/cmd/entire/cli/search/search.go +++ b/cmd/entire/cli/search/search.go @@ -15,21 +15,39 @@ const apiTimeout = 30 * time.Second // DefaultServiceURL is the production search service URL. const DefaultServiceURL = "https://entire.io" +// SearchMeta contains search ranking metadata for a result. +type SearchMeta struct { + RRFScore float64 `json:"rrfScore"` + MatchType string `json:"matchType"` + VectorRank *int `json:"vectorRank"` + BM25Rank *int `json:"bm25Rank"` +} + // Result represents a single search result from the search service. type Result struct { - CheckpointID string `json:"checkpoint_id"` - RRF float64 `json:"rrf"` - VectorRank *int `json:"vectorRank"` - BM25Rank *int `json:"bm25Rank"` - MatchType string `json:"matchType"` - Branch *string `json:"branch"` - Agent *string `json:"agent"` - Author *string `json:"author"` - CreatedAt *string `json:"created_at"` - CommitSHA *string `json:"commit_sha"` - CommitMessage *string `json:"commit_message"` - Prompt *string `json:"prompt"` - FilesTouched *string `json:"files_touched"` + CheckpointID string `json:"checkpointId"` + Branch string `json:"branch"` + CommitSHA *string `json:"commitSha"` + CommitMessage *string `json:"commitMessage"` + CommitAuthor *string `json:"commitAuthor"` + CommitAuthorUsername *string `json:"commitAuthorUsername"` + CommitDate *string `json:"commitDate"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + FilesChanged int `json:"filesChanged"` + FilesTouched []string `json:"filesTouched"` + FileStats interface{} `json:"fileStats"` + Prompt *string `json:"prompt"` + Agent string `json:"agent"` + Steps int `json:"steps"` + SessionCount int `json:"sessionCount"` + CreatedAt string `json:"createdAt"` + InputTokens *int `json:"inputTokens"` + OutputTokens *int `json:"outputTokens"` + CacheCreationTokens *int `json:"cacheCreationTokens"` + CacheReadTokens *int `json:"cacheReadTokens"` + APICallCount *int `json:"apiCallCount"` + SearchMeta SearchMeta `json:"searchMeta"` } // Response is the search service response. diff --git a/cmd/entire/cli/search_cmd.go b/cmd/entire/cli/search_cmd.go index b5b08832e..dbd8033cf 100644 --- a/cmd/entire/cli/search_cmd.go +++ b/cmd/entire/cli/search_cmd.go @@ -117,20 +117,20 @@ OpenAI embeddings with BM25 full-text search.`, w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) fmt.Fprintln(w, "RANK\tCHECKPOINT\tSCORE\tMATCH\tBRANCH\tAUTHOR\tPROMPT") for i, r := range resp.Results { - branch := "-" - if r.Branch != nil { - branch = truncateStr(*r.Branch, 20) - } + branch := truncateStr(r.Branch, 20) author := "-" - if r.Author != nil { - author = *r.Author + if r.CommitAuthorUsername != nil { + author = *r.CommitAuthorUsername + } else if r.CommitAuthor != nil { + author = *r.CommitAuthor } prompt := "-" if r.Prompt != nil { prompt = truncateStr(*r.Prompt, 40) } fmt.Fprintf(w, "%d\t%s\t%.4f\t%s\t%s\t%s\t%s\n", - i+1, r.CheckpointID, r.RRF, r.MatchType, branch, author, prompt) + i+1, truncateStr(r.CheckpointID, 12), r.SearchMeta.RRFScore, + r.SearchMeta.MatchType, branch, author, prompt) } _ = w.Flush() fmt.Fprintln(cmd.OutOrStdout()) From cd80afc391100c01a59e5e68cd8ab44d216a8c90 Mon Sep 17 00:00:00 2001 From: evisdren Date: Tue, 10 Mar 2026 15:10:58 -0700 Subject: [PATCH 05/19] Simplify search command to JSON-only output for agents Remove TUI, tabwriter, and --json flag. Output is always JSON, making the command straightforward for agent and script consumption. Co-Authored-By: Claude Opus 4.6 --- cmd/entire/cli/search_cmd.go | 59 +++++------------------------------- 1 file changed, 8 insertions(+), 51 deletions(-) diff --git a/cmd/entire/cli/search_cmd.go b/cmd/entire/cli/search_cmd.go index dbd8033cf..50f14eebc 100644 --- a/cmd/entire/cli/search_cmd.go +++ b/cmd/entire/cli/search_cmd.go @@ -5,7 +5,6 @@ import ( "os" "os/exec" "strings" - "text/tabwriter" "github.com/entireio/cli/cmd/entire/cli/jsonutil" "github.com/entireio/cli/cmd/entire/cli/search" @@ -15,7 +14,6 @@ import ( func newSearchCmd() *cobra.Command { var ( - jsonFlag bool branchFlag string limitFlag int ) @@ -31,7 +29,9 @@ Requires a GitHub token for authentication. The token is resolved from: 2. gh auth token (GitHub CLI) Results are ranked using Reciprocal Rank Fusion (RRF) combining -OpenAI embeddings with BM25 full-text search.`, +OpenAI embeddings with BM25 full-text search. + +Output is JSON by default for easy consumption by agents and scripts.`, Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -72,10 +72,6 @@ OpenAI embeddings with BM25 full-text search.`, return fmt.Errorf("parsing remote URL: %w", err) } - if !jsonFlag { - fmt.Fprintf(cmd.ErrOrStderr(), "Searching %s/%s for: %s\n", owner, repoName, query) - } - serviceURL := os.Getenv("ENTIRE_SEARCH_URL") if serviceURL == "" { serviceURL = search.DefaultServiceURL @@ -95,60 +91,21 @@ OpenAI embeddings with BM25 full-text search.`, } if len(resp.Results) == 0 { - if jsonFlag { - fmt.Fprintln(cmd.OutOrStdout(), "[]") - } else { - fmt.Fprintln(cmd.OutOrStdout(), "No results found.") - } - return nil - } - - if jsonFlag { - data, err := jsonutil.MarshalIndentWithNewline(resp.Results, "", " ") - if err != nil { - return fmt.Errorf("marshaling results: %w", err) - } - fmt.Fprint(cmd.OutOrStdout(), string(data)) + fmt.Fprintln(cmd.OutOrStdout(), "[]") return nil } - // Pretty print - fmt.Fprintf(cmd.OutOrStdout(), "\nFound %d results for %s:\n\n", resp.Total, resp.Repo) - w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "RANK\tCHECKPOINT\tSCORE\tMATCH\tBRANCH\tAUTHOR\tPROMPT") - for i, r := range resp.Results { - branch := truncateStr(r.Branch, 20) - author := "-" - if r.CommitAuthorUsername != nil { - author = *r.CommitAuthorUsername - } else if r.CommitAuthor != nil { - author = *r.CommitAuthor - } - prompt := "-" - if r.Prompt != nil { - prompt = truncateStr(*r.Prompt, 40) - } - fmt.Fprintf(w, "%d\t%s\t%.4f\t%s\t%s\t%s\t%s\n", - i+1, truncateStr(r.CheckpointID, 12), r.SearchMeta.RRFScore, - r.SearchMeta.MatchType, branch, author, prompt) + data, err := jsonutil.MarshalIndentWithNewline(resp.Results, "", " ") + if err != nil { + return fmt.Errorf("marshaling results: %w", err) } - _ = w.Flush() - fmt.Fprintln(cmd.OutOrStdout()) - + fmt.Fprint(cmd.OutOrStdout(), string(data)) return nil }, } - cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output results as JSON") cmd.Flags().StringVar(&branchFlag, "branch", "", "Filter results by branch name") cmd.Flags().IntVar(&limitFlag, "limit", 20, "Maximum number of results") return cmd } - -func truncateStr(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen-3] + "..." -} From c1078a33acc7471e724815adf234f211ac60258f Mon Sep 17 00:00:00 2001 From: evisdren Date: Tue, 10 Mar 2026 15:19:40 -0700 Subject: [PATCH 06/19] Rename SearchMeta to Meta to fix revive stutter lint Co-Authored-By: Claude Opus 4.6 --- cmd/entire/cli/search/search.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/search/search.go b/cmd/entire/cli/search/search.go index 716b94015..6be80adc6 100644 --- a/cmd/entire/cli/search/search.go +++ b/cmd/entire/cli/search/search.go @@ -15,8 +15,8 @@ const apiTimeout = 30 * time.Second // DefaultServiceURL is the production search service URL. const DefaultServiceURL = "https://entire.io" -// SearchMeta contains search ranking metadata for a result. -type SearchMeta struct { +// Meta contains search ranking metadata for a result. +type Meta struct { RRFScore float64 `json:"rrfScore"` MatchType string `json:"matchType"` VectorRank *int `json:"vectorRank"` @@ -47,7 +47,7 @@ type Result struct { CacheCreationTokens *int `json:"cacheCreationTokens"` CacheReadTokens *int `json:"cacheReadTokens"` APICallCount *int `json:"apiCallCount"` - SearchMeta SearchMeta `json:"searchMeta"` + Meta Meta `json:"searchMeta"` } // Response is the search service response. From 41573b95beae94631e74fc34783668bde3030af8 Mon Sep 17 00:00:00 2001 From: evisdren Date: Tue, 10 Mar 2026 15:29:19 -0700 Subject: [PATCH 07/19] Address PR review comments - TrimSpace on GITHUB_TOKEN env var to handle trailing newlines - Reject non-github.com remotes in ParseGitHubRemote with clear error - Add httptest-based tests for Search(): URL/query construction, auth header, branch/limit omission, JSON error handling, raw body error, and successful result parsing - truncateStr was already removed with the TUI deletion Co-Authored-By: Claude Opus 4.6 --- cmd/entire/cli/search/github.go | 7 + cmd/entire/cli/search/search_test.go | 196 +++++++++++++++++++++++++++ cmd/entire/cli/search_cmd.go | 2 +- 3 files changed, 204 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/search/github.go b/cmd/entire/cli/search/github.go index 3ba472ced..98db80638 100644 --- a/cmd/entire/cli/search/github.go +++ b/cmd/entire/cli/search/github.go @@ -23,6 +23,10 @@ func ParseGitHubRemote(remoteURL string) (owner, repo string, err error) { if idx < 0 { return "", "", fmt.Errorf("invalid SSH remote URL: %s", remoteURL) } + host := remoteURL[len("git@"):idx] + if host != "github.com" { + return "", "", fmt.Errorf("remote is not a GitHub repository (host: %s)", host) + } path = remoteURL[idx+1:] } else { // HTTPS format: https://github.com/owner/repo.git @@ -30,6 +34,9 @@ func ParseGitHubRemote(remoteURL string) (owner, repo string, err error) { if parseErr != nil { return "", "", fmt.Errorf("parsing remote URL: %w", parseErr) } + if u.Host != "github.com" { + return "", "", fmt.Errorf("remote is not a GitHub repository (host: %s)", u.Host) + } path = strings.TrimPrefix(u.Path, "/") } diff --git a/cmd/entire/cli/search/search_test.go b/cmd/entire/cli/search/search_test.go index 030650e13..c03ddd6fc 100644 --- a/cmd/entire/cli/search/search_test.go +++ b/cmd/entire/cli/search/search_test.go @@ -1,12 +1,18 @@ package search import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" "testing" ) const testOwner = "entirehq" const testRepo = "entire.io" +// -- ParseGitHubRemote tests -- + func TestParseGitHubRemote_SSH(t *testing.T) { t.Parallel() owner, repo, err := ParseGitHubRemote("git@github.com:entirehq/entire.io.git") @@ -52,3 +58,193 @@ func TestParseGitHubRemote_Invalid(t *testing.T) { t.Error("expected error for invalid URL") } } + +func TestParseGitHubRemote_NonGitHubSSH(t *testing.T) { + t.Parallel() + _, _, err := ParseGitHubRemote("git@gitlab.com:entirehq/entire.io.git") + if err == nil { + t.Error("expected error for non-GitHub SSH remote") + } +} + +func TestParseGitHubRemote_NonGitHubHTTPS(t *testing.T) { + t.Parallel() + _, _, err := ParseGitHubRemote("https://gitlab.com/entirehq/entire.io.git") + if err == nil { + t.Error("expected error for non-GitHub HTTPS remote") + } +} + +// -- Search() tests -- + +func TestSearch_URLConstruction(t *testing.T) { + t.Parallel() + + var capturedReq *http.Request + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedReq = r + resp := Response{Results: []Result{}, Query: "test", Repo: "o/r", Total: 0} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + })) + defer srv.Close() + + _, err := Search(context.Background(), Config{ + ServiceURL: srv.URL, + GitHubToken: "ghp_test123", + Owner: "myowner", + Repo: "myrepo", + Query: "find bugs", + Branch: "main", + Limit: 10, + }) + if err != nil { + t.Fatal(err) + } + + if capturedReq.URL.Path != "/search/v1/myowner/myrepo" { + t.Errorf("path = %s, want /search/v1/myowner/myrepo", capturedReq.URL.Path) + } + if capturedReq.URL.Query().Get("q") != "find bugs" { + t.Errorf("q = %s, want 'find bugs'", capturedReq.URL.Query().Get("q")) + } + if capturedReq.URL.Query().Get("branch") != "main" { + t.Errorf("branch = %s, want 'main'", capturedReq.URL.Query().Get("branch")) + } + if capturedReq.URL.Query().Get("limit") != "10" { + t.Errorf("limit = %s, want '10'", capturedReq.URL.Query().Get("limit")) + } + if capturedReq.Header.Get("Authorization") != "token ghp_test123" { + t.Errorf("auth header = %s, want 'token ghp_test123'", capturedReq.Header.Get("Authorization")) + } + if capturedReq.Header.Get("User-Agent") != "entire-cli" { + t.Errorf("user-agent = %s, want 'entire-cli'", capturedReq.Header.Get("User-Agent")) + } +} + +func TestSearch_NoBranchOmitsParam(t *testing.T) { + t.Parallel() + + var capturedReq *http.Request + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedReq = r + resp := Response{Results: []Result{}, Query: "q", Repo: "o/r", Total: 0} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + })) + defer srv.Close() + + _, err := Search(context.Background(), Config{ + ServiceURL: srv.URL, + GitHubToken: "tok", + Owner: "o", + Repo: "r", + Query: "q", + }) + if err != nil { + t.Fatal(err) + } + + if capturedReq.URL.Query().Has("branch") { + t.Error("branch param should be omitted when empty") + } + if capturedReq.URL.Query().Has("limit") { + t.Error("limit param should be omitted when zero") + } +} + +func TestSearch_ErrorJSON(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "Invalid token"}) //nolint:errcheck + })) + defer srv.Close() + + _, err := Search(context.Background(), Config{ + ServiceURL: srv.URL, + GitHubToken: "bad", + Owner: "o", + Repo: "r", + Query: "q", + }) + if err == nil { + t.Fatal("expected error for 401") + } + if got := err.Error(); got != "search service error (401): Invalid token" { + t.Errorf("error = %q, want 'search service error (401): Invalid token'", got) + } +} + +func TestSearch_ErrorRawBody(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte("Bad Gateway")) //nolint:errcheck + })) + defer srv.Close() + + _, err := Search(context.Background(), Config{ + ServiceURL: srv.URL, + GitHubToken: "tok", + Owner: "o", + Repo: "r", + Query: "q", + }) + if err == nil { + t.Fatal("expected error for 502") + } + if got := err.Error(); got != "search service returned 502: Bad Gateway" { + t.Errorf("error = %q", got) + } +} + +func TestSearch_SuccessWithResults(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := Response{ + Results: []Result{ + { + CheckpointID: "abc123def456", + Branch: "main", + Agent: "Claude Code", + Steps: 3, + Meta: Meta{ + RRFScore: 0.042, + MatchType: "both", + }, + }, + }, + Query: "test", + Repo: "o/r", + Total: 1, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + })) + defer srv.Close() + + resp, err := Search(context.Background(), Config{ + ServiceURL: srv.URL, + GitHubToken: "tok", + Owner: "o", + Repo: "r", + Query: "test", + }) + if err != nil { + t.Fatal(err) + } + if len(resp.Results) != 1 { + t.Fatalf("got %d results, want 1", len(resp.Results)) + } + if resp.Results[0].CheckpointID != "abc123def456" { + t.Errorf("checkpoint = %s, want abc123def456", resp.Results[0].CheckpointID) + } + if resp.Results[0].Meta.MatchType != "both" { + t.Errorf("matchType = %s, want both", resp.Results[0].Meta.MatchType) + } +} diff --git a/cmd/entire/cli/search_cmd.go b/cmd/entire/cli/search_cmd.go index 50f14eebc..40016cb40 100644 --- a/cmd/entire/cli/search_cmd.go +++ b/cmd/entire/cli/search_cmd.go @@ -38,7 +38,7 @@ Output is JSON by default for easy consumption by agents and scripts.`, query := strings.Join(args, " ") // Resolve GitHub token - ghToken := os.Getenv("GITHUB_TOKEN") + ghToken := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) if ghToken == "" { // Try gh CLI out, err := exec.CommandContext(ctx, "gh", "auth", "token").Output() From 65affccdd2e1caedd212c1e697ce5cb139ba78ba Mon Sep 17 00:00:00 2001 From: evisdren Date: Tue, 10 Mar 2026 15:33:41 -0700 Subject: [PATCH 08/19] Add nolint explanations to fix nolintlint Co-Authored-By: Claude Opus 4.6 --- cmd/entire/cli/search/search_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/entire/cli/search/search_test.go b/cmd/entire/cli/search/search_test.go index c03ddd6fc..0a639893d 100644 --- a/cmd/entire/cli/search/search_test.go +++ b/cmd/entire/cli/search/search_test.go @@ -85,7 +85,7 @@ func TestSearch_URLConstruction(t *testing.T) { capturedReq = r resp := Response{Results: []Result{}, Query: "test", Repo: "o/r", Total: 0} w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) //nolint:errcheck + json.NewEncoder(w).Encode(resp) //nolint:errcheck // test helper response })) defer srv.Close() @@ -130,7 +130,7 @@ func TestSearch_NoBranchOmitsParam(t *testing.T) { capturedReq = r resp := Response{Results: []Result{}, Query: "q", Repo: "o/r", Total: 0} w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) //nolint:errcheck + json.NewEncoder(w).Encode(resp) //nolint:errcheck // test helper response })) defer srv.Close() @@ -159,7 +159,7 @@ func TestSearch_ErrorJSON(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) - json.NewEncoder(w).Encode(map[string]string{"error": "Invalid token"}) //nolint:errcheck + json.NewEncoder(w).Encode(map[string]string{"error": "Invalid token"}) //nolint:errcheck // test helper response })) defer srv.Close() @@ -183,7 +183,7 @@ func TestSearch_ErrorRawBody(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadGateway) - w.Write([]byte("Bad Gateway")) //nolint:errcheck + w.Write([]byte("Bad Gateway")) //nolint:errcheck // test helper response })) defer srv.Close() @@ -224,7 +224,7 @@ func TestSearch_SuccessWithResults(t *testing.T) { Total: 1, } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) //nolint:errcheck + json.NewEncoder(w).Encode(resp) //nolint:errcheck // test helper response })) defer srv.Close() From eb8de1d7e465ef981a2f088f9293cef8a041a17c Mon Sep 17 00:00:00 2001 From: evisdren Date: Tue, 10 Mar 2026 17:18:07 -0700 Subject: [PATCH 09/19] Add GitHub device flow auth with entire login/logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - entire login: GitHub OAuth device flow, stores token in .entire/auth.json - entire logout: removes stored credentials - entire auth-status: shows token source (file, env, or gh CLI) - Token resolution: .entire/auth.json → GITHUB_TOKEN → gh auth token - Search command uses new resolver instead of inline token logic Co-Authored-By: Claude Opus 4.6 --- cmd/entire/cli/auth/github_device.go | 220 +++++++++++++++++++++++++++ cmd/entire/cli/auth/keyring.go | 142 +++++++++++++++++ cmd/entire/cli/auth/resolve.go | 44 ++++++ cmd/entire/cli/auth_cmd.go | 138 +++++++++++++++++ cmd/entire/cli/root.go | 3 + cmd/entire/cli/search_cmd.go | 15 +- go.mod | 1 + go.sum | 3 + 8 files changed, 555 insertions(+), 11 deletions(-) create mode 100644 cmd/entire/cli/auth/github_device.go create mode 100644 cmd/entire/cli/auth/keyring.go create mode 100644 cmd/entire/cli/auth/resolve.go create mode 100644 cmd/entire/cli/auth_cmd.go diff --git a/cmd/entire/cli/auth/github_device.go b/cmd/entire/cli/auth/github_device.go new file mode 100644 index 000000000..4fb6166aa --- /dev/null +++ b/cmd/entire/cli/auth/github_device.go @@ -0,0 +1,220 @@ +// Package auth implements GitHub OAuth device flow authentication for the CLI. +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + githubDeviceCodeURL = "https://github.com/login/device/code" + githubTokenURL = "https://github.com/login/oauth/access_token" + githubUserURL = "https://api.github.com/user" + + // Scopes: read:user for identity, repo for private repo access checks. + defaultScopes = "read:user repo" +) + +// DeviceCodeResponse is the response from GitHub's device code endpoint. +type DeviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int64 `json:"expires_in"` + Interval int64 `json:"interval"` +} + +// TokenResponse is the response from GitHub's token endpoint. +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` +} + +var ( + // ErrAuthorizationPending means the user hasn't authorized yet. + ErrAuthorizationPending = errors.New("authorization_pending") + // ErrDeviceCodeExpired means the device code has expired. + ErrDeviceCodeExpired = errors.New("device code expired") + // ErrSlowDown means the client is polling too frequently. + ErrSlowDown = errors.New("slow_down") + // ErrAccessDenied means the user denied the authorization request. + ErrAccessDenied = errors.New("access_denied") +) + +// RequestDeviceCode initiates the GitHub device authorization flow. +func RequestDeviceCode(ctx context.Context, clientID string) (*DeviceCodeResponse, error) { + form := url.Values{} + form.Set("client_id", clientID) + form.Set("scope", defaultScopes) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, githubDeviceCodeURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) //nolint:gosec // URL is a constant + if err != nil { + return nil, fmt.Errorf("requesting device code: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("device code request failed (%d): %s", resp.StatusCode, string(body)) + } + + var deviceResp DeviceCodeResponse + if err := json.Unmarshal(body, &deviceResp); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + + return &deviceResp, nil +} + +// PollForToken polls GitHub's token endpoint once. +func PollForToken(ctx context.Context, clientID, deviceCode string) (*TokenResponse, error) { + form := url.Values{} + form.Set("client_id", clientID) + form.Set("device_code", deviceCode) + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, githubTokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) //nolint:gosec // URL is a constant + if err != nil { + return nil, fmt.Errorf("polling for token: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + // GitHub returns 200 for both success and pending states + var raw map[string]interface{} + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + + if errStr, ok := raw["error"].(string); ok { + switch errStr { + case "authorization_pending": + return nil, ErrAuthorizationPending + case "slow_down": + return nil, ErrSlowDown + case "expired_token": + return nil, ErrDeviceCodeExpired + case "access_denied": + return nil, ErrAccessDenied + default: + desc, _ := raw["error_description"].(string) + return nil, fmt.Errorf("token request failed: %s - %s", errStr, desc) + } + } + + var tokenResp TokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("parsing token response: %w", err) + } + + if tokenResp.AccessToken == "" { + return nil, fmt.Errorf("empty access token in response") + } + + return &tokenResp, nil +} + +// WaitForAuthorization polls until the user authorizes or the code expires. +func WaitForAuthorization(ctx context.Context, clientID, deviceCode string, interval, expiresIn time.Duration) (*TokenResponse, error) { + deadline := time.Now().Add(expiresIn) + currentInterval := interval + + for { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("authorization cancelled: %w", ctx.Err()) + default: + } + + if time.Until(deadline) <= 0 { + return nil, ErrDeviceCodeExpired + } + + tokenResp, err := PollForToken(ctx, clientID, deviceCode) + if err == nil { + return tokenResp, nil + } + + switch { + case errors.Is(err, ErrAuthorizationPending): + // continue polling + case errors.Is(err, ErrSlowDown): + currentInterval += 5 * time.Second + default: + return nil, err + } + + timer := time.NewTimer(currentInterval) + select { + case <-ctx.Done(): + timer.Stop() + return nil, fmt.Errorf("authorization cancelled: %w", ctx.Err()) + case <-timer.C: + } + } +} + +// GitHubUser is the response from GitHub's /user endpoint. +type GitHubUser struct { + Login string `json:"login"` + ID int `json:"id"` +} + +// GetGitHubUser fetches the authenticated user's info. +func GetGitHubUser(ctx context.Context, token string) (*GitHubUser, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubUserURL, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", "token "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "entire-cli") + + resp, err := http.DefaultClient.Do(req) //nolint:gosec // URL is a constant + if err != nil { + return nil, fmt.Errorf("fetching user: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API error (%d)", resp.StatusCode) + } + + var user GitHubUser + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, fmt.Errorf("parsing user: %w", err) + } + + return &user, nil +} diff --git a/cmd/entire/cli/auth/keyring.go b/cmd/entire/cli/auth/keyring.go new file mode 100644 index 000000000..be5b14310 --- /dev/null +++ b/cmd/entire/cli/auth/keyring.go @@ -0,0 +1,142 @@ +package auth + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +const ( + entireDir = ".entire" + authFileName = "auth.json" +) + +type storedAuth struct { + Token string `json:"token"` + Username string `json:"username,omitempty"` +} + +// authFilePath returns the path to .entire/auth.json in the current repo root. +// Walks up from cwd to find the .entire directory. +func authFilePath() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("getting working directory: %w", err) + } + + for { + candidate := filepath.Join(dir, entireDir) + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return filepath.Join(candidate, authFileName), nil + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + // Fall back to cwd/.entire/auth.json + return filepath.Join(entireDir, authFileName), nil +} + +func readAuth() (*storedAuth, error) { + path, err := authFilePath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) //nolint:gosec // reading from controlled .entire path + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("reading auth file: %w", err) + } + + var a storedAuth + if err := json.Unmarshal(data, &a); err != nil { + return nil, fmt.Errorf("parsing auth file: %w", err) + } + + return &a, nil +} + +func writeAuth(a *storedAuth) error { + path, err := authFilePath() + if err != nil { + return err + } + + // Ensure .entire directory exists + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("creating directory: %w", err) + } + + data, err := json.MarshalIndent(a, "", " ") + if err != nil { + return fmt.Errorf("marshaling auth: %w", err) + } + + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("writing auth file: %w", err) + } + + return nil +} + +// GetStoredToken retrieves the GitHub token from .entire/auth.json. +// Returns ("", nil) if no token is stored. +func GetStoredToken() (string, error) { + a, err := readAuth() + if err != nil || a == nil { + return "", err + } + return a.Token, nil +} + +// SetStoredToken stores the GitHub token in .entire/auth.json. +func SetStoredToken(token string) error { + a, _ := readAuth() + if a == nil { + a = &storedAuth{} + } + a.Token = token + return writeAuth(a) +} + +// DeleteStoredToken removes the auth file. +func DeleteStoredToken() error { + path, err := authFilePath() + if err != nil { + return err + } + err = os.Remove(path) + if os.IsNotExist(err) { + return nil + } + return err //nolint:wrapcheck // os error is descriptive enough +} + +// GetStoredUsername retrieves the stored GitHub username. +// Returns ("", nil) if no username is stored. +func GetStoredUsername() (string, error) { + a, err := readAuth() + if err != nil || a == nil { + return "", err + } + return a.Username, nil +} + +// SetStoredUsername stores the GitHub username in .entire/auth.json. +func SetStoredUsername(username string) error { + a, _ := readAuth() + if a == nil { + a = &storedAuth{} + } + a.Username = username + return writeAuth(a) +} diff --git a/cmd/entire/cli/auth/resolve.go b/cmd/entire/cli/auth/resolve.go new file mode 100644 index 000000000..10af23635 --- /dev/null +++ b/cmd/entire/cli/auth/resolve.go @@ -0,0 +1,44 @@ +package auth + +import ( + "context" + "os" + "os/exec" + "strings" +) + +// Source indicates where a resolved token came from. +type Source string + +const ( + SourceEntireDir Source = ".entire/auth.json" + SourceEnvironment Source = "GITHUB_TOKEN" + SourceGHCLI Source = "gh auth token" +) + +// ResolveGitHubToken resolves a GitHub token from available sources. +// Resolution order: .entire/auth.json → GITHUB_TOKEN env → gh auth token. +func ResolveGitHubToken(ctx context.Context) (token string, source Source, err error) { + // 1. .entire/auth.json (entire login) + token, err = GetStoredToken() + if err == nil && token != "" { + return token, SourceEntireDir, nil + } + + // 2. GITHUB_TOKEN environment variable + token = strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) + if token != "" { + return token, SourceEnvironment, nil + } + + // 3. gh CLI + out, err := exec.CommandContext(ctx, "gh", "auth", "token").Output() + if err == nil { + token = strings.TrimSpace(string(out)) + if token != "" { + return token, SourceGHCLI, nil + } + } + + return "", "", nil +} diff --git a/cmd/entire/cli/auth_cmd.go b/cmd/entire/cli/auth_cmd.go new file mode 100644 index 000000000..04388ae1e --- /dev/null +++ b/cmd/entire/cli/auth_cmd.go @@ -0,0 +1,138 @@ +package cli + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/pkg/browser" + "github.com/spf13/cobra" +) + +// Default GitHub App client ID for the device flow. +// This is a public value (no secret needed for device flow). +// Override with ENTIRE_GITHUB_CLIENT_ID env var. +const defaultGitHubClientID = "Iv23li7ashZngVIxWbpx" + +func getGitHubClientID() string { + if id := os.Getenv("ENTIRE_GITHUB_CLIENT_ID"); id != "" { + return id + } + return defaultGitHubClientID +} + +func newLoginCmd() *cobra.Command { + var noBrowser bool + + cmd := &cobra.Command{ + Use: "login", + Short: "Authenticate with GitHub using device flow", + Long: `Authenticate with GitHub to enable search and other features. + +Uses GitHub's device flow: you'll get a code to enter at github.com. +The token is stored in .entire/auth.json and scoped to repo metadata access.`, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + clientID := getGitHubClientID() + + deviceResp, err := auth.RequestDeviceCode(ctx, clientID) + if err != nil { + return fmt.Errorf("requesting device code: %w", err) + } + + fmt.Fprintln(cmd.OutOrStdout()) + fmt.Fprintf(cmd.OutOrStdout(), " Open this URL in your browser: %s\n", deviceResp.VerificationURI) + fmt.Fprintf(cmd.OutOrStdout(), " Enter code: %s\n", deviceResp.UserCode) + fmt.Fprintln(cmd.OutOrStdout()) + + if !noBrowser && deviceResp.VerificationURIComplete != "" { + if err := browser.OpenURL(deviceResp.VerificationURIComplete); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Could not open browser automatically. Please open the URL manually.\n") + } + } + + fmt.Fprintf(cmd.ErrOrStderr(), "Waiting for authorization...\n") + + interval := max(deviceResp.Interval, 5) + tokenResp, err := auth.WaitForAuthorization( + ctx, + clientID, + deviceResp.DeviceCode, + secondsToDuration(interval), + secondsToDuration(deviceResp.ExpiresIn), + ) + if err != nil { + return fmt.Errorf("authorization failed: %w", err) + } + + user, err := auth.GetGitHubUser(ctx, tokenResp.AccessToken) + if err != nil { + return fmt.Errorf("fetching user info: %w", err) + } + + if err := auth.SetStoredToken(tokenResp.AccessToken); err != nil { + return fmt.Errorf("storing token: %w", err) + } + if err := auth.SetStoredUsername(user.Login); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not store username: %v\n", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Logged in as %s\n", user.Login) + return nil + }, + } + + cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Don't open the browser automatically") + + return cmd +} + +func newLogoutCmd() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Remove stored GitHub credentials", + RunE: func(cmd *cobra.Command, _ []string) error { + if err := auth.DeleteStoredToken(); err != nil { + return fmt.Errorf("removing credentials: %w", err) + } + fmt.Fprintln(cmd.OutOrStdout(), "Logged out.") + return nil + }, + } +} + +func newAuthStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "auth-status", + Short: "Show current authentication status", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + token, source, _ := auth.ResolveGitHubToken(ctx) + if token == "" { + fmt.Fprintln(cmd.OutOrStdout(), "Not authenticated.") + fmt.Fprintln(cmd.OutOrStdout(), "Run 'entire login' to authenticate with GitHub.") + return nil + } + + masked := token[:4] + strings.Repeat("*", 8) + token[len(token)-4:] + + fmt.Fprintf(cmd.OutOrStdout(), "Authenticated via %s\n", source) + fmt.Fprintf(cmd.OutOrStdout(), "Token: %s\n", masked) + + if source == auth.SourceEntireDir { + if username, err := auth.GetStoredUsername(); err == nil && username != "" { + fmt.Fprintf(cmd.OutOrStdout(), "GitHub user: %s\n", username) + } + } + + return nil + }, + } +} + +func secondsToDuration(secs int64) time.Duration { + return time.Duration(secs) * time.Second +} diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index 5f86d860f..dc3cd0155 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -84,6 +84,9 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newExplainCmd()) cmd.AddCommand(newDoctorCmd()) cmd.AddCommand(newTrailCmd()) + cmd.AddCommand(newLoginCmd()) + cmd.AddCommand(newLogoutCmd()) + cmd.AddCommand(newAuthStatusCmd()) cmd.AddCommand(newSearchCmd()) cmd.AddCommand(newSendAnalyticsCmd()) cmd.AddCommand(newCurlBashPostInstallCmd()) diff --git a/cmd/entire/cli/search_cmd.go b/cmd/entire/cli/search_cmd.go index 40016cb40..92e25ddf6 100644 --- a/cmd/entire/cli/search_cmd.go +++ b/cmd/entire/cli/search_cmd.go @@ -3,9 +3,9 @@ package cli import ( "fmt" "os" - "os/exec" "strings" + "github.com/entireio/cli/cmd/entire/cli/auth" "github.com/entireio/cli/cmd/entire/cli/jsonutil" "github.com/entireio/cli/cmd/entire/cli/search" "github.com/entireio/cli/cmd/entire/cli/strategy" @@ -37,17 +37,10 @@ Output is JSON by default for easy consumption by agents and scripts.`, ctx := cmd.Context() query := strings.Join(args, " ") - // Resolve GitHub token - ghToken := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) + // Resolve GitHub token (keychain → GITHUB_TOKEN → gh CLI) + ghToken, _, _ := auth.ResolveGitHubToken(ctx) if ghToken == "" { - // Try gh CLI - out, err := exec.CommandContext(ctx, "gh", "auth", "token").Output() - if err == nil { - ghToken = strings.TrimSpace(string(out)) - } - } - if ghToken == "" { - return fmt.Errorf("GitHub token required. Set GITHUB_TOKEN or install gh CLI (gh auth login)") + return fmt.Errorf("GitHub token required. Run 'entire auth login', set GITHUB_TOKEN, or install gh CLI") } // Get the repo's GitHub remote URL diff --git a/go.mod b/go.mod index 745dbdb5d..ff9b65d16 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/denisbrodbeck/machineid v1.0.1 github.com/go-git/go-git/v6 v6.0.0-20260305211659-2083cf940afa github.com/google/uuid v1.6.0 + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/posthog/posthog-go v1.11.1 github.com/sergi/go-diff v1.4.0 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 30d0cc21d..e5122f553 100644 --- a/go.sum +++ b/go.sum @@ -250,6 +250,8 @@ github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= @@ -416,6 +418,7 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= From be5ce4dd61c17f0ae3c4a8901fbe29a72c98e05b Mon Sep 17 00:00:00 2001 From: evisdren Date: Wed, 11 Mar 2026 10:18:39 -0700 Subject: [PATCH 10/19] =?UTF-8?q?Remove=20PAT=20fallbacks=20from=20auth=20?= =?UTF-8?q?resolution=20=E2=80=94=20use=20device=20flow=20token=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes GITHUB_TOKEN env var and gh CLI token fallbacks from ResolveGitHubToken so the CLI exclusively uses the token stored by 'entire login' (GitHub device flow). If no token is found, the user is directed to run 'entire login'. Co-Authored-By: Claude Sonnet 4.6 Entire-Checkpoint: c175a1a3a7c5 --- cmd/entire/cli/auth/resolve.go | 50 +++++++--------------------------- cmd/entire/cli/auth_cmd.go | 15 +++++----- cmd/entire/cli/search_cmd.go | 12 ++++---- 3 files changed, 23 insertions(+), 54 deletions(-) diff --git a/cmd/entire/cli/auth/resolve.go b/cmd/entire/cli/auth/resolve.go index 10af23635..ab9a8cfb7 100644 --- a/cmd/entire/cli/auth/resolve.go +++ b/cmd/entire/cli/auth/resolve.go @@ -1,44 +1,14 @@ package auth -import ( - "context" - "os" - "os/exec" - "strings" -) - -// Source indicates where a resolved token came from. -type Source string - -const ( - SourceEntireDir Source = ".entire/auth.json" - SourceEnvironment Source = "GITHUB_TOKEN" - SourceGHCLI Source = "gh auth token" -) - -// ResolveGitHubToken resolves a GitHub token from available sources. -// Resolution order: .entire/auth.json → GITHUB_TOKEN env → gh auth token. -func ResolveGitHubToken(ctx context.Context) (token string, source Source, err error) { - // 1. .entire/auth.json (entire login) - token, err = GetStoredToken() - if err == nil && token != "" { - return token, SourceEntireDir, nil - } - - // 2. GITHUB_TOKEN environment variable - token = strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) - if token != "" { - return token, SourceEnvironment, nil - } - - // 3. gh CLI - out, err := exec.CommandContext(ctx, "gh", "auth", "token").Output() - if err == nil { - token = strings.TrimSpace(string(out)) - if token != "" { - return token, SourceGHCLI, nil - } +// SourceEntireDir is the display name for the device flow token source. +const SourceEntireDir = ".entire/auth.json" + +// ResolveGitHubToken returns the token stored by the device flow login. +// If no token is found, an empty string is returned with no error. +func ResolveGitHubToken() (string, error) { + token, err := GetStoredToken() + if err != nil { + return "", err } - - return "", "", nil + return token, nil } diff --git a/cmd/entire/cli/auth_cmd.go b/cmd/entire/cli/auth_cmd.go index 04388ae1e..6c72668af 100644 --- a/cmd/entire/cli/auth_cmd.go +++ b/cmd/entire/cli/auth_cmd.go @@ -108,9 +108,10 @@ func newAuthStatusCmd() *cobra.Command { Use: "auth-status", Short: "Show current authentication status", RunE: func(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - - token, source, _ := auth.ResolveGitHubToken(ctx) + token, err := auth.ResolveGitHubToken() + if err != nil { + return fmt.Errorf("reading stored credentials: %w", err) + } if token == "" { fmt.Fprintln(cmd.OutOrStdout(), "Not authenticated.") fmt.Fprintln(cmd.OutOrStdout(), "Run 'entire login' to authenticate with GitHub.") @@ -119,13 +120,11 @@ func newAuthStatusCmd() *cobra.Command { masked := token[:4] + strings.Repeat("*", 8) + token[len(token)-4:] - fmt.Fprintf(cmd.OutOrStdout(), "Authenticated via %s\n", source) + fmt.Fprintf(cmd.OutOrStdout(), "Authenticated via %s\n", auth.SourceEntireDir) fmt.Fprintf(cmd.OutOrStdout(), "Token: %s\n", masked) - if source == auth.SourceEntireDir { - if username, err := auth.GetStoredUsername(); err == nil && username != "" { - fmt.Fprintf(cmd.OutOrStdout(), "GitHub user: %s\n", username) - } + if username, err := auth.GetStoredUsername(); err == nil && username != "" { + fmt.Fprintf(cmd.OutOrStdout(), "GitHub user: %s\n", username) } return nil diff --git a/cmd/entire/cli/search_cmd.go b/cmd/entire/cli/search_cmd.go index 92e25ddf6..1f51b84b8 100644 --- a/cmd/entire/cli/search_cmd.go +++ b/cmd/entire/cli/search_cmd.go @@ -24,9 +24,7 @@ func newSearchCmd() *cobra.Command { Long: `Search checkpoints using hybrid search (semantic + keyword), powered by the Entire search service. -Requires a GitHub token for authentication. The token is resolved from: - 1. GITHUB_TOKEN environment variable - 2. gh auth token (GitHub CLI) +Requires authentication via 'entire login' (GitHub device flow). Results are ranked using Reciprocal Rank Fusion (RRF) combining OpenAI embeddings with BM25 full-text search. @@ -37,10 +35,12 @@ Output is JSON by default for easy consumption by agents and scripts.`, ctx := cmd.Context() query := strings.Join(args, " ") - // Resolve GitHub token (keychain → GITHUB_TOKEN → gh CLI) - ghToken, _, _ := auth.ResolveGitHubToken(ctx) + ghToken, err := auth.ResolveGitHubToken() + if err != nil { + return fmt.Errorf("reading credentials: %w", err) + } if ghToken == "" { - return fmt.Errorf("GitHub token required. Run 'entire auth login', set GITHUB_TOKEN, or install gh CLI") + return fmt.Errorf("not authenticated. Run 'entire login' to authenticate with GitHub") } // Get the repo's GitHub remote URL From 541fa8ec5919e025ef99bbc2433c6c21fcb86c5a Mon Sep 17 00:00:00 2001 From: evisdren Date: Wed, 11 Mar 2026 10:36:21 -0700 Subject: [PATCH 11/19] mise run fmt --- cmd/entire/cli/search/search.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/entire/cli/search/search.go b/cmd/entire/cli/search/search.go index 6be80adc6..7baa5c559 100644 --- a/cmd/entire/cli/search/search.go +++ b/cmd/entire/cli/search/search.go @@ -47,7 +47,7 @@ type Result struct { CacheCreationTokens *int `json:"cacheCreationTokens"` CacheReadTokens *int `json:"cacheReadTokens"` APICallCount *int `json:"apiCallCount"` - Meta Meta `json:"searchMeta"` + Meta Meta `json:"searchMeta"` } // Response is the search service response. From 87850cab8f6feaeb7f176319ecdfe62f554832c1 Mon Sep 17 00:00:00 2001 From: evisdren Date: Wed, 11 Mar 2026 11:36:47 -0700 Subject: [PATCH 12/19] Fix lint issues in auth and search packages - gosec: nolint OAuth endpoint URL (G101) and token field (G117) false positives - errcheck: explicit type assertion for error_description; handle readAuth errors in Set* funcs - nilnil: replace (nil, nil) with errNoAuth sentinel in readAuth - forbidigo: nolint os.Getwd() in authFilePath (walks up dirs, handles subdirectories) - perfsprint: errors.New for static strings, strconv.Itoa for int formatting Co-Authored-By: Claude Sonnet 4.6 --- cmd/entire/cli/auth/github_device.go | 11 +++++++---- cmd/entire/cli/auth/keyring.go | 28 ++++++++++++++++++++++------ cmd/entire/cli/search/github.go | 3 ++- cmd/entire/cli/search/search.go | 3 ++- cmd/entire/cli/search_cmd.go | 5 +++-- 5 files changed, 36 insertions(+), 14 deletions(-) diff --git a/cmd/entire/cli/auth/github_device.go b/cmd/entire/cli/auth/github_device.go index 4fb6166aa..87cfdfbc3 100644 --- a/cmd/entire/cli/auth/github_device.go +++ b/cmd/entire/cli/auth/github_device.go @@ -15,7 +15,7 @@ import ( const ( githubDeviceCodeURL = "https://github.com/login/device/code" - githubTokenURL = "https://github.com/login/oauth/access_token" + githubTokenURL = "https://github.com/login/oauth/access_token" //nolint:gosec // G101: OAuth endpoint URL, not a credential githubUserURL = "https://api.github.com/user" // Scopes: read:user for identity, repo for private repo access checks. @@ -34,7 +34,7 @@ type DeviceCodeResponse struct { // TokenResponse is the response from GitHub's token endpoint. type TokenResponse struct { - AccessToken string `json:"access_token"` + AccessToken string `json:"access_token"` //nolint:gosec // G117: field holds an OAuth token, pattern match is a false positive TokenType string `json:"token_type"` Scope string `json:"scope"` } @@ -128,7 +128,10 @@ func PollForToken(ctx context.Context, clientID, deviceCode string) (*TokenRespo case "access_denied": return nil, ErrAccessDenied default: - desc, _ := raw["error_description"].(string) + var desc string + if d, ok := raw["error_description"].(string); ok { + desc = d + } return nil, fmt.Errorf("token request failed: %s - %s", errStr, desc) } } @@ -139,7 +142,7 @@ func PollForToken(ctx context.Context, clientID, deviceCode string) (*TokenRespo } if tokenResp.AccessToken == "" { - return nil, fmt.Errorf("empty access token in response") + return nil, errors.New("empty access token in response") } return &tokenResp, nil diff --git a/cmd/entire/cli/auth/keyring.go b/cmd/entire/cli/auth/keyring.go index be5b14310..721dcceba 100644 --- a/cmd/entire/cli/auth/keyring.go +++ b/cmd/entire/cli/auth/keyring.go @@ -2,6 +2,7 @@ package auth import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -12,6 +13,9 @@ const ( authFileName = "auth.json" ) +// errNoAuth is returned by readAuth when no auth file exists. +var errNoAuth = errors.New("no auth file") + type storedAuth struct { Token string `json:"token"` Username string `json:"username,omitempty"` @@ -20,7 +24,7 @@ type storedAuth struct { // authFilePath returns the path to .entire/auth.json in the current repo root. // Walks up from cwd to find the .entire directory. func authFilePath() (string, error) { - dir, err := os.Getwd() + dir, err := os.Getwd() //nolint:forbidigo // walks up to find .entire, handles subdirectory case explicitly if err != nil { return "", fmt.Errorf("getting working directory: %w", err) } @@ -50,7 +54,7 @@ func readAuth() (*storedAuth, error) { data, err := os.ReadFile(path) //nolint:gosec // reading from controlled .entire path if os.IsNotExist(err) { - return nil, nil + return nil, errNoAuth } if err != nil { return nil, fmt.Errorf("reading auth file: %w", err) @@ -92,7 +96,10 @@ func writeAuth(a *storedAuth) error { // Returns ("", nil) if no token is stored. func GetStoredToken() (string, error) { a, err := readAuth() - if err != nil || a == nil { + if errors.Is(err, errNoAuth) { + return "", nil + } + if err != nil { return "", err } return a.Token, nil @@ -100,7 +107,10 @@ func GetStoredToken() (string, error) { // SetStoredToken stores the GitHub token in .entire/auth.json. func SetStoredToken(token string) error { - a, _ := readAuth() + a, err := readAuth() + if err != nil && !errors.Is(err, errNoAuth) { + return fmt.Errorf("reading existing auth: %w", err) + } if a == nil { a = &storedAuth{} } @@ -125,7 +135,10 @@ func DeleteStoredToken() error { // Returns ("", nil) if no username is stored. func GetStoredUsername() (string, error) { a, err := readAuth() - if err != nil || a == nil { + if errors.Is(err, errNoAuth) { + return "", nil + } + if err != nil { return "", err } return a.Username, nil @@ -133,7 +146,10 @@ func GetStoredUsername() (string, error) { // SetStoredUsername stores the GitHub username in .entire/auth.json. func SetStoredUsername(username string) error { - a, _ := readAuth() + a, err := readAuth() + if err != nil && !errors.Is(err, errNoAuth) { + return fmt.Errorf("reading existing auth: %w", err) + } if a == nil { a = &storedAuth{} } diff --git a/cmd/entire/cli/search/github.go b/cmd/entire/cli/search/github.go index 98db80638..75bb87bb1 100644 --- a/cmd/entire/cli/search/github.go +++ b/cmd/entire/cli/search/github.go @@ -2,6 +2,7 @@ package search import ( + "errors" "fmt" "net/url" "strings" @@ -12,7 +13,7 @@ import ( func ParseGitHubRemote(remoteURL string) (owner, repo string, err error) { remoteURL = strings.TrimSpace(remoteURL) if remoteURL == "" { - return "", "", fmt.Errorf("empty remote URL") + return "", "", errors.New("empty remote URL") } var path string diff --git a/cmd/entire/cli/search/search.go b/cmd/entire/cli/search/search.go index 7baa5c559..a751f914a 100644 --- a/cmd/entire/cli/search/search.go +++ b/cmd/entire/cli/search/search.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "strconv" "time" ) @@ -93,7 +94,7 @@ func Search(ctx context.Context, cfg Config) (*Response, error) { q.Set("branch", cfg.Branch) } if cfg.Limit > 0 { - q.Set("limit", fmt.Sprintf("%d", cfg.Limit)) + q.Set("limit", strconv.Itoa(cfg.Limit)) } u.RawQuery = q.Encode() diff --git a/cmd/entire/cli/search_cmd.go b/cmd/entire/cli/search_cmd.go index 1f51b84b8..17e879a5a 100644 --- a/cmd/entire/cli/search_cmd.go +++ b/cmd/entire/cli/search_cmd.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "fmt" "os" "strings" @@ -40,7 +41,7 @@ Output is JSON by default for easy consumption by agents and scripts.`, return fmt.Errorf("reading credentials: %w", err) } if ghToken == "" { - return fmt.Errorf("not authenticated. Run 'entire login' to authenticate with GitHub") + return errors.New("not authenticated. Run 'entire login' to authenticate with GitHub") } // Get the repo's GitHub remote URL @@ -57,7 +58,7 @@ Output is JSON by default for easy consumption by agents and scripts.`, } urls := remote.Config().URLs if len(urls) == 0 { - return fmt.Errorf("origin remote has no URLs configured") + return errors.New("origin remote has no URLs configured") } owner, repoName, err := search.ParseGitHubRemote(urls[0]) From 4d16bb121929b85c13079210a53072563af89671 Mon Sep 17 00:00:00 2001 From: evisdren Date: Wed, 11 Mar 2026 11:39:07 -0700 Subject: [PATCH 13/19] Rename keyring.go to store.go The file manages a JSON file in .entire/auth.json, not a system keyring. Co-Authored-By: Claude Sonnet 4.6 --- cmd/entire/cli/auth/{keyring.go => store.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename cmd/entire/cli/auth/{keyring.go => store.go} (100%) diff --git a/cmd/entire/cli/auth/keyring.go b/cmd/entire/cli/auth/store.go similarity index 100% rename from cmd/entire/cli/auth/keyring.go rename to cmd/entire/cli/auth/store.go From ef1587990f293df1e4f0d4bd1a9d0d10d83da536 Mon Sep 17 00:00:00 2001 From: evisdren Date: Wed, 11 Mar 2026 12:33:39 -0700 Subject: [PATCH 14/19] Address code review feedback on auth and search packages - auth_cmd.go: guard against panic on short tokens in auth-status masking - auth_cmd.go: use SetStoredAuth for atomic token+username write on login - auth_cmd.go: pass raw interval to WaitForAuthorization (floor moved to auth logic) - auth_cmd.go: call GetStoredToken directly, remove ResolveGitHubToken indirection - github_device.go: unexport pollForToken (internal implementation detail) - github_device.go: enforce 5s minimum polling interval inside WaitForAuthorization - resolve.go: remove ResolveGitHubToken wrapper, keep only SourceEntireDir constant - search_cmd.go: call GetStoredToken directly - search.go: use http.DefaultClient consistently (matches auth package) - store.go: replace deprecated os.IsNotExist with errors.Is(err, fs.ErrNotExist) - store.go: add SetStoredAuth for atomic single-write of token+username - store_test.go: add 5 tests covering walk-up, round-trip, no-file, atomic write, preservation - README.md: document device flow auth, remove GITHUB_TOKEN/--json references Co-Authored-By: Claude Sonnet 4.6 --- README.md | 12 +- cmd/entire/cli/auth/github_device.go | 9 +- cmd/entire/cli/auth/resolve.go | 10 -- cmd/entire/cli/auth/store.go | 18 ++- cmd/entire/cli/auth/store_test.go | 169 +++++++++++++++++++++++++++ cmd/entire/cli/auth_cmd.go | 19 +-- cmd/entire/cli/search/search.go | 3 +- cmd/entire/cli/search_cmd.go | 2 +- 8 files changed, 205 insertions(+), 37 deletions(-) create mode 100644 cmd/entire/cli/auth/store_test.go diff --git a/README.md b/README.md index 9eac50ed3..4ecd875be 100644 --- a/README.md +++ b/README.md @@ -179,31 +179,23 @@ entire search "implement login feature" # Filter by branch entire search "fix auth bug" --branch main -# JSON output (for agent/script consumption) -entire search "refactor database layer" --json - # Limit results entire search "add tests" --limit 10 ``` | Flag | Description | | ---------- | ------------------------------------ | -| `--json` | Output results as JSON | | `--branch` | Filter results by branch name | | `--limit` | Maximum number of results (default: 20) | -**Authentication:** `entire search` requires a GitHub token to verify repo access. The token is resolved automatically from: - -1. `GITHUB_TOKEN` environment variable -2. `gh auth token` (GitHub CLI, if installed) +**Authentication:** `entire search` requires authentication. Run `entire login` first to authenticate via GitHub device flow — your token is stored in `.entire/auth.json` and used automatically. -No other commands require a GitHub token — search is the only command that calls an external service. +No other commands require authentication — search is the only command that calls an external service. **Environment variables:** | Variable | Description | | -------------------- | ---------------------------------------------------------- | -| `GITHUB_TOKEN` | GitHub personal access token or fine-grained token | | `ENTIRE_SEARCH_URL` | Override the search service URL (default: `https://entire.io`) | ### `entire enable` Flags diff --git a/cmd/entire/cli/auth/github_device.go b/cmd/entire/cli/auth/github_device.go index 87cfdfbc3..0e88ebc00 100644 --- a/cmd/entire/cli/auth/github_device.go +++ b/cmd/entire/cli/auth/github_device.go @@ -86,8 +86,8 @@ func RequestDeviceCode(ctx context.Context, clientID string) (*DeviceCodeRespons return &deviceResp, nil } -// PollForToken polls GitHub's token endpoint once. -func PollForToken(ctx context.Context, clientID, deviceCode string) (*TokenResponse, error) { +// pollForToken polls GitHub's token endpoint once. +func pollForToken(ctx context.Context, clientID, deviceCode string) (*TokenResponse, error) { form := url.Values{} form.Set("client_id", clientID) form.Set("device_code", deviceCode) @@ -150,6 +150,9 @@ func PollForToken(ctx context.Context, clientID, deviceCode string) (*TokenRespo // WaitForAuthorization polls until the user authorizes or the code expires. func WaitForAuthorization(ctx context.Context, clientID, deviceCode string, interval, expiresIn time.Duration) (*TokenResponse, error) { + if interval < 5*time.Second { + interval = 5 * time.Second + } deadline := time.Now().Add(expiresIn) currentInterval := interval @@ -164,7 +167,7 @@ func WaitForAuthorization(ctx context.Context, clientID, deviceCode string, inte return nil, ErrDeviceCodeExpired } - tokenResp, err := PollForToken(ctx, clientID, deviceCode) + tokenResp, err := pollForToken(ctx, clientID, deviceCode) if err == nil { return tokenResp, nil } diff --git a/cmd/entire/cli/auth/resolve.go b/cmd/entire/cli/auth/resolve.go index ab9a8cfb7..87762ea69 100644 --- a/cmd/entire/cli/auth/resolve.go +++ b/cmd/entire/cli/auth/resolve.go @@ -2,13 +2,3 @@ package auth // SourceEntireDir is the display name for the device flow token source. const SourceEntireDir = ".entire/auth.json" - -// ResolveGitHubToken returns the token stored by the device flow login. -// If no token is found, an empty string is returned with no error. -func ResolveGitHubToken() (string, error) { - token, err := GetStoredToken() - if err != nil { - return "", err - } - return token, nil -} diff --git a/cmd/entire/cli/auth/store.go b/cmd/entire/cli/auth/store.go index 721dcceba..16287a2a4 100644 --- a/cmd/entire/cli/auth/store.go +++ b/cmd/entire/cli/auth/store.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "os" "path/filepath" ) @@ -53,7 +54,7 @@ func readAuth() (*storedAuth, error) { } data, err := os.ReadFile(path) //nolint:gosec // reading from controlled .entire path - if os.IsNotExist(err) { + if errors.Is(err, fs.ErrNotExist) { return nil, errNoAuth } if err != nil { @@ -118,6 +119,19 @@ func SetStoredToken(token string) error { return writeAuth(a) } +// SetStoredAuth stores both the GitHub token and username atomically in a single write. +func SetStoredAuth(token, username string) error { + a, err := readAuth() + if errors.Is(err, errNoAuth) || a == nil { + a = &storedAuth{} + } else if err != nil { + return fmt.Errorf("reading existing auth: %w", err) + } + a.Token = token + a.Username = username + return writeAuth(a) +} + // DeleteStoredToken removes the auth file. func DeleteStoredToken() error { path, err := authFilePath() @@ -125,7 +139,7 @@ func DeleteStoredToken() error { return err } err = os.Remove(path) - if os.IsNotExist(err) { + if errors.Is(err, fs.ErrNotExist) { return nil } return err //nolint:wrapcheck // os error is descriptive enough diff --git a/cmd/entire/cli/auth/store_test.go b/cmd/entire/cli/auth/store_test.go new file mode 100644 index 000000000..63c98206a --- /dev/null +++ b/cmd/entire/cli/auth/store_test.go @@ -0,0 +1,169 @@ +package auth + +import ( + "os" + "path/filepath" + "testing" +) + +// setupTempRepoDir creates a temp dir with a .entire subdirectory and returns: +// - the root of the temp dir (where .entire lives) +// - a deeply-nested subdirectory to simulate a project subdirectory +func setupTempRepoDir(t *testing.T) (root, subsubdir string) { + t.Helper() + root = t.TempDir() + entireDirPath := filepath.Join(root, entireDir) + if err := os.MkdirAll(entireDirPath, 0o700); err != nil { + t.Fatalf("creating .entire dir: %v", err) + } + subsubdir = filepath.Join(root, "subdir", "subsubdir") + if err := os.MkdirAll(subsubdir, 0o755); err != nil { + t.Fatalf("creating subsubdir: %v", err) + } + return root, subsubdir +} + +// chdirTo changes the process cwd and returns a cleanup function that restores it. +func chdirTo(t *testing.T, dir string) func() { + t.Helper() + orig, err := os.Getwd() + if err != nil { + t.Fatalf("os.Getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("os.Chdir(%q): %v", dir, err) + } + return func() { + if err := os.Chdir(orig); err != nil { + t.Errorf("restoring cwd to %q: %v", orig, err) + } + } +} + +// TestAuthFilePathWalkUp verifies that authFilePath finds the .entire directory +// by walking up from a nested subdirectory. +func TestAuthFilePathWalkUp(t *testing.T) { + root, subsubdir := setupTempRepoDir(t) + defer chdirTo(t, subsubdir)() + + got, err := authFilePath() + if err != nil { + t.Fatalf("authFilePath() error: %v", err) + } + + want := filepath.Join(root, entireDir, authFileName) + + // Resolve symlinks on the directory portions only (the file itself doesn't exist yet). + // On macOS /var is a symlink to /private/var, so os.Getwd() and t.TempDir() may + // return different-looking but equivalent paths. + resolveDir := func(t *testing.T, p string) string { + t.Helper() + real, err := filepath.EvalSymlinks(filepath.Dir(p)) + if err != nil { + t.Fatalf("EvalSymlinks(%q): %v", filepath.Dir(p), err) + } + return filepath.Join(real, filepath.Base(p)) + } + if resolveDir(t, got) != resolveDir(t, want) { + t.Errorf("authFilePath() = %q, want %q", got, want) + } +} + +// TestReadWriteAuthRoundTrip verifies that writeAuth followed by readAuth +// returns the same data. +func TestReadWriteAuthRoundTrip(t *testing.T) { + _, subsubdir := setupTempRepoDir(t) + defer chdirTo(t, subsubdir)() + + in := &storedAuth{Token: "tok123", Username: "alice"} + if err := writeAuth(in); err != nil { + t.Fatalf("writeAuth: %v", err) + } + + out, err := readAuth() + if err != nil { + t.Fatalf("readAuth: %v", err) + } + if out.Token != in.Token { + t.Errorf("Token = %q, want %q", out.Token, in.Token) + } + if out.Username != in.Username { + t.Errorf("Username = %q, want %q", out.Username, in.Username) + } +} + +// TestGetStoredTokenNoFile verifies that GetStoredToken returns ("", nil) when +// no auth file exists. +func TestGetStoredTokenNoFile(t *testing.T) { + // Use a temp dir with a .entire directory but no auth.json inside. + _, subsubdir := setupTempRepoDir(t) + defer chdirTo(t, subsubdir)() + + tok, err := GetStoredToken() + if err != nil { + t.Fatalf("GetStoredToken() unexpected error: %v", err) + } + if tok != "" { + t.Errorf("GetStoredToken() = %q, want empty string", tok) + } +} + +// TestSetStoredAuthWritesBothFields verifies that SetStoredAuth stores both +// token and username in a single operation. +func TestSetStoredAuthWritesBothFields(t *testing.T) { + _, subsubdir := setupTempRepoDir(t) + defer chdirTo(t, subsubdir)() + + if err := SetStoredAuth("mytoken", "bob"); err != nil { + t.Fatalf("SetStoredAuth: %v", err) + } + + tok, err := GetStoredToken() + if err != nil { + t.Fatalf("GetStoredToken: %v", err) + } + if tok != "mytoken" { + t.Errorf("token = %q, want %q", tok, "mytoken") + } + + user, err := GetStoredUsername() + if err != nil { + t.Fatalf("GetStoredUsername: %v", err) + } + if user != "bob" { + t.Errorf("username = %q, want %q", user, "bob") + } +} + +// TestSetStoredTokenPreservesUsername verifies that SetStoredToken does not +// overwrite an existing username. +func TestSetStoredTokenPreservesUsername(t *testing.T) { + _, subsubdir := setupTempRepoDir(t) + defer chdirTo(t, subsubdir)() + + // Pre-populate with a username. + if err := SetStoredUsername("carol"); err != nil { + t.Fatalf("SetStoredUsername: %v", err) + } + + // Now set a token; username should be preserved. + if err := SetStoredToken("newtoken"); err != nil { + t.Fatalf("SetStoredToken: %v", err) + } + + user, err := GetStoredUsername() + if err != nil { + t.Fatalf("GetStoredUsername: %v", err) + } + if user != "carol" { + t.Errorf("username = %q, want %q", user, "carol") + } + + tok, err := GetStoredToken() + if err != nil { + t.Fatalf("GetStoredToken: %v", err) + } + if tok != "newtoken" { + t.Errorf("token = %q, want %q", tok, "newtoken") + } +} diff --git a/cmd/entire/cli/auth_cmd.go b/cmd/entire/cli/auth_cmd.go index 6c72668af..0a2f44691 100644 --- a/cmd/entire/cli/auth_cmd.go +++ b/cmd/entire/cli/auth_cmd.go @@ -55,12 +55,11 @@ The token is stored in .entire/auth.json and scoped to repo metadata access.`, fmt.Fprintf(cmd.ErrOrStderr(), "Waiting for authorization...\n") - interval := max(deviceResp.Interval, 5) tokenResp, err := auth.WaitForAuthorization( ctx, clientID, deviceResp.DeviceCode, - secondsToDuration(interval), + secondsToDuration(deviceResp.Interval), secondsToDuration(deviceResp.ExpiresIn), ) if err != nil { @@ -72,11 +71,8 @@ The token is stored in .entire/auth.json and scoped to repo metadata access.`, return fmt.Errorf("fetching user info: %w", err) } - if err := auth.SetStoredToken(tokenResp.AccessToken); err != nil { - return fmt.Errorf("storing token: %w", err) - } - if err := auth.SetStoredUsername(user.Login); err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not store username: %v\n", err) + if err := auth.SetStoredAuth(tokenResp.AccessToken, user.Login); err != nil { + return fmt.Errorf("storing credentials: %w", err) } fmt.Fprintf(cmd.OutOrStdout(), "Logged in as %s\n", user.Login) @@ -108,7 +104,7 @@ func newAuthStatusCmd() *cobra.Command { Use: "auth-status", Short: "Show current authentication status", RunE: func(cmd *cobra.Command, _ []string) error { - token, err := auth.ResolveGitHubToken() + token, err := auth.GetStoredToken() if err != nil { return fmt.Errorf("reading stored credentials: %w", err) } @@ -118,7 +114,12 @@ func newAuthStatusCmd() *cobra.Command { return nil } - masked := token[:4] + strings.Repeat("*", 8) + token[len(token)-4:] + var masked string + if len(token) > 8 { + masked = token[:4] + strings.Repeat("*", len(token)-8) + token[len(token)-4:] + } else { + masked = strings.Repeat("*", len(token)) + } fmt.Fprintf(cmd.OutOrStdout(), "Authenticated via %s\n", auth.SourceEntireDir) fmt.Fprintf(cmd.OutOrStdout(), "Token: %s\n", masked) diff --git a/cmd/entire/cli/search/search.go b/cmd/entire/cli/search/search.go index a751f914a..39f10933a 100644 --- a/cmd/entire/cli/search/search.go +++ b/cmd/entire/cli/search/search.go @@ -105,8 +105,7 @@ func Search(ctx context.Context, cfg Config) (*Response, error) { req.Header.Set("Authorization", "token "+cfg.GitHubToken) req.Header.Set("User-Agent", "entire-cli") - client := &http.Client{} - resp, err := client.Do(req) //nolint:gosec // URL is constructed from trusted config + resp, err := http.DefaultClient.Do(req) //nolint:gosec // URL is constructed from trusted config if err != nil { return nil, fmt.Errorf("calling search service: %w", err) } diff --git a/cmd/entire/cli/search_cmd.go b/cmd/entire/cli/search_cmd.go index 17e879a5a..2bb8dcae6 100644 --- a/cmd/entire/cli/search_cmd.go +++ b/cmd/entire/cli/search_cmd.go @@ -36,7 +36,7 @@ Output is JSON by default for easy consumption by agents and scripts.`, ctx := cmd.Context() query := strings.Join(args, " ") - ghToken, err := auth.ResolveGitHubToken() + ghToken, err := auth.GetStoredToken() if err != nil { return fmt.Errorf("reading credentials: %w", err) } From d34de4673e527f3f9fb65c489b596adfb50f4102 Mon Sep 17 00:00:00 2001 From: evisdren Date: Wed, 11 Mar 2026 12:57:00 -0700 Subject: [PATCH 15/19] Address second-round review feedback - store.go: unexport setStoredToken/setStoredUsername (dead external API) - store.go: move SourceEntireDir constant here, delete resolve.go - github_device.go: add io.LimitReader(1MB) to all three auth HTTP reads - README.md: add login/logout/auth-status to commands table and Authentication section Co-Authored-By: Claude Sonnet 4.6 --- README.md | 37 ++++++++++++++++++---------- cmd/entire/cli/auth/github_device.go | 10 +++++--- cmd/entire/cli/auth/resolve.go | 4 --- cmd/entire/cli/auth/store.go | 11 ++++++--- cmd/entire/cli/auth/store_test.go | 10 ++++---- 5 files changed, 43 insertions(+), 29 deletions(-) delete mode 100644 cmd/entire/cli/auth/resolve.go diff --git a/README.md b/README.md index 4ecd875be..527d95171 100644 --- a/README.md +++ b/README.md @@ -154,19 +154,30 @@ Multiple AI sessions can run on the same commit. If you start a second session w ## Commands Reference -| Command | Description | -| ---------------- | ------------------------------------------------------------------------------------------------- | -| `entire clean` | Clean up orphaned Entire data | -| `entire disable` | Remove Entire hooks from repository | -| `entire doctor` | Fix or clean up stuck sessions | -| `entire enable` | Enable Entire in your repository | -| `entire explain` | Explain a session or commit | -| `entire reset` | Delete the shadow branch and session state for the current HEAD commit | -| `entire resume` | Switch to a branch, restore latest checkpointed session metadata, and show command(s) to continue | -| `entire rewind` | Rewind to a previous checkpoint | -| `entire search` | Search checkpoints using semantic and keyword matching | -| `entire status` | Show current session info | -| `entire version` | Show Entire CLI version | +| Command | Description | +| --------------------- | ------------------------------------------------------------------------------------------------- | +| `entire auth-status` | Show current authentication state and masked token | +| `entire clean` | Clean up orphaned Entire data | +| `entire disable` | Remove Entire hooks from repository | +| `entire doctor` | Fix or clean up stuck sessions | +| `entire enable` | Enable Entire in your repository | +| `entire explain` | Explain a session or commit | +| `entire login` | Authenticate with GitHub via device flow; stores token in `.entire/auth.json` | +| `entire logout` | Remove stored credentials | +| `entire reset` | Delete the shadow branch and session state for the current HEAD commit | +| `entire resume` | Switch to a branch, restore latest checkpointed session metadata, and show command(s) to continue | +| `entire rewind` | Rewind to a previous checkpoint | +| `entire search` | Search checkpoints using semantic and keyword matching | +| `entire status` | Show current session info | +| `entire version` | Show Entire CLI version | + +### Authentication + +`entire search` is the only command that calls an external service and therefore requires authentication. Use the following commands to manage your credentials: + +- **`entire login`** — authenticate with GitHub via device flow. Follow the printed URL and code to authorize in your browser. On success, your token is stored in `.entire/auth.json` and used automatically for subsequent `entire search` calls. +- **`entire logout`** — remove stored credentials from `.entire/auth.json`. You will need to run `entire login` again before using `entire search`. +- **`entire auth-status`** — display your current authentication state and a masked version of the stored token so you can confirm which account is active without exposing the full secret. ### `entire search` diff --git a/cmd/entire/cli/auth/github_device.go b/cmd/entire/cli/auth/github_device.go index 0e88ebc00..e0f147e10 100644 --- a/cmd/entire/cli/auth/github_device.go +++ b/cmd/entire/cli/auth/github_device.go @@ -69,7 +69,7 @@ func RequestDeviceCode(ctx context.Context, clientID string) (*DeviceCodeRespons } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, fmt.Errorf("reading response: %w", err) } @@ -106,7 +106,7 @@ func pollForToken(ctx context.Context, clientID, deviceCode string) (*TokenRespo } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, fmt.Errorf("reading response: %w", err) } @@ -217,8 +217,12 @@ func GetGitHubUser(ctx context.Context, token string) (*GitHubUser, error) { return nil, fmt.Errorf("GitHub API error (%d)", resp.StatusCode) } + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } var user GitHubUser - if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + if err := json.Unmarshal(body, &user); err != nil { return nil, fmt.Errorf("parsing user: %w", err) } diff --git a/cmd/entire/cli/auth/resolve.go b/cmd/entire/cli/auth/resolve.go deleted file mode 100644 index 87762ea69..000000000 --- a/cmd/entire/cli/auth/resolve.go +++ /dev/null @@ -1,4 +0,0 @@ -package auth - -// SourceEntireDir is the display name for the device flow token source. -const SourceEntireDir = ".entire/auth.json" diff --git a/cmd/entire/cli/auth/store.go b/cmd/entire/cli/auth/store.go index 16287a2a4..5e68047f4 100644 --- a/cmd/entire/cli/auth/store.go +++ b/cmd/entire/cli/auth/store.go @@ -9,6 +9,9 @@ import ( "path/filepath" ) +// SourceEntireDir is the display name for the device flow token source. +const SourceEntireDir = ".entire/auth.json" + const ( entireDir = ".entire" authFileName = "auth.json" @@ -106,8 +109,8 @@ func GetStoredToken() (string, error) { return a.Token, nil } -// SetStoredToken stores the GitHub token in .entire/auth.json. -func SetStoredToken(token string) error { +// setStoredToken stores the GitHub token in .entire/auth.json. +func setStoredToken(token string) error { a, err := readAuth() if err != nil && !errors.Is(err, errNoAuth) { return fmt.Errorf("reading existing auth: %w", err) @@ -158,8 +161,8 @@ func GetStoredUsername() (string, error) { return a.Username, nil } -// SetStoredUsername stores the GitHub username in .entire/auth.json. -func SetStoredUsername(username string) error { +// setStoredUsername stores the GitHub username in .entire/auth.json. +func setStoredUsername(username string) error { a, err := readAuth() if err != nil && !errors.Is(err, errNoAuth) { return fmt.Errorf("reading existing auth: %w", err) diff --git a/cmd/entire/cli/auth/store_test.go b/cmd/entire/cli/auth/store_test.go index 63c98206a..b22d0da2f 100644 --- a/cmd/entire/cli/auth/store_test.go +++ b/cmd/entire/cli/auth/store_test.go @@ -135,20 +135,20 @@ func TestSetStoredAuthWritesBothFields(t *testing.T) { } } -// TestSetStoredTokenPreservesUsername verifies that SetStoredToken does not +// TestSetStoredTokenPreservesUsername verifies that setStoredToken does not // overwrite an existing username. func TestSetStoredTokenPreservesUsername(t *testing.T) { _, subsubdir := setupTempRepoDir(t) defer chdirTo(t, subsubdir)() // Pre-populate with a username. - if err := SetStoredUsername("carol"); err != nil { - t.Fatalf("SetStoredUsername: %v", err) + if err := setStoredUsername("carol"); err != nil { + t.Fatalf("setStoredUsername: %v", err) } // Now set a token; username should be preserved. - if err := SetStoredToken("newtoken"); err != nil { - t.Fatalf("SetStoredToken: %v", err) + if err := setStoredToken("newtoken"); err != nil { + t.Fatalf("setStoredToken: %v", err) } user, err := GetStoredUsername() From 44bdb4049432769aa757bef887defaf0100e1045 Mon Sep 17 00:00:00 2001 From: evisdren Date: Wed, 11 Mar 2026 13:01:48 -0700 Subject: [PATCH 16/19] Fix lint issues in store_test.go - revive: rename 'real' variable to 'resolved' (shadows builtin) - usetesting: replace os.Chdir with t.Chdir (handles restore automatically) Co-Authored-By: Claude Sonnet 4.6 --- cmd/entire/cli/auth/store_test.go | 32 +++++++++++-------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/cmd/entire/cli/auth/store_test.go b/cmd/entire/cli/auth/store_test.go index b22d0da2f..550aa76b2 100644 --- a/cmd/entire/cli/auth/store_test.go +++ b/cmd/entire/cli/auth/store_test.go @@ -23,28 +23,18 @@ func setupTempRepoDir(t *testing.T) (root, subsubdir string) { return root, subsubdir } -// chdirTo changes the process cwd and returns a cleanup function that restores it. -func chdirTo(t *testing.T, dir string) func() { +// chdirTo changes the process cwd for the duration of the test. +// Restoration is handled automatically by t.Chdir. +func chdirTo(t *testing.T, dir string) { t.Helper() - orig, err := os.Getwd() - if err != nil { - t.Fatalf("os.Getwd: %v", err) - } - if err := os.Chdir(dir); err != nil { - t.Fatalf("os.Chdir(%q): %v", dir, err) - } - return func() { - if err := os.Chdir(orig); err != nil { - t.Errorf("restoring cwd to %q: %v", orig, err) - } - } + t.Chdir(dir) } // TestAuthFilePathWalkUp verifies that authFilePath finds the .entire directory // by walking up from a nested subdirectory. func TestAuthFilePathWalkUp(t *testing.T) { root, subsubdir := setupTempRepoDir(t) - defer chdirTo(t, subsubdir)() + chdirTo(t, subsubdir) got, err := authFilePath() if err != nil { @@ -58,11 +48,11 @@ func TestAuthFilePathWalkUp(t *testing.T) { // return different-looking but equivalent paths. resolveDir := func(t *testing.T, p string) string { t.Helper() - real, err := filepath.EvalSymlinks(filepath.Dir(p)) + resolved, err := filepath.EvalSymlinks(filepath.Dir(p)) if err != nil { t.Fatalf("EvalSymlinks(%q): %v", filepath.Dir(p), err) } - return filepath.Join(real, filepath.Base(p)) + return filepath.Join(resolved, filepath.Base(p)) } if resolveDir(t, got) != resolveDir(t, want) { t.Errorf("authFilePath() = %q, want %q", got, want) @@ -73,7 +63,7 @@ func TestAuthFilePathWalkUp(t *testing.T) { // returns the same data. func TestReadWriteAuthRoundTrip(t *testing.T) { _, subsubdir := setupTempRepoDir(t) - defer chdirTo(t, subsubdir)() + chdirTo(t, subsubdir) in := &storedAuth{Token: "tok123", Username: "alice"} if err := writeAuth(in); err != nil { @@ -97,7 +87,7 @@ func TestReadWriteAuthRoundTrip(t *testing.T) { func TestGetStoredTokenNoFile(t *testing.T) { // Use a temp dir with a .entire directory but no auth.json inside. _, subsubdir := setupTempRepoDir(t) - defer chdirTo(t, subsubdir)() + chdirTo(t, subsubdir) tok, err := GetStoredToken() if err != nil { @@ -112,7 +102,7 @@ func TestGetStoredTokenNoFile(t *testing.T) { // token and username in a single operation. func TestSetStoredAuthWritesBothFields(t *testing.T) { _, subsubdir := setupTempRepoDir(t) - defer chdirTo(t, subsubdir)() + chdirTo(t, subsubdir) if err := SetStoredAuth("mytoken", "bob"); err != nil { t.Fatalf("SetStoredAuth: %v", err) @@ -139,7 +129,7 @@ func TestSetStoredAuthWritesBothFields(t *testing.T) { // overwrite an existing username. func TestSetStoredTokenPreservesUsername(t *testing.T) { _, subsubdir := setupTempRepoDir(t) - defer chdirTo(t, subsubdir)() + chdirTo(t, subsubdir) // Pre-populate with a username. if err := setStoredUsername("carol"); err != nil { From 24554d9d167e3131b36110b734fc1f76efacc931 Mon Sep 17 00:00:00 2001 From: evisdren Date: Wed, 11 Mar 2026 13:55:59 -0700 Subject: [PATCH 17/19] Add pluggable credential store with OS keyring backend Default to OS keyring (macOS Keychain, Linux Secret Service, Windows Credential Manager) via go-keyring. Fall back to .entire/auth.json when ENTIRE_TOKEN_STORE=file, matching the entiredb tokenstore pattern. - store.go: introduce tokenStore interface with keyringTokenStore and fileTokenStore implementations; sync.Once backend resolution - auth_cmd.go: use TokenSource() instead of hardcoded SourceEntireDir - store_test.go: TestMain forces file backend; resetBackend() between tests; add TestTokenSourceFileBackend Co-Authored-By: Claude Sonnet 4.6 --- cmd/entire/cli/auth/store.go | 193 ++++++++++++++++++++++-------- cmd/entire/cli/auth/store_test.go | 57 ++++++--- cmd/entire/cli/auth_cmd.go | 2 +- go.mod | 4 + go.sum | 12 ++ 5 files changed, 199 insertions(+), 69 deletions(-) diff --git a/cmd/entire/cli/auth/store.go b/cmd/entire/cli/auth/store.go index 5e68047f4..4155abe07 100644 --- a/cmd/entire/cli/auth/store.go +++ b/cmd/entire/cli/auth/store.go @@ -1,3 +1,11 @@ +// Package auth implements credential storage for the Entire CLI. +// +// By default tokens are stored in the OS keyring (macOS Keychain, Linux Secret +// Service, Windows Credential Manager). Set ENTIRE_TOKEN_STORE=file to use a +// JSON file instead, which is useful in CI environments that lack a keyring daemon. +// +// When using the file backend tokens are stored in .entire/auth.json, discovered +// by walking up from the current working directory. package auth import ( @@ -7,26 +15,142 @@ import ( "io/fs" "os" "path/filepath" -) + "sync" -// SourceEntireDir is the display name for the device flow token source. -const SourceEntireDir = ".entire/auth.json" + "github.com/zalando/go-keyring" +) const ( + // SourceEntireDir is the display name for the file-based token store. + SourceEntireDir = ".entire/auth.json" + // SourceKeyring is the display name for the OS keyring token store. + SourceKeyring = "OS keyring" + entireDir = ".entire" authFileName = "auth.json" + + keyringService = "entire-cli" + keyringTokenKey = "github-token" + keyringUsernameKey = "github-username" ) -// errNoAuth is returned by readAuth when no auth file exists. +// errNoAuth is returned by the file store when no auth file exists. var errNoAuth = errors.New("no auth file") +var ( + once sync.Once + backend tokenStore +) + +// tokenStore is the interface for pluggable credential storage. +type tokenStore interface { + GetToken() (string, error) + GetUsername() (string, error) + SetAuth(token, username string) error + DeleteAuth() error + Source() string +} + +func resolveBackend() { + once.Do(func() { + if os.Getenv("ENTIRE_TOKEN_STORE") == "file" { + backend = fileTokenStore{} + } else { + backend = keyringTokenStore{} + } + }) +} + +// GetStoredToken retrieves the GitHub token. Returns ("", nil) if not stored. +func GetStoredToken() (string, error) { + resolveBackend() + return backend.GetToken() +} + +// GetStoredUsername retrieves the stored GitHub username. Returns ("", nil) if not stored. +func GetStoredUsername() (string, error) { + resolveBackend() + return backend.GetUsername() +} + +// SetStoredAuth stores both the GitHub token and username atomically. +func SetStoredAuth(token, username string) error { + resolveBackend() + return backend.SetAuth(token, username) +} + +// DeleteStoredToken removes all stored credentials. +func DeleteStoredToken() error { + resolveBackend() + return backend.DeleteAuth() +} + +// TokenSource returns the display name of the active credential store. +func TokenSource() string { + resolveBackend() + return backend.Source() +} + +// ─── Keyring backend ────────────────────────────────────────────────────────── + +type keyringTokenStore struct{} + +func (keyringTokenStore) GetToken() (string, error) { + tok, err := keyring.Get(keyringService, keyringTokenKey) + if errors.Is(err, keyring.ErrNotFound) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("reading token from keyring: %w", err) + } + return tok, nil +} + +func (keyringTokenStore) GetUsername() (string, error) { + u, err := keyring.Get(keyringService, keyringUsernameKey) + if errors.Is(err, keyring.ErrNotFound) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("reading username from keyring: %w", err) + } + return u, nil +} + +func (keyringTokenStore) SetAuth(token, username string) error { + if err := keyring.Set(keyringService, keyringTokenKey, token); err != nil { + return fmt.Errorf("storing token in keyring: %w", err) + } + if username != "" { + if err := keyring.Set(keyringService, keyringUsernameKey, username); err != nil { + return fmt.Errorf("storing username in keyring: %w", err) + } + } + return nil +} + +func (keyringTokenStore) DeleteAuth() error { + if err := keyring.Delete(keyringService, keyringTokenKey); err != nil && !errors.Is(err, keyring.ErrNotFound) { + return fmt.Errorf("deleting token from keyring: %w", err) + } + if err := keyring.Delete(keyringService, keyringUsernameKey); err != nil && !errors.Is(err, keyring.ErrNotFound) { + return fmt.Errorf("deleting username from keyring: %w", err) + } + return nil +} + +func (keyringTokenStore) Source() string { return SourceKeyring } + +// ─── File backend ───────────────────────────────────────────────────────────── + +type fileTokenStore struct{} + type storedAuth struct { Token string `json:"token"` Username string `json:"username,omitempty"` } -// authFilePath returns the path to .entire/auth.json in the current repo root. -// Walks up from cwd to find the .entire directory. +// authFilePath returns the path to .entire/auth.json by walking up from cwd. func authFilePath() (string, error) { dir, err := os.Getwd() //nolint:forbidigo // walks up to find .entire, handles subdirectory case explicitly if err != nil { @@ -38,7 +162,6 @@ func authFilePath() (string, error) { if info, err := os.Stat(candidate); err == nil && info.IsDir() { return filepath.Join(candidate, authFileName), nil } - parent := filepath.Dir(dir) if parent == dir { break @@ -68,7 +191,6 @@ func readAuth() (*storedAuth, error) { if err := json.Unmarshal(data, &a); err != nil { return nil, fmt.Errorf("parsing auth file: %w", err) } - return &a, nil } @@ -78,9 +200,7 @@ func writeAuth(a *storedAuth) error { return err } - // Ensure .entire directory exists - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o700); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("creating directory: %w", err) } @@ -92,13 +212,10 @@ func writeAuth(a *storedAuth) error { if err := os.WriteFile(path, data, 0o600); err != nil { return fmt.Errorf("writing auth file: %w", err) } - return nil } -// GetStoredToken retrieves the GitHub token from .entire/auth.json. -// Returns ("", nil) if no token is stored. -func GetStoredToken() (string, error) { +func (fileTokenStore) GetToken() (string, error) { a, err := readAuth() if errors.Is(err, errNoAuth) { return "", nil @@ -109,21 +226,18 @@ func GetStoredToken() (string, error) { return a.Token, nil } -// setStoredToken stores the GitHub token in .entire/auth.json. -func setStoredToken(token string) error { +func (fileTokenStore) GetUsername() (string, error) { a, err := readAuth() - if err != nil && !errors.Is(err, errNoAuth) { - return fmt.Errorf("reading existing auth: %w", err) + if errors.Is(err, errNoAuth) { + return "", nil } - if a == nil { - a = &storedAuth{} + if err != nil { + return "", err } - a.Token = token - return writeAuth(a) + return a.Username, nil } -// SetStoredAuth stores both the GitHub token and username atomically in a single write. -func SetStoredAuth(token, username string) error { +func (fileTokenStore) SetAuth(token, username string) error { a, err := readAuth() if errors.Is(err, errNoAuth) || a == nil { a = &storedAuth{} @@ -135,8 +249,7 @@ func SetStoredAuth(token, username string) error { return writeAuth(a) } -// DeleteStoredToken removes the auth file. -func DeleteStoredToken() error { +func (fileTokenStore) DeleteAuth() error { path, err := authFilePath() if err != nil { return err @@ -148,28 +261,4 @@ func DeleteStoredToken() error { return err //nolint:wrapcheck // os error is descriptive enough } -// GetStoredUsername retrieves the stored GitHub username. -// Returns ("", nil) if no username is stored. -func GetStoredUsername() (string, error) { - a, err := readAuth() - if errors.Is(err, errNoAuth) { - return "", nil - } - if err != nil { - return "", err - } - return a.Username, nil -} - -// setStoredUsername stores the GitHub username in .entire/auth.json. -func setStoredUsername(username string) error { - a, err := readAuth() - if err != nil && !errors.Is(err, errNoAuth) { - return fmt.Errorf("reading existing auth: %w", err) - } - if a == nil { - a = &storedAuth{} - } - a.Username = username - return writeAuth(a) -} +func (fileTokenStore) Source() string { return SourceEntireDir } diff --git a/cmd/entire/cli/auth/store_test.go b/cmd/entire/cli/auth/store_test.go index 550aa76b2..ab5809052 100644 --- a/cmd/entire/cli/auth/store_test.go +++ b/cmd/entire/cli/auth/store_test.go @@ -3,9 +3,23 @@ package auth import ( "os" "path/filepath" + "sync" "testing" ) +// TestMain forces the file backend for all tests. The keyring backend requires +// an OS keyring daemon which is not available in CI or test environments. +func TestMain(m *testing.M) { + os.Setenv("ENTIRE_TOKEN_STORE", "file") //nolint:errcheck,tenv // set before any test runs; intentional global + os.Exit(m.Run()) +} + +// resetBackend resets the backend singleton so each test starts with a clean slate. +func resetBackend() { + once = sync.Once{} + backend = nil +} + // setupTempRepoDir creates a temp dir with a .entire subdirectory and returns: // - the root of the temp dir (where .entire lives) // - a deeply-nested subdirectory to simulate a project subdirectory @@ -85,7 +99,7 @@ func TestReadWriteAuthRoundTrip(t *testing.T) { // TestGetStoredTokenNoFile verifies that GetStoredToken returns ("", nil) when // no auth file exists. func TestGetStoredTokenNoFile(t *testing.T) { - // Use a temp dir with a .entire directory but no auth.json inside. + resetBackend() _, subsubdir := setupTempRepoDir(t) chdirTo(t, subsubdir) @@ -101,6 +115,7 @@ func TestGetStoredTokenNoFile(t *testing.T) { // TestSetStoredAuthWritesBothFields verifies that SetStoredAuth stores both // token and username in a single operation. func TestSetStoredAuthWritesBothFields(t *testing.T) { + resetBackend() _, subsubdir := setupTempRepoDir(t) chdirTo(t, subsubdir) @@ -125,20 +140,29 @@ func TestSetStoredAuthWritesBothFields(t *testing.T) { } } -// TestSetStoredTokenPreservesUsername verifies that setStoredToken does not -// overwrite an existing username. -func TestSetStoredTokenPreservesUsername(t *testing.T) { +// TestSetStoredAuthPreservesExistingUsername verifies that calling SetStoredAuth +// with a new token still stores the username supplied in the same call. +func TestSetStoredAuthPreservesExistingUsername(t *testing.T) { + resetBackend() _, subsubdir := setupTempRepoDir(t) chdirTo(t, subsubdir) - // Pre-populate with a username. - if err := setStoredUsername("carol"); err != nil { - t.Fatalf("setStoredUsername: %v", err) + // First login: both token and username. + if err := SetStoredAuth("token-v1", "carol"); err != nil { + t.Fatalf("SetStoredAuth (first): %v", err) } - // Now set a token; username should be preserved. - if err := setStoredToken("newtoken"); err != nil { - t.Fatalf("setStoredToken: %v", err) + // Re-auth with a new token for the same user. + if err := SetStoredAuth("token-v2", "carol"); err != nil { + t.Fatalf("SetStoredAuth (second): %v", err) + } + + tok, err := GetStoredToken() + if err != nil { + t.Fatalf("GetStoredToken: %v", err) + } + if tok != "token-v2" { + t.Errorf("token = %q, want %q", tok, "token-v2") } user, err := GetStoredUsername() @@ -148,12 +172,13 @@ func TestSetStoredTokenPreservesUsername(t *testing.T) { if user != "carol" { t.Errorf("username = %q, want %q", user, "carol") } +} - tok, err := GetStoredToken() - if err != nil { - t.Fatalf("GetStoredToken: %v", err) - } - if tok != "newtoken" { - t.Errorf("token = %q, want %q", tok, "newtoken") +// TestTokenSourceFileBackend verifies TokenSource returns the file backend name +// when ENTIRE_TOKEN_STORE=file is set. +func TestTokenSourceFileBackend(t *testing.T) { + resetBackend() + if got := TokenSource(); got != SourceEntireDir { + t.Errorf("TokenSource() = %q, want %q", got, SourceEntireDir) } } diff --git a/cmd/entire/cli/auth_cmd.go b/cmd/entire/cli/auth_cmd.go index 0a2f44691..a982072c5 100644 --- a/cmd/entire/cli/auth_cmd.go +++ b/cmd/entire/cli/auth_cmd.go @@ -121,7 +121,7 @@ func newAuthStatusCmd() *cobra.Command { masked = strings.Repeat("*", len(token)) } - fmt.Fprintf(cmd.OutOrStdout(), "Authenticated via %s\n", auth.SourceEntireDir) + fmt.Fprintf(cmd.OutOrStdout(), "Authenticated via %s\n", auth.TokenSource()) fmt.Fprintf(cmd.OutOrStdout(), "Token: %s\n", masked) if username, err := auth.GetStoredUsername(); err == nil && username != "" { diff --git a/go.mod b/go.mod index ff9b65d16..7bbcca13f 100644 --- a/go.mod +++ b/go.mod @@ -15,12 +15,14 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 + github.com/zalando/go-keyring v0.2.6 github.com/zricethezav/gitleaks/v8 v8.30.0 golang.org/x/mod v0.34.0 golang.org/x/term v0.41.0 ) require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect dario.cat/mergo v1.0.1 // indirect github.com/BobuSumisu/aho-corasick v1.0.3 // indirect github.com/Masterminds/goutils v1.1.1 // indirect @@ -45,6 +47,7 @@ require ( github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -56,6 +59,7 @@ require ( github.com/go-git/gcfg/v2 v2.0.2 // indirect github.com/go-git/go-billy/v6 v6.0.0-20260226131633-45bd0956d66f // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/h2non/filetype v1.1.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index e5122f553..3d60be7f8 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -96,6 +98,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -136,6 +140,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -164,6 +170,8 @@ github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OI github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= @@ -296,6 +304,8 @@ github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -320,6 +330,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= github.com/zricethezav/gitleaks/v8 v8.30.0 h1:5heLlxRQkHfXgTJgdQsJhi/evX1oj6i+xBanDu2XUM8= github.com/zricethezav/gitleaks/v8 v8.30.0/go.mod h1:M5JQW5L+vZmkAqs9EX29hFQnn7uFz9sOQCPNewaZD9E= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= From 43742d7b87752a5bf4d7397a5093878dafd91c7b Mon Sep 17 00:00:00 2001 From: evisdren Date: Wed, 11 Mar 2026 13:56:57 -0700 Subject: [PATCH 18/19] mise run fmt --- cmd/entire/cli/auth/store.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/auth/store.go b/cmd/entire/cli/auth/store.go index 4155abe07..cfb0d3a91 100644 --- a/cmd/entire/cli/auth/store.go +++ b/cmd/entire/cli/auth/store.go @@ -29,9 +29,9 @@ const ( entireDir = ".entire" authFileName = "auth.json" - keyringService = "entire-cli" - keyringTokenKey = "github-token" - keyringUsernameKey = "github-username" + keyringService = "entire-cli" + keyringTokenKey = "github-token" + keyringUsernameKey = "github-username" ) // errNoAuth is returned by the file store when no auth file exists. From 5aacb9dca3511fbc2d92d17e0e7238f0806e45cd Mon Sep 17 00:00:00 2001 From: evisdren Date: Wed, 11 Mar 2026 14:13:13 -0700 Subject: [PATCH 19/19] Fix lint issues in store.go and store_test.go - wrapcheck: nolint thin public wrappers over tokenStore interface - nolintlint: remove unused errcheck from TestMain nolint directive Co-Authored-By: Claude Sonnet 4.6 --- cmd/entire/cli/auth/store.go | 8 ++++---- cmd/entire/cli/auth/store_test.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/entire/cli/auth/store.go b/cmd/entire/cli/auth/store.go index cfb0d3a91..d16402d33 100644 --- a/cmd/entire/cli/auth/store.go +++ b/cmd/entire/cli/auth/store.go @@ -64,25 +64,25 @@ func resolveBackend() { // GetStoredToken retrieves the GitHub token. Returns ("", nil) if not stored. func GetStoredToken() (string, error) { resolveBackend() - return backend.GetToken() + return backend.GetToken() //nolint:wrapcheck // thin wrapper; backends wrap their own errors } // GetStoredUsername retrieves the stored GitHub username. Returns ("", nil) if not stored. func GetStoredUsername() (string, error) { resolveBackend() - return backend.GetUsername() + return backend.GetUsername() //nolint:wrapcheck // thin wrapper; backends wrap their own errors } // SetStoredAuth stores both the GitHub token and username atomically. func SetStoredAuth(token, username string) error { resolveBackend() - return backend.SetAuth(token, username) + return backend.SetAuth(token, username) //nolint:wrapcheck // thin wrapper; backends wrap their own errors } // DeleteStoredToken removes all stored credentials. func DeleteStoredToken() error { resolveBackend() - return backend.DeleteAuth() + return backend.DeleteAuth() //nolint:wrapcheck // thin wrapper; backends wrap their own errors } // TokenSource returns the display name of the active credential store. diff --git a/cmd/entire/cli/auth/store_test.go b/cmd/entire/cli/auth/store_test.go index ab5809052..cc0af951b 100644 --- a/cmd/entire/cli/auth/store_test.go +++ b/cmd/entire/cli/auth/store_test.go @@ -10,7 +10,7 @@ import ( // TestMain forces the file backend for all tests. The keyring backend requires // an OS keyring daemon which is not available in CI or test environments. func TestMain(m *testing.M) { - os.Setenv("ENTIRE_TOKEN_STORE", "file") //nolint:errcheck,tenv // set before any test runs; intentional global + os.Setenv("ENTIRE_TOKEN_STORE", "file") //nolint:tenv // set before any test runs; intentional global os.Exit(m.Run()) }