feat(auth): add services metadata and listing

This commit is contained in:
Peter Steinberger 2026-01-08 01:24:22 +01:00
parent 4af95ae7e8
commit 024deee55d
6 changed files with 246 additions and 1 deletions

View File

@ -123,7 +123,7 @@ gog auth list
### Service Scopes
By default, `gog auth add` requests access to the **user** services (gmail, calendar, drive, docs, contacts, tasks, sheets, people).
By default, `gog auth add` requests access to the **user** services (see `gog auth services` for the current list and scopes).
To request fewer scopes:
@ -143,6 +143,12 @@ gog auth add you@gmail.com --services sheets --force-consent
Docs commands are implemented via the Drive API, and `docs` requests both Drive and Docs API scopes.
To render a Markdown table of services and scopes:
```bash
gog auth services --markdown
```
### Google Keep (Workspace only)
The Google Keep API requires a service account with domain-wide delegation (Workspace).

View File

@ -131,6 +131,7 @@ Environment:
- `gog auth credentials <credentials.json|->`
- `gog auth add <email> [--services user|all|gmail,calendar,drive,docs,contacts,tasks,sheets,people] [--manual] [--force-consent]`
- `gog auth services [--markdown]`
- `gog auth keep <email> --key <service-account.json>` (Google Keep; Workspace only)
- `gog auth list`
- `gog auth status`

View File

@ -29,6 +29,7 @@ var (
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"`
Services AuthServicesCmd `cmd:"" name:"services" help:"List supported auth services and scopes"`
List AuthListCmd `cmd:"" name:"list" help:"List stored accounts"`
Status AuthStatusCmd `cmd:"" name:"status" help:"Show auth configuration and keyring backend"`
Remove AuthRemoveCmd `cmd:"" name:"remove" help:"Remove a stored refresh token"`
@ -475,6 +476,38 @@ func (c *AuthListCmd) Run(ctx context.Context) error {
return nil
}
type AuthServicesCmd struct {
Markdown bool `name:"markdown" help:"Output Markdown table"`
}
func (c *AuthServicesCmd) Run(ctx context.Context) error {
infos := googleauth.ServicesInfo()
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"services": infos})
}
if c.Markdown {
_, err := io.WriteString(os.Stdout, googleauth.ServicesMarkdown(infos))
return err
}
w, done := tableWriter(ctx)
defer done()
_, _ = fmt.Fprintln(w, "SERVICE\tUSER\tAPIS\tSCOPES\tNOTE")
for _, info := range infos {
_, _ = fmt.Fprintf(
w,
"%s\t%t\t%s\t%s\t%s\n",
info.Service,
info.User,
strings.Join(info.APIs, ", "),
strings.Join(info.Scopes, ", "),
info.Note,
)
}
return nil
}
type AuthRemoveCmd struct {
Email string `arg:"" name:"email" help:"Email"`
}

View File

@ -0,0 +1,68 @@
package cmd
import (
"context"
"encoding/json"
"io"
"testing"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
func TestAuthServices_JSON(t *testing.T) {
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
out := captureStdout(t, func() {
cmd := &AuthServicesCmd{}
if err := cmd.Run(ctx); err != nil {
t.Fatalf("run: %v", err)
}
})
var parsed map[string]any
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("parse json: %v", err)
}
raw, ok := parsed["services"].([]any)
if !ok || len(raw) == 0 {
t.Fatalf("missing services in output")
}
var docs map[string]any
for _, entry := range raw {
item, ok := entry.(map[string]any)
if !ok {
continue
}
if svc, _ := item["service"].(string); svc == "docs" {
docs = item
break
}
}
if docs == nil {
t.Fatalf("missing docs service")
}
scopes, _ := docs["scopes"].([]any)
if !containsString(scopes, "https://www.googleapis.com/auth/drive") {
t.Fatalf("docs missing drive scope")
}
if !containsString(scopes, "https://www.googleapis.com/auth/documents") {
t.Fatalf("docs missing documents scope")
}
}
func containsString(items []any, want string) bool {
for _, item := range items {
if s, _ := item.(string); s == want {
return true
}
}
return false
}

View File

