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:
parent
8e878fe485
commit
07ffcb5d84
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 == ".." {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
5
internal/cmd/strings.go
Normal file
@ -0,0 +1,5 @@
|
||||
package cmd
|
||||
|
||||
const (
|
||||
strFile = "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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user