cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

cat/module-path
012 / internal / jev / apikey.go
apikey.go 5.2K
1package jev23import (4 "bufio"5 "bytes"6 "context"7 "errors"8 "fmt"9 "os"10 "os/exec"11 "path/filepath"12 "strings"13 "sync"14 "time"1516 typesafe "github.com/FelineStateMachine/typesafe-go"1718 "src.rest/cat/012/internal/config"19 "src.rest/cat/012/internal/keyring"20)2122// The API key is never in the config file or on a command line. It comes23// from, in order:24//25// 1. TYPESAFE_API_KEY in the environment;26// 2. the OS credential store, where `012 config set-key` and File >27// Settings > JEV API key put it;28// 3. jev-api-key-command, a command that prints it (a password29// manager's CLI), run without a shell.3031// KeyEnv is the environment variable that holds the key.32const KeyEnv = "TYPESAFE_API_KEY"3334// KeyCommandTimeout bounds jev-api-key-command.35const KeyCommandTimeout = 10 * time.Second3637// KeySources are the places ResolveKey looks.38type KeySources struct {39 Getenv func(string) string40 Store keyring.Store // nil skips the credential store41 Command string // jev-api-key-command; empty skips it42 Timeout time.Duration // for Command; KeyCommandTimeout when zero43 // run runs the command; tests replace it. It gets the program and44 // its arguments exactly as they'd be executed.45 run func(ctx context.Context, argv []string) ([]byte, error)46}4748// ResolveKey finds the API key. from says where it came from:49// "TYPESAFE_API_KEY", the store's name, or "jev-api-key-command". With no50// key anywhere it returns "", "", nil. A store or command that fails is51// skipped, and its error is returned only if no later source has a key.52func ResolveKey(ctx context.Context, s KeySources) (key, from string, err error) {53 if s.Getenv != nil {54 if k := strings.TrimSpace(s.Getenv(KeyEnv)); k != "" {55 return k, KeyEnv, nil56 }57 }58 var errs []error59 if s.Store != nil {60 k, err := s.Store.Get(ctx)61 switch {62 case err == nil && strings.TrimSpace(k) != "":63 return strings.TrimSpace(k), s.Store.Name(), nil64 case err != nil && !errors.Is(err, keyring.ErrNotFound):65 errs = append(errs, err)66 }67 }68 if s.Command != "" {69 k, err := s.runCommand(ctx)70 if err == nil {71 return k, "jev-api-key-command", nil72 }73 errs = append(errs, err)74 }75 return "", "", errors.Join(errs...)76}7778// runCommand runs jev-api-key-command and returns the first line it79// prints. The command is split into words (quotes group, nothing is80// expanded) and run directly, not through a shell.81func (s KeySources) runCommand(ctx context.Context) (string, error) {82 argv, err := config.SplitCommand(s.Command)83 if err != nil {84 return "", fmt.Errorf("jev-api-key-command: %w", err)85 }86 timeout := s.Timeout87 if timeout <= 0 {88 timeout = KeyCommandTimeout89 }90 ctx, cancel := context.WithTimeout(ctx, timeout)91 defer cancel()92 run := s.run93 if run == nil {94 run = execCommand95 }96 out, err := run(ctx, argv)97 switch {98 case ctx.Err() == context.DeadlineExceeded:99 return "", fmt.Errorf("jev-api-key-command: %s didn't finish in %s", argv[0], timeout)100 case err != nil:101 return "", fmt.Errorf("jev-api-key-command: %s: %w", argv[0], err)102 }103 line, _, _ := strings.Cut(string(out), "\n")104 if line = strings.TrimSpace(line); line == "" {105 return "", fmt.Errorf("jev-api-key-command: %s printed nothing", argv[0])106 }107 return line, nil108}109110// execCommand runs argv with no stdin, returning stdout. What the command111// writes to stderr is reported by its first line only.112func execCommand(ctx context.Context, argv []string) ([]byte, error) {113 cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)114 var out, errOut bytes.Buffer115 cmd.Stdout, cmd.Stderr = &out, &errOut116 cmd.WaitDelay = time.Second // don't wait on children holding stdout open117 if err := cmd.Run(); err != nil {118 if msg, _, _ := strings.Cut(strings.TrimSpace(errOut.String()), "\n"); msg != "" {119 return nil, fmt.Errorf("%w: %s", err, msg)120 }121 return nil, err122 }123 return out.Bytes(), nil124}125126// DotEnvHasKey reports whether dir/.env sets TYPESAFE_API_KEY. It reads127// only the variable names: the key itself is never read into memory128// past the scanner's line, kept or shown. 012 no longer reads .env files129// (a .env beside a downloaded sheet could send the key elsewhere); this130// is only to suggest `012 config set-key` to people who used one.131func DotEnvHasKey(dir string) bool {132 f, err := os.Open(filepath.Join(dir, ".env"))133 if err != nil {134 return false135 }136 defer f.Close()137 sc := bufio.NewScanner(f)138 for sc.Scan() {139 line := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(sc.Text()), "export "))140 if name, _, ok := strings.Cut(line, "="); ok && strings.TrimSpace(name) == KeyEnv {141 return true142 }143 }144 return false145}146147// Lazy is a Client that connects the first time a question is asked,148// with connect, and remembers the result, error included. 012 uses it for149// jev-api-key-command, so a password manager is only asked for the key150// when a sheet actually uses JEV.151func Lazy(connect func() (Client, error)) Client {152 return &lazyClient{connect: connect}153}154155type lazyClient struct {156 once sync.Once157 connect func() (Client, error)158 client Client159 err error160}161162func (l *lazyClient) SystemOne(ctx context.Context, r typesafe.SystemOneRequest) (*typesafe.SystemOneResponse, error) {163 l.once.Do(func() { l.client, l.err = l.connect() })164 if l.err != nil {165 return nil, l.err166 }167 return l.client.SystemOne(ctx, r)168}