@ -26,6 +26,8 @@ var errUnknownService = errors.New("unknown service")
type serviceInfo struct {
scopes []string
user bool
apis []string
note string
}
var serviceOrder = []Service{
@ -44,14 +46,17 @@ var serviceInfoByService = map[Service]serviceInfo{
ServiceGmail: {
scopes: []string{"https://mail.google.com/"},
user: true,
apis: []string{"Gmail API"},
},
ServiceCalendar: {
scopes: []string{"https://www.googleapis.com/auth/calendar"},
user: true,
apis: []string{"Calendar API"},
},
ServiceDrive: {
scopes: []string{"https://www.googleapis.com/auth/drive"},
user: true,
apis: []string{"Drive API"},
},
ServiceDocs: {
// Docs commands are implemented via Drive APIs (export/copy/create),
@ -61,6 +66,8 @@ var serviceInfoByService = map[Service]serviceInfo{
"https://www.googleapis.com/auth/documents",
},
user: true,
apis: []string{"Docs API", "Drive API"},
note: "Export/copy/create via Drive",
},
ServiceContacts: {
scopes: []string{
@ -69,23 +76,32 @@ var serviceInfoByService = map[Service]serviceInfo{
"https://www.googleapis.com/auth/directory.readonly",
},
user: true,
apis: []string{"People API"},
note: "Contacts + other contacts + directory",
},
ServiceTasks: {
scopes: []string{"https://www.googleapis.com/auth/tasks"},
user: true,
apis: []string{"Tasks API"},
},
ServicePeople: {
// Needed for "people/me" requests.
scopes: []string{"profile"},
user: true,
apis: []string{"People API"},
note: "OIDC profile scope",
},
ServiceSheets: {
scopes: []string{"https://www.googleapis.com/auth/spreadsheets"},
user: true,
apis: []string{"Sheets API", "Drive API"},
note: "Export via Drive",
},
ServiceKeep: {
scopes: []string{"https://www.googleapis.com/auth/keep"},
user: false,
apis: []string{"Keep API"},
note: "Workspace only; service account",
},
}
@ -119,6 +135,77 @@ func Scopes(service Service) ([]string, error) {
return append([]string(nil), info.scopes...), nil
}
type ServiceInfo struct {
Service Service `json:"service"`
User bool `json:"user"`
Scopes []string `json:"scopes"`
APIs []string `json:"apis,omitempty"`
Note string `json:"note,omitempty"`
}
func ServicesInfo() []ServiceInfo {
out := make([]ServiceInfo, 0, len(serviceOrder))
for _, svc := range serviceOrder {
info, ok := serviceInfoByService[svc]
if !ok {
continue
}
out = append(out, ServiceInfo{
Service: svc,
User: info.user,
Scopes: append([]string(nil), info.scopes...),
APIs: append([]string(nil), info.apis...),
Note: info.note,
})
}
return out
}
func ServicesMarkdown(infos []ServiceInfo) string {
if len(infos) == 0 {
return ""
}
var b strings.Builder
b.WriteString("| Service | User | APIs | Scopes | Notes |\n")
b.WriteString("| --- | --- | --- | --- | --- |\n")
for _, info := range infos {
userLabel := "no"
if info.User {
userLabel = "yes"
}
b.WriteString("| ")
b.WriteString(string(info.Service))
b.WriteString(" | ")
b.WriteString(userLabel)
b.WriteString(" | ")
b.WriteString(strings.Join(info.APIs, ", "))
b.WriteString(" | ")
b.WriteString(markdownScopes(info.Scopes))
b.WriteString(" | ")
b.WriteString(info.Note)
b.WriteString(" |\n")
}
return b.String()
}
func markdownScopes(scopes []string) string {
if len(scopes) == 0 {
return ""
}
parts := make([]string, 0, len(scopes))
for _, scope := range scopes {
parts = append(parts, "`"+scope+"`")
}
return strings.Join(parts, "<br>")
}
func ScopesForServices(services []Service) ([]string, error) {
set := make(map[string]struct{})

View File

@ -120,6 +120,56 @@ func TestServiceOrderCoverage(t *testing.T) {
}
}
func TestServicesInfo_Metadata(t *testing.T) {
infos := ServicesInfo()
if len(infos) != len(serviceOrder) {
t.Fatalf("unexpected services info length: %d", len(infos))
}
docsInfo, foundDocs := findServiceInfo(infos, ServiceDocs)
if !foundDocs {
t.Fatalf("missing docs info")
}
if len(docsInfo.APIs) == 0 {
t.Fatalf("docs APIs missing")
}
for _, want := range []string{
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/documents",
} {
if !containsScope(docsInfo.Scopes, want) {
t.Fatalf("docs missing scope %q", want)
}
}
if markdown := ServicesMarkdown(infos); markdown == "" {
t.Fatalf("expected markdown output")
}
}
func findServiceInfo(infos []ServiceInfo, svc Service) (ServiceInfo, bool) {
for _, info := range infos {
if info.Service == svc {
return info, true
}
}
return ServiceInfo{}, false
}
func containsScope(scopes []string, want string) bool {
for _, scope := range scopes {
if scope == want {
return true
}
}
return false
}
func TestScopesForServices_UnionSorted(t *testing.T) {
scopes, err := ScopesForServices([]Service{ServiceContacts, ServiceGmail, ServiceTasks, ServicePeople, ServiceContacts})
if err != nil {