Skip to content

Commit 5bd774b

Browse files
fix: expose M365 read-only pilot CLI
1 parent 1fa0932 commit 5bd774b

7 files changed

Lines changed: 323 additions & 3 deletions

File tree

.deadcode-baseline.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ internal/googleauth/scopes.go:34:6: unreachable func: ScopesForCommands
4040
internal/googleauth/scopes.go:72:6: unreachable func: AllScopes
4141
internal/googleauth/scopes.go:96:6: unreachable func: knownCommandNames
4242
internal/msauth/scopes.go:17:6: unreachable func: canonicalPilotScope
43-
internal/msauth/scopes.go:29:6: unreachable func: PilotAllowedScopes
4443
internal/msauth/scopes.go:40:6: unreachable func: GuardPilotScopes
4544
internal/officetext/extract.go:46:6: unreachable func: ExtractTextByMIME
4645
internal/secrets/keychain_other.go:11:6: unreachable func: CheckKeychainLocked

internal/cmd/auth.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/automagik-dev/workit/internal/authclient"
1515
"github.com/automagik-dev/workit/internal/config"
1616
"github.com/automagik-dev/workit/internal/googleauth"
17+
"github.com/automagik-dev/workit/internal/msauth"
1718
"github.com/automagik-dev/workit/internal/outfmt"
1819
"github.com/automagik-dev/workit/internal/secrets"
1920
"github.com/automagik-dev/workit/internal/setup"
@@ -1371,12 +1372,12 @@ type AuthServicesCmd struct {
13711372
}
13721373

13731374
func (c *AuthServicesCmd) Run(ctx context.Context, _ *RootFlags) error {
1374-
infos := googleauth.ServicesInfo()
1375+
infos := appendAuthServiceInfos(googleauth.ServicesInfo(), msauth.ServicesInfo())
13751376
if outfmt.IsJSON(ctx) {
13761377
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"services": infos})
13771378
}
13781379
if c.Markdown {
1379-
_, err := io.WriteString(os.Stdout, googleauth.ServicesMarkdown(infos))
1380+
_, err := io.WriteString(os.Stdout, authServicesMarkdown(infos))
13801381
return err
13811382
}
13821383

internal/cmd/auth_services_info.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package cmd
2+
3+
import (
4+
"strings"
5+
6+
"github.com/automagik-dev/workit/internal/googleauth"
7+
"github.com/automagik-dev/workit/internal/msauth"
8+
)
9+
10+
type authServiceInfo struct {
11+
Service string `json:"service"`
12+
User bool `json:"user"`
13+
Scopes []string `json:"scopes"`
14+
APIs []string `json:"apis,omitempty"`
15+
Note string `json:"note,omitempty"`
16+
}
17+
18+
func appendAuthServiceInfos(googleInfos []googleauth.ServiceInfo, m365Infos []msauth.ServiceInfo) []authServiceInfo {
19+
out := make([]authServiceInfo, 0, len(googleInfos)+len(m365Infos))
20+
for _, info := range googleInfos {
21+
out = append(out, authServiceInfo{
22+
Service: string(info.Service),
23+
User: info.User,
24+
Scopes: append([]string(nil), info.Scopes...),
25+
APIs: append([]string(nil), info.APIs...),
26+
Note: info.Note,
27+
})
28+
}
29+
for _, info := range m365Infos {
30+
out = append(out, authServiceInfo{
31+
Service: info.Service,
32+
User: info.User,
33+
Scopes: append([]string(nil), info.Scopes...),
34+
APIs: append([]string(nil), info.APIs...),
35+
Note: info.Note,
36+
})
37+
}
38+
39+
return out
40+
}
41+
42+
func authServicesMarkdown(infos []authServiceInfo) string {
43+
if len(infos) == 0 {
44+
return ""
45+
}
46+
47+
var b strings.Builder
48+
b.WriteString("| Service | User | APIs | Scopes | Notes |\n")
49+
b.WriteString("| --- | --- | --- | --- | --- |\n")
50+
for _, info := range infos {
51+
userLabel := "no"
52+
if info.User {
53+
userLabel = "yes"
54+
}
55+
b.WriteString("| ")
56+
b.WriteString(info.Service)
57+
b.WriteString(" | ")
58+
b.WriteString(userLabel)
59+
b.WriteString(" | ")
60+
b.WriteString(strings.Join(info.APIs, ", "))
61+
b.WriteString(" | ")
62+
b.WriteString(markdownAuthScopes(info.Scopes))
63+
b.WriteString(" | ")
64+
b.WriteString(info.Note)
65+
b.WriteString(" |\n")
66+
}
67+
68+
return b.String()
69+
}
70+
71+
func markdownAuthScopes(scopes []string) string {
72+
if len(scopes) == 0 {
73+
return ""
74+
}
75+
76+
parts := make([]string, 0, len(scopes))
77+
for _, scope := range scopes {
78+
parts = append(parts, "`"+scope+"`")
79+
}
80+
81+
return strings.Join(parts, "<br>")
82+
}

