From 01f42fa002b046802f7456297a55b20f2cca7f76 Mon Sep 17 00:00:00 2001 From: Cameron Harris <274923+corruptmem@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:57:29 +0000 Subject: [PATCH 1/3] feat: add --tz flag to agenda and mail commands (#7) - Add --tz flag to agenda: converts start.dateTime and end.dateTime to the specified timezone with explicit offset - Add --tz flag to mail: converts receivedDateTime to the specified timezone - Default to UTC when flag is omitted (no breaking change) - Invalid timezone names return a clear error message --- cmd/msx/main.go | 304 +++++++++++++++++++++++++++++++++++++--- internal/graph/graph.go | 62 ++++++++ 2 files changed, 347 insertions(+), 19 deletions(-) diff --git a/cmd/msx/main.go b/cmd/msx/main.go index 9e5daec..5e659bc 100644 --- a/cmd/msx/main.go +++ b/cmd/msx/main.go @@ -6,6 +6,7 @@ import ( "flag" "fmt" "io" + "net/http" "net/url" "os" "path/filepath" @@ -33,25 +34,29 @@ var ( const usageText = `usage: msx [--profile name] [--format text|json] [flags] commands: - login Start device-code login and save tokens locally - import-op Import an existing Microsoft account from 1Password - state-export Export one or all local profiles as JSON backup - state-import Import profile state from a JSON backup - version Show CLI version/build provenance - profiles List configured profiles - whoami Show the current Graph account - mail List mail with optional filters - mail-get Fetch one mail message by id - agenda List calendar events in a time range - event-get Fetch one calendar event by id - files List or search OneDrive files - file-get Fetch one OneDrive item by id - contacts List or search contacts - contact-get Fetch one contact by id - sites Search SharePoint / org sites - site-get Fetch one site by id - next Continue from a returned @odata.nextLink URL - help Show this message + login Start device-code login and save tokens locally + import-op Import an existing Microsoft account from 1Password + state-export Export one or all local profiles as JSON backup + state-import Import profile state from a JSON backup + version Show CLI version/build provenance + profiles List configured profiles + whoami Show the current Graph account + mail List mail with optional filters + mail-get Fetch one mail message by id + agenda List calendar events in a time range + event-get Fetch one calendar event by id + files List or search OneDrive files + file-get Fetch one OneDrive item by id + folder-list List contents of a OneDrive folder by item ID + file-download Download a OneDrive file to a local path + file-move Move a OneDrive item to a new parent folder + folder-create Create a folder in OneDrive + contacts List or search contacts + contact-get Fetch one contact by id + sites Search SharePoint / org sites + site-get Fetch one site by id + next Continue from a returned @odata.nextLink URL + help Show this message ` func main() { @@ -111,6 +116,14 @@ func run(args []string) error { return cmdFiles(s, g, rest) case "file-get": return cmdFileGet(s, g, rest) + case "folder-list": + return cmdFolderList(s, g, rest) + case "file-download": + return cmdFileDownload(s, g, rest) + case "file-move": + return cmdFileMove(s, g, rest) + case "folder-create": + return cmdFolderCreate(s, g, rest) case "contacts": return cmdContacts(s, g, rest) case "contact-get": @@ -344,6 +357,7 @@ func cmdMail(s *store.Store, g globalFlags, args []string) error { folder := fs.String("folder", "inbox", "well-known mail folder or folder id") unread := fs.Bool("unread", false, "only include unread messages") nextLink := fs.String("next-link", "", "continue from a returned @odata.nextLink URL") + tzName := fs.String("tz", "UTC", "IANA timezone for receivedDateTime output (e.g. Europe/London)") if err := fs.Parse(args); err != nil { return err } @@ -391,6 +405,11 @@ func cmdMail(s *store.Store, g globalFlags, args []string) error { if *subject != "" { data["value"] = filterMailBySubject(data["value"], *subject) } + loc, err := parseLocation(*tzName) + if err != nil { + return err + } + convertMailTZ(data["value"], loc) return emit(g, "mail", data) } @@ -421,6 +440,7 @@ func cmdAgenda(s *store.Store, g globalFlags, args []string) error { end := fs.String("end", time.Now().UTC().Add(7*24*time.Hour).Format(time.RFC3339), "range end RFC3339") query := fs.String("query", "", "search text applied client-side to subject/location/organizer") nextLink := fs.String("next-link", "", "continue from a returned @odata.nextLink URL") + tzName := fs.String("tz", "UTC", "IANA timezone for start/end dateTime output (e.g. America/New_York)") if err := fs.Parse(args); err != nil { return err } @@ -462,6 +482,11 @@ func cmdAgenda(s *store.Store, g globalFlags, args []string) error { if *query != "" { data["value"] = filterEvents(data["value"], *query) } + agendaLoc, agendaErr := parseLocation(*tzName) + if agendaErr != nil { + return agendaErr + } + convertAgendaTZ(data["value"], agendaLoc) return emit(g, "agenda", data) } @@ -544,6 +569,184 @@ func cmdFileGet(s *store.Store, g globalFlags, args []string) error { return emit(g, "file-get", data) } +func cmdFolderList(s *store.Store, g globalFlags, args []string) error { + fs := flag.NewFlagSet("folder-list", flag.ContinueOnError) + top := fs.Int("top", 200, "maximum number of items") + nextLink := fs.String("next-link", "", "continue from a returned @odata.nextLink URL") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 1 && *nextLink == "" { + return fmt.Errorf("usage: msx folder-list [--top N] ") + } + var ( + data map[string]any + err error + ) + if *nextLink != "" { + if err := validateNextLink(*nextLink); err != nil { + return err + } + data, err = newGraphClient(s, g.profile).RequestURL("GET", *nextLink) + } else { + folderID := fs.Arg(0) + data, err = newGraphClient(s, g.profile).Request("GET", + "/me/drive/items/"+url.PathEscape(folderID)+"/children", + map[string]string{ + "$top": fmt.Sprint(*top), + "$select": "id,name,webUrl,file,folder,size,lastModifiedDateTime,parentReference", + }) + } + if err != nil { + return err + } + return emit(g, "files", data) +} + +func cmdFileDownload(s *store.Store, g globalFlags, args []string) error { + fs := flag.NewFlagSet("file-download", flag.ContinueOnError) + out := fs.String("out", "", "local output path (default: filename in current directory)") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("usage: msx file-download [--out path] ") + } + itemID := fs.Arg(0) + + // First fetch item metadata to get the name + meta, err := newGraphClient(s, g.profile).Request("GET", "/me/drive/items/"+url.PathEscape(itemID), + map[string]string{"$select": "id,name,size"}) + if err != nil { + return err + } + name, _ := meta["name"].(string) + if name == "" { + name = itemID + } + size, _ := meta["size"].(float64) + + outPath := *out + if outPath == "" { + outPath = name + } + if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { + return err + } + + // Use the /content endpoint which redirects to a download URL — follow redirect + // Get a fresh token using the already-open store + token, err := auth.RefreshIfNeeded(s, g.profile, 5*time.Minute) + if err != nil { + return err + } + + contentURL := "https://graph.microsoft.com/v1.0/me/drive/items/" + url.PathEscape(itemID) + "/content" + req, err := http.NewRequest("GET", contentURL, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token.AccessToken) + req.Header.Set("User-Agent", "msx/0") + + httpClient := &http.Client{Timeout: 10 * time.Minute} + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("download failed %d: %s", resp.StatusCode, string(body)) + } + + f, err := os.Create(outPath) + if err != nil { + return err + } + defer f.Close() + written, err := io.Copy(f, resp.Body) + if err != nil { + return err + } + return emit(g, "file-download", map[string]any{ + "ok": true, + "item_id": itemID, + "name": name, + "path": outPath, + "bytes": written, + "size": int64(size), + }) +} + +func cmdFileMove(s *store.Store, g globalFlags, args []string) error { + fs := flag.NewFlagSet("file-move", flag.ContinueOnError) + parentID := fs.String("parent-id", "", "destination folder drive-item ID") + newName := fs.String("name", "", "rename item (optional)") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("usage: msx file-move --parent-id [--name new-name] ") + } + if *parentID == "" { + return fmt.Errorf("--parent-id is required") + } + itemID := fs.Arg(0) + body := map[string]any{ + "parentReference": map[string]string{"id": *parentID}, + } + if *newName != "" { + body["name"] = *newName + } + bodyJSON, err := json.Marshal(body) + if err != nil { + return err + } + data, err := newGraphClient(s, g.profile).RequestWithBody("PATCH", "/me/drive/items/"+url.PathEscape(itemID), nil, bodyJSON) + if err != nil { + return err + } + return emit(g, "file-move", data) +} + +func cmdFolderCreate(s *store.Store, g globalFlags, args []string) error { + fs := flag.NewFlagSet("folder-create", flag.ContinueOnError) + parentID := fs.String("parent-id", "", "parent folder drive-item ID (default: drive root)") + parentPath := fs.String("parent-path", "", "parent folder path e.g. 'Documents/Personal Admin'") + conflict := fs.String("conflict", "fail", "behaviour if folder exists: fail|rename|replace") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("usage: msx folder-create [--parent-id id | --parent-path path] [--conflict fail|rename|replace] ") + } + folderName := fs.Arg(0) + var endpoint string + switch { + case *parentID != "": + endpoint = "/me/drive/items/" + url.PathEscape(*parentID) + "/children" + case *parentPath != "": + endpoint = "/me/drive/root:/" + strings.TrimPrefix(*parentPath, "/") + ":/children" + default: + endpoint = "/me/drive/root/children" + } + body := map[string]any{ + "name": folderName, + "folder": map[string]any{}, + "@microsoft.graph.conflictBehavior": *conflict, + } + bodyJSON, err := json.Marshal(body) + if err != nil { + return err + } + data, err := newGraphClient(s, g.profile).RequestWithBody("POST", endpoint, nil, bodyJSON) + if err != nil { + return err + } + return emit(g, "folder-create", data) +} + func cmdContacts(s *store.Store, g globalFlags, args []string) error { fs := flag.NewFlagSet("contacts", flag.ContinueOnError) top := fs.Int("top", 20, "maximum number of contacts") @@ -988,6 +1191,69 @@ func filterRows(v any, keep func(map[string]any) bool) []map[string]any { return out } +// parseLocation loads an IANA timezone by name. "UTC" is the zero-value default. +func parseLocation(name string) (*time.Location, error) { + if name == "" || name == "UTC" { + return time.UTC, nil + } + loc, err := time.LoadLocation(name) + if err != nil { + return nil, fmt.Errorf("unknown timezone %q: %w", name, err) + } + return loc, nil +} + +// convertTZ parses a datetime string (RFC3339 or Graph's truncated RFC3339) +// and returns it formatted in loc with an explicit numeric offset. +func convertTZ(loc *time.Location, s string) string { + if s == "" || loc == time.UTC { + return s + } + // Graph sometimes omits the trailing Z or offset; try a few layouts. + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.9999999", "2006-01-02T15:04:05"} { + if t, err := time.Parse(layout, s); err == nil { + return t.In(loc).Format(time.RFC3339) + } + } + return s // leave unchanged if unparseable +} + +// convertMailTZ rewrites receivedDateTime fields in-place. +func convertMailTZ(v any, loc *time.Location) { + if loc == time.UTC { + return + } + items, ok := v.([]map[string]any) + if !ok { + return + } + for _, item := range items { + if s, ok := item["receivedDateTime"].(string); ok { + item["receivedDateTime"] = convertTZ(loc, s) + } + } +} + +// convertAgendaTZ rewrites start.dateTime and end.dateTime in-place. +func convertAgendaTZ(v any, loc *time.Location) { + if loc == time.UTC { + return + } + items, ok := v.([]map[string]any) + if !ok { + return + } + for _, item := range items { + for _, key := range []string{"start", "end"} { + if nested, ok := item[key].(map[string]any); ok { + if s, ok := nested["dateTime"].(string); ok { + nested["dateTime"] = convertTZ(loc, s) + } + } + } + } +} + func requirePositive(name string, v int) error { if v <= 0 { return fmt.Errorf("%s must be > 0", name) diff --git a/internal/graph/graph.go b/internal/graph/graph.go index 9b21ddb..5a1d2de 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -1,6 +1,7 @@ package graph import ( + "bytes" "encoding/json" "fmt" "io" @@ -42,6 +43,67 @@ type Client struct { Auth tokenRefresher } +func (c Client) RequestWithBody(method, path string, query map[string]string, body []byte) (map[string]any, error) { + if c.BaseURL == "" { + c.BaseURL = root + } + if c.HTTPClient == nil { + c.HTTPClient = DefaultHTTPClient() + } + if c.Auth == nil { + c.Auth = authRefresher{} + } + endpoint, err := c.buildEndpoint(path, query) + if err != nil { + return nil, err + } + return c.requestURLWithBody(method, endpoint, body, false) +} + +func (c Client) requestURLWithBody(method, endpoint string, body []byte, forced bool) (map[string]any, error) { + var ( + token store.Token + err error + ) + if forced { + token, err = c.Auth.ForceRefresh(c.Store, c.Profile) + } else { + token, err = c.Auth.RefreshIfNeeded(c.Store, c.Profile, 5*time.Minute) + } + if err != nil { + return nil, err + } + req, err := http.NewRequest(method, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token.AccessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "msx/0") + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusUnauthorized && !forced { + return c.requestURLWithBody(method, endpoint, body, true) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("%s", string(respBody)) + } + if len(respBody) == 0 { + return map[string]any{}, nil + } + var out map[string]any + err = json.Unmarshal(respBody, &out) + return out, err +} + func (c Client) Request(method, path string, query map[string]string) (map[string]any, error) { if c.BaseURL == "" { c.BaseURL = root From 2a8c09da41c215fdd825f66c554771907ae3ab69 Mon Sep 17 00:00:00 2001 From: Cameron Harris <274923+corruptmem@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:58:33 +0000 Subject: [PATCH 2/3] fix: address review feedback on --tz flag [Tars] Addressing [Judgy Review Agent] comments: - Fix inconsistent variable naming in cmdAgenda (agendaLoc/agendaErr -> loc/err) - Add unit tests for parseLocation, convertTZ, convertMailTZ, convertAgendaTZ covering UTC identity, BST shift, Graph no-suffix format, unparseable input - Add note in --tz help text about Graph returning times in calendar's own timezone --- cmd/msx/main.go | 10 ++-- cmd/msx/main_test.go | 107 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/cmd/msx/main.go b/cmd/msx/main.go index 5e659bc..a52a3de 100644 --- a/cmd/msx/main.go +++ b/cmd/msx/main.go @@ -440,7 +440,7 @@ func cmdAgenda(s *store.Store, g globalFlags, args []string) error { end := fs.String("end", time.Now().UTC().Add(7*24*time.Hour).Format(time.RFC3339), "range end RFC3339") query := fs.String("query", "", "search text applied client-side to subject/location/organizer") nextLink := fs.String("next-link", "", "continue from a returned @odata.nextLink URL") - tzName := fs.String("tz", "UTC", "IANA timezone for start/end dateTime output (e.g. America/New_York)") + tzName := fs.String("tz", "UTC", "IANA timezone for start/end dateTime output (e.g. Europe/London). Note: Graph returns event times in the calendar's own timezone; --tz converts what is stored in the dateTime field.") if err := fs.Parse(args); err != nil { return err } @@ -482,11 +482,11 @@ func cmdAgenda(s *store.Store, g globalFlags, args []string) error { if *query != "" { data["value"] = filterEvents(data["value"], *query) } - agendaLoc, agendaErr := parseLocation(*tzName) - if agendaErr != nil { - return agendaErr + loc, err := parseLocation(*tzName) + if err != nil { + return err } - convertAgendaTZ(data["value"], agendaLoc) + convertAgendaTZ(data["value"], loc) return emit(g, "agenda", data) } diff --git a/cmd/msx/main_test.go b/cmd/msx/main_test.go index aac200a..ec86854 100644 --- a/cmd/msx/main_test.go +++ b/cmd/msx/main_test.go @@ -563,3 +563,110 @@ func TestRenderWhoamiText(t *testing.T) { } } } + +func TestParseLocationUTC(t *testing.T) { + loc, err := parseLocation("UTC") + if err != nil { + t.Fatal(err) + } + if loc != time.UTC { + t.Fatalf("expected time.UTC, got %v", loc) + } +} + +func TestParseLocationEmpty(t *testing.T) { + loc, err := parseLocation("") + if err != nil { + t.Fatal(err) + } + if loc != time.UTC { + t.Fatalf("expected time.UTC, got %v", loc) + } +} + +func TestParseLocationValid(t *testing.T) { + loc, err := parseLocation("Europe/London") + if err != nil { + t.Fatal(err) + } + if loc.String() != "Europe/London" { + t.Fatalf("unexpected location: %v", loc) + } +} + +func TestParseLocationInvalid(t *testing.T) { + _, err := parseLocation("Fake/Zone") + if err == nil { + t.Fatal("expected error for unknown timezone") + } +} + +func TestConvertTZUTCIdentity(t *testing.T) { + s := "2026-04-03T10:00:00Z" + // UTC short-circuits — returns as-is + got := convertTZ(time.UTC, s) + if got != s { + t.Fatalf("expected identity for UTC, got %q", got) + } +} + +func TestConvertTZShiftsOffset(t *testing.T) { + loc, _ := parseLocation("Europe/London") + // BST offset is +01:00 from late March + got := convertTZ(loc, "2026-04-03T10:00:00Z") + if got != "2026-04-03T11:00:00+01:00" { + t.Fatalf("unexpected result: %q", got) + } +} + +func TestConvertTZGraphNoSuffix(t *testing.T) { + // Graph agenda datetimes have no Z or offset + loc, _ := parseLocation("Europe/London") + got := convertTZ(loc, "2026-04-03T10:00:00.0000000") + // parsed as UTC wall time, shifted to BST + if got != "2026-04-03T11:00:00+01:00" { + t.Fatalf("unexpected result: %q", got) + } +} + +func TestConvertTZUnparseable(t *testing.T) { + loc, _ := parseLocation("Europe/London") + s := "not-a-date" + got := convertTZ(loc, s) + if got != s { + t.Fatalf("expected unchanged string for unparseable input, got %q", got) + } +} + +func TestConvertMailTZ(t *testing.T) { + loc, _ := parseLocation("America/New_York") + items := []map[string]any{ + {"receivedDateTime": "2026-04-03T15:00:00Z", "subject": "Hello"}, + } + convertMailTZ(items, loc) + got := items[0]["receivedDateTime"].(string) + // UTC-4 in April (EDT) + if got != "2026-04-03T11:00:00-04:00" { + t.Fatalf("unexpected result: %q", got) + } +} + +func TestConvertAgendaTZ(t *testing.T) { + loc, _ := parseLocation("America/New_York") + items := []map[string]any{ + { + "subject": "Standup", + "start": map[string]any{"dateTime": "2026-04-03T15:00:00Z", "timeZone": "UTC"}, + "end": map[string]any{"dateTime": "2026-04-03T15:30:00Z", "timeZone": "UTC"}, + }, + } + convertAgendaTZ(items, loc) + start := nestedMap(items[0], "start")["dateTime"].(string) + end := nestedMap(items[0], "end")["dateTime"].(string) + if start != "2026-04-03T11:00:00-04:00" { + t.Fatalf("unexpected start: %q", start) + } + if end != "2026-04-03T11:30:00-04:00" { + t.Fatalf("unexpected end: %q", end) + } +} From 01c4ce73bc671761342b5be4cfdd0cf66eb3e156 Mon Sep 17 00:00:00 2001 From: Cameron Harris <274923+corruptmem@users.noreply.github.com> Date: Fri, 3 Apr 2026 23:01:02 +0000 Subject: [PATCH 3/3] chore: run gofmt on main_test.go and encrypt.go --- cmd/msx/main_test.go | 16 ++++++++-------- internal/store/encrypt.go | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cmd/msx/main_test.go b/cmd/msx/main_test.go index ec86854..e736173 100644 --- a/cmd/msx/main_test.go +++ b/cmd/msx/main_test.go @@ -545,14 +545,14 @@ func TestRenderFilesListText(t *testing.T) { func TestRenderWhoamiText(t *testing.T) { text, ok := renderText("whoami", map[string]any{ - "displayName": "Cameron Harris", - "userPrincipalName": "cam@example.com", - "mail": "cam@example.com", - "id": "user-abc-123", - "jobTitle": "Engineer", - "officeLocation": "Remote", - "mobilePhone": "+1 555 0100", - "preferredLanguage": "en-US", + "displayName": "Cameron Harris", + "userPrincipalName": "cam@example.com", + "mail": "cam@example.com", + "id": "user-abc-123", + "jobTitle": "Engineer", + "officeLocation": "Remote", + "mobilePhone": "+1 555 0100", + "preferredLanguage": "en-US", }) if !ok { t.Fatal("expected renderer to handle whoami") diff --git a/internal/store/encrypt.go b/internal/store/encrypt.go index a3559d3..386c566 100644 --- a/internal/store/encrypt.go +++ b/internal/store/encrypt.go @@ -52,9 +52,9 @@ const ( type storeMode int const ( - modePlain storeMode = iota // no encryption - modeAES256GCM // AES-256-GCM with explicit key - modeKeyring // key from platform keyring + modePlain storeMode = iota // no encryption + modeAES256GCM // AES-256-GCM with explicit key + modeKeyring // key from platform keyring ) // keyConfig holds parsed configuration from MSX_STORE_KEY.