Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- Auth: add `gog auth import --client --email --refresh-token --services [--force]` for non-interactive token import without a browser flow.
- Drive: add `drive share --notify` for invite targets that require a Drive notification email.
- Calendar: keep `calendar appointments` as an explicit diagnostic because the Calendar API still rejects `eventTypes=appointmentSchedule`. (#329)
- CLI: add nested `docs tabs ...` and `forms questions ...` aliases for consistent sub-item command patterns while preserving existing flat commands. (#433)
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const (
type AuthCmd struct {
Credentials AuthCredentialsCmd `cmd:"" name:"credentials" help:"Manage OAuth client credentials"`
Add AuthAddCmd `cmd:"" name:"add" help:"Authorize and store a refresh token"`
Import AuthImportCmd `cmd:"" name:"import" help:"Import a refresh token non-interactively from flags"`
Services AuthServicesCmd `cmd:"" name:"services" help:"List supported auth services and scopes"`
List AuthListCmd `cmd:"" name:"list" help:"List stored accounts"`
Doctor AuthDoctorCmd `cmd:"" name:"doctor" help:"Diagnose auth, keyring, and refresh-token issues"`
Expand Down
84 changes: 84 additions & 0 deletions internal/cmd/auth_import.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package cmd

import (
"context"
"errors"
"fmt"
"strings"

"github.com/99designs/keyring"

"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/secrets"
"github.com/steipete/gogcli/internal/ui"
)

type AuthImportCmd struct {
Client string `name:"client" help:"OAuth client name to namespace the entry" default:"default"`
Email string `name:"email" required:"" help:"Account email"`
RefreshToken string `name:"refresh-token" required:"" help:"OAuth refresh token to store"`
ServicesCSV string `name:"services" help:"Comma-separated services to record on the token (informational; does not affect scopes)"`
Force bool `name:"force" help:"Overwrite an existing entry without prompting"`
}

func (c *AuthImportCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)

email := normalizeEmail(c.Email)
if email == "" {
return usage("--email is required")
}

refreshToken := strings.TrimSpace(c.RefreshToken)
if refreshToken == "" {
return usage("--refresh-token must not be empty")
}

client := strings.TrimSpace(c.Client)
if client == "" {
client = config.DefaultClientName
}

services := splitCommaList(c.ServicesCSV)

if err := dryRunExit(ctx, flags, "auth.import", map[string]any{
"client": client,
"email": email,
"services": services,
"force": c.Force,
}); err != nil {
return err
}

if err := ensureKeychainAccessIfNeeded(); err != nil {
return fmt.Errorf("keychain access: %w", err)
}

store, err := openSecretsStore()
if err != nil {
return err
}

if _, getErr := store.GetToken(client, email); getErr == nil {
if !c.Force {
return usagef("entry already exists for client=%q email=%q (use --force to overwrite)", client, email)
}
} else if !errors.Is(getErr, keyring.ErrKeyNotFound) {
return getErr
}

if err := store.SetToken(client, email, secrets.Token{
Client: client,
Email: email,
Services: services,
RefreshToken: refreshToken,
}); err != nil {
return err
}

return writeResult(ctx, u,
kv("imported", true),
kv("client", client),
kv("email", email),
)
}
201 changes: 201 additions & 0 deletions internal/cmd/auth_import_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package cmd

import (
"context"
"errors"
"io"
"strings"
"testing"

"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/secrets"
"github.com/steipete/gogcli/internal/ui"
)

func newImportTestContext(t *testing.T) context.Context {
t.Helper()
u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if err != nil {
t.Fatalf("ui.New: %v", err)
}
return outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{})
}

func withImportOverrides(t *testing.T, store secrets.Store) {
t.Helper()
origOpen := openSecretsStore
origKeychain := ensureKeychainAccess
t.Cleanup(func() {
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
})
openSecretsStore = func() (secrets.Store, error) { return store, nil }
ensureKeychainAccess = func() error { return nil }
}

func TestAuthImportCmd_RejectsEmptyRefreshToken(t *testing.T) {
store := newMemSecretsStore()
withImportOverrides(t, store)

cmd := &AuthImportCmd{
Client: "default",
Email: "a@b.com",
RefreshToken: " ",
ServicesCSV: "gmail",
}
err := cmd.Run(newImportTestContext(t), &RootFlags{})
if err == nil {
t.Fatal("expected error for empty refresh token")
}
var ee *ExitError
if !errors.As(err, &ee) || ee.Code != 2 {
t.Fatalf("expected ExitError code=2, got %#v", err)
}
if !strings.Contains(err.Error(), "refresh-token") {
t.Fatalf("expected refresh-token in error, got %q", err.Error())
}
}

func TestAuthImportCmd_RejectsEmptyEmail(t *testing.T) {
store := newMemSecretsStore()
withImportOverrides(t, store)

cmd := &AuthImportCmd{
Client: "default",
Email: " ",
RefreshToken: "rt",
}
err := cmd.Run(newImportTestContext(t), &RootFlags{})
if err == nil {
t.Fatal("expected error for empty email")
}
var ee *ExitError
if !errors.As(err, &ee) || ee.Code != 2 {
t.Fatalf("expected ExitError code=2, got %#v", err)
}
}

func TestAuthImportCmd_DefaultsClientToDefault(t *testing.T) {
store := newMemSecretsStore()
withImportOverrides(t, store)

cmd := &AuthImportCmd{
Client: "", // unset; must default to "default"
Email: "A@B.com",
RefreshToken: "rt-1",
ServicesCSV: "gmail, drive",
}
if err := cmd.Run(newImportTestContext(t), &RootFlags{}); err != nil {
t.Fatalf("Run: %v", err)
}

tok, err := store.GetToken(config.DefaultClientName, "a@b.com")
if err != nil {
t.Fatalf("GetToken default client: %v", err)
}
if tok.RefreshToken != "rt-1" {
t.Fatalf("unexpected refresh token: %q", tok.RefreshToken)
}
if tok.Email != "a@b.com" {
t.Fatalf("unexpected email: %q", tok.Email)
}
if strings.Join(tok.Services, ",") != "gmail,drive" {
t.Fatalf("unexpected services: %v", tok.Services)
}
}

func TestAuthImportCmd_RejectsExistingEntryWithoutForce(t *testing.T) {
store := newMemSecretsStore()
withImportOverrides(t, store)
if err := store.SetToken("default", "a@b.com", secrets.Token{RefreshToken: "rt-old"}); err != nil {
t.Fatalf("seed SetToken: %v", err)
}

cmd := &AuthImportCmd{
Client: "default",
Email: "a@b.com",
RefreshToken: "rt-new",
}
err := cmd.Run(newImportTestContext(t), &RootFlags{})
if err == nil {
t.Fatal("expected error when entry exists without --force")
}
var ee *ExitError
if !errors.As(err, &ee) || ee.Code != 2 {
t.Fatalf("expected ExitError code=2, got %#v", err)
}
if !strings.Contains(err.Error(), "--force") {
t.Fatalf("expected --force in error, got %q", err.Error())
}
tok, _ := store.GetToken("default", "a@b.com")
if tok.RefreshToken != "rt-old" {
t.Fatalf("expected unchanged token, got %q", tok.RefreshToken)
}
}

func TestAuthImportCmd_ForceOverwritesExistingEntry(t *testing.T) {
store := newMemSecretsStore()
withImportOverrides(t, store)
if err := store.SetToken("default", "a@b.com", secrets.Token{RefreshToken: "rt-old"}); err != nil {
t.Fatalf("seed SetToken: %v", err)
}

cmd := &AuthImportCmd{
Client: "default",
Email: "a@b.com",
RefreshToken: "rt-new",
Force: true,
}
if err := cmd.Run(newImportTestContext(t), &RootFlags{}); err != nil {
t.Fatalf("Run: %v", err)
}

tok, err := store.GetToken("default", "a@b.com")
if err != nil {
t.Fatalf("GetToken: %v", err)
}
if tok.RefreshToken != "rt-new" {
t.Fatalf("expected overwritten token, got %q", tok.RefreshToken)
}
}

func TestAuthImportCmd_CustomClientNamespace(t *testing.T) {
store := newMemSecretsStore()
withImportOverrides(t, store)

cmd := &AuthImportCmd{
Client: "org",
Email: "a@b.com",
RefreshToken: "rt",
}
if err := cmd.Run(newImportTestContext(t), &RootFlags{}); err != nil {
t.Fatalf("Run: %v", err)
}

if _, err := store.GetToken("org", "a@b.com"); err != nil {
t.Fatalf("expected token under custom client: %v", err)
}
if _, err := store.GetToken("default", "a@b.com"); err == nil {
t.Fatalf("expected no token under default client")
}
}

func TestAuthImportCmd_DryRunDoesNotWrite(t *testing.T) {
store := newMemSecretsStore()
withImportOverrides(t, store)

cmd := &AuthImportCmd{
Client: "default",
Email: "a@b.com",
RefreshToken: "rt",
}
err := cmd.Run(newImportTestContext(t), &RootFlags{DryRun: true})
var ee *ExitError
if !errors.As(err, &ee) || ee.Code != 0 {
t.Fatalf("expected dry-run ExitError code=0, got %#v", err)
}
if _, err := store.GetToken("default", "a@b.com"); err == nil {
t.Fatal("expected no token after dry-run")
}
}
54 changes: 54 additions & 0 deletions internal/secrets/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,60 @@ func TestOpenKeyring_NoDBus_ForcesFileBackend(t *testing.T) {
}
}

func TestKeyringStoreSetToken_RoundtripPreservesServices(t *testing.T) {
ring := keyring.NewArrayKeyring(nil)
store := &KeyringStore{ring: ring}
client := config.DefaultClientName

tok := Token{
Email: "import@example.com",
Services: []string{"gmail", "drive"},
RefreshToken: "imported-rt",
}
if err := store.SetToken(client, tok.Email, tok); err != nil {
t.Fatalf("SetToken: %v", err)
}

got, err := store.GetToken(client, tok.Email)
if err != nil {
t.Fatalf("GetToken: %v", err)
}
if got.Email != tok.Email {
t.Fatalf("email mismatch: got %q want %q", got.Email, tok.Email)
}
if got.RefreshToken != tok.RefreshToken {
t.Fatalf("refresh token mismatch: got %q want %q", got.RefreshToken, tok.RefreshToken)
}
if strings.Join(got.Services, ",") != "gmail,drive" {
t.Fatalf("services mismatch: got %v", got.Services)
}
if got.CreatedAt.IsZero() {
t.Fatalf("expected CreatedAt to be auto-populated")
}
}

func TestKeyringStoreSetToken_OverwritesExistingEntry(t *testing.T) {
ring := keyring.NewArrayKeyring(nil)
store := &KeyringStore{ring: ring}
client := config.DefaultClientName
email := "overwrite@example.com"

if err := store.SetToken(client, email, Token{RefreshToken: "rt-old"}); err != nil {
t.Fatalf("SetToken old: %v", err)
}
if err := store.SetToken(client, email, Token{RefreshToken: "rt-new"}); err != nil {
t.Fatalf("SetToken new: %v", err)
}

got, err := store.GetToken(client, email)
if err != nil {
t.Fatalf("GetToken: %v", err)
}
if got.RefreshToken != "rt-new" {
t.Fatalf("expected overwritten token, got %q", got.RefreshToken)
}
}

func TestOpenKeyring_ExplicitBackend_IgnoresDBusDetection(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
Expand Down