From 8e4f5acebb281bc385db2a37b78556015383a36b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 02:26:43 +0100 Subject: [PATCH] fix(drive): support stdout downloads --- CHANGELOG.md | 1 + README.md | 4 + docs/refactor/exports.md | 2 + docs/spec.md | 3 +- internal/cmd/drive.go | 17 ++- internal/cmd/drive_download_helpers.go | 3 + internal/cmd/drive_stdout_test.go | 175 +++++++++++++++++++++++++ internal/cmd/drive_testutil_test.go | 19 +++ internal/cmd/export_via_drive.go | 6 + internal/cmd/output_file_helpers.go | 6 + 10 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 internal/cmd/drive_stdout_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 35d191f..0853ce0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ ### Fixed - Calendar: display `calendar events` times and JSON local fields in the calendar timezone instead of preserving arbitrary event offsets. (#493) +- Drive/Docs/Sheets/Slides: treat `--out -` as stdout for downloads and exports instead of creating `-`/`-.ext` files; reject `--json --out -` to keep byte streams parseable. (#286) - Docs: deprecate editing-command `--tab-id` in favor of `--tab`, and resolve tab titles to canonical tab IDs before mutations. (#533) — thanks @johnbenjaminlewis. - Docs: convert Markdown formatting for `docs write --append --markdown` instead of appending raw Markdown syntax. (#530, #272) — thanks @eric-x-liu. - CLI: show direct Google Cloud API enablement links and matching `auth add --services ...` hints when Google returns API-not-enabled errors. diff --git a/README.md b/README.md index 6ed9811..fee47c6 100644 --- a/README.md +++ b/README.md @@ -597,6 +597,7 @@ Some open source Google CLIs ship a pre-configured OAuth client ID/secret copied Flag aliases: - `--out` also accepts `--output`. - `--out-dir` also accepts `--output-dir` (Gmail thread attachment downloads). +- Drive download/export commands accept `--out -` to write file bytes to stdout; do not combine this with `--json`. ### Authentication @@ -1002,6 +1003,7 @@ gog drive download --format pdf --out ./exported.pdf # Google Works gog drive download --format docx --out ./doc.docx gog drive download --format md --out ./note.md # Google Doc → Markdown gog drive download --format pptx --out ./slides.pptx +gog drive download --out - > downloaded.bin # Organize gog drive mkdir "New Folder" @@ -1034,6 +1036,7 @@ gog docs create "My Doc" --file ./doc.md # Import markdown gog docs create "My Doc" --pageless gog docs copy "My Doc Copy" gog docs export --format pdf --out ./doc.pdf +gog docs export --format txt --out - > doc.txt gog docs list-tabs gog docs cat --tab "Notes" gog docs cat --all-tabs @@ -1418,6 +1421,7 @@ gog docs export --format docx --out ./doc.docx gog docs export --format txt --out ./doc.txt gog docs export --format md --out ./doc.md gog docs export --format html --out ./doc.html +gog docs export --format txt --out - > doc.txt # Sed-style regex editing with Markdown formatting (sedmat) gog docs sed 's/pattern/replacement/g' diff --git a/docs/refactor/exports.md b/docs/refactor/exports.md index a06836d..77286ed 100644 --- a/docs/refactor/exports.md +++ b/docs/refactor/exports.md @@ -26,9 +26,11 @@ Each service command is a thin wrapper: - Type guard: compare `mimeType` and error with `file is not a (mimeType="...")`. - `--out` defaults to `$(os.UserConfigDir())/gogcli/drive-downloads/` (via `internal/config:EnsureDriveDownloadsDir`). - `--out` can be dir or explicit file path (via `internal/cmd/drive_download_helpers.go:resolveDriveDownloadDestPath`). +- `--out -` writes export bytes to stdout; JSON mode rejects it to avoid mixing metadata with bytes. - Output - `--json`: `{ "path": "...", "size": }` - text: `path\t...` / `size\t...` + - `--out -`: raw bytes only, no path/size status on stdout ## Add a new export command diff --git a/docs/spec.md b/docs/spec.md index ba5463f..7430376 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -158,6 +158,7 @@ Environment: Flag aliases: - `--out` also accepts `--output`. - `--out-dir` also accepts `--output-dir` (Gmail thread attachment downloads). +- Drive download/export commands accept `--out -` to write file bytes to stdout; `--json --out -` is rejected. ## Commands (current + planned) @@ -189,7 +190,7 @@ Flag aliases: - `gog drive ls [--all] [--parent ID] [--max N] [--page TOKEN] [--query Q] [--[no-]all-drives]` (`--all` and `--parent` are mutually exclusive) - `gog drive search [--raw-query] [--max N] [--page TOKEN] [--[no-]all-drives]` - `gog drive get ` -- `gog drive download [--out PATH] [--format F]` (`--format` only applies to Google Workspace files; `--format md` exports a Google Doc as Markdown) +- `gog drive download [--out PATH|-] [--format F]` (`--format` only applies to Google Workspace files; `--format md` exports a Google Doc as Markdown) - `gog drive upload [--name N] [--parent ID] [--convert] [--convert-to doc|sheet|slides] [--keep-frontmatter]` (Markdown → Google Doc with `--convert` or `--convert-to doc`: leading `---`/`---` frontmatter is stripped before upload unless `--keep-frontmatter`; delimiter-based, not a full YAML parse; large non-JSON uploads print progress to stderr) - `gog drive mkdir [--parent ID]` - `gog drive delete [--permanent]` diff --git a/internal/cmd/drive.go b/internal/cmd/drive.go index a5bc042..a337fb4 100644 --- a/internal/cmd/drive.go +++ b/internal/cmd/drive.go @@ -197,6 +197,9 @@ func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } + if outfmt.IsJSON(ctx) && isStdoutPath(destPath) { + return usage("can't combine --json with --out -") + } downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, c.Format) if err != nil { @@ -209,6 +212,9 @@ func (c *DriveDownloadCmd) Run(ctx context.Context, flags *RootFlags) error { "size": size, }) } + if isStdoutPath(downloadedPath) { + return nil + } u.Out().Printf("path\t%s", downloadedPath) u.Out().Printf("size\t%s", formatDriveSize(size)) @@ -936,7 +942,11 @@ func downloadDriveFile(ctx context.Context, svc *drive.Service, meta *drive.File return "", 0, mimeErr } } - outPath = replaceExt(destPath, driveExportExtension(exportMimeType)) + if isStdoutPath(destPath) { + outPath = stdoutPath + } else { + outPath = replaceExt(destPath, driveExportExtension(exportMimeType)) + } resp, err = driveExportDownload(ctx, svc, meta.Id, exportMimeType) } else { outPath = destPath @@ -952,6 +962,11 @@ func downloadDriveFile(ctx context.Context, svc *drive.Service, meta *drive.File return "", 0, fmt.Errorf("download failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) } + if isStdoutPath(outPath) { + n, copyErr := io.Copy(os.Stdout, resp.Body) + return stdoutPath, n, copyErr + } + f, outPath, err := createUserOutputFile(outPath) if err != nil { return "", 0, err diff --git a/internal/cmd/drive_download_helpers.go b/internal/cmd/drive_download_helpers.go index bd4c56e..88fb033 100644 --- a/internal/cmd/drive_download_helpers.go +++ b/internal/cmd/drive_download_helpers.go @@ -24,6 +24,9 @@ func resolveDriveDownloadDestPath(meta *drive.File, outPathFlag string) (string, } destPath := strings.TrimSpace(outPathFlag) + if isStdoutPath(destPath) { + return stdoutPath, nil + } // Expand ~ to home directory (shell doesn't expand when path is quoted). if destPath != "" { expanded, err := config.ExpandPath(destPath) diff --git a/internal/cmd/drive_stdout_test.go b/internal/cmd/drive_stdout_test.go new file mode 100644 index 0000000..ed24890 --- /dev/null +++ b/internal/cmd/drive_stdout_test.go @@ -0,0 +1,175 @@ +package cmd + +import ( + "context" + "io" + "net/http" + "os" + "strings" + "testing" + + "google.golang.org/api/drive/v3" +) + +func TestExecute_DriveDownload_WithOutStdout(t *testing.T) { + origDownload := driveDownload + t.Cleanup(func() { driveDownload = origDownload }) + t.Chdir(t.TempDir()) + + svc, closeSvc := newDriveMetadataTestService(t, "text/plain") + t.Cleanup(closeSvc) + stubDriveServiceForTest(t, svc) + + driveDownload = func(context.Context, *drive.Service, string) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader("abc")), + }, nil + } + + stdout := captureStdout(t, func() { + _ = captureStderr(t, func() { + if execErr := Execute([]string{ + "--account", "a@b.com", + "drive", "download", "id1", + "--out", "-", + }); execErr != nil { + t.Fatalf("Execute: %v", execErr) + } + }) + }) + + if stdout != "abc" { + t.Fatalf("stdout=%q, want raw bytes", stdout) + } + if _, statErr := os.Stat("-"); !os.IsNotExist(statErr) { + t.Fatalf("expected no file named -, stat=%v", statErr) + } +} + +func TestExecute_DriveDownload_WithOutStdout_JSONRejected(t *testing.T) { + origDownload := driveDownload + t.Cleanup(func() { driveDownload = origDownload }) + + svc, closeSvc := newDriveMetadataTestService(t, "text/plain") + t.Cleanup(closeSvc) + stubDriveServiceForTest(t, svc) + + called := false + driveDownload = func(context.Context, *drive.Service, string) (*http.Response, error) { + called = true + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader("abc")), + }, nil + } + + var execErr error + stdout := captureStdout(t, func() { + _ = captureStderr(t, func() { + execErr = Execute([]string{ + "--json", + "--account", "a@b.com", + "drive", "download", "id1", + "--out", "-", + }) + }) + }) + + if execErr == nil || !strings.Contains(execErr.Error(), "can't combine --json with --out -") { + t.Fatalf("unexpected error: %v", execErr) + } + if stdout != "" { + t.Fatalf("stdout=%q, want empty", stdout) + } + if called { + t.Fatalf("download should not be called") + } +} + +func TestExecute_DocsExport_WithOutStdout(t *testing.T) { + origExport := driveExportDownload + t.Cleanup(func() { driveExportDownload = origExport }) + t.Chdir(t.TempDir()) + + svc, closeSvc := newDriveMetadataTestService(t, "application/vnd.google-apps.document") + t.Cleanup(closeSvc) + stubDriveServiceForTest(t, svc) + + var gotExportMime string + driveExportDownload = func(_ context.Context, _ *drive.Service, _ string, mimeType string) (*http.Response, error) { + gotExportMime = mimeType + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader("plain text\n")), + }, nil + } + + stdout := captureStdout(t, func() { + _ = captureStderr(t, func() { + if execErr := Execute([]string{ + "--account", "a@b.com", + "docs", "export", "id1", + "--out", "-", + "--format", "txt", + }); execErr != nil { + t.Fatalf("Execute: %v", execErr) + } + }) + }) + + if stdout != "plain text\n" { + t.Fatalf("stdout=%q, want raw export bytes", stdout) + } + if gotExportMime != "text/plain" { + t.Fatalf("unexpected export mime type: %q", gotExportMime) + } + if _, statErr := os.Stat("-.txt"); !os.IsNotExist(statErr) { + t.Fatalf("expected no file named -.txt, stat=%v", statErr) + } +} + +func TestExecute_DocsExport_WithOutStdout_JSONRejected(t *testing.T) { + origExport := driveExportDownload + t.Cleanup(func() { driveExportDownload = origExport }) + + svc, closeSvc := newDriveMetadataTestService(t, "application/vnd.google-apps.document") + t.Cleanup(closeSvc) + stubDriveServiceForTest(t, svc) + + called := false + driveExportDownload = func(context.Context, *drive.Service, string, string) (*http.Response, error) { + called = true + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader("plain text\n")), + }, nil + } + + var execErr error + stdout := captureStdout(t, func() { + _ = captureStderr(t, func() { + execErr = Execute([]string{ + "--json", + "--account", "a@b.com", + "docs", "export", "id1", + "--out", "-", + "--format", "txt", + }) + }) + }) + + if execErr == nil || !strings.Contains(execErr.Error(), "can't combine --json with --out -") { + t.Fatalf("unexpected error: %v", execErr) + } + if stdout != "" { + t.Fatalf("stdout=%q, want empty", stdout) + } + if called { + t.Fatalf("export should not be called") + } +} diff --git a/internal/cmd/drive_testutil_test.go b/internal/cmd/drive_testutil_test.go index 5bf3889..8ce9c1f 100644 --- a/internal/cmd/drive_testutil_test.go +++ b/internal/cmd/drive_testutil_test.go @@ -2,9 +2,11 @@ package cmd import ( "context" + "encoding/json" "io" "net/http" "net/http/httptest" + "strings" "testing" "google.golang.org/api/drive/v3" @@ -38,6 +40,23 @@ func stubDriveServiceForTest(t *testing.T, svc *drive.Service) { newDriveService = stubDriveService(svc) } +func newDriveMetadataTestService(t *testing.T, mimeType string) (*drive.Service, func()) { + t.Helper() + + return newDriveTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/files/id1") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "id1", + "name": "Doc", + "mimeType": mimeType, + }) + })) +} + func requireQuery(t *testing.T, r *http.Request, key, want string) { t.Helper() if got := r.URL.Query().Get(key); got != want { diff --git a/internal/cmd/export_via_drive.go b/internal/cmd/export_via_drive.go index 9c27608..e9d1b3a 100644 --- a/internal/cmd/export_via_drive.go +++ b/internal/cmd/export_via_drive.go @@ -102,6 +102,9 @@ func exportViaDrive(ctx context.Context, flags *RootFlags, opts exportViaDriveOp if err != nil { return err } + if outfmt.IsJSON(ctx) && isStdoutPath(destPath) { + return usage("can't combine --json with --out -") + } downloadedPath, size, err := downloadDriveFile(ctx, svc, meta, destPath, format) if err != nil { @@ -111,6 +114,9 @@ func exportViaDrive(ctx context.Context, flags *RootFlags, opts exportViaDriveOp if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"path": downloadedPath, "size": size}) } + if isStdoutPath(downloadedPath) { + return nil + } u.Out().Printf("path\t%s", downloadedPath) u.Out().Printf("size\t%s", formatDriveSize(size)) return nil diff --git a/internal/cmd/output_file_helpers.go b/internal/cmd/output_file_helpers.go index f9aabc1..a2ceae7 100644 --- a/internal/cmd/output_file_helpers.go +++ b/internal/cmd/output_file_helpers.go @@ -9,12 +9,18 @@ import ( "github.com/steipete/gogcli/internal/config" ) +const stdoutPath = "-" + type outputFileOptions struct { Overwrite bool FileMode os.FileMode DirMode os.FileMode } +func isStdoutPath(path string) bool { + return strings.TrimSpace(path) == stdoutPath +} + func openUserOutputFile(path string, opts outputFileOptions) (*os.File, string, error) { path = strings.TrimSpace(path) if path == "" {