Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -751,7 +753,28 @@ linctl whoami
unset LINCTL_API_KEY
```

Precedence: `LINCTL_API_KEY` environment variable > config file (`~/.linctl-auth.json`).
### Storing the Key in `pass` (Optional)

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`

# Subsequent calls just work
linctl whoami
```

`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`).

## Time-based Filtering

Expand Down
18 changes: 15 additions & 3 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 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)
Expand All @@ -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 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")
Expand Down Expand Up @@ -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 entry.`,
Run: func(cmd *cobra.Command, args []string) {
plaintext := viper.GetBool("plaintext")
jsonOut := viper.GetBool("json")
Expand Down
133 changes: 109 additions & 24 deletions pkg/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,73 @@ import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/dorkitude/linctl/pkg/api"
"github.com/fatih/color"
)

// passEntryName returns the pass entry name to use, or "" if pass storage
// is not configured. Setting LINCTL_PASS_NAME=<entry> 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"))
}

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 := runPassCommand(nil, "show", "--", name)
if err != nil {
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)
}
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 {
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 := 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
}

type User struct {
ID string `json:"id"`
Name string `json:"name"`
Expand Down Expand Up @@ -48,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()
Expand All @@ -72,15 +141,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 (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
Expand All @@ -104,9 +181,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/<your-org>/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: ")
}

Expand All @@ -128,13 +210,17 @@ 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
}
if err := removeAuthConfig(); err != nil {
return err
}
} else {
if err := saveAuth(AuthConfig{APIKey: apiKey}); err != nil {
return err
}
}

if !plaintext && !jsonOut {
Expand Down Expand Up @@ -169,17 +255,16 @@ 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 {
configPath, err := getConfigPath()
if err != nil {
return err
}

err = os.Remove(configPath)
if err != nil && !os.IsNotExist(err) {
return err
var passErr error
if entry := passEntryName(); entry != "" {
if err := removeFromPass(entry); err != nil {
passErr = err
}
}

return nil
return errors.Join(passErr, removeAuthConfig())
}
Loading