From 119059d570e4d502ed6ff4e400bce332f0507080 Mon Sep 17 00:00:00 2001 From: Michael McCanna Date: Thu, 30 Apr 2026 13:35:56 -0700 Subject: [PATCH 1/4] feat(auth): optional pass(1) credential storage via LINCTL_PASS_NAME Setting LINCTL_PASS_NAME= makes `linctl` store and read the API key through the `pass` password manager instead of ~/.linctl-auth.json. - `linctl auth` writes via `pass insert -m -f ` - `GetAuthHeader` reads via `pass show ` (single-line, trimmed) - `linctl logout` removes via `pass rm -f ` - Legacy JSON config remains the default; opt-in via env var only Precedence becomes: LINCTL_API_KEY env > pass > config file. README updated with a setup snippet and the new precedence rule. --- README.md | 22 ++++++++++-- pkg/auth/auth.go | 94 ++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 95 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index e3ced6f..616b44c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A command-line interface for the Linear API, built with Go and Cobra. ## Features -- **Authentication**: personal API key auth (`linctl auth`) and env-var override support. +- **Authentication**: personal API key auth (`linctl auth`), env-var override, and optional [`pass`](https://www.passwordstore.org/) credential storage. - **Issues**: list/search/get/create/update/assign with support for: - cycles, labels, delegation, projects/milestones, parent/sub-issue links - due dates, attachments, comments, and rich issue detail output @@ -751,7 +751,25 @@ linctl whoami unset LINCTL_API_KEY ``` -Precedence: `LINCTL_API_KEY` environment variable > config file (`~/.linctl-auth.json`). +### Storing the Key in `pass` + +If you use the [`pass`](https://www.passwordstore.org/) password manager, set +`LINCTL_PASS_NAME` to the entry name and `linctl` will read/write the key +through `pass` instead of the JSON config file: + +```bash +# One-time setup +export LINCTL_PASS_NAME=linear-api-key +linctl auth # stores via `pass insert -m -f linear-api-key` + +# Subsequent calls just work +linctl whoami +``` + +`linctl logout` removes the entry via `pass rm -f`. + +Precedence: `LINCTL_API_KEY` environment variable > `pass` (when +`LINCTL_PASS_NAME` is set) > config file (`~/.linctl-auth.json`). ## Time-based Filtering diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 6773681..351b9d3 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "strings" @@ -13,6 +14,42 @@ import ( "github.com/fatih/color" ) +// passEntryName returns the pass(1) entry name to use, or "" if pass storage +// is not configured. Setting LINCTL_PASS_NAME= opts the user into +// storing the API key in `pass` instead of the JSON config file. +func passEntryName() string { + return strings.TrimSpace(os.Getenv("LINCTL_PASS_NAME")) +} + +func readFromPass(name string) (string, error) { + out, err := exec.Command("pass", "show", name).Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return "", fmt.Errorf("pass show %s: %s", name, strings.TrimSpace(string(exitErr.Stderr))) + } + return "", fmt.Errorf("pass show %s: %w", name, err) + } + return strings.TrimSpace(string(out)), nil +} + +func writeToPass(name, value string) error { + cmd := exec.Command("pass", "insert", "-m", "-f", name) + cmd.Stdin = strings.NewReader(value + "\n") + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("pass insert %s: %s", name, strings.TrimSpace(string(out))) + } + return nil +} + +func removeFromPass(name string) error { + out, err := exec.Command("pass", "rm", "-f", name).CombinedOutput() + if err != nil { + return fmt.Errorf("pass rm %s: %s", name, strings.TrimSpace(string(out))) + } + return nil +} + type User struct { ID string `json:"id"` Name string `json:"name"` @@ -72,15 +109,23 @@ func loadAuth() (*AuthConfig, error) { return &config, nil } -// GetAuthHeader returns the authorization header value -// Precedence: LINCTL_API_KEY env var > config file +// GetAuthHeader returns the authorization header value. +// Precedence: LINCTL_API_KEY env var > pass(1) (when LINCTL_PASS_NAME set) > config file. func GetAuthHeader() (string, error) { - // Check environment variable first (useful for CI/CD or temporary overrides) if envKey := strings.TrimSpace(os.Getenv("LINCTL_API_KEY")); envKey != "" { return envKey, nil } - // Fall back to config file + if entry := passEntryName(); entry != "" { + key, err := readFromPass(entry) + if err != nil { + return "", err + } + if key != "" { + return key, nil + } + } + config, err := loadAuth() if err != nil { return "", err @@ -104,9 +149,14 @@ func loginWithAPIKey(plaintext, jsonOut bool) error { fmt.Println("\n" + color.New(color.FgYellow).Sprint("📝 Personal API Key Authentication")) fmt.Println("Get your API key from: https://linear.app//settings/account/security") - // Get the config path to show to the user - configPath, _ := getConfigPath() - fmt.Printf("Your credentials will be stored in: %s\n", color.New(color.FgCyan).Sprint(configPath)) + var location string + if entry := passEntryName(); entry != "" { + location = fmt.Sprintf("pass entry %q", entry) + } else { + configPath, _ := getConfigPath() + location = configPath + } + fmt.Printf("Your credentials will be stored in: %s\n", color.New(color.FgCyan).Sprint(location)) fmt.Print("\nEnter your Personal API Key: ") } @@ -128,13 +178,14 @@ func loginWithAPIKey(plaintext, jsonOut bool) error { return fmt.Errorf("invalid API key: %v", err) } - // Save the API key - config := AuthConfig{ - APIKey: apiKey, - } - err = saveAuth(config) - if err != nil { - return err + if entry := passEntryName(); entry != "" { + if err := writeToPass(entry, apiKey); err != nil { + return err + } + } else { + if err := saveAuth(AuthConfig{APIKey: apiKey}); err != nil { + return err + } } if !plaintext && !jsonOut { @@ -169,17 +220,22 @@ func GetCurrentUser() (*User, error) { }, nil } -// Logout clears stored credentials +// Logout clears stored credentials. When LINCTL_PASS_NAME is set, the pass +// entry is removed; the legacy JSON file is also removed if present so a +// future re-login starts from a clean slate. func Logout() error { + if entry := passEntryName(); entry != "" { + if err := removeFromPass(entry); err != nil { + return err + } + } + configPath, err := getConfigPath() if err != nil { return err } - - err = os.Remove(configPath) - if err != nil && !os.IsNotExist(err) { + if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { return err } - return nil } From fec11411bcd43de7c71e2aa8670e6a1664ae1517 Mon Sep 17 00:00:00 2001 From: dorkitude Date: Sat, 23 May 2026 10:23:46 -0700 Subject: [PATCH 2/4] Harden pass credential storage --- pkg/auth/auth.go | 63 ++++++++++---- pkg/auth/auth_test.go | 188 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 17 deletions(-) create mode 100644 pkg/auth/auth_test.go diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 351b9d3..858b975 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -4,7 +4,9 @@ import ( "bufio" "context" "encoding/json" + "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -21,30 +23,49 @@ func passEntryName() string { return strings.TrimSpace(os.Getenv("LINCTL_PASS_NAME")) } +var runPassCommand = func(stdin io.Reader, args ...string) ([]byte, error) { + cmd := exec.Command("pass", args...) + cmd.Stdin = stdin + return cmd.CombinedOutput() +} + func readFromPass(name string) (string, error) { - out, err := exec.Command("pass", "show", name).Output() + out, err := runPassCommand(nil, "show", "--", name) if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - return "", fmt.Errorf("pass show %s: %s", name, strings.TrimSpace(string(exitErr.Stderr))) + if strings.TrimSpace(string(out)) != "" { + return "", fmt.Errorf("pass show %s: %s", name, strings.TrimSpace(string(out))) } return "", fmt.Errorf("pass show %s: %w", name, err) } - return strings.TrimSpace(string(out)), nil + scanner := bufio.NewScanner(strings.NewReader(string(out))) + for scanner.Scan() { + if line := strings.TrimSpace(scanner.Text()); line != "" { + return line, nil + } + } + if err := scanner.Err(); err != nil { + return "", err + } + return "", nil } func writeToPass(name, value string) error { - cmd := exec.Command("pass", "insert", "-m", "-f", name) - cmd.Stdin = strings.NewReader(value + "\n") - out, err := cmd.CombinedOutput() + out, err := runPassCommand(strings.NewReader(value+"\n"), "insert", "-m", "-f", "--", name) if err != nil { + if strings.TrimSpace(string(out)) == "" { + return fmt.Errorf("pass insert %s: %w", name, err) + } return fmt.Errorf("pass insert %s: %s", name, strings.TrimSpace(string(out))) } return nil } func removeFromPass(name string) error { - out, err := exec.Command("pass", "rm", "-f", name).CombinedOutput() + out, err := runPassCommand(nil, "rm", "-f", "--", name) if err != nil { + if strings.TrimSpace(string(out)) == "" { + return fmt.Errorf("pass rm %s: %w", name, err) + } return fmt.Errorf("pass rm %s: %s", name, strings.TrimSpace(string(out))) } return nil @@ -85,6 +106,17 @@ func saveAuth(config AuthConfig) error { return os.WriteFile(configPath, data, 0600) } +func removeAuthConfig() error { + configPath, err := getConfigPath() + if err != nil { + return err + } + if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + // loadAuth loads authentication credentials func loadAuth() (*AuthConfig, error) { configPath, err := getConfigPath() @@ -182,6 +214,9 @@ func loginWithAPIKey(plaintext, jsonOut bool) error { if err := writeToPass(entry, apiKey); err != nil { return err } + if err := removeAuthConfig(); err != nil { + return err + } } else { if err := saveAuth(AuthConfig{APIKey: apiKey}); err != nil { return err @@ -224,18 +259,12 @@ func GetCurrentUser() (*User, error) { // entry is removed; the legacy JSON file is also removed if present so a // future re-login starts from a clean slate. func Logout() error { + var passErr error if entry := passEntryName(); entry != "" { if err := removeFromPass(entry); err != nil { - return err + passErr = err } } - configPath, err := getConfigPath() - if err != nil { - return err - } - if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { - return err - } - return nil + return errors.Join(passErr, removeAuthConfig()) } diff --git a/pkg/auth/auth_test.go b/pkg/auth/auth_test.go new file mode 100644 index 0000000..01ac117 --- /dev/null +++ b/pkg/auth/auth_test.go @@ -0,0 +1,188 @@ +package auth + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func withTempHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + return home +} + +func withMockPass(t *testing.T, fn func(stdin io.Reader, args ...string) ([]byte, error)) { + t.Helper() + orig := runPassCommand + runPassCommand = fn + t.Cleanup(func() { + runPassCommand = orig + }) +} + +func writeStdin(t *testing.T, value string) func() { + t.Helper() + orig := os.Stdin + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("create stdin pipe: %v", err) + } + if _, err := writer.WriteString(value); err != nil { + t.Fatalf("write stdin pipe: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close stdin writer: %v", err) + } + os.Stdin = reader + return func() { + os.Stdin = orig + _ = reader.Close() + } +} + +func mockViewer(t *testing.T) func() { + t.Helper() + orig := http.DefaultTransport + http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + if got := req.Header.Get("Authorization"); got != "new-key" { + t.Fatalf("expected auth header new-key, got %q", got) + } + + var gqlReq struct { + Query string `json:"query"` + } + if err := json.NewDecoder(req.Body).Decode(&gqlReq); err != nil { + t.Fatalf("decode GraphQL request: %v", err) + } + if !strings.Contains(gqlReq.Query, "viewer") { + t.Fatalf("expected viewer query, got %q", gqlReq.Query) + } + + body := `{"data":{"viewer":{"id":"u1","name":"Test User","email":"test@example.com","avatarUrl":"","isMe":true,"active":true,"admin":false}}}` + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }) + return func() { + http.DefaultTransport = orig + } +} + +func TestGetAuthHeaderEnvOverridesPassAndConfig(t *testing.T) { + withTempHome(t) + t.Setenv("LINCTL_API_KEY", "env-key") + t.Setenv("LINCTL_PASS_NAME", "linear-api-key") + if err := saveAuth(AuthConfig{APIKey: "config-key"}); err != nil { + t.Fatalf("saveAuth: %v", err) + } + + withMockPass(t, func(stdin io.Reader, args ...string) ([]byte, error) { + t.Fatalf("pass should not be called when LINCTL_API_KEY is set") + return nil, nil + }) + + got, err := GetAuthHeader() + if err != nil { + t.Fatalf("GetAuthHeader returned error: %v", err) + } + if got != "env-key" { + t.Fatalf("expected env-key, got %q", got) + } +} + +func TestGetAuthHeaderReadsFirstPassLineWithOptionTerminator(t *testing.T) { + withTempHome(t) + t.Setenv("LINCTL_PASS_NAME", "-linear-api-key") + + withMockPass(t, func(stdin io.Reader, args ...string) ([]byte, error) { + want := []string{"show", "--", "-linear-api-key"} + if !reflect.DeepEqual(args, want) { + t.Fatalf("expected args %v, got %v", want, args) + } + return []byte("lin_api_x\nnotes should not be part of the header\n"), nil + }) + + got, err := GetAuthHeader() + if err != nil { + t.Fatalf("GetAuthHeader returned error: %v", err) + } + if got != "lin_api_x" { + t.Fatalf("expected first pass line, got %q", got) + } +} + +func TestLoginWithPassRemovesLegacyConfig(t *testing.T) { + home := withTempHome(t) + t.Setenv("LINCTL_PASS_NAME", "linear-api-key") + if err := saveAuth(AuthConfig{APIKey: "old-config-key"}); err != nil { + t.Fatalf("saveAuth: %v", err) + } + defer mockViewer(t)() + defer writeStdin(t, "new-key\n")() + + passInsertCalled := false + withMockPass(t, func(stdin io.Reader, args ...string) ([]byte, error) { + want := []string{"insert", "-m", "-f", "--", "linear-api-key"} + if !reflect.DeepEqual(args, want) { + t.Fatalf("expected args %v, got %v", want, args) + } + data, err := io.ReadAll(stdin) + if err != nil { + t.Fatalf("read pass stdin: %v", err) + } + if string(data) != "new-key\n" { + t.Fatalf("expected pass stdin to contain API key only, got %q", string(data)) + } + passInsertCalled = true + return []byte{}, nil + }) + + if err := Login(true, false); err != nil { + t.Fatalf("Login returned error: %v", err) + } + if !passInsertCalled { + t.Fatalf("expected pass insert to be called") + } + if _, err := os.Stat(filepath.Join(home, ".linctl-auth.json")); !os.IsNotExist(err) { + t.Fatalf("expected legacy auth config to be removed, stat err=%v", err) + } +} + +func TestLogoutRemovesConfigEvenWhenPassRemoveFails(t *testing.T) { + home := withTempHome(t) + t.Setenv("LINCTL_PASS_NAME", "linear-api-key") + if err := saveAuth(AuthConfig{APIKey: "old-config-key"}); err != nil { + t.Fatalf("saveAuth: %v", err) + } + + withMockPass(t, func(stdin io.Reader, args ...string) ([]byte, error) { + want := []string{"rm", "-f", "--", "linear-api-key"} + if !reflect.DeepEqual(args, want) { + t.Fatalf("expected args %v, got %v", want, args) + } + return []byte("pass entry missing"), errors.New("pass failed") + }) + + if err := Logout(); err == nil { + t.Fatalf("expected Logout to report pass rm failure") + } + if _, err := os.Stat(filepath.Join(home, ".linctl-auth.json")); !os.IsNotExist(err) { + t.Fatalf("expected legacy auth config to be removed despite pass failure, stat err=%v", err) + } +} From 80f12d7db1b51b4d6a1e87562865bd05fa5f6caa Mon Sep 17 00:00:00 2001 From: dorkitude Date: Sat, 23 May 2026 10:26:41 -0700 Subject: [PATCH 3/4] Clarify optional pass auth storage --- README.md | 19 ++++++++++++------- cmd/auth.go | 18 +++++++++++++++--- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 616b44c..a0fe75b 100644 --- a/README.md +++ b/README.md @@ -723,7 +723,9 @@ api: retries: 3 ``` -Authentication credentials are stored securely in `~/.linctl-auth.json`. +By default, authentication credentials are stored in `~/.linctl-auth.json`. +Users of the external [`pass`](https://www.passwordstore.org/) password manager +can opt in to GPG-backed credential storage with `LINCTL_PASS_NAME`. ## Authentication @@ -751,22 +753,25 @@ linctl whoami unset LINCTL_API_KEY ``` -### Storing the Key in `pass` +### Storing the Key in `pass` (Optional) -If you use the [`pass`](https://www.passwordstore.org/) password manager, set -`LINCTL_PASS_NAME` to the entry name and `linctl` will read/write the key -through `pass` instead of the JSON config file: +If you already use the external [`pass`](https://www.passwordstore.org/) +password manager, set `LINCTL_PASS_NAME` to the entry name and `linctl` will +read/write the key through `pass` instead of the JSON config file. If +`LINCTL_PASS_NAME` is unset, this feature is disabled and existing auth behavior +is unchanged. ```bash # One-time setup export LINCTL_PASS_NAME=linear-api-key -linctl auth # stores via `pass insert -m -f linear-api-key` +linctl auth # stores via `pass insert -m -f -- linear-api-key` # Subsequent calls just work linctl whoami ``` -`linctl logout` removes the entry via `pass rm -f`. +`linctl logout` removes the entry via `pass rm -f -- linear-api-key` and also +removes any legacy `~/.linctl-auth.json` file if one exists. Precedence: `LINCTL_API_KEY` environment variable > `pass` (when `LINCTL_PASS_NAME` is set) > config file (`~/.linctl-auth.json`). diff --git a/cmd/auth.go b/cmd/auth.go index 2c2071a..94803e1 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -17,11 +17,16 @@ var authCmd = &cobra.Command{ Short: "Authenticate with Linear", Long: `Authenticate with Linear using Personal API Key. +By default, linctl stores credentials in ~/.linctl-auth.json. Users of the +external pass(1) password manager can opt in by setting LINCTL_PASS_NAME to the +desired pass entry name before running auth commands. + Examples: linctl auth # Interactive authentication linctl auth login # Same as above linctl auth status # Check authentication status - linctl auth logout # Clear stored credentials`, + linctl auth logout # Clear stored credentials + LINCTL_PASS_NAME=linear-api-key linctl auth`, Run: func(cmd *cobra.Command, args []string) { // Default behavior is to run login loginCmd.Run(cmd, args) @@ -31,7 +36,11 @@ Examples: var loginCmd = &cobra.Command{ Use: "login", Short: "Login to Linear", - Long: `Authenticate with Linear using Personal API Key.`, + Long: `Authenticate with Linear using Personal API Key. + +By default, linctl stores credentials in ~/.linctl-auth.json. Users of the +external pass(1) password manager can opt in by setting LINCTL_PASS_NAME to the +desired pass entry name.`, Run: func(cmd *cobra.Command, args []string) { plaintext := viper.GetBool("plaintext") jsonOut := viper.GetBool("json") @@ -101,7 +110,10 @@ var statusCmd = &cobra.Command{ var logoutCmd = &cobra.Command{ Use: "logout", Short: "Logout from Linear", - Long: `Clear stored Linear credentials.`, + Long: `Clear stored Linear credentials. + +By default, this removes ~/.linctl-auth.json. When LINCTL_PASS_NAME is set, +linctl also removes that pass(1) entry.`, Run: func(cmd *cobra.Command, args []string) { plaintext := viper.GetBool("plaintext") jsonOut := viper.GetBool("json") From c93e58bb542c1b9442de1584176a282f3f069043 Mon Sep 17 00:00:00 2001 From: dorkitude Date: Sat, 23 May 2026 10:27:40 -0700 Subject: [PATCH 4/4] Use plain pass wording --- cmd/auth.go | 6 +++--- pkg/auth/auth.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/auth.go b/cmd/auth.go index 94803e1..c440e7d 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -18,7 +18,7 @@ var authCmd = &cobra.Command{ Long: `Authenticate with Linear using Personal API Key. By default, linctl stores credentials in ~/.linctl-auth.json. Users of the -external pass(1) password manager can opt in by setting LINCTL_PASS_NAME to the +external pass password manager can opt in by setting LINCTL_PASS_NAME to the desired pass entry name before running auth commands. Examples: @@ -39,7 +39,7 @@ var loginCmd = &cobra.Command{ Long: `Authenticate with Linear using Personal API Key. By default, linctl stores credentials in ~/.linctl-auth.json. Users of the -external pass(1) password manager can opt in by setting LINCTL_PASS_NAME to the +external pass password manager can opt in by setting LINCTL_PASS_NAME to the desired pass entry name.`, Run: func(cmd *cobra.Command, args []string) { plaintext := viper.GetBool("plaintext") @@ -113,7 +113,7 @@ var logoutCmd = &cobra.Command{ Long: `Clear stored Linear credentials. By default, this removes ~/.linctl-auth.json. When LINCTL_PASS_NAME is set, -linctl also removes that pass(1) entry.`, +linctl also removes that pass entry.`, Run: func(cmd *cobra.Command, args []string) { plaintext := viper.GetBool("plaintext") jsonOut := viper.GetBool("json") diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 858b975..18f9716 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -16,7 +16,7 @@ import ( "github.com/fatih/color" ) -// passEntryName returns the pass(1) entry name to use, or "" if pass storage +// passEntryName returns the pass entry name to use, or "" if pass storage // is not configured. Setting LINCTL_PASS_NAME= opts the user into // storing the API key in `pass` instead of the JSON config file. func passEntryName() string { @@ -142,7 +142,7 @@ func loadAuth() (*AuthConfig, error) { } // GetAuthHeader returns the authorization header value. -// Precedence: LINCTL_API_KEY env var > pass(1) (when LINCTL_PASS_NAME set) > config file. +// Precedence: LINCTL_API_KEY env var > pass (when LINCTL_PASS_NAME set) > config file. func GetAuthHeader() (string, error) { if envKey := strings.TrimSpace(os.Getenv("LINCTL_API_KEY")); envKey != "" { return envKey, nil