Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
### Fixed

- Calendar: report multi-calendar event truncation on stderr for text output and as per-calendar page tokens in JSON. (#831) — thanks @TurboTheTurtle.
- Downloads: protect Drive downloads, Docs/Sheets/Slides exports, Docs tab exports, and Slides thumbnails from replacing existing files unless `--overwrite` is passed. (#827, #829) — thanks @WadydX.
- Docs: update the Docker authentication example to persist file-keyring tokens with `GOG_HOME`. (#828, #830) — thanks @WadydX.

## 0.28.0 - 2026-06-15
Expand Down
1 change: 1 addition & 0 deletions docs/commands/gog-docs-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ gog docs (doc) export (download,dl) <docId> [flags]
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
| `--out`<br>`--output` | `string` | | Output file path (default: gogcli config dir) |
| `--overwrite` | `bool` | | Overwrite an existing output file |
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
Expand Down
1 change: 1 addition & 0 deletions docs/commands/gog-download.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ gog download (dl) <fileId> [flags]
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
| `--out`<br>`--output` | `string` | | Output file path (default: gogcli config dir) |
| `--overwrite` | `bool` | | Overwrite an existing output file |
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
Expand Down
1 change: 1 addition & 0 deletions docs/commands/gog-drive-download.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ gog drive (drv) download <fileId> [flags]
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
| `--out`<br>`--output` | `string` | | Output file path (default: gogcli config dir) |
| `--overwrite` | `bool` | | Overwrite an existing output file |
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
Expand Down
1 change: 1 addition & 0 deletions docs/commands/gog-sheets-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ gog sheets (sheet) export (download,dl) <spreadsheetId> [flags]
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
| `--out`<br>`--output` | `string` | | Output file path (default: gogcli config dir) |
| `--overwrite` | `bool` | | Overwrite an existing output file |
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
Expand Down
1 change: 1 addition & 0 deletions docs/commands/gog-slides-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ gog slides (slide) export (download,dl) <presentationId> [flags]
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
| `--out`<br>`--output` | `string` | | Output file path (default: gogcli config dir) |
| `--overwrite` | `bool` | | Overwrite an existing output file |
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
Expand Down
1 change: 1 addition & 0 deletions docs/commands/gog-slides-thumbnail.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ gog slides (slide) thumbnail (thumb) <presentationId> <slideId> [flags]
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
| `--out`<br>`--output` | `string` | | Write the thumbnail image to a local file |
| `--overwrite` | `bool` | | Overwrite an existing output file |
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
Expand Down
20 changes: 11 additions & 9 deletions internal/cmd/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,27 +152,29 @@ func projectRawDocumentTab(doc *docs.Document, tab *docs.Tab) (*docs.Document, e
}

type DocsExportCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Output OutputPathFlag `embed:""`
Format string `name:"format" help:"Export format: pdf|docx|txt|md|html" default:"pdf"`
Tab string `name:"tab" help:"(experimental) Export a specific tab by title or ID (see 'gog docs list-tabs')"`
DocID string `arg:"" name:"docId" help:"Doc ID"`
Output OutputPathFlag `embed:""`
Format string `name:"format" help:"Export format: pdf|docx|txt|md|html" default:"pdf"`
Tab string `name:"tab" help:"(experimental) Export a specific tab by title or ID (see 'gog docs list-tabs')"`
Overwrite bool `name:"overwrite" help:"Overwrite an existing output file"`
}

func (c *DocsExportCmd) Run(ctx context.Context, flags *RootFlags) error {
if tab := strings.TrimSpace(c.Tab); tab != "" {
return runDocsTabExport(ctx, flags, tabExportParams{
DocID: c.DocID,
OutFlag: c.Output.Path,
Format: c.Format,
TabQuery: tab,
DocID: c.DocID,
OutFlag: c.Output.Path,
Format: c.Format,
TabQuery: tab,
Overwrite: c.Overwrite,
})
}
return exportViaDrive(ctx, flags, exportViaDriveOptions{
ArgName: "docId",
ExpectedMime: "application/vnd.google-apps.document",
KindLabel: "Google Doc",
DefaultFormat: "pdf",
}, c.DocID, c.Output.Path, c.Format)
}, c.DocID, c.Output.Path, c.Format, c.Overwrite)
}

