diff --git a/README.md b/README.md index 659f091..cc05548 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/spec.md b/docs/spec.md index 9a70100..ab7de22 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -131,6 +131,7 @@ Environment: - `gog auth credentials ` - `gog auth add [--services user|all|gmail,calendar,drive,docs,contacts,tasks,sheets,people] [--manual] [--force-consent]` +- `gog auth services [--markdown]` - `gog auth keep --key ` (Google Keep; Workspace only) - `gog auth list` - `gog auth status` diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 12f22cc..8fcf2df 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -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"` } diff --git a/internal/cmd/auth_services_test.go b/internal/cmd/auth_services_test.go new file mode 100644 index 0000000..6eb9022 --- /dev/null +++ b/internal/cmd/auth_services_test.go @@ -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 +} diff --git a/internal/googleauth/service.go b/internal/googleauth/service.go index 55fad1e..3560846 100644 --- a/internal/googleauth/service.go +++ b/internal/googleauth/service.go @@ -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, "
") +} + func ScopesForServices(services []Service) ([]string, error) { set := make(map[string]struct{}) diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go index f13cce6..268c195 100644 --- a/internal/googleauth/service_test.go +++ b/internal/googleauth/service_test.go @@ -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 {