Skip to content
Closed
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
25 changes: 21 additions & 4 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,24 @@ import (
)

var (
openSecretsStore = secrets.OpenDefault
authorizeGoogle = googleauth.Authorize
startManageServer = googleauth.StartManageServer
checkRefreshToken = googleauth.CheckRefreshToken
openSecretsStore = secrets.OpenDefault
authorizeGoogle = googleauth.Authorize
startManageServer = googleauth.StartManageServer
checkRefreshToken = googleauth.CheckRefreshToken
ensureKeychainAccess = defaultEnsureKeychainAccess
)

// defaultEnsureKeychainAccess verifies keychain is accessible before starting OAuth flow.
func defaultEnsureKeychainAccess() error {
store, err := secrets.OpenDefault()
if err != nil {
return fmt.Errorf("keychain access: %w", err)
}
// Trigger a read to verify keychain access
_, _ = store.Keys()
return nil
}

type AuthCmd struct {
Credentials AuthCredentialsCmd `cmd:"" name:"credentials" help:"Store OAuth client credentials"`
Add AuthAddCmd `cmd:"" name:"add" help:"Authorize and store a refresh token"`
Expand Down Expand Up @@ -300,6 +312,11 @@ type AuthAddCmd struct {
func (c *AuthAddCmd) Run(ctx context.Context) error {
u := ui.FromContext(ctx)

// Verify keychain access before starting the OAuth flow
if err := ensureKeychainAccess(); err != nil {
return err
}

var services []googleauth.Service
if strings.EqualFold(strings.TrimSpace(c.ServicesCSV), "") || strings.EqualFold(strings.TrimSpace(c.ServicesCSV), "all") {
services = googleauth.AllServices()
Expand Down
59 changes: 55 additions & 4 deletions internal/cmd/calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,8 @@ type CalendarUpdateCmd struct {
To string `name:"to" help:"New end time (RFC3339; set empty to clear)"`
Description string `name:"description" help:"New description (set empty to clear)"`
Location string `name:"location" help:"New location (set empty to clear)"`
Attendees string `name:"attendees" help:"Comma-separated attendee emails (set empty to clear)"`
Attendees string `name:"attendees" help:"Comma-separated attendee emails (replaces all; set empty to clear)"`
AddAttendee string `name:"add-attendee" help:"Comma-separated attendee emails to add (preserves existing attendees)"`
AllDay bool `name:"all-day" help:"All-day event (use date-only in --from/--to)"`
}

Expand All @@ -295,6 +296,11 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
}
}

// Cannot use both --attendees and --add-attendee at the same time.
if flagProvided(kctx, "attendees") && flagProvided(kctx, "add-attendee") {
return usage("cannot use both --attendees and --add-attendee; use --attendees to replace all, or --add-attendee to add")
}

patch := &calendar.Event{}
changed := false
if flagProvided(kctx, "summary") {
Expand All @@ -321,15 +327,27 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
patch.Attendees = buildAttendees(c.Attendees)
changed = true
}
if !changed {
return usage("no updates provided")
}

svc, err := newCalendarService(ctx, account)
if err != nil {
return err
}

// For --add-attendee, fetch current event to preserve existing attendees with metadata.
if flagProvided(kctx, "add-attendee") {
var existing *calendar.Event
existing, err = svc.Events.Get(calendarID, eventID).Do()
if err != nil {
return fmt.Errorf("failed to fetch current event: %w", err)
}
patch.Attendees = mergeAttendees(existing.Attendees, c.AddAttendee)
changed = true
}

if !changed {
return usage("no updates provided")
}

updated, err := svc.Events.Patch(calendarID, eventID, patch).Do()
if err != nil {
return err
Expand Down Expand Up @@ -592,6 +610,39 @@ func buildAttendees(csv string) []*calendar.EventAttendee {
return out
}

// mergeAttendees preserves existing attendees (with all their metadata like responseStatus)
// and adds new attendees from the CSV string. Duplicates (by email) are skipped.
func mergeAttendees(existing []*calendar.EventAttendee, addCSV string) []*calendar.EventAttendee {
newEmails := splitCSV(addCSV)
if len(newEmails) == 0 {
return existing
}

// Build a set of existing emails for deduplication
existingEmails := make(map[string]bool, len(existing))
for _, a := range existing {
if a != nil && a.Email != "" {
existingEmails[strings.ToLower(a.Email)] = true
}
}

// Start with existing attendees (preserving all metadata)
out := make([]*calendar.EventAttendee, 0, len(existing)+len(newEmails))
out = append(out, existing...)

// Add new attendees that don't already exist
for _, email := range newEmails {
if !existingEmails[strings.ToLower(email)] {
out = append(out, &calendar.EventAttendee{
Email: email,
ResponseStatus: "needsAction",
})
existingEmails[strings.ToLower(email)] = true
}
}
return out
}

func splitCSV(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
Expand Down
99 changes: 99 additions & 0 deletions internal/cmd/calendar_add_attendee_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package cmd

import (
"testing"

"google.golang.org/api/calendar/v3"
)

func TestMergeAttendees(t *testing.T) {
tests := []struct {
name string
existing []*calendar.EventAttendee
addCSV string
wantLen int
}{
{
name: "add to empty list",
existing: nil,
addCSV: "a@test.com,b@test.com",
wantLen: 2,
},
{
name: "add to existing list",
existing: []*calendar.EventAttendee{
{Email: "existing@test.com", ResponseStatus: "accepted"},
},
addCSV: "new@test.com",
wantLen: 2,
},
{
name: "skip duplicates case-insensitive",
existing: []*calendar.EventAttendee{
{Email: "Existing@Test.com", ResponseStatus: "accepted"},
},
addCSV: "existing@test.com,new@test.com",
wantLen: 2,
},
{
name: "preserve existing metadata",
existing: []*calendar.EventAttendee{
{Email: "alice@test.com", ResponseStatus: "accepted", DisplayName: "Alice"},
},
addCSV: "bob@test.com",
wantLen: 2,
},
{
name: "empty add string",
existing: []*calendar.EventAttendee{
{Email: "keep@test.com", ResponseStatus: "accepted"},
},
addCSV: "",
wantLen: 1,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := mergeAttendees(tt.existing, tt.addCSV)
if len(got) != tt.wantLen {
t.Errorf("mergeAttendees() returned %d attendees, want %d", len(got), tt.wantLen)
}

if tt.name == "preserve existing metadata" && len(got) > 0 {
found := false
for _, a := range got {
if a.Email == "alice@test.com" {
found = true
if a.ResponseStatus != "accepted" {
t.Errorf("existing attendee lost responseStatus")
}
if a.DisplayName != "Alice" {
t.Errorf("existing attendee lost displayName")
}
}
}
if !found {
t.Errorf("existing attendee alice@test.com not found in result")
}
}
})
}
}

func TestMergeAttendeesNewHaveNeedsAction(t *testing.T) {
existing := []*calendar.EventAttendee{
{Email: "existing@test.com", ResponseStatus: "accepted"},
}
got := mergeAttendees(existing, "new@test.com")

for _, a := range got {
if a.Email == "new@test.com" {
if a.ResponseStatus != "needsAction" {
t.Errorf("new attendee should have responseStatus=needsAction, got %q", a.ResponseStatus)
}
return
}
}
t.Error("new attendee not found in result")
}
2 changes: 1 addition & 1 deletion internal/cmd/gmail_send.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error {
if err != nil {
return fmt.Errorf("invalid --from address %q: %w", c.From, err)
}
if sa.VerificationStatus != "accepted" {
if sa.VerificationStatus != gmailVerificationAccepted {
return fmt.Errorf("--from address %q is not verified (status: %s)", c.From, sa.VerificationStatus)
}
fromAddr = c.From
Expand Down
15 changes: 9 additions & 6 deletions internal/secrets/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,16 @@ type Token struct {
RefreshToken string `json:"-"`
}

const keyringPasswordEnv = "GOG_KEYRING_PASSWORD" //nolint:gosec // env var name, not a credential
const keyringBackendEnv = "GOG_KEYRING_BACKEND" //nolint:gosec // env var name, not a credential
const (
keyringPasswordEnv = "GOG_KEYRING_PASSWORD" //nolint:gosec // env var name, not a credential
keyringBackendEnv = "GOG_KEYRING_BACKEND" //nolint:gosec // env var name, not a credential
)

var (
errMissingEmail = errors.New("missing email")
errMissingRefreshToken = errors.New("missing refresh token")
errNoTTY = errors.New("no TTY available for keyring file backend password prompt")
errMissingEmail = errors.New("missing email")
errMissingRefreshToken = errors.New("missing refresh token")
errNoTTY = errors.New("no TTY available for keyring file backend password prompt")
errInvalidKeyringBackend = errors.New("invalid keyring backend")
)

func allowedBackendsFromEnv() ([]keyring.BackendType, error) {
Expand All @@ -55,7 +58,7 @@ func allowedBackendsFromEnv() ([]keyring.BackendType, error) {
case "file":
return []keyring.BackendType{keyring.FileBackend}, nil
default:
return nil, fmt.Errorf("invalid %s (expected auto, keychain, or file)", keyringBackendEnv)
return nil, fmt.Errorf("%w: %s (expected auto, keychain, or file)", errInvalidKeyringBackend, keyringBackendEnv)
}
}

Expand Down