type DocsInfoCmd struct {
Expand Down
24 changes: 15 additions & 9 deletions internal/cmd/docs_tab_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,11 @@ func isGoogleAuthHost(host string) bool {
}

type tabExportParams struct {
DocID string
OutFlag string
Format string
TabQuery string
DocID string
OutFlag string
Format string
TabQuery string
Overwrite bool
}

// sanitizeFilenameComponent replaces characters unsafe for filenames with
Expand Down Expand Up @@ -181,10 +182,11 @@ func runDocsTabExport(ctx context.Context, flags *RootFlags, p tabExportParams)
}

if dryErr := dryRunExit(ctx, flags, "docs.tab-export", map[string]any{
"docID": p.DocID,
"tab": p.TabQuery,
"format": format,
"out": outPath,
"docID": p.DocID,
"tab": p.TabQuery,
"format": format,
"out": outPath,
"overwrite": p.Overwrite,
}); dryErr != nil {
return dryErr
}
Expand Down Expand Up @@ -234,7 +236,11 @@ func runDocsTabExport(ctx context.Context, flags *RootFlags, p tabExportParams)
return copyErr
}

f, outPath, writeErr := createUserOutputFile(outPath)
f, outPath, writeErr := openUserOutputFile(outPath, outputFileOptions{
Overwrite: p.Overwrite,
FileMode: 0o600,
DirMode: 0o700,
})
if writeErr != nil {
return writeErr
}
Expand Down
46 changes: 42 additions & 4 deletions internal/cmd/docs_tab_export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -413,19 +414,56 @@ func TestRunDocsTabExport_JSONOutput(t *testing.T) {
}
}

func TestRunDocsTabExport_RequiresOverwrite(t *testing.T) {
ctx, _ := newTabExportTestContext(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/pdf")
_, _ = w.Write([]byte("replacement"))
}), false)

outPath := filepath.Join(t.TempDir(), "output.pdf")
if err := os.WriteFile(outPath, []byte("original"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
params := tabExportParams{
DocID: "doc1",
OutFlag: outPath,
Format: "pdf",
TabQuery: "First Tab",
}

if err := runDocsTabExport(ctx, &RootFlags{Account: "test@example.com"}, params); !errors.Is(err, os.ErrExist) {
t.Fatalf("expected existing-file error, got %v", err)
}
if got, err := os.ReadFile(outPath); err != nil || string(got) != "original" {
t.Fatalf("existing file changed: data=%q err=%v", got, err)
}

params.Overwrite = true
if err := runDocsTabExport(ctx, &RootFlags{Account: "test@example.com"}, params); err != nil {
t.Fatalf("runDocsTabExport overwrite: %v", err)
}
if got, err := os.ReadFile(outPath); err != nil || string(got) != "replacement" {
t.Fatalf("overwrite failed: data=%q err=%v", got, err)
}
}

func TestDocsExportCmd_TabRouting(t *testing.T) {
ctx, _ := newTabExportTestContext(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/pdf")
_, _ = w.Write([]byte("tab pdf"))
}), false)

