Skip to content

Commit 07ffcb5

Browse files
salmonumbrellaclaudesteipete
authored
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>
1 parent 8e878fe commit 07ffcb5

19 files changed

Lines changed: 188 additions & 19 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

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

512
### Fixed

internal/cmd/auth.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func ensureKeychainAccessIfNeeded() error {
3232
if err != nil {
3333
return fmt.Errorf("resolve keyring backend: %w", err)
3434
}
35-
if backendInfo.Value == "file" {
35+
if backendInfo.Value == strFile {
3636
return nil
3737
}
3838
return ensureKeychainAccess()
@@ -67,6 +67,10 @@ func (c *AuthCredentialsCmd) Run(ctx context.Context) error {
6767
if inPath == "-" {
6868
b, err = io.ReadAll(os.Stdin)
6969
} else {
70+
inPath, err = config.ExpandPath(inPath)
71+
if err != nil {
72+
return err
73+
}
7074
b, err = os.ReadFile(inPath) //nolint:gosec // user-provided path
7175
}
7276
if err != nil {
@@ -184,6 +188,10 @@ func (c *AuthTokensExportCmd) Run(ctx context.Context) error {
184188
if outPath == "" {
185189
return usage("empty outPath")
186190
}
191+
outPath, err := config.ExpandPath(outPath)
192+
if err != nil {
193+
return err
194+
}
187195

188196
store, err := openSecretsStore()
189197
if err != nil {
@@ -259,6 +267,10 @@ func (c *AuthTokensImportCmd) Run(ctx context.Context) error {
259267
if inPath == "-" {
260268
b, err = io.ReadAll(os.Stdin)
261269
} else {
270+
inPath, err = config.ExpandPath(inPath)
271+
if err != nil {
272+
return err
273+
}
262274
b, err = os.ReadFile(inPath) //nolint:gosec // user-provided path
263275
}
264276
if err != nil {
@@ -608,6 +620,10 @@ func (c *AuthKeepCmd) Run(ctx context.Context) error {
608620
if keyPath == "" {
609621
return usage("empty key path")
610622
}
623+
keyPath, err := config.ExpandPath(keyPath)
624+
if err != nil {
625+
return err
626+
}
611627

612628
data, err := os.ReadFile(keyPath) //nolint:gosec // user-provided path
613629
if err != nil {

internal/cmd/auth_keyring.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func (c *AuthKeyringCmd) Run(ctx context.Context) error {
6969
allowed := map[string]struct{}{
7070
"auto": {},
7171
"keychain": {},
72-
"file": {},
72+
strFile: {},
7373
}
7474
if _, ok := allowed[backend]; !ok {
7575
return usagef("invalid backend: %q (expected auto, keychain, or file)", c.Backend)
@@ -94,7 +94,7 @@ func (c *AuthKeyringCmd) Run(ctx context.Context) error {
9494
u.Err().Printf("NOTE: GOG_KEYRING_BACKEND=%s overrides config.json", v)
9595
}
9696

97-
if backend == "file" &&
97+
if backend == strFile &&
9898
u != nil &&
9999
!outfmt.IsJSON(ctx) &&
100100
!outfmt.IsPlain(ctx) {

internal/cmd/calendar.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
type CalendarCmd struct {
1414
Calendars CalendarCalendarsCmd `cmd:"" name:"calendars" help:"List calendars"`
1515
ACL CalendarAclCmd `cmd:"" name:"acl" help:"List calendar ACL"`
16-
Events CalendarEventsCmd `cmd:"" name:"events" help:"List events from a calendar or all calendars"`
16+
Events CalendarEventsCmd `cmd:"" name:"events" aliases:"list" help:"List events from a calendar or all calendars"`
1717
Event CalendarEventCmd `cmd:"" name:"event" help:"Get event"`
1818
Create CalendarCreateCmd `cmd:"" name:"create" help:"Create an event"`
1919
Update CalendarUpdateCmd `cmd:"" name:"update" help:"Update an event"`

internal/cmd/docs.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ func (c *DocsInfoCmd) Run(ctx context.Context, flags *RootFlags) error {
9090

9191
if outfmt.IsJSON(ctx) {
9292
return outfmt.WriteJSON(os.Stdout, map[string]any{
93-
"file": file,
93+
strFile: file,
9494
"document": doc,
9595
})
9696
}
@@ -151,7 +151,7 @@ func (c *DocsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
151151
}
152152

153153
if outfmt.IsJSON(ctx) {
154-
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": created})
154+
return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: created})
155155
}
156156

157157
u.Out().Printf("id\t%s", created.Id)

internal/cmd/drive.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"google.golang.org/api/drive/v3"
1414
gapi "google.golang.org/api/googleapi"
1515

16+
"github.com/steipete/gogcli/internal/config"
1617
"github.com/steipete/gogcli/internal/googleapi"
1718
"github.com/steipete/gogcli/internal/outfmt"
1819
"github.com/steipete/gogcli/internal/ui"
@@ -225,7 +226,7 @@ func (c *DriveGetCmd) Run(ctx context.Context, flags *RootFlags) error {
225226
}
226227

227228
if outfmt.IsJSON(ctx) {
228-
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": f})
229+
return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: f})
229230
}
230231

231232
u.Out().Printf("id\t%s", f.Id)
@@ -330,6 +331,10 @@ func (c *DriveUploadCmd) Run(ctx context.Context, flags *RootFlags) error {
330331
if localPath == "" {
331332
return usage("empty localPath")
332333
}
334+
localPath, err = config.ExpandPath(localPath)
335+
if err != nil {
336+
return err
337+
}
333338

334339
f, err := os.Open(localPath) //nolint:gosec // user-provided path
335340
if err != nil {
@@ -365,7 +370,7 @@ func (c *DriveUploadCmd) Run(ctx context.Context, flags *RootFlags) error {
365370
}
366371

367372
if outfmt.IsJSON(ctx) {
368-
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": created})
373+
return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: created})
369374
}
370375

371376
u.Out().Printf("id\t%s", created.Id)
@@ -513,7 +518,7 @@ func (c *DriveMoveCmd) Run(ctx context.Context, flags *RootFlags) error {
513518
}
514519

515520
if outfmt.IsJSON(ctx) {
516-
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": updated})
521+
return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: updated})
517522
}
518523

519524
u.Out().Printf("id\t%s", updated.Id)
@@ -556,7 +561,7 @@ func (c *DriveRenameCmd) Run(ctx context.Context, flags *RootFlags) error {
556561
}
557562

558563
if outfmt.IsJSON(ctx) {
559-
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": updated})
564+
return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: updated})
560565
}
561566

562567
u.Out().Printf("id\t%s", updated.Id)
@@ -818,7 +823,7 @@ func driveType(mimeType string) string {
818823
if mimeType == "application/vnd.google-apps.folder" {
819824
return "folder"
820825
}
821-
return "file" //nolint:goconst // readability; used as a display value and JSON key elsewhere.
826+
return strFile
822827
}
823828

824829
func formatDateTime(iso string) string {

internal/cmd/drive_copy.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func copyViaDrive(ctx context.Context, flags *RootFlags, opts copyViaDriveOption
8282
}
8383

8484
if outfmt.IsJSON(ctx) {
85-
return outfmt.WriteJSON(os.Stdout, map[string]any{"file": created})
85+
return outfmt.WriteJSON(os.Stdout, map[string]any{strFile: created})
8686
}
8787
u.Out().Printf("id\t%s", created.Id)
8888
u.Out().Printf("name\t%s", created.Name)

internal/cmd/drive_download_helpers.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ func resolveDriveDownloadDestPath(meta *drive.File, outPathFlag string) (string,
2424
}
2525

2626
destPath := strings.TrimSpace(outPathFlag)
27+
// Expand ~ to home directory (shell doesn't expand when path is quoted).
28+
if destPath != "" {
29+
expanded, err := config.ExpandPath(destPath)
30+
if err != nil {
31+
return "", err
32+
}
33+
destPath = expanded
34+
}
2735
// Sanitize filename to prevent path traversal.
2836
safeName := filepath.Base(meta.Name)
2937
if safeName == "" || safeName == "." || safeName == ".." {

internal/cmd/gmail_attachment.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ func (c *GmailAttachmentCmd) Run(ctx context.Context, flags *RootFlags) error {
7171
return nil
7272
}
7373

74-
path, cached, bytes, err := downloadAttachmentToPath(ctx, svc, messageID, attachmentID, c.Output.Path, -1)
74+
outPath, err := config.ExpandPath(c.Output.Path)
75+
if err != nil {
76+
return err
77+
}
78+
path, cached, bytes, err := downloadAttachmentToPath(ctx, svc, messageID, attachmentID, outPath, -1)
7579
if err != nil {
7680
return err
7781
}

internal/cmd/gmail_drafts.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,11 @@ func buildDraftMessage(ctx context.Context, svc *gmail.Service, account string,
331331

332332
atts := make([]mailAttachment, 0, len(input.Attach))
333333
for _, p := range input.Attach {
334-
atts = append(atts, mailAttachment{Path: p})
334+
expanded, expandErr := config.ExpandPath(p)
335+
if expandErr != nil {
336+
return nil, "", expandErr
337+
}
338+
atts = append(atts, mailAttachment{Path: expanded})
335339
}
336340

337341
raw, err := buildRFC822(mailOptions{

0 commit comments

Comments
 (0)