fix(drive): support stdout downloads

This commit is contained in:
Peter Steinberger 2026-04-28 02:26:43 +01:00
parent e30d870b3f
commit 8e4f5acebb
No known key found for this signature in database
10 changed files with 234 additions and 2 deletions

View File

@ -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.

View File

@ -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 <fileId> --format pdf --out ./exported.pdf # Google Works
gog drive download <fileId> --format docx --out ./doc.docx
gog drive download <fileId> --format md --out ./note.md # Google Doc → Markdown
gog drive download <fileId> --format pptx --out ./slides.pptx
gog drive download <fileId> --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 <docId> "My Doc Copy"
gog docs export <docId> --format pdf --out ./doc.pdf
gog docs export <docId> --format txt --out - > doc.txt
gog docs list-tabs <docId>
gog docs cat <docId> --tab "Notes"
gog docs cat <docId> --all-tabs
@ -1418,6 +1421,7 @@ gog docs export <docId> --format docx --out ./doc.docx
gog docs export <docId> --format txt --out ./doc.txt
gog docs export <docId> --format md --out ./doc.md
gog docs export <docId> --format html --out ./doc.html
gog docs export <docId> --format txt --out - > doc.txt
# Sed-style regex editing with Markdown formatting (sedmat)
gog docs sed <docId> 's/pattern/replacement/g'

View File

@ -26,9 +26,11 @@ Each service command is a thin wrapper:
- Type guard: compare `mimeType` and error with `file is not a <KindLabel> (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": <bytes> }`
- text: `path\t...` / `size\t...`
- `--out -`: raw bytes only, no path/size status on stdout
## Add a new export command

View File

@ -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 <text> [--raw-query] [--max N] [--page TOKEN] [--[no-]all-drives]`
- `gog drive get <fileId>`
- `gog drive download <fileId> [--out PATH] [--format F]` (`--format` only applies to Google Workspace files; `--format md` exports a Google Doc as Markdown)
- `gog drive download <fileId> [--out PATH|-] [--format F]` (`--format` only applies to Google Workspace files; `--format md` exports a Google Doc as Markdown)
- `gog drive upload <localPath> [--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 <name> [--parent ID]`
- `gog drive delete <fileId> [--permanent]`

View File

@ -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

View File

@ -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)

View File

@ -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")
}
}

View File

@ -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 {

View File

@ -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

View File

@ -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 == "" {