diff --git a/CHANGELOG.md b/CHANGELOG.md index c62550e..6228a38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added - Gmail: add `--exclude-labels` to `watch serve` (defaults: `SPAM,TRASH`). (#194) — thanks @salmonumbrella. - +- Drive: share files with an entire Workspace domain via `drive share --to domain`. (#192) — thanks @Danielkweber. ## 0.9.0 - 2026-01-22 ### Highlights diff --git a/README.md b/README.md index f526db5..40d01d1 100644 --- a/README.md +++ b/README.md @@ -777,11 +777,11 @@ gog drive rename "New Name" gog drive move --parent gog drive delete # Move to trash -# Permissions -gog drive permissions -gog drive share --email user@example.com --role reader -gog drive share --email user@example.com --role writer -gog drive unshare --permission-id + # Permissions + gog drive permissions + gog drive share --to user --email user@example.com --role reader + gog drive share --to user --email user@example.com --role writer + gog drive unshare --permission-id # Shared drives (Team Drives) gog drive drives --max 100 diff --git a/docs/spec.md b/docs/spec.md index 589bfe0..20bf058 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -174,7 +174,7 @@ Flag aliases: - `gog drive delete ` - `gog drive move --parent ID` - `gog drive rename ` -- `gog drive share [--anyone | --email addr] [--role reader|writer] [--discoverable]` +- `gog drive share --to anyone|user|domain [--email addr] [--domain example.com] [--role reader|writer] [--discoverable]` - `gog drive permissions [--max N] [--page TOKEN]` - `gog drive unshare ` - `gog drive url ` diff --git a/internal/cmd/drive.go b/internal/cmd/drive.go index 2adac67..ded8315 100644 --- a/internal/cmd/drive.go +++ b/internal/cmd/drive.go @@ -40,6 +40,13 @@ const ( extPptx = ".pptx" extPNG = ".png" extTXT = ".txt" + + driveShareToAnyone = "anyone" + driveShareToUser = "user" + driveShareToDomain = "domain" + + drivePermRoleReader = "reader" + drivePermRoleWriter = "writer" ) type DriveCmd struct { @@ -572,8 +579,10 @@ func (c *DriveRenameCmd) Run(ctx context.Context, flags *RootFlags) error { type DriveShareCmd struct { FileID string `arg:"" name:"fileId" help:"File ID"` - Anyone bool `name:"anyone" help:"Make publicly accessible"` - Email string `name:"email" help:"Share with specific user"` + To string `name:"to" help:"Share target: anyone|user|domain"` + Anyone bool `name:"anyone" hidden:"" help:"(deprecated) Use --to=anyone"` + Email string `name:"email" help:"User email (for --to=user)"` + Domain string `name:"domain" help:"Domain (for --to=domain; e.g. example.com)"` Role string `name:"role" help:"Permission: reader|writer" default:"reader"` Discoverable bool `name:"discoverable" help:"Allow file discovery in search (anyone/domain only)"` } @@ -589,14 +598,58 @@ func (c *DriveShareCmd) Run(ctx context.Context, flags *RootFlags) error { return usage("empty fileId") } - if !c.Anyone && strings.TrimSpace(c.Email) == "" { - return usage("must specify --anyone or --email") + to := strings.TrimSpace(c.To) + email := strings.TrimSpace(c.Email) + domain := strings.TrimSpace(c.Domain) + + // Back-compat: allow legacy target flags without --to, but keep it unambiguous. + // New UX: prefer explicit --to + matching parameter. + if to == "" { + switch { + case c.Anyone && email == "" && domain == "": + to = driveShareToAnyone + case !c.Anyone && email != "" && domain == "": + to = driveShareToUser + case !c.Anyone && email == "" && domain != "": + to = driveShareToDomain + case !c.Anyone && email == "" && domain == "": + return usage("must specify --to (anyone|user|domain)") + default: + return usage("ambiguous share target (use --to=anyone|user|domain)") + } + } + + switch to { + case driveShareToAnyone: + if email != "" || domain != "" { + return usage("--to=anyone cannot be combined with --email or --domain") + } + case driveShareToUser: + if email == "" { + return usage("missing --email for --to=user") + } + if domain != "" || c.Anyone { + return usage("--to=user cannot be combined with --anyone or --domain") + } + if c.Discoverable { + return usage("--discoverable is only valid for --to=anyone or --to=domain") + } + case driveShareToDomain: + if domain == "" { + return usage("missing --domain for --to=domain") + } + if email != "" || c.Anyone { + return usage("--to=domain cannot be combined with --anyone or --email") + } + default: + // Should be guarded by enum, but keep a friendly message for future changes. + return usage("invalid --to (expected anyone|user|domain)") } role := strings.TrimSpace(c.Role) if role == "" { - role = "reader" + role = drivePermRoleReader } - if role != "reader" && role != "writer" { + if role != drivePermRoleReader && role != drivePermRoleWriter { return usage("invalid --role (expected reader|writer)") } @@ -606,18 +659,23 @@ func (c *DriveShareCmd) Run(ctx context.Context, flags *RootFlags) error { } perm := &drive.Permission{Role: role} - if c.Anyone { + switch to { + case driveShareToAnyone: perm.Type = "anyone" perm.AllowFileDiscovery = c.Discoverable - } else { + case driveShareToDomain: + perm.Type = "domain" + perm.Domain = domain + perm.AllowFileDiscovery = c.Discoverable + default: perm.Type = "user" - perm.EmailAddress = strings.TrimSpace(c.Email) + perm.EmailAddress = email } created, err := svc.Permissions.Create(fileID, perm). SupportsAllDrives(true). SendNotificationEmail(false). - Fields("id, type, role, emailAddress"). + Fields("id, type, role, emailAddress, domain, allowFileDiscovery"). Context(ctx). Do() if err != nil { @@ -713,7 +771,7 @@ func (c *DrivePermissionsCmd) Run(ctx context.Context, flags *RootFlags) error { call := svc.Permissions.List(fileID). SupportsAllDrives(true). - Fields("nextPageToken, permissions(id, type, role, emailAddress)"). + Fields("nextPageToken, permissions(id, type, role, emailAddress, domain)"). Context(ctx) if c.Max > 0 { call = call.PageSize(c.Max) @@ -744,6 +802,9 @@ func (c *DrivePermissionsCmd) Run(ctx context.Context, flags *RootFlags) error { fmt.Fprintln(w, "ID\tTYPE\tROLE\tEMAIL") for _, p := range resp.Permissions { email := p.EmailAddress + if email == "" && p.Domain != "" { + email = p.Domain + } if email == "" { email = "-" } diff --git a/internal/cmd/drive_commands_more_test.go b/internal/cmd/drive_commands_more_test.go index c367cfc..c572802 100644 --- a/internal/cmd/drive_commands_more_test.go +++ b/internal/cmd/drive_commands_more_test.go @@ -107,13 +107,63 @@ func TestDriveCommands_MoreCoverage(t *testing.T) { w.WriteHeader(http.StatusNoContent) return case r.Method == http.MethodPost && strings.HasSuffix(path, "/permissions"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "id": "perm1", - "type": "user", - "role": "reader", - "emailAddress": "share@example.com", - }) - return + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + typ, _ := req["type"].(string) + role, _ := req["role"].(string) + if role == "" { + role = "reader" + } + + switch typ { + case "user": + email, _ := req["emailAddress"].(string) + if email == "" { + http.Error(w, "missing emailAddress", http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "perm1", + "type": "user", + "role": role, + "emailAddress": email, + }) + return + case "domain": + domain, _ := req["domain"].(string) + if domain == "" { + http.Error(w, "missing domain", http.StatusBadRequest) + return + } + resp := map[string]any{ + "id": "perm1", + "type": "domain", + "role": role, + "domain": domain, + } + if afd, ok := req["allowFileDiscovery"].(bool); ok { + resp["allowFileDiscovery"] = afd + } + _ = json.NewEncoder(w).Encode(resp) + return + case "anyone": + resp := map[string]any{ + "id": "perm1", + "type": "anyone", + "role": role, + } + if afd, ok := req["allowFileDiscovery"].(bool); ok { + resp["allowFileDiscovery"] = afd + } + _ = json.NewEncoder(w).Encode(resp) + return + default: + http.Error(w, "invalid type", http.StatusBadRequest) + return + } case r.Method == http.MethodDelete && strings.Contains(path, "/permissions/"): w.WriteHeader(http.StatusNoContent) return @@ -191,11 +241,16 @@ func TestDriveCommands_MoreCoverage(t *testing.T) { t.Fatalf("unexpected rename output: %q", out) } - out = run("--json", "--account", "a@b.com", "drive", "share", "file1", "--email", "share@example.com") + out = run("--json", "--account", "a@b.com", "drive", "share", "file1", "--to", "user", "--email", "share@example.com") if !strings.Contains(out, "\"permissionId\"") { t.Fatalf("unexpected share json: %q", out) } + out = run("--json", "--account", "a@b.com", "drive", "share", "file1", "--to", "domain", "--domain", "example.com", "--role", "writer") + if !strings.Contains(out, "\"permissionId\"") { + t.Fatalf("unexpected domain share json: %q", out) + } + out = run("--force", "--account", "a@b.com", "drive", "unshare", "file1", "perm1") if !strings.Contains(out, "removed") { t.Fatalf("unexpected unshare output: %q", out) diff --git a/internal/cmd/drive_errors_test.go b/internal/cmd/drive_errors_test.go index b03fc09..37054df 100644 --- a/internal/cmd/drive_errors_test.go +++ b/internal/cmd/drive_errors_test.go @@ -15,14 +15,39 @@ func TestDriveCommand_ValidationErrors(t *testing.T) { } shareCmd := &DriveShareCmd{} - if err := runKong(t, shareCmd, []string{"file1"}, context.Background(), flags); err == nil || !strings.Contains(err.Error(), "must specify") { - t.Fatalf("expected share validation error, got %v", err) + if err := runKong(t, shareCmd, []string{"file1"}, context.Background(), flags); err == nil || !strings.Contains(err.Error(), "must specify --to") { + t.Fatalf("expected share target error, got %v", err) } shareCmd = &DriveShareCmd{} - if err := runKong(t, shareCmd, []string{"file1", "--anyone", "--role", "owner"}, context.Background(), flags); err == nil || !strings.Contains(err.Error(), "invalid --role") { + if err := runKong(t, shareCmd, []string{"file1", "--to", "domain", "--domain", "example.com", "--role", "owner"}, context.Background(), flags); err == nil || !strings.Contains(err.Error(), "invalid --role") { + t.Fatalf("expected role error for domain share, got %v", err) + } + + shareCmd = &DriveShareCmd{} + if err := runKong(t, shareCmd, []string{"file1", "--to", "anyone", "--role", "owner"}, context.Background(), flags); err == nil || !strings.Contains(err.Error(), "invalid --role") { t.Fatalf("expected role error, got %v", err) } + + shareCmd = &DriveShareCmd{} + if err := runKong(t, shareCmd, []string{"file1", "--to", "user"}, context.Background(), flags); err == nil || !strings.Contains(err.Error(), "missing --email") { + t.Fatalf("expected missing email error, got %v", err) + } + + shareCmd = &DriveShareCmd{} + if err := runKong(t, shareCmd, []string{"file1", "--to", "domain"}, context.Background(), flags); err == nil || !strings.Contains(err.Error(), "missing --domain") { + t.Fatalf("expected missing domain error, got %v", err) + } + + shareCmd = &DriveShareCmd{} + if err := runKong(t, shareCmd, []string{"file1", "--to", "user", "--email", "a@b.com", "--discoverable"}, context.Background(), flags); err == nil || !strings.Contains(err.Error(), "discoverable") { + t.Fatalf("expected discoverable error, got %v", err) + } + + shareCmd = &DriveShareCmd{} + if err := runKong(t, shareCmd, []string{"file1", "--email", "a@b.com", "--domain", "example.com"}, context.Background(), flags); err == nil || !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("expected ambiguous target error, got %v", err) + } } func TestDriveDeleteUnshare_NoInput(t *testing.T) {