outPath := filepath.Join(t.TempDir(), "out.pdf")
if err := os.WriteFile(outPath, []byte("original"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}

cmd := &DocsExportCmd{
DocID: "doc1",
Format: "pdf",
Tab: "Second Tab",
Output: OutputPathFlag{Path: outPath},
DocID: "doc1",
Format: "pdf",
Tab: "Second Tab",
Output: OutputPathFlag{Path: outPath},
Overwrite: true,
}

if err := cmd.Run(ctx, &RootFlags{Account: "test@example.com"}); err != nil {
Expand Down
29 changes: 18 additions & 11 deletions internal/cmd/drive_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ import (
)

type DriveDownloadCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
Output OutputPathFlag `embed:""`
Format string `name:"format" help:"Export format for Google Docs files: pdf|csv|xlsx|pptx|txt|png|docx|md (default: inferred)"`
Tab string `name:"tab" help:"(experimental) Export a specific tab by title or ID (Google Docs only; see 'gog docs list-tabs')"`
FileID string `arg:"" name:"fileId" help:"File ID"`
Output OutputPathFlag `embed:""`
Format string `name:"format" help:"Export format for Google Docs files: pdf|csv|xlsx|pptx|txt|png|docx|md (default: inferred)"`
Tab string `name:"tab" help:"(experimental) Export a specific tab by title or ID (Google Docs only; see 'gog docs list-tabs')"`
Overwrite bool `name:"overwrite" help:"Overwrite an existing output file"`
}

func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error {
Expand All @@ -36,10 +37,11 @@ func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error {
}
}
return runDocsTabExport(ctx, flags, tabExportParams{
DocID: fileID,
OutFlag: c.Output.Path,
Format: c.Format,
TabQuery: tab,
DocID: fileID,
OutFlag: c.Output.Path,
Format: c.Format,
TabQuery: tab,
Overwrite: c.Overwrite,
})
}

Expand Down Expand Up @@ -72,6 +74,7 @@ func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error {
"out": outPathFlag,
"default_downloads_dir": defaultDir,
"format": strings.ToLower(strings.TrimSpace(c.Format)),
"overwrite": c.Overwrite,
}); dryRunErr != nil {
return dryRunErr
}
Expand Down Expand Up @@ -106,7 +109,7 @@ func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}

downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, c.Format)
downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, c.Format, c.Overwrite)
if err != nil {
return err
}
Expand All @@ -126,7 +129,7 @@ func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error {
return nil
}

