Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/current-clippy-baseline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---

Keep Apps Script file selection compatible with the current Clippy checks.
9 changes: 9 additions & 0 deletions .changeset/offline-dry-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@googleworkspace/cli": patch
---

Skip authentication for Discovery-generated API and `docs +write` dry-runs.
Validate and preview requests without accessing the keyring or reading, changing,
or deleting stored credentials and token caches. Dry-runs work offline with a
fresh cached Discovery schema; schema fetching on first use or cache expiry is
unchanged. Real requests retain their existing authentication and error handling.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,21 @@ gws schema drive.files.list
gws drive files list --params '{"pageSize": 100}' --page-all | jq -r '.files[].name'
```

Discovery-generated API commands and `gws docs +write` support credential-free
`--dry-run`: they validate inputs and display the request without obtaining a
token, accessing the keyring, reading or changing stored credentials, or sending
the API request.

```bash
# Preview a Docs append without signing in
gws docs +write --document DOC_ID --text 'Hello, world!' --dry-run
```

These previews work offline with a fresh cached Discovery schema (24-hour TTL).
First use or an expired cache can still fetch the schema over the network.
Other helpers may need authenticated reads to prepare their plans; this guarantee
applies to raw API commands and `docs +write`.

## Authentication

The CLI supports multiple auth workflows so it works on your laptop, in CI, and on a server.
Expand Down
15 changes: 10 additions & 5 deletions crates/google-workspace-cli/src/helpers/docs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,15 @@ TIPS:
let (params_str, body_str, scopes) = build_write_request(matches, doc)?;

let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match auth::get_token(&scope_strs).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None),
Err(e) => return Err(GwsError::Auth(format!("Docs auth failed: {e}"))),
let dry_run = matches.get_flag("dry-run");
// Skip auth entirely: even failed auth can mutate stored credentials.
let (token, auth_method) = if dry_run {
(None, executor::AuthMethod::None)
} else {
match auth::get_token(&scope_strs).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(e) => return Err(GwsError::Auth(format!("Docs auth failed: {e}"))),
}
};

// Method: documents.batchUpdate
Expand All @@ -100,7 +105,7 @@ TIPS:
auth_method,
None,
None,
matches.get_flag("dry-run"),
dry_run,
&pagination,
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
Expand Down
8 changes: 1 addition & 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,7 @@ 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"),
_ => return Ok(None),
};

Expand Down
31 changes: 18 additions & 13 deletions crates/google-workspace-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,19 +261,24 @@ async fn run() -> Result<(), GwsError> {
// to avoid restrictive scopes like gmail.metadata that block query parameters.
let scopes: Vec<&str> = select_scope(&method.scopes).into_iter().collect();

// Authenticate: try OAuth, fail with error if credentials exist but are broken
let (token, auth_method) = match auth::get_token(&scopes).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(e) => {
// If credentials were found but failed (e.g. decryption error, invalid token),
// propagate the error instead of silently falling back to unauthenticated.
// Only fall back to None if no credentials exist at all.
let err_msg = format!("{e:#}");
// NB: matches the bail!() message in auth::load_credentials_inner
if err_msg.starts_with("No credentials found") {
(None, executor::AuthMethod::None)
} else {
return Err(GwsError::Auth(format!("Authentication failed: {err_msg}")));
// Dry-runs only need the schema and inputs. Do not load credentials:
// authentication may access the keyring or remove corrupt credential files.
let (token, auth_method) = if dry_run {
(None, executor::AuthMethod::None)
} else {
match auth::get_token(&scopes).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(e) => {
// If credentials were found but failed (e.g. decryption error, invalid token),
// propagate the error instead of silently falling back to unauthenticated.
// Only fall back to None if no credentials exist at all.
let err_msg = format!("{e:#}");
// NB: matches the bail!() message in auth::load_credentials_inner
if err_msg.starts_with("No credentials found") {
(None, executor::AuthMethod::None)
} else {
return Err(GwsError::Auth(format!("Authentication failed: {err_msg}")));
}
}
}
};
Expand Down
Loading
Loading