internal/cmd/m365.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"strings"
6+
7+
"github.com/automagik-dev/workit/internal/ui"
8+
)
9+
10+
type M365Cmd struct {
11+
Outlook M365OutlookCmd `cmd:"" help:"Microsoft Outlook read-only pilot commands"`
12+
Calendar M365CalendarCmd `cmd:"" help:"Microsoft Calendar read-only pilot commands"`
13+
}
14+
15+
type M365OutlookCmd struct {
16+
Search M365OutlookSearchCmd `cmd:"" help:"Search Outlook messages (read-only pilot)"`
17+
Message M365OutlookMessageCmd `cmd:"" help:"Read Outlook messages (read-only pilot)"`
18+
}
19+
20+
type M365OutlookSearchCmd struct {
21+
Query string `name:"query" help:"Microsoft Graph message search query"`
22+
Top int `name:"top" help:"Maximum messages to return" default:"10"`
23+
}
24+
25+
type M365OutlookMessageCmd struct {
26+
Get M365OutlookMessageGetCmd `cmd:"" help:"Get an Outlook message by id (read-only pilot)"`
27+
}
28+
29+
type M365OutlookMessageGetCmd struct {
30+
ID string `arg:"" name:"id" help:"Microsoft Graph message id"`
31+
}
32+
33+
type M365CalendarCmd struct {
34+
Events M365CalendarEventsCmd `cmd:"" help:"List calendar events (read-only pilot)"`
35+
FreeBusy M365CalendarFreeBusyCmd `cmd:"" name:"freebusy" help:"Check free/busy availability (read-only pilot)"`
36+
}
37+
38+
type M365CalendarEventsCmd struct {
39+
From string `name:"from" help:"Start time (RFC3339)"`
40+
To string `name:"to" help:"End time (RFC3339)"`
41+
Top int `name:"top" help:"Maximum events to return" default:"10"`
42+
}
43+
44+
type M365CalendarFreeBusyCmd struct {
45+
Users string `name:"users" help:"Comma-separated email addresses/resources"`
46+
From string `name:"from" help:"Start time (RFC3339)"`
47+
To string `name:"to" help:"End time (RFC3339)"`
48+
}
49+
50+
func (c *M365OutlookSearchCmd) Run(ctx context.Context, flags *RootFlags) error {
51+
return writeM365PilotResult(ctx, flags, "m365.outlook.search", map[string]any{
52+
"query": strings.TrimSpace(c.Query),
53+
"top": c.Top,
54+
})
55+
}
56+
57+
func (c *M365OutlookMessageGetCmd) Run(ctx context.Context, flags *RootFlags) error {
58+
return writeM365PilotResult(ctx, flags, "m365.outlook.message.get", map[string]any{
59+
"id": strings.TrimSpace(c.ID),
60+
})
61+
}
62+
63+
func (c *M365CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error {
64+
return writeM365PilotResult(ctx, flags, "m365.calendar.events", map[string]any{
65+
"from": strings.TrimSpace(c.From),
66+
"to": strings.TrimSpace(c.To),
67+
"top": c.Top,
68+
})
69+
}
70+
71+
func (c *M365CalendarFreeBusyCmd) Run(ctx context.Context, flags *RootFlags) error {
72+
return writeM365PilotResult(ctx, flags, "m365.calendar.freebusy", map[string]any{
73+
"users": splitCommaList(c.Users),
74+
"from": strings.TrimSpace(c.From),
75+
"to": strings.TrimSpace(c.To),
76+
})
77+
}
78+
79+
func writeM365PilotResult(ctx context.Context, flags *RootFlags, operation string, request map[string]any) error {
80+
if flags == nil || !flags.ReadOnly {
81+
return usage("m365 pilot commands require explicit --read-only")
82+
}
83+
84+
u := ui.FromContext(ctx)
85+
return writeResult(ctx, u,
86+
kv("operation", operation),
87+
kv("provider", "microsoft_graph"),
88+
kv("mode", "read_only_pilot"),
89+
kv("status", "ready_for_m365_auth"),
90+
kv("request", request),
91+
)
92+
}

internal/cmd/m365_pilot_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestM365ReadOnlyPilotCommandsAreExposed(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
args []string
13+
want string
14+
}{
15+
{
16+
name: "outlook search",
17+
args: []string{"--json", "--read-only", "m365", "outlook", "search", "--query", "from:felipe"},
18+
want: "m365.outlook.search",
19+
},
20+
{
21+
name: "outlook message get",
22+
args: []string{"--json", "--read-only", "m365", "outlook", "message", "get", "AAMk-message-id"},
23+
want: "m365.outlook.message.get",
24+
},
25+
{
26+
name: "calendar events",
27+
args: []string{"--json", "--read-only", "m365", "calendar", "events", "--from", "2026-05-31T00:00:00Z", "--to", "2026-06-01T00:00:00Z"},
28+
want: "m365.calendar.events",
29+
},
30+
{
31+
name: "calendar freebusy",
32+
args: []string{"--json", "--read-only", "m365", "calendar", "freebusy", "--users", "bernardo@example.com,felipe@example.com"},
33+
want: "m365.calendar.freebusy",
34+
},
35+
}
36+
37+
for _, tt := range tests {
38+
t.Run(tt.name, func(t *testing.T) {
39+
out := captureStdout(t, func() {
40+
_ = captureStderr(t, func() {
41+
if err := Execute(tt.args); err != nil {
42+
t.Fatalf("Execute(%v): %v", tt.args, err)
43+
}
44+
})
45+
})
46+
47+
var got map[string]any
48+
if err := json.Unmarshal([]byte(out), &got); err != nil {
49+
t.Fatalf("json output: %v\n%s", err, out)
50+
}
51+
if got["operation"] != tt.want {
52+
t.Fatalf("operation = %v, want %s; output=%s", got["operation"], tt.want, out)
53+
}
54+
if got["provider"] != "microsoft_graph" {
55+
t.Fatalf("provider = %v, want microsoft_graph; output=%s", got["provider"], out)
56+
}
57+
if got["mode"] != "read_only_pilot" {
58+
t.Fatalf("mode = %v, want read_only_pilot; output=%s", got["mode"], out)
59+
}
60+
})
61+
}
62+
}
63+
64+
func TestM365PilotCommandsRequireExplicitReadOnlyFlag(t *testing.T) {
65+
_ = captureStderr(t, func() {
66+
err := Execute([]string{"--json", "m365", "outlook", "search", "--query", "from:felipe"})
67+
if err == nil {
68+
t.Fatal("expected m365 pilot command without --read-only to fail closed")
69+
}
70+
if !strings.Contains(err.Error(), "--read-only") {
71+
t.Fatalf("expected --read-only error, got: %v", err)
72+
}
73+
})
74+
}
75+
76+
func TestAuthServicesJSONIncludesM365PilotReadOnlyScopes(t *testing.T) {
77+
out := captureStdout(t, func() {
78+
_ = captureStderr(t, func() {
79+
if err := Execute([]string{"--json", "auth", "services"}); err != nil {
80+
t.Fatalf("auth services: %v", err)
81+
}
82+
})
83+
})
84+
85+
var payload struct {
86+
Services []struct {
87+
Service string `json:"service"`
88+
Scopes []string `json:"scopes"`
89+
} `json:"services"`
90+
}
91+
if err := json.Unmarshal([]byte(out), &payload); err != nil {
92+
t.Fatalf("json output: %v\n%s", err, out)
93+
}
94+
95+
var scopes []string
96+
for _, service := range payload.Services {
97+
if service.Service == "m365" {
98+
scopes = service.Scopes
99+
break
100+
}
101+
}
102+
if len(scopes) == 0 {
103+
t.Fatalf("auth services missing m365 service: %s", out)
104+
}
105+
for _, scope := range []string{"User.Read", "Mail.Read", "Calendars.Read"} {
106+
if !stringSliceContains(scopes, scope) {
107+
t.Fatalf("m365 auth services missing %s: %#v", scope, scopes)
108+
}
109+
}
110+
for _, forbidden := range []string{"Mail.Send", "Calendars.ReadWrite"} {
111+
if stringSliceContains(scopes, forbidden) {
112+
t.Fatalf("m365 auth services exposed write scope %s: %#v", forbidden, scopes)
113+
}
114+
}
115+
}
116+
117+
func stringSliceContains(values []string, want string) bool {
118+
for _, value := range values {
119+
if value == want {
120+
return true
121+
}
122+
}
123+
return false
124+
}