func downloadDriveFile(ctx context.Context, svc *drive.Service, meta *drive.File, destPath string, format string) (string, int64, error) {
func downloadDriveFile(ctx context.Context, svc *drive.Service, meta *drive.File, destPath string, format string, overwrite bool) (string, int64, error) {
isGoogleDoc := strings.HasPrefix(meta.MimeType, "application/vnd.google-apps.")
normalizedFormat := strings.ToLower(strings.TrimSpace(format))
if normalizedFormat == formatAuto {
Expand Down Expand Up @@ -182,7 +185,11 @@ func downloadDriveFile(ctx context.Context, svc *drive.Service, meta *drive.File
return stdoutPath, n, copyErr
}

f, outPath, err := createUserOutputFile(outPath)
f, outPath, err := openUserOutputFile(outPath, outputFileOptions{
Overwrite: overwrite,
FileMode: 0o600,
DirMode: 0o700,
})
if err != nil {
return "", 0, err
}
Expand Down
44 changes: 39 additions & 5 deletions internal/cmd/drive_download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
Expand All @@ -14,6 +15,39 @@ import (
"google.golang.org/api/option"
)

func TestDownloadDriveFile_RequiresOverwrite(t *testing.T) {
download := func(context.Context, *drive.Service, string) (*http.Response, error) {
return &http.Response{
Status: "200 OK",
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("replacement")),
}, nil
}
ctx := withDriveTestOperations(context.Background(), &drive.Service{}, download, nil)
dest := filepath.Join(t.TempDir(), "file.bin")
if err := os.WriteFile(dest, []byte("original"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}

if _, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "", false); !errors.Is(err, os.ErrExist) {
t.Fatalf("expected existing-file error, got %v", err)
}
if got, err := os.ReadFile(dest); err != nil || string(got) != "original" {
t.Fatalf("existing file changed: data=%q err=%v", got, err)
}

outPath, size, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "", true)
if err != nil {
t.Fatalf("downloadDriveFile overwrite: %v", err)
}
if outPath != dest || size != int64(len("replacement")) {
t.Fatalf("outPath=%q size=%d", outPath, size)
}
if got, err := os.ReadFile(dest); err != nil || string(got) != "replacement" {
t.Fatalf("overwrite failed: data=%q err=%v", got, err)
}
}

func TestDownloadDriveFile_NonGoogleDoc(t *testing.T) {
body := "hello"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -39,7 +73,7 @@ func TestDownloadDriveFile_NonGoogleDoc(t *testing.T) {
tmp := t.TempDir()
dest := filepath.Join(tmp, "file.bin")
ctx := withDriveTestOperations(context.Background(), svc, driveDownload, driveExportDownload)
outPath, n, err := downloadDriveFile(ctx, svc, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "")
outPath, n, err := downloadDriveFile(ctx, svc, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "", false)
if err != nil {
t.Fatalf("downloadDriveFile: %v", err)
}
Expand Down Expand Up @@ -71,7 +105,7 @@ func TestDownloadDriveFile_NonGoogleDocFormatRejected(t *testing.T) {

dest := filepath.Join(t.TempDir(), "file.html")
ctx := withDriveTestOperations(context.Background(), &drive.Service{}, download, nil)
_, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "html")
_, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "html", false)
if err == nil {
t.Fatalf("expected error")
}
Expand Down Expand Up @@ -111,7 +145,7 @@ func TestDownloadDriveFile_GoogleDocExport(t *testing.T) {
tmp := t.TempDir()
dest := filepath.Join(tmp, "doc.txt")
ctx := withDriveTestOperations(context.Background(), svc, driveDownload, driveExportDownload)
outPath, n, err := downloadDriveFile(ctx, svc, &drive.File{Id: "id1", MimeType: "application/vnd.google-apps.document"}, dest, "")
outPath, n, err := downloadDriveFile(ctx, svc, &drive.File{Id: "id1", MimeType: "application/vnd.google-apps.document"}, dest, "", false)
if err != nil {
t.Fatalf("downloadDriveFile: %v", err)
}
Expand Down Expand Up @@ -142,7 +176,7 @@ func TestDownloadDriveFile_HTTPError(t *testing.T) {
tmp := t.TempDir()
dest := filepath.Join(tmp, "file.bin")
ctx := withDriveTestOperations(context.Background(), &drive.Service{}, download, nil)
_, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "")
_, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "", false)
if err == nil {
t.Fatalf("expected error")
}
Expand All @@ -163,7 +197,7 @@ func TestDownloadDriveFile_CreatesMissingParentDirs(t *testing.T) {
tmp := t.TempDir()
dest := filepath.Join(tmp, "no-such-dir", "file.bin")
ctx := withDriveTestOperations(context.Background(), &drive.Service{}, download, nil)
outPath, size, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "")
outPath, size, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "id1", MimeType: "application/pdf"}, dest, "", false)
if err != nil {
t.Fatalf("downloadDriveFile: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/drive_export_format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ func TestDownloadDriveFile_InvalidExportFormat(t *testing.T) {
_, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{
Id: "id1",
MimeType: "application/vnd.google-apps.document",
}, dest, "xlsx")
}, dest, "xlsx", false)
if err == nil {
t.Fatalf("expected error")
}
Expand Down
4 changes: 2 additions & 2 deletions internal/cmd/drive_validation_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,10 +476,10 @@ func TestDownloadDriveFile_ErrorPaths(t *testing.T) {
}

ctx := withDriveTestOperations(context.Background(), &drive.Service{}, download, export)
if _, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "x", MimeType: "text/plain"}, "out", ""); err == nil {
if _, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "x", MimeType: "text/plain"}, "out", "", false); err == nil {
t.Fatalf("expected download error")
}
if _, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "x", MimeType: driveMimeGoogleDoc}, "out", ""); err == nil {
if _, _, err := downloadDriveFile(ctx, &drive.Service{}, &drive.File{Id: "x", MimeType: driveMimeGoogleDoc}, "out", "", false); err == nil {
t.Fatalf("expected export error")
}
}
Expand Down
Loading
Loading