fix(paths): expand ~ in user-provided file paths (#56)

* fix(paths): expand ~ in user-provided file paths

When users specify paths with ~ (e.g., --out ~/Downloads/file.pdf) and
the path is quoted in the shell command, the tilde is not expanded by
the shell. This caused files to be written to a literal ~/Downloads
directory instead of the user's home directory.

Add config.ExpandPath() function that expands ~ at the beginning of
paths to the user's home directory. Apply this fix to all user-provided
file paths across:

- gmail attachment download (--out)
- drive download/export (--out)
- drive upload (localPath argument)
- auth token export (--out)
- auth credentials/import/keep (input paths)
- gmail thread attachments (--out-dir)
- gmail send/drafts (--attach)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(lint): address wrapcheck and wsl issues

* fix(calendar): support ISO 8601 time format and add 'list' alias

- Add parsing for ISO 8601 datetime with numeric timezone without colon
  (e.g., 2026-01-09T16:38:41-0800), which is the format produced by
  macOS `date +%Y-%m-%dT%H:%M:%S%z`
- Add 'list' as an alias for 'events' subcommand for more intuitive CLI
  usage (gog calendar list instead of gog calendar events)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore(changelog): note PR #56

* chore(lint): dedupe file string

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
salmonumbrella 2026-01-09 18:19:30 -08:00 committed by GitHub
parent 8e878fe485
commit 07ffcb5d84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 188 additions and 19 deletions

View File

@ -1,5 +1,12 @@
# Changelog
## Unreleased
### Fixed
- Paths: expand leading `~` in user-provided file paths (e.g. `--out "~/Downloads/file.pdf"`). (#56) — thanks @salmonumbrella.
- Calendar: accept ISO 8601 timezones without colon (e.g. `-0800`) and add `gog calendar list` alias. (#56) — thanks @salmonumbrella.
## 0.5.3 - 2026-01-10
### Fixed

View File

@ -32,7 +32,7 @@ func ensureKeychainAccessIfNeeded() error {
if err != nil {
return fmt.Errorf("resolve keyring backend: %w", err)
}
if backendInfo.Value == "file" {
if backendInfo.Value == strFile {
return nil
}
return ensureKeychainAccess()
@ -67,6 +67,10 @@ func (c *AuthCredentialsCmd) Run(ctx context.Context) error {
if inPath == "-" {
b, err = io.ReadAll(os.Stdin)
} else {
inPath, err = config.ExpandPath(inPath)
if err != nil {
return err
}
b, err = os.ReadFile(inPath) //nolint:gosec // user-provided path
}
if err != nil {
@ -184,6 +188,10 @@ func (c *AuthTokensExportCmd) Run(ctx context.Context) error {
if outPath == "" {
return usage("empty outPath")
}
outPath, err := config.ExpandPath(outPath)
if err != nil {
return err
}
store, err := openSecretsStore()
if err != nil {
@ -259,6 +267,10 @@ func (c *AuthTokensImportCmd) Run(ctx context.Context) error {
if inPath == "-" {
b, err = io.ReadAll(os.Stdin)
} else {
inPath, err = config.ExpandPath(inPath)
if err != nil {
return err
}
b, err = os.ReadFile(inPath) //nolint:gosec // user-provided path
}
if err != nil {
@ -608,6 +620,10 @@ func (c *AuthKeepCmd) Run(ctx context.Context) error {
if keyPath == "" {
return usage("empty key path")
}
keyPath, err := config.ExpandPath(keyPath)
if err != nil {
return err
}
data, err := os.ReadFile(keyPath) //nolint:gosec // user-provided path
if err != nil {

View File

@ -69,7 +69,7 @@ func (c *AuthKeyringCmd) Run(ctx context.Context) error {
allowed := map[string]struct{}{
"auto": {},
"keychain": {},
"file": {},
strFile: {},
}
if _, ok := allowed[backend]; !ok {
return usagef("invalid backend: %q (expected auto, keychain, or file)", c.Backend)
@ -94,7 +94,7 @@ func (c *AuthKeyringCmd) Run(ctx context.Context) error {
u.Err().Printf("NOTE: GOG_KEYRING_BACKEND=%s overrides config.json", v)
}
if backend == "file" &&
if backend == strFile &&
u != nil &&
!outfmt.IsJSON(ctx) &&
!outfmt.IsPlain(ctx) {

View File

@ -13,7 +13,7 @@ import (
type CalendarCmd struct {
Calendars CalendarCalendarsCmd `cmd:"" name:"calendars" help:"List calendars"`
ACL CalendarAclCmd `cmd:"" name:"acl" help:"List calendar ACL"`
Events CalendarEventsCmd `cmd:"" name:"events" help:"List events from a calendar or all calendars"`
Events CalendarEventsCmd `cmd:"" name:"events" aliases:"list" help:"List events from a calendar or all calendars"`
Event CalendarEventCmd `cmd:"" name:"event" help:"Get event"`
Create CalendarCreateCmd `cmd:"" name:"create" help:"Create an event"`
Update CalendarUpdateCmd `cmd:"" name:"update" help:"Update an event"`

View File

@ -90,7 +90,7 @@ func (c *DocsInfoCmd) Run(ctx context.Context, flags *RootFlags) error {
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"file": file,
strFile: file,
"document": doc,
})
}
@ -151,7 +151,7 @@ func (c *DocsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": created})
return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: created})
}
u.Out().Printf("id\t%s", created.Id)

View File

@ -13,6 +13,7 @@ import (
"google.golang.org/api/drive/v3"
gapi "google.golang.org/api/googleapi"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
@ -225,7 +226,7 @@ func (c *DriveGetCmd) Run(ctx context.Context, flags *RootFlags) error {
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": f})
return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: f})
}
u.Out().Printf("id\t%s", f.Id)
@ -330,6 +331,10 @@ func (c *DriveUploadCmd) Run(ctx context.Context, flags *RootFlags) error {
if localPath == "" {
return usage("empty localPath")
}
localPath, err = config.ExpandPath(localPath)
if err != nil {
return err
}
f, err := os.Open(localPath) //nolint:gosec // user-provided path
if err != nil {
@ -365,7 +370,7 @@ func (c *DriveUploadCmd) Run(ctx context.Context, flags *RootFlags) error {
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": created})
return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: created})
}
u.Out().Printf("id\t%s", created.Id)
@ -513,7 +518,7 @@ func (c *DriveMoveCmd) Run(ctx context.Context, flags *RootFlags) error {
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": updated})
return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: updated})
}
u.Out().Printf("id\t%s", updated.Id)
@ -556,7 +561,7 @@ func (c *DriveRenameCmd) Run(ctx context.Context, flags *RootFlags) error {
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": updated})
return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: updated})
}
u.Out().Printf("id\t%s", updated.Id)
@ -818,7 +823,7 @@ func driveType(mimeType string) string {
if mimeType == "application/vnd.google-apps.folder" {
return "folder"
}
return "file" //nolint:goconst // readability; used as a display value and JSON key elsewhere.
return strFile
}
func formatDateTime(iso string) string {

View File

@ -82,7 +82,7 @@ func copyViaDrive(ctx context.Context, flags *RootFlags, opts copyViaDriveOption
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": created})
return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: created})
}
u.Out().Printf("id\t%s", created.Id)
u.Out().Printf("name\t%s", created.Name)

View File

@ -24,6 +24,14 @@ func resolveDriveDownloadDestPath(meta *drive.File, outPathFlag string) (string,
}
destPath := strings.TrimSpace(outPathFlag)
// Expand ~ to home directory (shell doesn't expand when path is quoted).
if destPath != "" {
expanded, err := config.ExpandPath(destPath)
if err != nil {
return "", err
}
destPath = expanded
}
// Sanitize filename to prevent path traversal.
safeName := filepath.Base(meta.Name)
if safeName == "" || safeName == "." || safeName == ".." {

View File

@ -71,7 +71,11 @@ func (c *GmailAttachmentCmd) Run(ctx context.Context, flags *RootFlags) error {
return nil
}
path, cached, bytes, err := downloadAttachmentToPath(ctx, svc, messageID, attachmentID, c.Output.Path, -1)
outPath, err := config.ExpandPath(c.Output.Path)
if err != nil {
return err
}
path, cached, bytes, err := downloadAttachmentToPath(ctx, svc, messageID, attachmentID, outPath, -1)
if err != nil {
return err
}

View File

@ -331,7 +331,11 @@ func buildDraftMessage(ctx context.Context, svc *gmail.Service, account string,
atts := make([]mailAttachment, 0, len(input.Attach))
for _, p := range input.Attach {
atts = append(atts, mailAttachment{Path: p})
expanded, expandErr := config.ExpandPath(p)
if expandErr != nil {
return nil, "", expandErr
}
atts = append(atts, mailAttachment{Path: expanded})
}
raw, err := buildRFC822(mailOptions{

View File

@ -10,6 +10,7 @@ import (
"google.golang.org/api/gmail/v1"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/tracking"
"github.com/steipete/gogcli/internal/ui"
@ -147,7 +148,11 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error {
atts := make([]mailAttachment, 0, len(c.Attach))
for _, p := range c.Attach {
atts = append(atts, mailAttachment{Path: p})
expanded, expandErr := config.ExpandPath(p)
if expandErr != nil {
return expandErr
}
atts = append(atts, mailAttachment{Path: expanded})
}
var trackingCfg *tracking.Config

View File

@ -14,6 +14,7 @@ import (
"google.golang.org/api/gmail/v1"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
@ -81,7 +82,11 @@ func (c *GmailThreadGetCmd) Run(ctx context.Context, flags *RootFlags) error {
// Default: current directory, not gogcli config dir.
attachDir = "."
} else {
attachDir = filepath.Clean(c.OutputDir.Dir)
expanded, err := config.ExpandPath(c.OutputDir.Dir)
if err != nil {
return err
}
attachDir = filepath.Clean(expanded)
}
}
@ -292,7 +297,11 @@ func (c *GmailThreadAttachmentsCmd) Run(ctx context.Context, flags *RootFlags) e
if strings.TrimSpace(c.OutputDir.Dir) == "" {
attachDir = "."
} else {
attachDir = filepath.Clean(c.OutputDir.Dir)
expanded, err := config.ExpandPath(c.OutputDir.Dir)
if err != nil {
return err
}
attachDir = filepath.Clean(expanded)
}
}

View File

@ -60,7 +60,7 @@ func infoViaDrive(ctx context.Context, flags *RootFlags, opts infoViaDriveOption
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": f})
return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: f})
}
u.Out().Printf("id\t%s", f.Id)

View File

@ -90,7 +90,7 @@ func (c *SlidesCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": created})
return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: created})
}
u.Out().Printf("id\t%s", created.Id)

5
internal/cmd/strings.go Normal file
View File

@ -0,0 +1,5 @@
package cmd
const (
strFile = "file"
)

View File

@ -130,6 +130,7 @@ func ResolveTimeRangeWithDefaults(ctx context.Context, svc *calendar.Service, fl
// parseTimeExpr parses a time expression which can be:
// - RFC3339: 2026-01-05T14:00:00-08:00
// - ISO 8601 with numeric timezone: 2026-01-05T14:00:00-0800 (no colon)
// - Date only: 2026-01-05 (interpreted as start of day in user's timezone)
// - Relative: today, tomorrow, monday, next tuesday
func parseTimeExpr(expr string, now time.Time, loc *time.Location) (time.Time, error) {
@ -140,6 +141,12 @@ func parseTimeExpr(expr string, now time.Time, loc *time.Location) (time.Time, e
return t, nil
}
// Try ISO 8601 with numeric timezone without colon (e.g., -0800)
// This is what macOS `date +%Y-%m-%dT%H:%M:%S%z` produces
if t, err := time.Parse("2006-01-02T15:04:05-0700", expr); err == nil {
return t, nil
}
// Now lowercase for relative expressions
exprLower := strings.ToLower(expr)

View File

@ -44,6 +44,19 @@ func TestParseTimeExprMore(t *testing.T) {
t.Fatalf("expected UTC location, got %v", parsed.Location())
}
// Test ISO 8601 with numeric timezone without colon (macOS date +%z format)
parsed, err = parseTimeExpr("2025-01-09T16:38:41-0800", now, loc)
if err != nil {
t.Fatalf("parseTimeExpr iso8601 numeric tz: %v", err)
}
if parsed.Hour() != 16 || parsed.Minute() != 38 || parsed.Second() != 41 {
t.Fatalf("unexpected iso8601 numeric tz time: %v", parsed)
}
_, offset := parsed.Zone()
if offset != -8*3600 {
t.Fatalf("unexpected iso8601 numeric tz offset: %d", offset)
}
parsed, err = parseTimeExpr("yesterday", now, loc)
if err != nil {
t.Fatalf("parseTimeExpr yesterday: %v", err)

View File

@ -153,3 +153,32 @@ func EnsureGmailWatchDir() (string, error) {
return dir, nil
}
// ExpandPath expands ~ at the beginning of a path to the user's home directory.
// This is needed because ~ is a shell feature and is not expanded when paths
// are quoted (e.g., --out "~/Downloads/file.pdf").
func ExpandPath(path string) (string, error) {
if path == "" {
return "", nil
}
if path == "~" {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("expand home dir: %w", err)
}
return home, nil
}
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("expand home dir: %w", err)
}
return filepath.Join(home, path[2:]), nil
}
return path, nil
}

View File

@ -71,6 +71,63 @@ func TestPaths_CreateDirs(t *testing.T) {
}
}
func TestExpandPath(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
tests := []struct {
name string
input string
want string
wantErr bool
}{
{
name: "empty path",
input: "",
want: "",
},
{
name: "tilde only",
input: "~",
want: home,
},
{
name: "tilde with subpath",
input: "~/Downloads/file.txt",
want: filepath.Join(home, "Downloads/file.txt"),
},
{
name: "absolute path unchanged",
input: "/usr/local/bin",
want: "/usr/local/bin",
},
{
name: "relative path unchanged",
input: "relative/path/file.txt",
want: "relative/path/file.txt",
},
{
name: "tilde in middle unchanged",
input: "/some/~/path",
want: "/some/~/path",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ExpandPath(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ExpandPath() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("ExpandPath() = %q, want %q", got, tt.want)
}
})
}
}
func TestKeepServiceAccountPath_SafeFilename(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)