Skip to content

Commit f0d39db

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

7 files changed

Lines changed: 338 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: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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+
users := splitCommaList(c.Users)
73+
if users == nil {
74+
users = []string{}
75+
}
76+
77+
return writeM365PilotResult(ctx, flags, "m365.calendar.freebusy", map[string]any{
78+
"users": users,
79+
"from": strings.TrimSpace(c.From),
80+
"to": strings.TrimSpace(c.To),
81+
})
82+
}
83+
84+
func writeM365PilotResult(ctx context.Context, flags *RootFlags, operation string, request map[string]any) error {
85+
if flags == nil || !flags.ReadOnly {
86+
return usage("m365 pilot commands require explicit --read-only")
87+
}
88+
89+
u := ui.FromContext(ctx)
90+
return writeResult(ctx, u,
91+
kv("operation", operation),
92+
kv("provider", "microsoft_graph"),
93+
kv("mode", "read_only_pilot"),
94+
kv("status", "ready_for_m365_auth"),
95+
kv("request", request),
96+
)
97+
}

internal/cmd/m365_pilot_test.go

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

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)