internal/cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ type CLI struct {
8484
Sheets SheetsCmd `cmd:"" aliases:"sheet" help:"Google Sheets"`
8585
Forms FormsCmd `cmd:"" aliases:"form" help:"Google Forms"`
8686
AppScript AppScriptCmd `cmd:"" name:"appscript" aliases:"script,apps-script" help:"Google Apps Script"`
87+
M365 M365Cmd `cmd:"" name:"m365" aliases:"microsoft,graph" help:"Microsoft 365 read-only pilot"`
8788
Sync SyncCmd `cmd:"" help:"Google Drive sync"`
8889
Templates TemplatesCmd `cmd:"" help:"Manage document templates"`
8990
Setup SetupCmd `cmd:"" help:"Validate environment dependencies"`

internal/msauth/service.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package msauth
2+
3+
type ServiceInfo struct {
4+
Service string `json:"service"`
5+
User bool `json:"user"`
6+
Scopes []string `json:"scopes"`
7+
APIs []string `json:"apis,omitempty"`
8+
Note string `json:"note,omitempty"`
9+
}
10+
11+
func ServicesInfo() []ServiceInfo {
12+
return []ServiceInfo{
13+
{
14+
Service: "m365",
15+
User: true,
16+
Scopes: PilotAllowedScopes(),
17+
APIs: []string{"Microsoft Graph"},
18+
Note: "Read-only Microsoft 365 pilot; writes remain KHAW-gated/disabled",
19+
},
20+
}
21+
}

0 commit comments

Comments
 (0)