Skip to content
Open
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
20 changes: 20 additions & 0 deletions .changeset/fix-binary-stream-to-stdout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@googleworkspace/cli": minor
---

Binary responses now stream to stdout when no `--output` flag is provided.

Previously, commands like `drive files export` and `drive files get --alt media`
always wrote content to a `download.{ext}` file in the current working directory
and printed a status JSON object to stdout, with no way to pipe the content
directly to another process.

Now, omitting `--output` streams the raw bytes to stdout (the Unix/curl default).
Use `--output <path>` to save the content to a named file as before.

The `mime_to_extension` helper, which existed only to generate the default
`download.{ext}` filename, has been removed as it is no longer needed.

**Migration note:** Scripts that read `download.txt` / `download.pdf` / etc.
from cwd after running `drive files export` must be updated to either redirect
stdout (`gws ... > file.txt`) or supply `--output file.txt` explicitly.
146 changes: 64 additions & 82 deletions crates/google-workspace-cli/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,50 +336,84 @@ async fn handle_json_response(
Ok(false)
}

/// Handle a binary response by streaming it to a file.
/// Handle a binary response by streaming it to a file or stdout.
///
/// When `output_path` is provided the content is saved to that file and a
/// status JSON is printed/captured. When no path is given the raw bytes are
/// streamed directly to stdout so the caller can pipe or redirect them.
async fn handle_binary_response(
response: reqwest::Response,
content_type: &str,
output_path: Option<&str>,
output_format: &crate::formatter::OutputFormat,
capture_output: bool,
) -> Result<Option<Value>, GwsError> {
let file_path = if let Some(p) = output_path {
PathBuf::from(p)
} else {
let ext = mime_to_extension(content_type);
PathBuf::from(format!("download.{ext}"))
};
if let Some(p) = output_path {
let file_path = PathBuf::from(p);

let mut file = tokio::fs::File::create(&file_path)
.await
.context("Failed to create output file")?;
let mut file = tokio::fs::File::create(&file_path)
.await
.context("Failed to create output file")?;

let mut stream = response.bytes_stream();
let mut total_bytes: u64 = 0;
let mut stream = response.bytes_stream();
let mut total_bytes: u64 = 0;

while let Some(chunk) = stream.next().await {
let chunk = chunk.context("Failed to read response chunk")?;
file.write_all(&chunk)
.await
.context("Failed to write to file")?;
total_bytes += chunk.len() as u64;
}
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("Failed to read response chunk")?;
file.write_all(&chunk)
.await
.context("Failed to write to file")?;
total_bytes += chunk.len() as u64;
}

file.flush().await.context("Failed to flush file")?;
file.flush().await.context("Failed to flush file")?;
Comment thread
hoyt-harness marked this conversation as resolved.

let result = json!({
"status": "success",
"saved_file": file_path.display().to_string(),
"mimeType": content_type,
"bytes": total_bytes,
});
let result = json!({
"status": "success",
"saved_file": file_path.display().to_string(),
"mimeType": content_type,
"bytes": total_bytes,
});

if capture_output {
return Ok(Some(result));
}
if capture_output {
return Ok(Some(result));
}

println!("{}", crate::formatter::format_value(&result, output_format));
println!("{}", crate::formatter::format_value(&result, output_format));
} else {
if !capture_output && std::io::IsTerminal::is_terminal(&std::io::stdout()) {
return Err(GwsError::Validation(
"Refusing to stream binary content to a terminal. \
Use --output <path> to save to a file, or redirect stdout (e.g. '> file')."
.to_string(),
));
}

let mut stdout = tokio::io::stdout();
let mut stream = response.bytes_stream();
let mut total_bytes: u64 = 0;

while let Some(chunk) = stream.next().await {
let chunk = chunk.context("Failed to read response chunk")?;
total_bytes += chunk.len() as u64;
if !capture_output {
stdout
.write_all(&chunk)
.await
.context("Failed to write to stdout")?;
}
}
Comment thread
hoyt-harness marked this conversation as resolved.

if capture_output {
return Ok(Some(json!({
"status": "success",
"mimeType": content_type,
"bytes": total_bytes,
})));
}

stdout.flush().await.context("Failed to flush stdout")?;
}
Comment thread
hoyt-harness marked this conversation as resolved.

Ok(None)
}
Expand Down Expand Up @@ -1151,40 +1185,6 @@ fn get_value_type(val: &Value) -> &'static str {
}
}

/// Maps a MIME type to a file extension.
pub fn mime_to_extension(mime: &str) -> &str {
if mime.contains("pdf") {
"pdf"
} else if mime.contains("png") {
"png"
} else if mime.contains("jpeg") || mime.contains("jpg") {
"jpg"
} else if mime.contains("gif") {
"gif"
} else if mime.contains("csv") {
"csv"
} else if mime.contains("zip") {
"zip"
} else if mime.contains("xml") {
"xml"
} else if mime.contains("html") {
"html"
} else if mime.contains("plain") {
"txt"
} else if mime.contains("octet-stream") {
"bin"
} else if mime.contains("spreadsheet") || mime.contains("xlsx") {
"xlsx"
} else if mime.contains("document") || mime.contains("docx") {
"docx"
} else if mime.contains("presentation") || mime.contains("pptx") {
"pptx"
} else if mime.contains("script") {
"json"
} else {
"bin"
}
}

#[cfg(test)]
mod tests {
Expand All @@ -1209,24 +1209,6 @@ mod tests {
assert_ne!(AuthMethod::OAuth, AuthMethod::None);
}

#[test]
fn test_mime_to_extension_more_types() {
assert_eq!(mime_to_extension("text/plain"), "txt");
assert_eq!(mime_to_extension("text/csv"), "csv");
assert_eq!(mime_to_extension("application/zip"), "zip");
assert_eq!(mime_to_extension("application/xml"), "xml");
assert_eq!(mime_to_extension("text/html"), "html");
assert_eq!(mime_to_extension("application/json"), "bin"); // Default for unknown specific json types if not scripts
assert_eq!(
mime_to_extension("application/vnd.google-apps.script"),
"json"
);
assert_eq!(
mime_to_extension("application/vnd.google-apps.presentation"),
"pptx"
);
}

#[test]
fn test_validate_body_valid() {
let mut properties = HashMap::new();
Expand Down
9 changes: 2 additions & 7 deletions crates/google-workspace-cli/src/helpers/script.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,8 @@ fn process_file(path: &Path) -> Result<Option<serde_json::Value>, GwsError> {
filename.trim_end_matches(".js").trim_end_matches(".gs"),
),
"html" => ("HTML", filename.trim_end_matches(".html")),
"json" => {
if filename == "appsscript.json" {
("JSON", "appsscript")
} else {
return Ok(None);
}
}
"json" if filename == "appsscript.json" => ("JSON", "appsscript"),
"json" => return Ok(None),
_ => return Ok(None),
};

Expand Down
Loading