diff --git a/.deadcode-baseline.txt b/.deadcode-baseline.txt index bb92158..3d9bbeb 100644 --- a/.deadcode-baseline.txt +++ b/.deadcode-baseline.txt @@ -40,7 +40,6 @@ internal/googleauth/scopes.go:34:6: unreachable func: ScopesForCommands internal/googleauth/scopes.go:72:6: unreachable func: AllScopes internal/googleauth/scopes.go:96:6: unreachable func: knownCommandNames internal/msauth/scopes.go:17:6: unreachable func: canonicalPilotScope -internal/msauth/scopes.go:29:6: unreachable func: PilotAllowedScopes internal/msauth/scopes.go:40:6: unreachable func: GuardPilotScopes internal/officetext/extract.go:46:6: unreachable func: ExtractTextByMIME internal/secrets/keychain_other.go:11:6: unreachable func: CheckKeychainLocked diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 0148267..448ecb6 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -14,6 +14,7 @@ import ( "github.com/automagik-dev/workit/internal/authclient" "github.com/automagik-dev/workit/internal/config" "github.com/automagik-dev/workit/internal/googleauth" + "github.com/automagik-dev/workit/internal/msauth" "github.com/automagik-dev/workit/internal/outfmt" "github.com/automagik-dev/workit/internal/secrets" "github.com/automagik-dev/workit/internal/setup" @@ -1371,12 +1372,12 @@ type AuthServicesCmd struct { } func (c *AuthServicesCmd) Run(ctx context.Context, _ *RootFlags) error { - infos := googleauth.ServicesInfo() + infos := appendAuthServiceInfos(googleauth.ServicesInfo(), msauth.ServicesInfo()) if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"services": infos}) } if c.Markdown { - _, err := io.WriteString(os.Stdout, googleauth.ServicesMarkdown(infos)) + _, err := io.WriteString(os.Stdout, authServicesMarkdown(infos)) return err } diff --git a/internal/cmd/auth_services_info.go b/internal/cmd/auth_services_info.go new file mode 100644 index 0000000..d422ddd --- /dev/null +++ b/internal/cmd/auth_services_info.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "strings" + + "github.com/automagik-dev/workit/internal/googleauth" + "github.com/automagik-dev/workit/internal/msauth" +) + +type authServiceInfo struct { + Service string `json:"service"` + User bool `json:"user"` + Scopes []string `json:"scopes"` + APIs []string `json:"apis,omitempty"` + Note string `json:"note,omitempty"` +} + +func appendAuthServiceInfos(googleInfos []googleauth.ServiceInfo, m365Infos []msauth.ServiceInfo) []authServiceInfo { + out := make([]authServiceInfo, 0, len(googleInfos)+len(m365Infos)) + for _, info := range googleInfos { + out = append(out, authServiceInfo{ + Service: string(info.Service), + User: info.User, + Scopes: append([]string(nil), info.Scopes...), + APIs: append([]string(nil), info.APIs...), + Note: info.Note, + }) + } + for _, info := range m365Infos { + out = append(out, authServiceInfo{ + Service: info.Service, + User: info.User, + Scopes: append([]string(nil), info.Scopes...), + APIs: append([]string(nil), info.APIs...), + Note: info.Note, + }) + } + + return out +} + +func authServicesMarkdown(infos []authServiceInfo) 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(info.Service) + b.WriteString(" | ") + b.WriteString(userLabel) + b.WriteString(" | ") + b.WriteString(strings.Join(info.APIs, ", ")) + b.WriteString(" | ") + b.WriteString(markdownAuthScopes(info.Scopes)) + b.WriteString(" | ") + b.WriteString(info.Note) + b.WriteString(" |\n") + } + + return b.String() +} + +func markdownAuthScopes(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, "
") +} diff --git a/internal/cmd/m365.go b/internal/cmd/m365.go new file mode 100644 index 0000000..3ec2cea --- /dev/null +++ b/internal/cmd/m365.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "context" + "strings" + + "github.com/automagik-dev/workit/internal/ui" +) + +type M365Cmd struct { + Outlook M365OutlookCmd `cmd:"" help:"Microsoft Outlook read-only pilot commands"` + Calendar M365CalendarCmd `cmd:"" help:"Microsoft Calendar read-only pilot commands"` +} + +type M365OutlookCmd struct { + Search M365OutlookSearchCmd `cmd:"" help:"Search Outlook messages (read-only pilot)"` + Message M365OutlookMessageCmd `cmd:"" help:"Read Outlook messages (read-only pilot)"` +} + +type M365OutlookSearchCmd struct { + Query string `name:"query" help:"Microsoft Graph message search query"` + Top int `name:"top" help:"Maximum messages to return" default:"10"` +} + +type M365OutlookMessageCmd struct { + Get M365OutlookMessageGetCmd `cmd:"" help:"Get an Outlook message by id (read-only pilot)"` +} + +type M365OutlookMessageGetCmd struct { + ID string `arg:"" name:"id" help:"Microsoft Graph message id"` +} + +type M365CalendarCmd struct { + Events M365CalendarEventsCmd `cmd:"" help:"List calendar events (read-only pilot)"` + FreeBusy M365CalendarFreeBusyCmd `cmd:"" name:"freebusy" help:"Check free/busy availability (read-only pilot)"` +} + +type M365CalendarEventsCmd struct { + From string `name:"from" help:"Start time (RFC3339)"` + To string `name:"to" help:"End time (RFC3339)"` + Top int `name:"top" help:"Maximum events to return" default:"10"` +} + +type M365CalendarFreeBusyCmd struct { + Users string `name:"users" help:"Comma-separated email addresses/resources"` + From string `name:"from" help:"Start time (RFC3339)"` + To string `name:"to" help:"End time (RFC3339)"` +} + +func (c *M365OutlookSearchCmd) Run(ctx context.Context, flags *RootFlags) error { + return writeM365PilotResult(ctx, flags, "m365.outlook.search", map[string]any{ + "query": strings.TrimSpace(c.Query), + "top": c.Top, + }) +} + +func (c *M365OutlookMessageGetCmd) Run(ctx context.Context, flags *RootFlags) error { + return writeM365PilotResult(ctx, flags, "m365.outlook.message.get", map[string]any{ + "id": strings.TrimSpace(c.ID), + }) +} + +func (c *M365CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error { + return writeM365PilotResult(ctx, flags, "m365.calendar.events", map[string]any{ + "from": strings.TrimSpace(c.From), + "to": strings.TrimSpace(c.To), + "top": c.Top, + }) +} + +func (c *M365CalendarFreeBusyCmd) Run(ctx context.Context, flags *RootFlags) error { + users := splitCommaList(c.Users) + if users == nil { + users = []string{} + } + + return writeM365PilotResult(ctx, flags, "m365.calendar.freebusy", map[string]any{ + "users": users, + "from": strings.TrimSpace(c.From), + "to": strings.TrimSpace(c.To), + }) +} + +func writeM365PilotResult(ctx context.Context, flags *RootFlags, operation string, request map[string]any) error { + if flags == nil || !flags.ReadOnly { + return usage("m365 pilot commands require explicit --read-only") + } + + u := ui.FromContext(ctx) + return writeResult(ctx, u, + kv("operation", operation), + kv("provider", "microsoft_graph"), + kv("mode", "read_only_pilot"), + kv("status", "ready_for_m365_auth"), + kv("request", request), + ) +} diff --git a/internal/cmd/m365_pilot_test.go b/internal/cmd/m365_pilot_test.go new file mode 100644 index 0000000..5395348 --- /dev/null +++ b/internal/cmd/m365_pilot_test.go @@ -0,0 +1,134 @@ +package cmd + +import ( + "encoding/json" + "slices" + "strings" + "testing" +) + +func TestM365ReadOnlyPilotCommandsAreExposed(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "outlook search", + args: []string{"--json", "--read-only", "m365", "outlook", "search", "--query", "from:felipe"}, + want: "m365.outlook.search", + }, + { + name: "outlook message get", + args: []string{"--json", "--read-only", "m365", "outlook", "message", "get", "AAMk-message-id"}, + want: "m365.outlook.message.get", + }, + { + name: "calendar events", + args: []string{"--json", "--read-only", "m365", "calendar", "events", "--from", "2026-05-31T00:00:00Z", "--to", "2026-06-01T00:00:00Z"}, + want: "m365.calendar.events", + }, + { + name: "calendar freebusy", + args: []string{"--json", "--read-only", "m365", "calendar", "freebusy", "--users", "bernardo@example.com,felipe@example.com"}, + want: "m365.calendar.freebusy", + }, + { + name: "calendar freebusy without users", + args: []string{"--json", "--read-only", "m365", "calendar", "freebusy"}, + want: "m365.calendar.freebusy", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute(tt.args); err != nil { + t.Fatalf("Execute(%v): %v", tt.args, err) + } + }) + }) + + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("json output: %v\n%s", err, out) + } + if got["operation"] != tt.want { + t.Fatalf("operation = %v, want %s; output=%s", got["operation"], tt.want, out) + } + if got["provider"] != "microsoft_graph" { + t.Fatalf("provider = %v, want microsoft_graph; output=%s", got["provider"], out) + } + if got["mode"] != "read_only_pilot" { + t.Fatalf("mode = %v, want read_only_pilot; output=%s", got["mode"], out) + } + if tt.name == "calendar freebusy without users" { + request, ok := got["request"].(map[string]any) + if !ok { + t.Fatalf("request has type %T, want object; output=%s", got["request"], out) + } + users, ok := request["users"].([]any) + if !ok { + t.Fatalf("request.users has type %T, want empty array; output=%s", request["users"], out) + } + if len(users) != 0 { + t.Fatalf("request.users = %#v, want empty array", users) + } + } + }) + } +} + +func TestM365PilotCommandsRequireExplicitReadOnlyFlag(t *testing.T) { + _ = captureStderr(t, func() { + err := Execute([]string{"--json", "m365", "outlook", "search", "--query", "from:felipe"}) + if err == nil { + t.Fatal("expected m365 pilot command without --read-only to fail closed") + } + if !strings.Contains(err.Error(), "--read-only") { + t.Fatalf("expected --read-only error, got: %v", err) + } + }) +} + +func TestAuthServicesJSONIncludesM365PilotReadOnlyScopes(t *testing.T) { + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "auth", "services"}); err != nil { + t.Fatalf("auth services: %v", err) + } + }) + }) + + var payload struct { + Services []struct { + Service string `json:"service"` + Scopes []string `json:"scopes"` + } `json:"services"` + } + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("json output: %v\n%s", err, out) + } + + var scopes []string + for _, service := range payload.Services { + if service.Service == "m365" { + scopes = service.Scopes + break + } + } + if len(scopes) == 0 { + t.Fatalf("auth services missing m365 service: %s", out) + } + for _, scope := range []string{"User.Read", "Mail.Read", "Calendars.Read"} { + if !slices.Contains(scopes, scope) { + t.Fatalf("m365 auth services missing %s: %#v", scope, scopes) + } + } + for _, forbidden := range []string{"Mail.Send", "Calendars.ReadWrite"} { + if slices.Contains(scopes, forbidden) { + t.Fatalf("m365 auth services exposed write scope %s: %#v", forbidden, scopes) + } + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 058ed94..a5f9a3e 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -84,6 +84,7 @@ type CLI struct { Sheets SheetsCmd `cmd:"" aliases:"sheet" help:"Google Sheets"` Forms FormsCmd `cmd:"" aliases:"form" help:"Google Forms"` AppScript AppScriptCmd `cmd:"" name:"appscript" aliases:"script,apps-script" help:"Google Apps Script"` + M365 M365Cmd `cmd:"" name:"m365" aliases:"microsoft,graph" help:"Microsoft 365 read-only pilot"` Sync SyncCmd `cmd:"" help:"Google Drive sync"` Templates TemplatesCmd `cmd:"" help:"Manage document templates"` Setup SetupCmd `cmd:"" help:"Validate environment dependencies"` diff --git a/internal/msauth/service.go b/internal/msauth/service.go new file mode 100644 index 0000000..ee128b2 --- /dev/null +++ b/internal/msauth/service.go @@ -0,0 +1,21 @@ +package msauth + +type ServiceInfo struct { + Service string `json:"service"` + User bool `json:"user"` + Scopes []string `json:"scopes"` + APIs []string `json:"apis,omitempty"` + Note string `json:"note,omitempty"` +} + +func ServicesInfo() []ServiceInfo { + return []ServiceInfo{ + { + Service: "m365", + User: true, + Scopes: PilotAllowedScopes(), + APIs: []string{"Microsoft Graph"}, + Note: "Read-only Microsoft 365 pilot; writes remain KHAW-gated/disabled", + }, + } +}