diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 10738abfa..2e43b4213 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -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"` @@ -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() diff --git a/internal/cmd/calendar.go b/internal/cmd/calendar.go index 83e7083a2..7c2f15f36 100644 --- a/internal/cmd/calendar.go +++ b/internal/cmd/calendar.go @@ -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)"` } @@ -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") { @@ -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 @@ -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 == "" { diff --git a/internal/cmd/calendar_add_attendee_test.go b/internal/cmd/calendar_add_attendee_test.go new file mode 100644 index 000000000..59b4159ce --- /dev/null +++ b/internal/cmd/calendar_add_attendee_test.go @@ -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") +} diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go index 4c1261f7c..19e1a7a9e 100644 --- a/internal/cmd/gmail_send.go +++ b/internal/cmd/gmail_send.go @@ -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 diff --git a/internal/secrets/store.go b/internal/secrets/store.go index 488215635..1c83884ca 100644 --- a/internal/secrets/store.go +++ b/internal/secrets/store.go @@ -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) { @@ -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) } }