chore: import upstream snapshot with attribution
Automation / Format (push) Waiting to run
Automation / File Labeler (push) Waiting to run
Automation / Gemini Review (push) Waiting to run
Automation / Gemini Reviewed (push) Waiting to run
CI / Detect Changes (push) Waiting to run
CI / Test (macos-latest) (push) Blocked by required conditions
CI / Test (ubuntu-latest) (push) Blocked by required conditions
CI / Nix (push) Blocked by required conditions
CI / Lint (push) Blocked by required conditions
CI / Cargo Deny (push) Blocked by required conditions
CI / Verify Skills (push) Blocked by required conditions
CI / Lint Skills (push) Blocked by required conditions
CI / Build (Linux x86_64) (push) Blocked by required conditions
CI / Build (macos-latest, aarch64-apple-darwin) (push) Blocked by required conditions
CI / Build (macos-latest, x86_64-apple-darwin) (push) Blocked by required conditions
CI / Build (ubuntu-latest, aarch64-unknown-linux-gnu) (push) Blocked by required conditions
CI / Build (windows-latest, x86_64-pc-windows-msvc) (push) Blocked by required conditions
CI / API Smoketest (push) Blocked by required conditions
Coverage / Coverage (push) Waiting to run
Policy / Policy Check (push) Waiting to run
Release (Changeset) / Release (push) Waiting to run
Publish OpenClaw Skills / publish (push) Has been cancelled
Audit / Security Audit (push) Has been cancelled
Automation / Format (push) Waiting to run
Automation / File Labeler (push) Waiting to run
Automation / Gemini Review (push) Waiting to run
Automation / Gemini Reviewed (push) Waiting to run
CI / Detect Changes (push) Waiting to run
CI / Test (macos-latest) (push) Blocked by required conditions
CI / Test (ubuntu-latest) (push) Blocked by required conditions
CI / Nix (push) Blocked by required conditions
CI / Lint (push) Blocked by required conditions
CI / Cargo Deny (push) Blocked by required conditions
CI / Verify Skills (push) Blocked by required conditions
CI / Lint Skills (push) Blocked by required conditions
CI / Build (Linux x86_64) (push) Blocked by required conditions
CI / Build (macos-latest, aarch64-apple-darwin) (push) Blocked by required conditions
CI / Build (macos-latest, x86_64-apple-darwin) (push) Blocked by required conditions
CI / Build (ubuntu-latest, aarch64-unknown-linux-gnu) (push) Blocked by required conditions
CI / Build (windows-latest, x86_64-pc-windows-msvc) (push) Blocked by required conditions
CI / API Smoketest (push) Blocked by required conditions
Coverage / Coverage (push) Waiting to run
Policy / Policy Check (push) Waiting to run
Release (Changeset) / Release (push) Waiting to run
Publish OpenClaw Skills / publish (push) Has been cancelled
Audit / Security Audit (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
---
|
||||
description: Writing and editing VHS `.tape` files for terminal demo GIFs
|
||||
---
|
||||
|
||||
# VHS Tape Files
|
||||
|
||||
[VHS](https://github.com/charmbracelet/vhs) records terminal sessions into GIFs/MP4s/WebMs from `.tape` scripts. Run with `vhs demo.tape`.
|
||||
|
||||
## Critical Syntax Rules
|
||||
|
||||
### Type command and inline directives
|
||||
|
||||
`Type`, `Sleep`, `Enter` are **separate directives on the same line**, delimited by the closing `"` of the `Type` string. The most common bug is forgetting to close the `Type` string, which causes `Sleep`/`Enter` to be typed literally into the terminal.
|
||||
|
||||
```
|
||||
# ✅ CORRECT — closing " before Sleep
|
||||
Type "echo hello" Sleep 300ms Enter
|
||||
|
||||
# ❌ WRONG — Sleep and Enter are typed as literal text
|
||||
Type "echo hello Sleep 300ms Enter
|
||||
```
|
||||
|
||||
### Type with @speed override
|
||||
|
||||
Override typing speed per-command with `@<time>` immediately after `Type` (no space):
|
||||
|
||||
```
|
||||
Type@80ms '{"pageSize": 2}' Sleep 100ms
|
||||
```
|
||||
|
||||
### Quoting
|
||||
|
||||
- Double quotes `"..."` are the standard Type delimiter
|
||||
- Single quotes `'...'` also work and are useful when the typed content contains double quotes (e.g. JSON)
|
||||
- Escape quotes inside strings with backticks: `` Type `VAR="value"` ``
|
||||
- When building shell commands with nested quotes, split across multiple `Type` lines:
|
||||
|
||||
```
|
||||
Type "gws drive files list --params '" Sleep 100ms
|
||||
Type@80ms '{"pageSize": 2, "fields": "nextPageToken,files(id)"}' Sleep 100ms
|
||||
Type "' --page-all" Sleep 300ms Enter
|
||||
```
|
||||
|
||||
> **Pitfall**: Every `Type` line that is followed by `Sleep` or `Enter` on the same line MUST close its string first. Audit each line to ensure the quote is closed before any directive.
|
||||
|
||||
## Settings (top of file only)
|
||||
|
||||
Settings must appear before any non-setting command (except `Output`). `TypingSpeed` is the only setting that can be changed mid-tape.
|
||||
|
||||
```
|
||||
Output demo.gif
|
||||
|
||||
Set Shell "bash"
|
||||
Set FontSize 14
|
||||
Set Width 1200
|
||||
Set Height 1200
|
||||
Set Theme "Catppuccin Mocha"
|
||||
Set WindowBar Colorful
|
||||
Set WindowBarSize 40
|
||||
Set TypingSpeed 40ms
|
||||
Set Padding 20
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
|
||||
| Command | Example | Notes |
|
||||
|---|---|---|
|
||||
| `Output` | `Output demo.gif` | `.gif`, `.mp4`, `.webm` |
|
||||
| `Type` | `Type "ls -la"` | Type characters |
|
||||
| `Type@<time>` | `Type@80ms "slow"` | Override typing speed |
|
||||
| `Sleep` | `Sleep 2s`, `Sleep 300ms` | Pause recording |
|
||||
| `Enter` | `Enter` | Press enter |
|
||||
| `Hide` / `Show` | `Hide` ... `Show` | Hide setup commands |
|
||||
| `Ctrl+<key>` | `Ctrl+C` | Key combos |
|
||||
| `Tab`, `Space`, `Backspace` | `Tab 2` | Optional repeat count |
|
||||
| `Up`, `Down`, `Left`, `Right` | `Up 3` | Arrow keys |
|
||||
| `Wait` | `Wait /pattern/` | Wait for regex on screen |
|
||||
| `Screenshot` | `Screenshot out.png` | Capture frame |
|
||||
| `Env` | `Env FOO "bar"` | Set env var |
|
||||
| `Source` | `Source other.tape` | Include another tape |
|
||||
| `Require` | `Require jq` | Assert program exists |
|
||||
|
||||
## Hide/Show for Setup
|
||||
|
||||
Use `Hide`/`Show` to run setup commands (e.g. setting `$PATH`, clearing screen) without recording them:
|
||||
|
||||
```
|
||||
Hide
|
||||
Type "export PATH=$PWD/target/release:$PATH" Enter
|
||||
Type "clear" Enter
|
||||
Sleep 2s
|
||||
Show
|
||||
```
|
||||
|
||||
## Checklist When Editing Tape Files
|
||||
|
||||
1. **Every `Type` string must be closed** before `Sleep`/`Enter` on the same line
|
||||
2. **Multi-line Type sequences** that build a single shell command: ensure the final line closes its string and includes `Enter`
|
||||
3. **Sleep durations** after commands should be long enough for the command to finish (network calls may need 8s+)
|
||||
4. **Settings go at the top** — only `TypingSpeed` can appear later
|
||||
5. **Test locally** with `vhs <file>.tape` before committing
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
description: Verify all skills/*/SKILL.md files against actual CLI output for accuracy
|
||||
---
|
||||
|
||||
# Verify Skills
|
||||
|
||||
Ensure every `skills/*/SKILL.md` file is accurate and optimized for AI agent consumption.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **List all skill files**
|
||||
|
||||
```bash
|
||||
find skills -name SKILL.md | sort
|
||||
```
|
||||
|
||||
2. **Get top-level help for every service**
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
for svc in drive sheets gmail calendar admin admin-reports docs slides tasks people chat vault groupssettings reseller licensing apps-script; do
|
||||
echo "=== $svc ==="
|
||||
./target/debug/gws $svc --help 2>&1
|
||||
echo
|
||||
done
|
||||
```
|
||||
|
||||
3. **Get sub-resource help for key services** (spot-check method names used in examples)
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
./target/debug/gws drive files --help 2>&1
|
||||
./target/debug/gws gmail users messages --help 2>&1
|
||||
./target/debug/gws sheets spreadsheets --help 2>&1
|
||||
./target/debug/gws sheets spreadsheets values --help 2>&1
|
||||
./target/debug/gws calendar events --help 2>&1
|
||||
./target/debug/gws people people --help 2>&1
|
||||
./target/debug/gws chat spaces --help 2>&1
|
||||
./target/debug/gws vault matters --help 2>&1
|
||||
./target/debug/gws admin users --help 2>&1
|
||||
./target/debug/gws tasks tasks --help 2>&1
|
||||
```
|
||||
|
||||
4. **For each SKILL.md, verify the following against the CLI `--help` output:**
|
||||
|
||||
- [ ] **Resource names** match exactly (e.g., `files`, `spreadsheets`, `users`)
|
||||
- [ ] **Method names** match exactly (e.g., `list`, `insert`, `batchUpdate`, `getContent`)
|
||||
- [ ] **Nested resource paths** are correct (e.g., `spreadsheets values get`, not `values get`)
|
||||
- [ ] **Alias** mentioned in the file matches `services.rs` (e.g., `gws script` for apps-script)
|
||||
- [ ] **API version** in the header is correct
|
||||
- [ ] **Example commands** use valid `--params` and `--json` flag syntax
|
||||
- [ ] **No OAuth scopes section** — scopes should not be listed in skill files
|
||||
- [ ] **Tips section** contains accurate, actionable advice
|
||||
|
||||
5. **Cross-check `shared/SKILL.md`** covers:
|
||||
|
||||
- [ ] `--fields` / field mask syntax
|
||||
- [ ] CLI syntax (`--params`, `--json`, `--output`, `--upload`, `--page-all`, `--page-limit`, `--page-delay`)
|
||||
- [ ] Authentication (`GOOGLE_WORKSPACE_CLI_CREDENTIALS`, `GOOGLE_WORKSPACE_API_KEY`)
|
||||
- [ ] Auto-pagination (`--page-all`) with NDJSON output
|
||||
- [ ] `gws schema <method>` introspection
|
||||
- [ ] Error handling JSON structure
|
||||
- [ ] Binary download with `--output`
|
||||
- [ ] Version override (`--api-version`, colon syntax)
|
||||
|
||||
6. **Fix any issues found** — update the SKILL.md files directly.
|
||||
|
||||
7. **Rebuild and re-verify** if any examples were changed.
|
||||
|
||||
// turbo
|
||||
```bash
|
||||
cargo build 2>&1
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@googleworkspace/cli": minor
|
||||
---
|
||||
|
||||
Add `--range` flag to `sheets +append` for targeting specific sheet tabs
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"attribution": {
|
||||
"commit": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# gws — Google Workspace CLI
|
||||
# Copy this file to .env and uncomment the variables you need.
|
||||
# All variables are optional. See README.md for details.
|
||||
|
||||
# ── Authentication ────────────────────────────────────────────────
|
||||
# Pre-obtained OAuth2 access token (highest priority; bypasses all credential loading)
|
||||
# GOOGLE_WORKSPACE_CLI_TOKEN=
|
||||
|
||||
# Path to OAuth credentials JSON (user or service account)
|
||||
# GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=
|
||||
|
||||
# ── OAuth Client ──────────────────────────────────────────────────
|
||||
# OAuth client ID and secret (alternative to saving client_secret.json)
|
||||
# GOOGLE_WORKSPACE_CLI_CLIENT_ID=
|
||||
# GOOGLE_WORKSPACE_CLI_CLIENT_SECRET=
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────
|
||||
# Override the config directory (default: ~/.config/gws)
|
||||
# GOOGLE_WORKSPACE_CLI_CONFIG_DIR=
|
||||
|
||||
# ── Model Armor (response sanitization) ──────────────────────────
|
||||
# Default Model Armor template (overridden by --sanitize flag)
|
||||
# GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE=
|
||||
# Sanitization mode: warn (default) or block
|
||||
# GOOGLE_WORKSPACE_CLI_SANITIZE_MODE=warn
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────
|
||||
# GCP project ID fallback for gmail watch and events subscribe (overridden by --project)
|
||||
# GOOGLE_WORKSPACE_PROJECT_ID=
|
||||
@@ -0,0 +1,7 @@
|
||||
code_review:
|
||||
comment_severity_threshold: HIGH
|
||||
pull_request_opened:
|
||||
help: false
|
||||
summary: true
|
||||
code_review: true
|
||||
include_drafts: false
|
||||
@@ -0,0 +1,39 @@
|
||||
# Code Review Style Guide
|
||||
|
||||
## Project Architecture
|
||||
|
||||
`gws` is a Rust CLI that dynamically generates commands from Google Discovery Documents at runtime. It does NOT use generated Rust crates (`google-drive3`, etc.) for API interaction. Do not suggest adding API-specific crates to `Cargo.toml`.
|
||||
|
||||
For additional context, read `AGENTS.md`.
|
||||
|
||||
## Security: Trusted vs Untrusted Inputs
|
||||
|
||||
This CLI is frequently invoked by AI/LLM agents. CLI arguments may be adversarial.
|
||||
|
||||
- **CLI arguments (untrusted)** — Must validate paths against traversal (`../../`), reject control characters, percent-encode URL path segments, and use `reqwest .query()` for query parameters. Validators: `validate_safe_output_dir()`, `validate_safe_dir_path()`, `encode_path_segment()`, `validate_resource_name()`.
|
||||
- **Environment variables (trusted)** — Set by the user in their shell profile, `.env` file, or deployment config. Do NOT flag missing path validation on environment variable values. This is consistent with `XDG_CONFIG_HOME`, `CARGO_HOME`, etc.
|
||||
|
||||
## Test Coverage
|
||||
|
||||
The `codecov/patch` check requires new/modified lines to be covered by tests. Prefer extracting testable helper functions over embedding logic in `main`/`run`. Tests should cover both happy paths and rejection paths (e.g., pass `../../.ssh` and assert `Err`).
|
||||
|
||||
## Changesets
|
||||
|
||||
Every PR must include a `.changeset/<name>.md` file. The package name **must** be `"@googleworkspace/cli"` (not `"googleworkspace-cli"`). Use `patch` for fixes/chores, `minor` for features, `major` for breaking changes.
|
||||
|
||||
## PR Scope
|
||||
|
||||
Review comments must stay within the PR's stated scope. If you spot an improvement opportunity that is unrelated to the PR's purpose (e.g., refactoring constants, adding support for a different credential type, making an unrelated function atomic), mark it as a **follow-up** suggestion — not a blocking review comment. Do not request changes that expand the PR beyond its original intent.
|
||||
|
||||
Examples of scope creep to avoid:
|
||||
- A bug-fix PR should not grow into a refactoring PR.
|
||||
- Adding constants for strings used elsewhere is a separate cleanup task.
|
||||
- Making a pre-existing function atomic is an enhancement, not a fix for the current PR.
|
||||
|
||||
## Severity Calibration
|
||||
|
||||
Mark issues as **critical** only when they cause data loss, security vulnerabilities, or incorrect behavior under normal conditions. Theoretical failures in infallible system APIs (e.g., `tokio::signal::ctrl_c()` registration) are **low** severity — do not label them critical. Contradicting a prior review suggestion (e.g., suggesting `expect()` then flagging `expect()` as wrong) erodes trust; verify consistency with earlier comments before posting.
|
||||
|
||||
## Helper Commands (`+verb`)
|
||||
|
||||
Helpers are handwritten commands that provide value Discovery-based commands cannot: multi-step orchestration, format translation, or multi-API composition. **Do not accept helpers that wrap a single API call, add flags to expose data already in the API response, or re-implement Discovery parameters as custom flags.** See [`src/helpers/README.md`](../src/helpers/README.md) for full guidelines and anti-patterns.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Codeowners
|
||||
|
||||
# Core engine code strictly requires your review
|
||||
# Isolates agents to `skills/` or `src/helpers/` unless absolutely necessary
|
||||
/src/main.rs @jpoehnelt
|
||||
/src/executor.rs @jpoehnelt
|
||||
/src/discovery.rs @jpoehnelt
|
||||
/src/commands.rs @jpoehnelt
|
||||
/src/auth.rs @jpoehnelt
|
||||
/src/schema.rs @jpoehnelt
|
||||
@@ -0,0 +1,16 @@
|
||||
## Description
|
||||
|
||||
Please include a summary of the change and which issue is fixed. If adding a new feature or command, please include the output of running it with `--dry-run` to prove the JSON request body matches the Discovery Document schema.
|
||||
|
||||
**Dry Run Output:**
|
||||
```json
|
||||
// Paste --dry-run output here if applicable
|
||||
```
|
||||
|
||||
## Checklist:
|
||||
|
||||
- [ ] My code follows the `AGENTS.md` guidelines (no generated `google-*` crates).
|
||||
- [ ] I have run `cargo fmt --all` to format the code perfectly.
|
||||
- [ ] I have run `cargo clippy -- -D warnings` and resolved all warnings.
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works.
|
||||
- [ ] I have provided a Changeset file (e.g. via `pnpx changeset`) to document my changes.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Labels applied to PRs based on changed files.
|
||||
# Used by the actions/labeler action in .github/workflows/automation.yml
|
||||
|
||||
"area: auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- crates/google-workspace-cli/src/auth.rs
|
||||
- crates/google-workspace-cli/src/auth_commands.rs
|
||||
- crates/google-workspace-cli/src/setup.rs
|
||||
- crates/google-workspace-cli/src/accounts.rs
|
||||
- crates/google-workspace-cli/src/credential_store.rs
|
||||
- crates/google-workspace-cli/src/token_storage.rs
|
||||
- crates/google-workspace-cli/src/oauth_config.rs
|
||||
|
||||
"area: discovery":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- crates/google-workspace-cli/src/discovery.rs
|
||||
- crates/google-workspace-cli/src/services.rs
|
||||
- crates/google-workspace/src/discovery.rs
|
||||
- crates/google-workspace/src/services.rs
|
||||
|
||||
"area: http":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- crates/google-workspace-cli/src/executor.rs
|
||||
- crates/google-workspace-cli/src/client.rs
|
||||
- crates/google-workspace/src/client.rs
|
||||
|
||||
"area: tui":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- crates/google-workspace-cli/src/setup_tui.rs
|
||||
|
||||
"area: mcp":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- crates/google-workspace-cli/src/mcp_server.rs
|
||||
|
||||
"area: skills":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- crates/google-workspace-cli/src/generate_skills.rs
|
||||
- skills/**
|
||||
|
||||
"area: docs":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "*.md"
|
||||
- docs/**
|
||||
|
||||
"area: distribution":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- .github/workflows/release.yml
|
||||
- .github/workflows/release-changesets.yml
|
||||
- dist-workspace.toml
|
||||
- Cargo.toml
|
||||
- crates/*/Cargo.toml
|
||||
|
||||
"area: core":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- crates/google-workspace-cli/src/main.rs
|
||||
- crates/google-workspace-cli/src/commands.rs
|
||||
- crates/google-workspace-cli/src/error.rs
|
||||
- crates/google-workspace-cli/src/formatter.rs
|
||||
- crates/google-workspace-cli/src/fs_util.rs
|
||||
- crates/google-workspace-cli/src/helpers/**
|
||||
- crates/google-workspace-cli/src/text.rs
|
||||
- crates/google-workspace-cli/src/validate.rs
|
||||
- crates/google-workspace-cli/src/schema.rs
|
||||
|
||||
"crate: google-workspace":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- crates/google-workspace/src/**
|
||||
- crates/google-workspace/Cargo.toml
|
||||
@@ -0,0 +1,45 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Audit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['Cargo.lock', 'Cargo.toml', 'crates/*/Cargo.toml']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['Cargo.lock', 'Cargo.toml', 'crates/*/Cargo.toml']
|
||||
schedule:
|
||||
- cron: '0 6 * * *' # Daily at 06:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: Security Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Install cargo-audit
|
||||
uses: taiki-e/install-action@a37010ded18ff788be4440302bd6830b1ae50d8b # cargo-llvm-cov
|
||||
with:
|
||||
tool: cargo-audit
|
||||
|
||||
- name: Run cargo audit
|
||||
run: cargo audit
|
||||
@@ -0,0 +1,142 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Automation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
format:
|
||||
name: Format
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
with:
|
||||
components: rustfmt
|
||||
|
||||
- name: Run cargo fmt
|
||||
run: cargo fmt --all
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
git diff --cached --quiet || git commit -m "style: cargo fmt" && git push
|
||||
|
||||
file-labeler:
|
||||
name: File Labeler
|
||||
if: github.event_name == 'pull_request_target'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5
|
||||
with:
|
||||
repo-token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
|
||||
sync-labels: true
|
||||
|
||||
gemini-review:
|
||||
name: Gemini Review
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'synchronize'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: gemini-review-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Remove reviewed label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
name: 'gemini: reviewed',
|
||||
});
|
||||
} catch (e) {
|
||||
// Label not present — ignore
|
||||
}
|
||||
|
||||
- name: Debounce
|
||||
run: sleep 60
|
||||
|
||||
- name: Trigger Gemini Code Assist review
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body: '/gemini review',
|
||||
});
|
||||
|
||||
gemini-reviewed:
|
||||
name: Gemini Reviewed
|
||||
if: >-
|
||||
github.event_name == 'pull_request_review' &&
|
||||
github.event.review.user.login == 'gemini-code-assist[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add reviewed label if review matches HEAD
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
});
|
||||
|
||||
if (context.payload.review.commit_id !== pr.data.head.sha) {
|
||||
console.log(`Review is for ${context.payload.review.commit_id} but HEAD is ${pr.data.head.sha} — skipping label`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: ['gemini: reviewed'],
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.status === 403) {
|
||||
console.log(`Token cannot add labels for this review event (${e.message}) — skipping`);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "true"
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect Changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
rust: ${{ steps.filter.outputs.rust }}
|
||||
nix: ${{ steps.filter.outputs.nix }}
|
||||
skills: ${{ steps.filter.outputs.skills }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
rust:
|
||||
- '**/*.rs'
|
||||
- 'Cargo.toml'
|
||||
- 'crates/*/Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'build.rs'
|
||||
- '.cargo/**'
|
||||
nix:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
skills:
|
||||
- 'skills/**'
|
||||
|
||||
test:
|
||||
name: Test
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true' || github.event_name == 'push'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
|
||||
- name: Setup sccache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7
|
||||
continue-on-error: true
|
||||
|
||||
- name: Enable sccache
|
||||
if: steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
if sccache --start-server 2>/dev/null; then
|
||||
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::warning::sccache server failed to start, building without cache"
|
||||
fi
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2
|
||||
with:
|
||||
key: test-${{ matrix.os }}
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --workspace --verbose
|
||||
|
||||
nix:
|
||||
name: Nix
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.nix == 'true' || github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@d96bc962e61b3049ce8128d03d57a1144fa96539 # main
|
||||
- name: Magic Nix Cache
|
||||
uses: DeterminateSystems/magic-nix-cache-action@cec65ff6f104850203b152861d3f9e5f1747885d # main
|
||||
- name: Check flake
|
||||
run: nix flake check
|
||||
- name: Build flake
|
||||
run: nix build .
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true' || github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Setup sccache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7
|
||||
continue-on-error: true
|
||||
|
||||
- name: Enable sccache
|
||||
if: steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
if sccache --start-server 2>/dev/null; then
|
||||
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::warning::sccache server failed to start, building without cache"
|
||||
fi
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2
|
||||
with:
|
||||
key: lint
|
||||
|
||||
- name: Check formatting
|
||||
run: |
|
||||
if ! cargo fmt --all -- --check; then
|
||||
echo "::error::Cargo fmt failed. Please run 'cargo fmt --all' locally and commit the changes."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace -- -D warnings
|
||||
|
||||
deny:
|
||||
name: Cargo Deny
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true' || github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Cargo deny
|
||||
uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979 # v2.0.15
|
||||
with:
|
||||
command: check
|
||||
|
||||
|
||||
skills:
|
||||
name: Verify Skills
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true' || github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
|
||||
- name: Setup sccache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7
|
||||
continue-on-error: true
|
||||
|
||||
- name: Enable sccache
|
||||
if: steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
if sccache --start-server 2>/dev/null; then
|
||||
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::warning::sccache server failed to start, building without cache"
|
||||
fi
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2
|
||||
with:
|
||||
key: skills
|
||||
|
||||
- name: Regenerate skills
|
||||
run: cargo run -- generate-skills --output-dir skills
|
||||
|
||||
- name: Check for drift
|
||||
run: |
|
||||
if ! git diff --exit-code skills/; then
|
||||
echo "::warning::Skills are out of date — the hourly auto-sync PR will fix this automatically."
|
||||
fi
|
||||
|
||||
lint-skills:
|
||||
name: Lint Skills
|
||||
needs: changes
|
||||
if: needs.changes.outputs.skills == 'true' || github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
|
||||
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
- name: Validate skills
|
||||
run: |
|
||||
failed=0
|
||||
for skill_dir in skills/*/; do
|
||||
if ! uvx --from skills-ref@0.1.1 agentskills validate "$skill_dir"; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo "::error::One or more skills failed validation."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-linux:
|
||||
name: Build (Linux x86_64)
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true' || github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Setup sccache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7
|
||||
continue-on-error: true
|
||||
|
||||
- name: Enable sccache
|
||||
if: steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
if sccache --start-server 2>/dev/null; then
|
||||
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::warning::sccache server failed to start, building without cache"
|
||||
fi
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2
|
||||
with:
|
||||
key: build-x86_64-unknown-linux-gnu
|
||||
cache-targets: "false"
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release --target x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: gws-linux-x86_64
|
||||
path: target/x86_64-unknown-linux-gnu/release/gws
|
||||
retention-days: 1
|
||||
|
||||
build:
|
||||
name: Build
|
||||
needs: [smoketest, changes]
|
||||
if: |
|
||||
always() && !cancelled() && !failure()
|
||||
&& (needs.changes.outputs.rust == 'true' || github.event_name == 'push')
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Setup sccache
|
||||
id: sccache
|
||||
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7
|
||||
continue-on-error: true
|
||||
|
||||
- name: Enable sccache
|
||||
if: steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
if sccache --start-server 2>/dev/null; then
|
||||
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::warning::sccache server failed to start, building without cache"
|
||||
fi
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2
|
||||
with:
|
||||
key: build-${{ matrix.target }}
|
||||
cache-targets: "false"
|
||||
|
||||
- name: Install cross-compilation tools
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
|
||||
- name: Disable Windows Defender scanning for cargo
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
Add-MpPreference -ExclusionPath "$env:USERPROFILE\.cargo"
|
||||
Add-MpPreference -ExclusionPath "$env:USERPROFILE\.rustup"
|
||||
Add-MpPreference -ExclusionPath "${{ github.workspace }}\target"
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release --target ${{ matrix.target }}
|
||||
env:
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
||||
|
||||
smoketest:
|
||||
name: API Smoketest
|
||||
needs: build-linux
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Download binary
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: gws-linux-x86_64
|
||||
path: ./bin
|
||||
|
||||
- name: Make binary executable
|
||||
run: chmod +x ./bin/gws
|
||||
|
||||
- name: Decode credentials
|
||||
env:
|
||||
GOOGLE_CREDENTIALS_JSON: ${{ secrets.GOOGLE_CREDENTIALS_JSON }}
|
||||
run: |
|
||||
if [ -z "$GOOGLE_CREDENTIALS_JSON" ]; then
|
||||
echo "::error::GOOGLE_CREDENTIALS_JSON secret is not set"
|
||||
exit 1
|
||||
fi
|
||||
echo "$GOOGLE_CREDENTIALS_JSON" | base64 -d > /tmp/credentials.json
|
||||
|
||||
- name: Smoketest — help
|
||||
run: ./bin/gws --help
|
||||
|
||||
- name: Smoketest — schema introspection
|
||||
run: ./bin/gws schema drive.files.list | jq -e '.httpMethod'
|
||||
|
||||
- name: Smoketest — Drive files list
|
||||
env:
|
||||
GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE: /tmp/credentials.json
|
||||
run: |
|
||||
./bin/gws drive files list \
|
||||
--params '{"pageSize": 1, "fields": "files(id,mimeType)"}' \
|
||||
| jq -e '.files'
|
||||
|
||||
- name: Smoketest — Gmail messages
|
||||
env:
|
||||
GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE: /tmp/credentials.json
|
||||
run: |
|
||||
./bin/gws gmail users messages list \
|
||||
--params '{"userId": "me", "maxResults": 1, "fields": "messages(id)"}' \
|
||||
| jq -e '.messages'
|
||||
|
||||
- name: Smoketest — Calendar events
|
||||
env:
|
||||
GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE: /tmp/credentials.json
|
||||
run: |
|
||||
./bin/gws calendar events list \
|
||||
--params '{"calendarId": "primary", "maxResults": 1, "fields": "kind,items(id,status)"}' \
|
||||
| jq -e '.kind'
|
||||
|
||||
- name: Smoketest — Slides presentation
|
||||
env:
|
||||
GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE: /tmp/credentials.json
|
||||
run: |
|
||||
./bin/gws slides presentations get \
|
||||
--params '{"presentationId": "1knOKD_87JWE4qsEbO4r5O91IxTER5ybBBhOJgZ1yLFI", "fields": "presentationId,slides(objectId)"}' \
|
||||
| jq -e '.presentationId'
|
||||
|
||||
- name: Smoketest — pagination
|
||||
env:
|
||||
GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE: /tmp/credentials.json
|
||||
run: |
|
||||
LINES=$(./bin/gws drive files list \
|
||||
--params '{"pageSize": 1, "fields": "nextPageToken,files(id)"}' \
|
||||
--page-all --page-limit 2 \
|
||||
| wc -l)
|
||||
if [ "$LINES" -lt 2 ]; then
|
||||
echo "::error::Expected at least 2 NDJSON lines from pagination, got $LINES"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Smoketest — error handling
|
||||
run: |
|
||||
if ./bin/gws fakeservice list 2>&1; then
|
||||
echo "::error::Expected exit code 1 for unknown service"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Cleanup credentials
|
||||
if: always()
|
||||
run: rm -f /tmp/credentials.json
|
||||
@@ -0,0 +1,60 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: CLA
|
||||
|
||||
on:
|
||||
check_run:
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
cla-label:
|
||||
name: CLA Label
|
||||
if: github.event.check_run.name == 'cla/google'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update CLA label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
|
||||
script: |
|
||||
const cr = context.payload.check_run;
|
||||
const passed = cr.conclusion === 'success';
|
||||
|
||||
for (const pr of cr.pull_requests) {
|
||||
const labels = passed
|
||||
? { add: 'cla: yes', remove: 'cla: no' }
|
||||
: { add: 'cla: no', remove: 'cla: yes' };
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
labels: [labels.add],
|
||||
});
|
||||
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
name: labels.remove,
|
||||
});
|
||||
} catch (e) {
|
||||
// Label not present — ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['**/*.rs', 'Cargo.toml', 'Cargo.lock']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['**/*.rs', 'Cargo.toml', 'Cargo.lock']
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@a37010ded18ff788be4440302bd6830b1ae50d8b # cargo-llvm-cov
|
||||
with:
|
||||
tool: cargo-llvm-cov
|
||||
|
||||
- name: Generate code coverage
|
||||
run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4
|
||||
with:
|
||||
files: lcov.info
|
||||
fail_ci_if_error: false
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -0,0 +1,119 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Generate Skills
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 * * * *" # Hourly — keeps skills in sync with Discovery API changes
|
||||
workflow_dispatch: # Manual trigger
|
||||
push:
|
||||
branches-ignore:
|
||||
- main # main is kept up to date by PR merges
|
||||
|
||||
concurrency:
|
||||
group: generate-skills-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
name: Generate and commit skills
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
# For cron/dispatch: check out main. For push: check out the branch.
|
||||
ref: ${{ github.head_ref || github.ref_name }}
|
||||
token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2
|
||||
with:
|
||||
key: generate-skills-ubuntu
|
||||
|
||||
- name: Generate skills
|
||||
run: cargo run -- generate-skills
|
||||
|
||||
- name: Check for changes
|
||||
id: diff
|
||||
run: |
|
||||
if git diff --quiet skills/ docs/skills.md; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# --- Cron / workflow_dispatch: open a PR against main ---
|
||||
- name: Create changeset for sync PR
|
||||
if: >-
|
||||
steps.diff.outputs.changed == 'true' &&
|
||||
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
|
||||
run: |
|
||||
mkdir -p .changeset
|
||||
cat > .changeset/sync-skills.md << 'EOF'
|
||||
---
|
||||
"@googleworkspace/cli": patch
|
||||
---
|
||||
|
||||
Sync generated skills with latest Google Discovery API specs
|
||||
EOF
|
||||
|
||||
- name: Create or update sync PR
|
||||
if: >-
|
||||
steps.diff.outputs.changed == 'true' &&
|
||||
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
|
||||
with:
|
||||
token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
|
||||
branch: chore/sync-skills
|
||||
title: "chore: sync skills with Discovery API"
|
||||
body: |
|
||||
Automated PR — the Google Discovery API specs have changed and the
|
||||
generated skill files are out of date.
|
||||
|
||||
Created by the **Generate Skills** workflow (`generate-skills.yml`).
|
||||
commit-message: "chore: regenerate skills from Discovery API"
|
||||
add-paths: |
|
||||
skills/
|
||||
docs/skills.md
|
||||
.changeset/sync-skills.md
|
||||
delete-branch: true
|
||||
|
||||
# --- Push events (non-main branches): commit directly ---
|
||||
- name: Commit and push if changed
|
||||
if: >-
|
||||
steps.diff.outputs.changed == 'true' &&
|
||||
github.event_name == 'push'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
|
||||
run: |
|
||||
git config user.name "googleworkspace-bot"
|
||||
git config user.email "googleworkspace-bot@users.noreply.github.com"
|
||||
|
||||
git add skills/ docs/skills.md
|
||||
git commit -m "chore: regenerate skills [skip ci]"
|
||||
git push
|
||||
@@ -0,0 +1,67 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Policy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
policy-check:
|
||||
name: Policy Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Enforce AGENTS.md rules
|
||||
run: |
|
||||
# Check CLI crate for prohibited google-* registry crates.
|
||||
# Path dependencies (e.g. google-workspace = { path = ... }) are allowed.
|
||||
if grep -E "^google-[a-zA-Z0-9_-]+[[:space:]]*=" crates/google-workspace-cli/Cargo.toml | grep -v 'path[[:space:]]*='; then
|
||||
echo "::error file=crates/google-workspace-cli/Cargo.toml::Violates AGENTS.md: Adding generated google-* crates is prohibited. The CLI uses dynamic schema discovery at runtime."
|
||||
exit 1
|
||||
fi
|
||||
echo "Policy check passed."
|
||||
- name: Enforce Changeset File
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
git fetch origin ${{ github.base_ref }}
|
||||
# Skip changeset requirement if no Rust/Cargo files changed
|
||||
if ! git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -qE '\.(rs)$|^Cargo\.(toml|lock)$'; then
|
||||
echo "No Rust/Cargo files changed; skipping changeset requirement."
|
||||
exit 0
|
||||
fi
|
||||
if ! git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q "^.changeset/.*\.md$"; then
|
||||
echo "::error::A Changeset file is required! Please run 'npx changeset' or manually create a markdown file in the .changeset directory describing your changes to automatically version and release this PR."
|
||||
exit 1
|
||||
fi
|
||||
echo "Changeset file found!"
|
||||
- name: Validate Changeset Package Name
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
for f in $(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep "^.changeset/.*\.md$"); do
|
||||
if grep -q '"googleworkspace-cli"' "$f"; then
|
||||
echo "::error file=$f::Wrong package name. Use '\"@googleworkspace/cli\"' not '\"googleworkspace-cli\"'."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "Changeset package names valid!"
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Publish OpenClaw Skills
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "skills/**"
|
||||
- ".github/workflows/publish-skills.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "skills/**"
|
||||
- ".github/workflows/publish-skills.yml"
|
||||
schedule:
|
||||
- cron: "0 * * * *" # Hourly, to drip-publish past rate limits
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# Skip fork PRs — secrets (CLAWHUB_TOKEN) are not available
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install ClawHub CLI
|
||||
run: npm i -g clawhub@0.7.0
|
||||
|
||||
- name: Authenticate ClawHub
|
||||
env:
|
||||
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$CLAWHUB_TOKEN" ]; then
|
||||
echo "::error::CLAWHUB_TOKEN secret is not set"
|
||||
exit 1
|
||||
fi
|
||||
clawhub login --token "$CLAWHUB_TOKEN"
|
||||
|
||||
- name: Publish skills
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
clawhub sync --root skills --all --dry-run
|
||||
else
|
||||
clawhub sync --root skills --all
|
||||
fi
|
||||
@@ -0,0 +1,72 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Release (Changeset)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@08dcb3a5e62fa31e2da3d490afc4176ef55ecd72 # v30
|
||||
with:
|
||||
github_access_token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
|
||||
|
||||
- uses: pnpm/action-setup@c5ba7f7862a0f64c1b1a05fbac13e0b8e86ba08c # v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- run: |
|
||||
git config --global user.name "googleworkspace-bot"
|
||||
git config --global user.email "googleworkspace-bot@google.com"
|
||||
|
||||
- name: Create Release Pull Request or Tag
|
||||
id: changesets
|
||||
uses: changesets/action@6a0a831ff30acef54f2c6aa1cbbc1096b066edaf # v1
|
||||
with:
|
||||
version: pnpm run version-sync
|
||||
publish: pnpm run tag-release
|
||||
commit: 'chore: release versions'
|
||||
title: 'chore: release versions'
|
||||
setupGitUser: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
|
||||
@@ -0,0 +1,227 @@
|
||||
# Release workflow for @googleworkspace/cli
|
||||
#
|
||||
# Triggered by pushing a semver tag (e.g. v0.22.3).
|
||||
# Builds platform binaries, creates a GitHub Release, publishes to npm and crates.io.
|
||||
|
||||
name: Release
|
||||
permissions:
|
||||
contents: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+*'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
prerelease: ${{ steps.meta.outputs.prerelease }}
|
||||
publishing: ${{ github.ref_type == 'tag' }}
|
||||
steps:
|
||||
- id: meta
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
VERSION="${TAG#v}"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$VERSION" == *-* ]]; then
|
||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: plan
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-apple-darwin
|
||||
runner: macos-latest
|
||||
archive: tar.gz
|
||||
- target: x86_64-apple-darwin
|
||||
runner: macos-latest
|
||||
archive: tar.gz
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
runner: ubuntu-latest
|
||||
archive: tar.gz
|
||||
cross: true
|
||||
- target: aarch64-unknown-linux-musl
|
||||
runner: ubuntu-latest
|
||||
archive: tar.gz
|
||||
cross: true
|
||||
- target: x86_64-unknown-linux-gnu
|
||||
runner: ubuntu-latest
|
||||
archive: tar.gz
|
||||
- target: x86_64-unknown-linux-musl
|
||||
runner: ubuntu-latest
|
||||
archive: tar.gz
|
||||
cross: true
|
||||
- target: x86_64-pc-windows-msvc
|
||||
runner: windows-latest
|
||||
archive: zip
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install cross
|
||||
if: matrix.cross
|
||||
run: cargo install cross --git https://github.com/cross-rs/cross --tag v0.2.5
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
if [ "${{ matrix.cross }}" = "true" ]; then
|
||||
cross build --release --target ${{ matrix.target }} --locked
|
||||
else
|
||||
cargo build --release --target ${{ matrix.target }} --locked
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Package (unix)
|
||||
if: matrix.archive == 'tar.gz'
|
||||
run: |
|
||||
ARTIFACT="google-workspace-cli-${{ matrix.target }}"
|
||||
mkdir -p staging
|
||||
cp "target/${{ matrix.target }}/release/gws" staging/
|
||||
cp LICENSE README.md CHANGELOG.md staging/
|
||||
tar czf "${ARTIFACT}.tar.gz" -C staging .
|
||||
shasum -a 256 "${ARTIFACT}.tar.gz" > "${ARTIFACT}.tar.gz.sha256"
|
||||
shell: bash
|
||||
|
||||
- name: Package (windows)
|
||||
if: matrix.archive == 'zip'
|
||||
run: |
|
||||
$ARTIFACT = "google-workspace-cli-${{ matrix.target }}"
|
||||
New-Item -ItemType Directory -Path $ARTIFACT
|
||||
Copy-Item "target/${{ matrix.target }}/release/gws.exe" "$ARTIFACT/"
|
||||
Copy-Item LICENSE, README.md, CHANGELOG.md "$ARTIFACT/"
|
||||
Compress-Archive -Path "$ARTIFACT/*" -DestinationPath "$ARTIFACT.zip"
|
||||
(Get-FileHash "$ARTIFACT.zip" -Algorithm SHA256).Hash.ToLower() + " $ARTIFACT.zip" | Out-File -Encoding ascii "$ARTIFACT.zip.sha256"
|
||||
shell: pwsh
|
||||
|
||||
- name: Attest
|
||||
if: github.ref_type == 'tag'
|
||||
uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 # v3
|
||||
with:
|
||||
subject-path: google-workspace-cli-${{ matrix.target }}.${{ matrix.archive }}
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: binary-${{ matrix.target }}
|
||||
path: |
|
||||
google-workspace-cli-${{ matrix.target }}.${{ matrix.archive }}
|
||||
google-workspace-cli-${{ matrix.target }}.${{ matrix.archive }}.sha256
|
||||
|
||||
release:
|
||||
needs: [plan, build]
|
||||
if: github.ref_type == 'tag'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: binary-*
|
||||
path: artifacts/
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PRERELEASE_FLAG=""
|
||||
if [ "${{ needs.plan.outputs.prerelease }}" = "true" ]; then
|
||||
PRERELEASE_FLAG="--prerelease"
|
||||
fi
|
||||
|
||||
NOTES="## Installation
|
||||
|
||||
Download the archive for your OS and architecture from the assets below.
|
||||
|
||||
### Linux / macOS
|
||||
|
||||
Replace \`<target>\` with your platform (e.g., \`aarch64-apple-darwin\` or \`x86_64-unknown-linux-gnu\`).
|
||||
|
||||
\`\`\`bash
|
||||
# 1. Download the archive and its checksum
|
||||
curl -sLO https://github.com/googleworkspace/cli/releases/download/${{ github.ref_name }}/google-workspace-cli-<target>.tar.gz
|
||||
curl -sLO https://github.com/googleworkspace/cli/releases/download/${{ github.ref_name }}/google-workspace-cli-<target>.tar.gz.sha256
|
||||
|
||||
# 2. Verify the checksum
|
||||
shasum -a 256 -c google-workspace-cli-<target>.tar.gz.sha256
|
||||
|
||||
# 3. Extract and install
|
||||
tar -xzf google-workspace-cli-<target>.tar.gz
|
||||
chmod +x gws
|
||||
sudo mv gws /usr/local/bin/
|
||||
\`\`\`
|
||||
|
||||
### Windows
|
||||
|
||||
1. Download \`google-workspace-cli-x86_64-pc-windows-msvc.zip\` and its \`.sha256\` file
|
||||
2. Verify the checksum (e.g., using PowerShell \`Get-FileHash\`)
|
||||
3. Extract the archive and move \`gws.exe\` to a directory included in your system \`PATH\`.
|
||||
|
||||
---
|
||||
"
|
||||
|
||||
gh release create "${{ github.ref_name }}" \
|
||||
--target "${{ github.sha }}" \
|
||||
--title "${{ github.ref_name }}" \
|
||||
--notes "$NOTES" \
|
||||
--generate-notes \
|
||||
$PRERELEASE_FLAG \
|
||||
artifacts/*
|
||||
|
||||
publish-npm:
|
||||
needs: [plan, release]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.plan.outputs.publishing == 'true' && needs.plan.outputs.prerelease == 'false' }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://wombat-dressing-room.appspot.com'
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: npm
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
publish-cargo:
|
||||
needs: [plan, release]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.plan.outputs.publishing == 'true' && needs.plan.outputs.prerelease == 'false' }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
|
||||
# Publish library crate first (CLI depends on it)
|
||||
- name: Publish google-workspace
|
||||
run: cargo publish --package google-workspace
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
|
||||
# Wait for crates.io to index the library crate
|
||||
- name: Wait for crates.io index
|
||||
run: sleep 30
|
||||
|
||||
- name: Publish google-workspace-cli
|
||||
run: cargo publish --package google-workspace-cli
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
@@ -0,0 +1,36 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: 'Close Stale PRs'
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
|
||||
with:
|
||||
days-before-issue-stale: -1
|
||||
days-before-issue-close: -1
|
||||
days-before-pr-stale: 3
|
||||
days-before-pr-close: 0
|
||||
stale-pr-message: 'This PR has been inactive for 72 hours. Closing to keep the queue clean.'
|
||||
close-pr-message: 'This PR was closed because it has been stalled for 72 hours. Feel free to magically reopen it if you want to continue working on it!'
|
||||
exempt-pr-labels: 'keep-alive'
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# Rust
|
||||
/target/
|
||||
**/*.rs.bk
|
||||
bin/
|
||||
lcov.info
|
||||
|
||||
|
||||
.emails/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.npm
|
||||
|
||||
# Build outputs
|
||||
/dist/
|
||||
*.tsbuildinfo
|
||||
/bin/gws-native*
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Test artifacts
|
||||
*.log
|
||||
coverage/
|
||||
.nyc_output/
|
||||
download.txt
|
||||
files.jsonl
|
||||
|
||||
# Plans (local design docs)
|
||||
docs/plans/
|
||||
|
||||
# Generated
|
||||
demo.mp4
|
||||
download.html
|
||||
@@ -0,0 +1,239 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project Overview
|
||||
|
||||
`gws` is a Rust CLI tool for interacting with Google Workspace APIs. It dynamically generates its command surface at runtime by parsing Google Discovery Service JSON documents.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Dynamic Discovery**: This project does NOT use generated Rust crates (e.g., `google-drive3`) for API interaction. Instead, it fetches the Discovery JSON at runtime and builds `clap` commands dynamically. When adding a new service, you only need to register it in `crates/google-workspace/src/services.rs` and verify the Discovery URL pattern in `crates/google-workspace/src/discovery.rs`. Do NOT add new crates to `Cargo.toml` for standard Google APIs.
|
||||
|
||||
> [!NOTE]
|
||||
> **Package Manager**: Use `pnpm` instead of `npm` for Node.js package management in this repository.
|
||||
|
||||
## Build & Test
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Test Coverage**: The `codecov/patch` check requires that new or modified lines are covered by tests. When adding code, extract testable helper functions rather than embedding logic in `main`/`run` where it's hard to unit-test. Run `cargo test` locally and verify new branches are exercised.
|
||||
|
||||
```bash
|
||||
cargo build # Build in dev mode
|
||||
cargo clippy -- -D warnings # Lint check
|
||||
cargo test # Run tests
|
||||
```
|
||||
|
||||
## Changesets
|
||||
|
||||
Every PR must include a changeset file. Create one at `.changeset/<descriptive-name>.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
"@googleworkspace/cli": patch
|
||||
---
|
||||
|
||||
Brief description of the change
|
||||
```
|
||||
|
||||
Use `patch` for fixes/chores, `minor` for new features, `major` for breaking changes. The CI policy check will fail without a changeset.
|
||||
|
||||
## Architecture
|
||||
|
||||
The CLI uses a **two-phase argument parsing** strategy:
|
||||
|
||||
1. Parse argv to extract the service name (e.g., `drive`)
|
||||
2. Fetch the service's Discovery Document, build a dynamic `clap::Command` tree, then re-parse
|
||||
|
||||
### Workspace Layout
|
||||
|
||||
The repository is a Cargo workspace with two crates:
|
||||
|
||||
| Crate | Package | Purpose |
|
||||
| ------------------------------ | ----------------------- | ------------------------------------------------- |
|
||||
| `crates/google-workspace/` | `google-workspace` | Publishable library — core types and helpers |
|
||||
| `crates/google-workspace-cli/` | `google-workspace-cli` | Binary crate — the `gws` CLI |
|
||||
|
||||
#### Library (`crates/google-workspace/src/`)
|
||||
|
||||
| File | Purpose |
|
||||
| ---------------- | ---------------------------------------------------------- |
|
||||
| `discovery.rs` | Serde models for Discovery Document + async fetch/cache |
|
||||
| `services.rs` | Service alias → Discovery API name/version mapping |
|
||||
| `error.rs` | `GwsError` enum, exit codes, JSON serialization |
|
||||
| `validate.rs` | Path/URL/resource validators, `encode_path_segment()` |
|
||||
| `client.rs` | HTTP client with retry logic |
|
||||
|
||||
#### CLI (`crates/google-workspace-cli/src/`)
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------- | ------------------------------------------------------------------------ |
|
||||
| `main.rs` | Entrypoint, two-phase CLI parsing, method resolution |
|
||||
| `auth.rs` | OAuth2 token acquisition via env vars, encrypted credentials, or ADC |
|
||||
| `credential_store.rs` | AES-256-GCM encryption/decryption of credential files |
|
||||
| `auth_commands.rs` | `gws auth` subcommands: `login`, `logout`, `setup`, `status`, `export` |
|
||||
| `commands.rs` | Recursive `clap::Command` builder from Discovery resources |
|
||||
| `executor.rs` | HTTP request construction, response handling, schema validation |
|
||||
| `schema.rs` | `gws schema` command — introspect API method schemas |
|
||||
| `logging.rs` | Opt-in structured logging (stderr + file) via `tracing` |
|
||||
| `timezone.rs` | Account timezone resolution: `--timezone` flag, Calendar Settings API |
|
||||
|
||||
## Demo Videos
|
||||
|
||||
Demo recordings are generated with [VHS](https://github.com/charmbracelet/vhs) (`.tape` files).
|
||||
|
||||
```bash
|
||||
vhs docs/demo.tape
|
||||
```
|
||||
|
||||
### VHS quoting rules
|
||||
|
||||
- Use **double quotes** for simple strings: `Type "gws --help" Enter`
|
||||
- Use **backtick quotes** when the typed text contains JSON with double quotes:
|
||||
```
|
||||
Type `gws drive files list --params '{"pageSize":5}'` Enter
|
||||
```
|
||||
`\"` escapes inside double-quoted `Type` strings are **not supported** by VHS and will cause parse errors.
|
||||
|
||||
### Scene art
|
||||
|
||||
ASCII art title cards live in `art/`. The `scripts/show-art.sh` helper clears the screen and cats the file. Portrait scenes use `scene*.txt`; landscape chapters use `long-*.txt`.
|
||||
|
||||
## Input Validation & URL Safety
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This CLI is frequently invoked by AI/LLM agents. Always assume inputs can be adversarial — validate paths against traversal (`../../.ssh`), restrict format strings to allowlists, reject control characters, and encode user values before embedding them in URLs.
|
||||
|
||||
> [!NOTE]
|
||||
> **Environment variables are trusted inputs.** The validation rules above apply to **CLI arguments** that may be passed by untrusted AI agents. Environment variables (e.g. `GOOGLE_WORKSPACE_CLI_CONFIG_DIR`) are set by the user themselves — in their shell profile, `.env` file, or deployment config — and are not subject to path traversal validation. This is consistent with standard conventions like `XDG_CONFIG_HOME`, `CARGO_HOME`, etc.
|
||||
|
||||
### Path Safety (`crates/google-workspace/src/validate.rs`)
|
||||
|
||||
When adding new helpers or CLI flags that accept file paths, **always validate** using the shared helpers:
|
||||
|
||||
| Scenario | Validator | Rejects |
|
||||
| -------------------------------------- | ---------------------------------------- | -------------------------------------------------------------------- |
|
||||
| File path for writing (`--output-dir`) | `validate::validate_safe_output_dir()` | Absolute paths, `../` traversal, symlinks outside CWD, control chars |
|
||||
| File path for reading (`--dir`) | `validate::validate_safe_dir_path()` | Absolute paths, `../` traversal, symlinks outside CWD, control chars |
|
||||
| Enum/allowlist values (`--msg-format`) | clap `value_parser` (see `gmail/mod.rs`) | Any value not in the allowlist |
|
||||
|
||||
```rust
|
||||
// In your argument parser:
|
||||
if let Some(output_dir) = matches.get_one::<String>("output-dir") {
|
||||
crate::validate::validate_safe_output_dir(output_dir)?;
|
||||
builder.output_dir(Some(output_dir.clone()));
|
||||
}
|
||||
```
|
||||
|
||||
### URL Encoding (`crates/google-workspace-cli/src/helpers/mod.rs`)
|
||||
|
||||
User-supplied values embedded in URL **path segments** must be percent-encoded. Use the shared helper:
|
||||
|
||||
```rust
|
||||
// CORRECT — encodes slashes, spaces, and special characters
|
||||
let url = format!(
|
||||
"https://www.googleapis.com/drive/v3/files/{}",
|
||||
crate::helpers::encode_path_segment(file_id),
|
||||
);
|
||||
|
||||
// WRONG — raw user input in URL path
|
||||
let url = format!("https://www.googleapis.com/drive/v3/files/{}", file_id);
|
||||
```
|
||||
|
||||
For **query parameters**, use reqwest's `.query()` builder which handles encoding automatically:
|
||||
|
||||
```rust
|
||||
// CORRECT — reqwest encodes query values
|
||||
client.get(url).query(&[("q", user_query)]).send().await?;
|
||||
|
||||
// WRONG — manual string interpolation in query strings
|
||||
let url = format!("{}?q={}", base_url, user_query);
|
||||
```
|
||||
|
||||
### Resource Name Validation (`crates/google-workspace-cli/src/helpers/mod.rs`)
|
||||
|
||||
When a user-supplied string is used as a GCP resource identifier (project ID, topic name, space name, etc.) that gets embedded in a URL path, validate it first:
|
||||
|
||||
```rust
|
||||
// Validates the string does not contain path traversal segments (`..`), control characters, or URL-breaking characters like `?` and `#`.
|
||||
let project = crate::validate::validate_resource_name(&project_id)?;
|
||||
let url = format!("https://pubsub.googleapis.com/v1/projects/{}/topics/my-topic", project);
|
||||
```
|
||||
|
||||
This prevents injection of query parameters, path traversal, or other malicious payloads through resource name arguments like `--project` or `--space`.
|
||||
|
||||
### Checklist for New Features
|
||||
|
||||
When adding a new helper or CLI command:
|
||||
|
||||
1. **File paths** → Use `validate_safe_output_dir` / `validate_safe_dir_path`
|
||||
2. **Enum flags** → Constrain via clap `value_parser` or `validate_msg_format`
|
||||
3. **URL path segments** → Use `encode_path_segment()`
|
||||
4. **Query parameters** → Use reqwest `.query()` builder
|
||||
5. **Resource names** (project IDs, space names, topic names) → Use `validate_resource_name()`
|
||||
6. **Write tests** for both the happy path AND the rejection path (e.g., pass `../../.ssh` and assert `Err`)
|
||||
|
||||
## PR Labels
|
||||
|
||||
Use these labels to categorize pull requests and issues:
|
||||
|
||||
- `area: discovery` — Discovery document fetching, caching, parsing
|
||||
- `area: http` — Request execution, URL building, response handling
|
||||
- `area: docs` — README, contributing guides, documentation
|
||||
- `area: tui` — Setup wizard, picker, input fields
|
||||
- `area: distribution` — Nix flake, npm packaging, GitHub Actions release workflow, install methods
|
||||
- `area: auth` — OAuth, credentials, multi-account, ADC
|
||||
- `area: skills` — AI skill generation and management
|
||||
|
||||
## Helper Commands (`+verb`)
|
||||
|
||||
Helpers are handwritten commands prefixed with `+` that provide value the schema-driven Discovery commands cannot: multi-step orchestration, format translation (e.g., Markdown → Docs JSON), or multi-API composition.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Do NOT add a helper that** wraps a single API call already available via Discovery, adds flags to expose data already in the API response, or re-implements Discovery parameters as custom flags. Helper flags must control orchestration logic — use `--params` and `--format`/`jq` for API parameters and output filtering.
|
||||
|
||||
See [`src/helpers/README.md`](crates/google-workspace-cli/src/helpers/README.md) for full guidelines, anti-patterns, and a checklist for new helpers.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Authentication
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `GOOGLE_WORKSPACE_CLI_TOKEN` | Pre-obtained OAuth2 access token (highest priority; bypasses all credential file loading) |
|
||||
| `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` | Path to OAuth credentials JSON (no default; if unset, falls back to encrypted credentials in `~/.config/gws/`) |
|
||||
| `GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND` | Keyring backend: `keyring` (default, uses OS keyring with file fallback) or `file` (file only, for Docker/CI/headless) |
|
||||
|
||||
| `GOOGLE_APPLICATION_CREDENTIALS` | Standard Google ADC path; used as fallback when no gws-specific credentials are configured |
|
||||
|
||||
### Configuration
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override the config directory (default: `~/.config/gws`) |
|
||||
|
||||
### OAuth Client
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID (for `gws auth login` when no `client_secret.json` is saved) |
|
||||
| `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret (paired with `CLIENT_ID` above) |
|
||||
|
||||
### Sanitization (Model Armor)
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE` | Default Model Armor template (overridden by `--sanitize` flag) |
|
||||
| `GOOGLE_WORKSPACE_CLI_SANITIZE_MODE` | `warn` (default) or `block` |
|
||||
|
||||
### Helpers
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `GOOGLE_WORKSPACE_PROJECT_ID` | GCP project ID override for quota/billing and fallback for helper commands (overridden by `--project` flag) |
|
||||
|
||||
### Logging
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `GOOGLE_WORKSPACE_CLI_LOG` | Log level filter for stderr output (e.g., `gws=debug`). Off by default. |
|
||||
| `GOOGLE_WORKSPACE_CLI_LOG_FILE` | Directory for JSON-line log files with daily rotation. Off by default. |
|
||||
|
||||
All variables can also live in a `.env` file (loaded via `dotenvy`).
|
||||
+861
@@ -0,0 +1,861 @@
|
||||
# @googleworkspace/cli
|
||||
|
||||
## 0.22.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5d24ac2: Add cargo-audit CI workflow for automated dependency vulnerability scanning
|
||||
- ecddf2e: Add cargo-deny configuration for license, advisory, and source auditing
|
||||
- 503315b: Update installation instructions to prioritize GitHub Releases over npm
|
||||
- 6ccbb42: fix: auto-install binary on run if missing
|
||||
|
||||
pnpm skips postinstall when the package is already up to date.
|
||||
This ensures run.js will auto-trigger install.js if the
|
||||
binary is missing, fixing the 'gws binary not found' error.
|
||||
|
||||
- b307856: Migrated the internal AI skills registry (personas and recipes) from YAML to TOML. This allows us to drop the unmaintained serde_yaml dependency, improving the project's supply chain security posture.
|
||||
- 158f93a: Verify SHA256 checksum of downloaded binary in npm postinstall script
|
||||
- b422e5d: Pin cross-rs to v0.2.5 in release workflow to prevent unpinned git HEAD builds
|
||||
|
||||
## 0.22.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 86c08cf: Remove cargo-dist; use native Node.js fetch for npm binary installer
|
||||
|
||||
Replaces the cargo-dist generated release pipeline and npm package with:
|
||||
|
||||
- A custom GitHub Actions release workflow with matrix cross-compilation
|
||||
- A zero-dependency npm installer using native `fetch()` (Node 18+)
|
||||
- Removes axios, rimraf, detect-libc, console.table, and axios-proxy-builder dependencies from the published npm package
|
||||
|
||||
## 0.22.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 674d53a: Fix `Lint Skills` CI job by installing `uv` via `astral-sh/setup-uv` before running `uvx`
|
||||
- c7c42f6: fix: register script service and resolve test path validation errors
|
||||
- 80bd150: feat(auth): use strict OS keychain integration on macOS and Windows
|
||||
|
||||
Closes #623. The CLI no longer writes a fallback `.encryption_key` text file on macOS and Windows when securely storing credentials. Instead, it strictly uses the native OS keychain (Keychain Access on macOS, Credential Manager on Windows). If an old `.encryption_key` file is found during a successful keychain login, it will be automatically deleted for security.
|
||||
Linux deployments continue to use a seamless file-based fallback by default to ensure maximum compatibility with headless continuous integration (CI) runners, Docker containers, and SSH environments without desktop DBUS services.
|
||||
|
||||
- ec7f56b: Sync generated skills with latest Google Discovery API specs
|
||||
|
||||
## 0.22.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a52d297: Improve proxy-aware OAuth flows and clean up review feedback for auth login.
|
||||
|
||||
## 0.22.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6a45832: Sync generated skills with latest Google Discovery API specs
|
||||
|
||||
## 0.22.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 0850c48: Add `--draft` flag to Gmail `+send`, `+reply`, `+reply-all`, and `+forward` helpers to save messages as drafts instead of sending them immediately
|
||||
|
||||
## 0.21.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c4448b9: Add crates.io publishing to release workflow
|
||||
|
||||
Publishes both `google-workspace` and `google-workspace-cli` to crates.io on each release. The library crate is published first (as a dependency), followed by the CLI crate.
|
||||
|
||||
## 0.21.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ea0849a: Fix version-sync script and bump CLI crate version to 0.21.0
|
||||
|
||||
The `version-sync.sh` script was updating the root `Cargo.toml` which no longer has a `[package]` section after the workspace refactor. Updated to target `crates/google-workspace-cli/Cargo.toml`. Also syncs the CLI crate version to 0.21.0 to match `package.json`.
|
||||
|
||||
## 0.21.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 029e5de: Extract `google-workspace` library crate for programmatic Rust API access (closes #386)
|
||||
|
||||
Introduces a Cargo workspace with a new `google-workspace` library crate (`crates/google-workspace/`)
|
||||
that exposes the core modules for use as a Rust dependency:
|
||||
|
||||
- `discovery` — Discovery Document types and fetching
|
||||
- `error` — Structured `GwsError` type
|
||||
- `services` — Service registry and resolution
|
||||
- `validate` — Input validation and URL encoding
|
||||
- `client` — HTTP client with retry logic
|
||||
|
||||
The `gws` binary crate re-exports all library types transparently — zero behavioral changes.
|
||||
|
||||
## 0.20.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b8fd3d9: fix(client): add 10s connect timeout to prevent hangs on initial connection
|
||||
- 2bfcca9: Move version from top-level SKILL.md frontmatter to metadata and track CLI version
|
||||
- 2ddb46e: test(gmail): add regression tests for RFC 2822 display name quoting
|
||||
- 75a7121: Sync generated skills with latest Google Discovery API specs
|
||||
|
||||
## 0.20.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e782dd7: Forward original attachments by default and preserve inline images in HTML mode.
|
||||
|
||||
`+forward` now includes the original message's attachments and inline images by default,
|
||||
matching Gmail web behavior. Use `--no-original-attachments` to opt out.
|
||||
`+reply`/`+reply-all` with `--html` preserve inline images in the quoted body via
|
||||
`multipart/related`. In plain-text mode, inline images are not included (matching Gmail web).
|
||||
|
||||
## 0.19.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- a078945: Refactor all `gws auth` subcommands to use clap for argument parsing
|
||||
|
||||
Replace manual argument parsing in `handle_auth_command`, `handle_login`, `resolve_scopes`, and `handle_export` with structured `clap::Command` definitions. Introduces `ScopeMode` enum for type-safe scope selection and adds proper `--help` support for all auth subcommands.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8a749c2: feat(helpers): add --dry-run support to events helper commands
|
||||
|
||||
Add dry-run mode to `gws events +renew` and `gws events +subscribe` commands.
|
||||
When --dry-run is specified, the commands will print what actions would be
|
||||
taken without making any API calls. This allows agents to simulate requests
|
||||
and learn without reaching the server.
|
||||
|
||||
- d679401: Fix `mask_secret` panic on multi-byte UTF-8 secrets by using char-based indexing instead of byte-offset slicing
|
||||
- d341de2: Handle --help/-h in `gws auth setup` before launching the setup wizard, preventing accidental project creation when users just want usage info
|
||||
- f157208: fix: use block-style YAML sequences in generated SKILL.md frontmatter
|
||||
|
||||
Replace flow sequences (`bins: ["gws"]`, `skills: [...]`) with block-style
|
||||
sequences (`bins:\n - gws`) in all generated SKILL.md frontmatter templates.
|
||||
|
||||
Flow sequences are valid YAML but rejected by `strictyaml`, which the
|
||||
Agent Skills reference implementation (`agentskills validate`) uses to parse
|
||||
frontmatter. This caused all 93 generated skills to fail validation.
|
||||
|
||||
Fixes #521
|
||||
|
||||
- b4d5e26: Fix auth error propagation: properly propagate errors when token directory creation or permission setting fails, instead of silently ignoring them
|
||||
|
||||
## 0.18.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a87037b: Handle SIGTERM in `gws gmail +watch` and `gws events +subscribe` for clean container shutdown.
|
||||
|
||||
Long-running pull loops now exit gracefully on SIGTERM (in addition to Ctrl+C),
|
||||
enabling clean shutdown under Kubernetes, Docker, and systemd.
|
||||
|
||||
## 0.18.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 908cf73: feat(gmail): auto-populate From header with display name from send-as settings
|
||||
|
||||
Fetch the user's send-as identities to set the From header with a display name in all mail helpers (+send, +reply, +reply-all, +forward), matching Gmail web client behavior. Also enriches bare `--from` emails with their configured display name.
|
||||
|
||||
- 6e4daaf: Gmail helpers rollup: mail-builder migration, --attach flag (upload endpoint), +read helper
|
||||
|
||||
- Migrate `+send`, `+reply`, `+reply-all`, and `+forward` to the `mail-builder` crate for RFC-compliant MIME construction
|
||||
- Add `--from` flag to `+send` for send-as alias support
|
||||
- Add `-a`/`--attach` flag to all mail helpers (`+send`, `+reply`, `+reply-all`, `+forward`) with `mime_guess2` auto-detection, 25MB size validation, and upload endpoint support (35MB API limit vs 5MB metadata-only)
|
||||
- Add `+read` helper to extract message body and headers (text, HTML, or JSON output)
|
||||
- Make `OriginalMessage.thread_id` optional (`Option<String>`) for draft compatibility
|
||||
- RFC 2822 display name quoting is handled natively by `mail-builder`
|
||||
- Introduce `UploadSource` enum in executor for type-safe upload strategies
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1e90380: fix(gmail): remove dead `--attachment` arg from `+send`
|
||||
|
||||
The `+send` subcommand defined a duplicate `"attachment"` arg alongside the
|
||||
`"attach"` arg already provided by `common_mail_args`. Since `parse_attachments`
|
||||
reads `"attach"`, the `--attachment` flag was silently ignored. Removed the
|
||||
dead duplicate.
|
||||
|
||||
- 908cf73: fix(gmail): handle reply-all to own message correctly
|
||||
|
||||
Reply-all to a message you sent no longer errors with "No To recipient remains." The original To recipients are now used as reply targets, matching Gmail web client behavior.
|
||||
|
||||
- 2e909ae: Consolidate terminal sanitization, coloring, and output helpers into a new `output.rs` module. Fixes raw ANSI escape codes in `watch.rs` that bypassed `NO_COLOR` and TTY detection, upgrades `sanitize_for_terminal` to also strip dangerous Unicode characters (bidi overrides, zero-width spaces, directional isolates), and sanitizes previously raw API error body and user query outputs.
|
||||
|
||||
## 0.17.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 1b0a21f: feat: support google meet video conferencing in calendar +insert
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 811fe7b: Fix critical security vulnerability (TOCTOU/Symlink race) in atomic file writes.
|
||||
|
||||
The atomic_write and atomic_write_async utilities now use:
|
||||
|
||||
- Randomized temporary filenames to prevent predictability.
|
||||
- O_EXCL creation flags to prevent following pre-existing symlinks.
|
||||
- Strict 0600 permissions from the moment of file creation on Unix systems.
|
||||
- Redundant post-write permission calls have been removed to close race windows.
|
||||
|
||||
- b241a5b: fix(security): cap Retry-After sleep, sanitize upload mimeType, and validate --upload/--output paths
|
||||
- 6f92e5b: Stderr/output hygiene rollup: route diagnostics to stderr, add colored error labels, propagate auth errors.
|
||||
|
||||
- **triage.rs**: "No messages found" sent to stderr so stdout stays valid JSON for pipes
|
||||
- **modelarmor.rs**: response body printed only on success; error message now includes body for diagnostics
|
||||
- **error.rs**: colored `error[variant]:` labels on stderr (respects `NO_COLOR` env var), `hint:` prefix for accessNotConfigured guidance
|
||||
- **calendar, chat, docs, drive, script, sheets**: auth failures now propagate as `GwsError::Auth` instead of silently proceeding unauthenticated (dry-run still works without auth)
|
||||
|
||||
- 398e80c: Sync generated skills with latest Google Discovery API specs
|
||||
- 8458104: Extend input validation to reject dangerous Unicode characters (zero-width chars, bidi overrides, Unicode line/paragraph separators) that were not caught by the previous ASCII-range check
|
||||
|
||||
## 0.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 47afe5f: Use Google account timezone instead of machine-local time for day-boundary calculations in calendar and workflow helpers. Adds `--timezone` flag to `+agenda` for explicit override. Timezone is fetched from Calendar Settings API and cached for 24 hours.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c61b9cb: fix(gmail): RFC 2047 encode non-ASCII display names in To/From/Cc/Bcc headers
|
||||
|
||||
Fixes mojibake when sending emails to recipients with non-ASCII display names (e.g. Japanese, Spanish accented characters). The new `encode_address_header()` function parses mailbox lists, encodes only the display-name portion via RFC 2047 Base64, and leaves email addresses untouched.
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6f3e090: Add opt-in structured HTTP request logging via `tracing`
|
||||
|
||||
New environment variables:
|
||||
|
||||
- `GOOGLE_WORKSPACE_CLI_LOG`: stderr log filter (e.g., `gws=debug`)
|
||||
- `GOOGLE_WORKSPACE_CLI_LOG_FILE`: directory for JSON log files with daily rotation
|
||||
|
||||
Logging is completely silent by default (zero overhead). Only PII-free metadata is logged: API method ID, HTTP method, status code, latency, and content-type.
|
||||
|
||||
## 0.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- dc561e0: Add `--upload-content-type` flag and smart MIME inference for multipart uploads
|
||||
|
||||
Previously, multipart uploads used the metadata `mimeType` field for both the Drive
|
||||
metadata and the media part's `Content-Type` header. This made it impossible to upload
|
||||
a file in one format (e.g. Markdown) and have Drive convert it to another (e.g. Google Docs),
|
||||
because the media `Content-Type` and the target `mimeType` must differ for import conversions.
|
||||
|
||||
The new `--upload-content-type` flag allows setting the media `Content-Type` explicitly.
|
||||
When omitted, the media type is now inferred from the file extension before falling back
|
||||
to the metadata `mimeType`. This matches Google Drive's model where metadata `mimeType`
|
||||
is the _target_ type (what the file should become) while the media `Content-Type` is the
|
||||
_source_ type (what the bytes are).
|
||||
|
||||
This means import conversions now work automatically:
|
||||
|
||||
```bash
|
||||
# Extension inference detects text/markdown → conversion just works
|
||||
gws drive files create \
|
||||
--json '{"name":"My Doc","mimeType":"application/vnd.google-apps.document"}' \
|
||||
--upload notes.md
|
||||
|
||||
# Explicit flag still available as an override
|
||||
gws drive files create \
|
||||
--json '{"name":"My Doc","mimeType":"application/vnd.google-apps.document"}' \
|
||||
--upload notes.md \
|
||||
--upload-content-type text/markdown
|
||||
```
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 945ac91: Stream multipart uploads to avoid OOM on large files. File content is now streamed in chunks via `ReaderStream` instead of being read entirely into memory, reducing memory usage from O(file_size) to O(64 KB).
|
||||
|
||||
## 0.13.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8ef27a2: fix(calendar): use local timezone for agenda day boundaries instead of UTC
|
||||
- 4d7b420: Fix `+append --json-values` flattening multi-row arrays into a single row by preserving the `Vec<Vec<String>>` row structure through to the API request body
|
||||
- bb94016: fix(security): validate space name in chat +send to prevent path traversal
|
||||
- 4b827cd: chore: fix maintainer email typo in flake.nix and harden coverage.sh
|
||||
- 44767ed: Map People service to `contacts` and `directory` scope prefixes so `gws auth login -s people` includes the required OAuth scopes
|
||||
- 8fce003: fix(docs): correct flag names in recipes (--spreadsheet-id, --attendees, --duration)
|
||||
- 21b1840: Expose `repeated: true` in `gws schema` output and expand JSON arrays into repeated query parameters for `repeated` fields
|
||||
- 1346d47: Sync generated skills with latest Google Discovery API specs
|
||||
- 957b999: test(gmail): add unit tests for +triage argument parsing and format selection
|
||||
|
||||
## 0.13.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3dcf818: Refresh OAuth access tokens for long-running Gmail watch and Workspace Events subscribe helpers before each Pub/Sub and Gmail request.
|
||||
- 86ea6de: Validate `--subscription` resource name in `gmail +watch` and deduplicate `PUBSUB_API_BASE` constant.
|
||||
|
||||
## 0.13.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 510024f: Centralize token cache filenames as constants and support ServiceAccount credentials at the default plaintext path
|
||||
- 510024f: Auto-recover from stale encrypted credentials after upgrade: remove undecryptable `credentials.enc` and fall through to other credential sources (plaintext, ADC) instead of hard-erroring. Also sync encryption key file backup when keyring has key but file is missing.
|
||||
- e104106: Add shell tips section to gws-shared skill warning about zsh `!` history expansion, and replace single quotes with double quotes around sheet ranges containing `!` in recipes and skill examples
|
||||
|
||||
## 0.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9d937af: Add `--html` flag to `+send`, `+reply`, `+reply-all`, and `+forward` for HTML email composition.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2df32ee: Document helper commands (`+` prefix) in README
|
||||
|
||||
Adds a "Helper Commands" section to the Advanced Usage chapter explaining
|
||||
the `+` prefix convention, listing all 24 helper commands across 10 services
|
||||
with descriptions and usage examples.
|
||||
|
||||
## 0.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 247e27a: Add structured exit codes for scriptable error handling
|
||||
|
||||
`gws` now exits with a type-specific code instead of always using `1`:
|
||||
|
||||
| Code | Meaning |
|
||||
| ---- | --------------------------------------------------------------- |
|
||||
| `0` | Success |
|
||||
| `1` | API error — Google returned a 4xx/5xx response |
|
||||
| `2` | Auth error — credentials missing, expired, or invalid |
|
||||
| `3` | Validation error — bad arguments, unknown service, invalid flag |
|
||||
| `4` | Discovery error — could not fetch the API schema document |
|
||||
| `5` | Internal error — unexpected failure |
|
||||
|
||||
Exit codes are documented in `gws --help` and in the README.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 087066f: Fix `gws auth login` encrypted credential persistence by enabling native keyring backends for the `keyring` crate on supported desktop platforms instead of silently falling back to the in-memory mock store.
|
||||
|
||||
## 0.11.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- adbca87: Fix `--format csv` for array-of-arrays responses (e.g. Sheets values API)
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 4d4b09f: Add `--cc` and `--bcc` flags to `+send`, `--to` and `--bcc` to `+reply` and `+reply-all`, and `--bcc` to `+forward`.
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 8d89325: Add `GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND` env var for explicit keyring backend selection (`keyring` or `file`). Fixes credential key loss in Docker/keyring-less environments by never deleting `.encryption_key` and always persisting it as a fallback.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 06aa698: fix(auth): dynamically fetch scopes from Discovery docs when `-s` specifies services not in static scope lists
|
||||
- 06aa698: fix(auth): format extract_scopes_from_doc and deduplicate dynamic scopes
|
||||
- 5e7d120: Bring `+forward` behavior in line with Gmail's web UI: keep the forward in the sender's original thread, add a blank line between the forwarded message metadata and body, and remove the spurious closing delimiter.
|
||||
- 2782cf1: Fix gmail +triage 403 error by using gmail.readonly scope instead of gmail.modify to avoid conflict with gmail.metadata scope that does not support the q parameter
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5872dbe: Stop persisting encryption key to `.encryption_key` file when OS keyring is available. Existing file-based keys are migrated into the keyring and the file is removed on next CLI invocation.
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 7d15365: feat(gmail): add +reply, +reply-all, and +forward helpers
|
||||
|
||||
Adds three new Gmail helper commands:
|
||||
|
||||
- `+reply` -- reply to a message with automatic threading
|
||||
- `+reply-all` -- reply to all recipients with --remove/--cc support
|
||||
- `+forward` -- forward a message to new recipients
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 08716f8: Fix garbled non-ASCII email subjects in `gmail +send` by RFC 2047 encoding the Subject header and adding MIME-Version/Content-Type headers.
|
||||
- f083eb9: Improve `gws auth setup` project creation failures in step 3:
|
||||
- Detect Google Cloud Terms of Service precondition failures and show actionable guidance (`gcloud auth list`, account verification, Console ToS URL).
|
||||
- Detect invalid project ID format / already-in-use errors and show clearer guidance.
|
||||
- In interactive setup, keep the wizard open and re-prompt for a new project ID instead of exiting immediately on create failures.
|
||||
- 789e7f1: Switch reqwest TLS from bundled Mozilla roots to native OS certificate store
|
||||
|
||||
This allows the CLI to trust custom or corporate CA certificates installed
|
||||
in the system trust store, fixing TLS errors in enterprise environments.
|
||||
|
||||
## 0.8.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4d41e52: Prioritize local project configuration and `GOOGLE_WORKSPACE_PROJECT_ID` over global Application Default Credentials (ADC) for quota attribution. This fixes 403 errors when the Drive API is disabled in a global gcloud project but enabled in the project configured for gws.
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- dd3fc90: Remove `mcp` command
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e1505af: Remove multi-account, domain-wide delegation, and impersonation support. Removes `gws auth list`, `gws auth default`, `--account` flag, `GOOGLE_WORKSPACE_CLI_ACCOUNT` and `GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER` env vars.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 54b3b31: Move x-goog-user-project header from default client headers to API request builder, fixing Discovery Document fetches failing with 403 when the quota project lacks certain APIs enabled
|
||||
|
||||
## 0.6.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 322529d: Document all environment variables and enable GOOGLE_WORKSPACE_CLI_CONFIG_DIR in release builds
|
||||
- 2173a92: Send x-goog-user-project header when using ADC with a quota_project_id
|
||||
- 1f47420: fix: extract CLA label job into dedicated workflow to prevent feedback loop
|
||||
|
||||
The Automation workflow's `check_run: [completed]` trigger caused a feedback
|
||||
loop — every workflow completion fired a check_run event, re-triggering
|
||||
Automation, which produced another check_run event, and so on. Moving the
|
||||
CLA label job to its own `cla.yml` workflow eliminates the trigger from
|
||||
Automation entirely.
|
||||
|
||||
- 132c3b1: fix: warn on credential file permission failures instead of ignoring
|
||||
|
||||
Replaced silent `let _ =` on `set_permissions` calls in `save_encrypted`
|
||||
with `eprintln!` warnings so users are aware if their credential files
|
||||
end up with insecure permissions. Also log keyring access failures
|
||||
instead of silently falling through to file storage.
|
||||
|
||||
- a2cc523: Add `x86_64-unknown-linux-musl` build target for Linux musl/static binary support
|
||||
- c86b964: Fix multi-account selection: MCP server now respects `GOOGLE_WORKSPACE_CLI_ACCOUNT` env var (#221), and `--account` flag before service name no longer causes parse errors (#181)
|
||||
- ff53538: Fix scope selection to use first (broadest) scope instead of all method scopes, preventing gmail.metadata restrictions from blocking query parameters
|
||||
- c80eb52: Replace strip_suffix(".readonly").unwrap() with unwrap_or fallback
|
||||
|
||||
Two call sites used `.strip_suffix(".readonly").unwrap()` which would
|
||||
panic if a scope URL marked as `is_readonly` didn't actually end with
|
||||
".readonly". While the current data makes this unlikely, using
|
||||
`unwrap_or` is a defensive improvement that prevents potential panics
|
||||
from inconsistent discovery data.
|
||||
|
||||
- 9a780d7: Log token cache decryption/parse errors instead of silently swallowing
|
||||
|
||||
Previously, `load_from_disk` used four nested `if let Ok` blocks that
|
||||
silently returned an empty map on any failure. When the encryption key
|
||||
changed or the cache was corrupted, tokens silently stopped loading and
|
||||
users were forced to re-authenticate with no explanation.
|
||||
|
||||
Now logs specific warnings to stderr for decryption failures, invalid
|
||||
UTF-8, and JSON parse errors, with a hint to re-authenticate.
|
||||
|
||||
- 6daf90d: Fix MCP tool schemas to conditionally include `body`, `upload`, and `page_all` properties only when the underlying Discovery Document method supports them. `body` is included only when a request body is defined, `upload` only when `supportsMediaUpload` is true, and `page_all` only when the method has a `pageToken` parameter. Also drops empty `body: {}` objects that LLMs commonly send on GET methods, preventing 400 errors from Google APIs.
|
||||
|
||||
## 0.6.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 28fa25a: Clean up nits from PR #175 auth fix
|
||||
|
||||
- Update stale docstring on `resolve_account` to match new fallthrough behavior
|
||||
- Add breadcrumb comment on string-based error matching in `main.rs`
|
||||
- Move identity scope injection before authenticator build for readability
|
||||
|
||||
## 0.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 88cb65c: chore: add automation workflow for auto-fmt, CLA labeling, and file-based PR triage
|
||||
- a926e3f: Fix auth failures when accounts.json registry is missing
|
||||
|
||||
Three related bugs caused all API calls to fail with "Access denied. No credentials provided" even after a successful `gws auth login`:
|
||||
|
||||
1. `resolve_account()` rejected valid `credentials.enc` as "legacy" when `accounts.json` was absent, instead of using them.
|
||||
2. `main.rs` silently swallowed all auth errors, masking real failures behind a generic message.
|
||||
3. `auth login` didn't include `openid`/`email` scopes, so `fetch_userinfo_email()` couldn't identify the user, causing credentials to be saved without an `accounts.json` entry.
|
||||
|
||||
- cb1f988: Add Content-Length: 0 header for POST/PUT/PATCH requests with no body to fix HTTP 411 errors
|
||||
- 3d59b2e: fix: isolate flaky auth tests from host ADC credentials
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- b38b760: Add Application Default Credentials (ADC) support.
|
||||
|
||||
`gws` now discovers ADC as a fourth credential source, after the encrypted
|
||||
and plaintext credential files. The lookup order is:
|
||||
|
||||
1. `GOOGLE_WORKSPACE_CLI_TOKEN` env var (raw access token, highest priority)
|
||||
2. `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` env var
|
||||
3. Encrypted credentials (`~/.config/gws/credentials.enc`)
|
||||
4. Plaintext credentials (`~/.config/gws/credentials.json`)
|
||||
5. **ADC** — `GOOGLE_APPLICATION_CREDENTIALS` env var (hard error if file missing), then
|
||||
`~/.config/gcloud/application_default_credentials.json` (silent if absent)
|
||||
|
||||
This means `gcloud auth application-default login --client-id-file=client_secret.json`
|
||||
is now a fully supported auth flow — no need to run `gws auth login` separately.
|
||||
Both `authorized_user` and `service_account` ADC formats are supported.
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9cf6e0e: Add `--tool-mode compact|full` flag to `gws mcp`. Compact mode exposes one tool per service plus a `gws_discover` meta-tool, reducing context window usage from 200-400 tools to ~26.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 0a16d0b: Add `-s`/`--services` flag to `gws auth login` to filter the scope picker
|
||||
by service name (e.g. `-s drive,gmail,sheets`). Also expands the workspace
|
||||
admin scope blocklist to include `chat.admin.*` and `classroom.*` patterns.
|
||||
- 5205467: fix(setup): drain stale keypresses between TUI screen transitions
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e1e08eb: Fix highlight color on light terminal themes by using reverse video instead of a dark-gray background
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fc6bc95: Exclude Workspace-admin-only scopes from the "Recommended" scope preset.
|
||||
|
||||
Scopes that require Google Workspace domain-admin access (`apps.*`,
|
||||
`cloud-identity.*`, `ediscovery`, `directory.readonly`, `groups`) now return
|
||||
`400 invalid_scope` when used by personal `@gmail.com` accounts. These scopes
|
||||
are no longer included in the "Recommended" template, preventing login failures
|
||||
for non-Workspace users.
|
||||
|
||||
Workspace admins can still select these scopes manually via the "Full Access"
|
||||
template or by picking them individually in the scope picker.
|
||||
|
||||
Adds a new `is_workspace_admin_scope()` helper (mirroring the existing
|
||||
`is_app_only_scope()`) that centralises this detection logic.
|
||||
|
||||
- 2aa6084: docs: Comprehensive README overhaul addressing user feedback.
|
||||
|
||||
Added a Prerequisites section prior to the Quick Start to highlight the optional `gcloud` dependency.
|
||||
Expanded the Authentication section with a decision matrix to help users choose the correct authentication path.
|
||||
Added prominent warnings about OAuth "testing mode" limitations (the 25-scope cap) and the strict requirement to explicitly add the authorizing account as a "Test user" (#130).
|
||||
Added a dedicated Troubleshooting section detailing fixes for common OAuth consent errors, "Access blocked" issues, and `redirect_uri_mismatch` failures.
|
||||
Included shell escaping examples for Google Sheets A1 notation (`!`).
|
||||
Clarified the `npm` installation rationale and added explicit links to pre-built native binaries on GitHub Releases.
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d3e90e4: fix: use ~/.config/gws on all platforms for consistent config path
|
||||
|
||||
Previously used `dirs::config_dir()` which resolves to different paths per OS
|
||||
(e.g. ~/Library/Application Support/gws on macOS, %APPDATA%\gws on Windows),
|
||||
contradicting the documented ~/.config/gws/ path. Now uses ~/.config/gws/
|
||||
everywhere with a fallback to the legacy OS-specific path for existing installs.
|
||||
|
||||
## 0.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- dbda001: Add "Enter project ID manually" option to project picker in `gws auth setup`.
|
||||
|
||||
Users with large numbers of GCP projects often hit the 10-second listing timeout.
|
||||
The picker now includes a "⌨ Enter project ID manually" item so users can type a
|
||||
known project ID directly without waiting for `gcloud projects list` to complete.
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 87e4bb1: Add Linux ARM64 build targets (aarch64-unknown-linux-gnu and aarch64-unknown-linux-musl) to cargo-dist, enabling prebuilt binaries for ARM64 Linux users via npm, the shell installer, and GitHub Releases.
|
||||
- d1825f9: ### Multi-Account Support
|
||||
|
||||
Add support for managing multiple Google accounts with per-account credential storage.
|
||||
|
||||
**New features:**
|
||||
|
||||
- `--account EMAIL` global flag available on every command
|
||||
- `GOOGLE_WORKSPACE_CLI_ACCOUNT` environment variable as fallback
|
||||
- `gws auth login --account EMAIL` — associates credentials with a specific account
|
||||
- `gws auth list` — lists all registered accounts
|
||||
- `gws auth default EMAIL` — sets the default account
|
||||
- `gws auth logout --account EMAIL` — removes a specific account
|
||||
- `login_hint` in OAuth URL for automatic account pre-selection in browser
|
||||
- Email validation via Google userinfo endpoint after OAuth flow
|
||||
|
||||
**Breaking change:** Existing users must run `gws auth login` again after upgrading. The credential storage format has changed from a single `credentials.enc` to per-account files (`credentials.<b64-email>.enc`) with an `accounts.json` registry.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a6994ad: Filter out `apps.alerts` scopes from user OAuth login flow since they require service account with domain-wide delegation
|
||||
- 1ad4f34: fix: replace unwrap() calls with proper error handling in MCP server
|
||||
|
||||
Replaced four `unwrap()` calls in `mcp_server.rs` that could panic the MCP
|
||||
server process with graceful error handling. Also added a warning log when
|
||||
authentication silently falls back to unauthenticated mode.
|
||||
|
||||
- a1be14f: fix: drain stdout pipe to prevent project listing timeout during auth setup
|
||||
|
||||
Fixed `gws auth setup` timing out at step 3 (GCP project selection) for users
|
||||
with many projects. The `gcloud projects list` stdout pipe was only read after
|
||||
the child process exited, causing a deadlock when output exceeded the OS pipe
|
||||
buffer (~64 KB). Stdout is now drained in a background thread to prevent the
|
||||
pipe from filling up.
|
||||
|
||||
- 364542b: fix: reject DEL character (0x7F) in input validation
|
||||
|
||||
The `reject_control_chars` helper rejected bytes 0x00–0x1F but allowed
|
||||
the DEL character (0x7F), which is also an ASCII control character. This
|
||||
could allow malformed input from LLM agents to bypass validation.
|
||||
|
||||
- 75cec1b: Fix URL template expansion so media upload endpoints substitute path parameters and avoid iterative replacement side effects.
|
||||
- ed409e3: Harden URL and path construction across helper modules (gmail/watch, modelarmor, discovery)
|
||||
- 263a8e5: fix: use gcloud.cmd on Windows and show platform-correct config paths
|
||||
|
||||
On Windows, gcloud is installed as `gcloud.cmd` which Rust's `Command`
|
||||
cannot find without the extension. Also replaced hardcoded `~/.config/gws/`
|
||||
in error messages with the actual platform-resolved path.
|
||||
|
||||
## 0.3.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4bca693: fix: credential masking panic and silent token write errors
|
||||
|
||||
Fixed `gws auth export` masking which panicked on short strings and showed
|
||||
the entire secret instead of masking it. Also fixed silent token cache write
|
||||
failures in `save_to_disk` that returned `Ok(())` even when the write failed.
|
||||
|
||||
- f84ce37: Fix URL template path expansion to safely encode path parameters, including
|
||||
Sheets `range` values with Unicode and reserved characters. `{var}` expansions
|
||||
now encode as a path segment, `{+var}` preserves slashes while encoding each
|
||||
segment, and invalid path parameter/template mismatches fail fast.
|
||||
- eb0347a: fix: correct author email typo in package.json
|
||||
- 70d0cdd: Fix Slides presentations.get failure caused by flatPath placeholder mismatch
|
||||
|
||||
When a Discovery Document's `flatPath` uses placeholder names that don't match
|
||||
the method's parameter names (e.g., `{presentationsId}` vs `presentationId`),
|
||||
`build_url` now falls back to the `path` field which uses RFC 6570 operators
|
||||
that resolve correctly.
|
||||
|
||||
Fixes #118
|
||||
|
||||
- 37ab483: Add flake.nix for nix & NixOS installs
|
||||
- 1991d53: Add prominent disclaimer that this is not an officially supported Google product to README, --help, and --version output
|
||||
|
||||
## 0.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 704928b: fix(setup): enable APIs individually and surface gcloud errors
|
||||
|
||||
Previously `gws auth setup` used a single batch `gcloud services enable` call
|
||||
for all Workspace APIs. If any one API failed, the entire batch was marked as
|
||||
failed and stderr was silently discarded. APIs are now enabled individually and
|
||||
in parallel, with error messages surfaced to the user.
|
||||
|
||||
## 0.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 92e66a3: Add `gws version` as a bare subcommand alongside `gws --version` and `gws -V`
|
||||
|
||||
## 0.3.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8fadbd6: Smarter truncation of method and resource descriptions from discovery docs. Descriptions now truncate at sentence boundaries when possible, fall back to word boundaries with an ellipsis, and strip markdown links to reclaim character budget. Fixes #64.
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b3669e0: Add hourly cron to generate-skills workflow to auto-sync skills with upstream Google Discovery API changes via PR
|
||||
- e8d533e: Add workflow to publish OpenClaw skills to ClawHub
|
||||
- 3b38c8d: Sync generated skills with latest Google Discovery API specs
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 670267f: feat: add `gws mcp` Model Context Protocol server
|
||||
|
||||
Adds a new `gws mcp` subcommand that starts an MCP server over stdio,
|
||||
exposing Google Workspace APIs as structured tools to any MCP-compatible
|
||||
client (Claude Desktop, Gemini CLI, VS Code, etc.).
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8c1042a: Fix x-goog-api-client header format to use `gl-rust/gws-<version>`
|
||||
- 3de9762: Fix docs: `gws setup` → `gws auth setup` (fixes #56, #57)
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f281797: docs(auth): add manual Google Cloud OAuth client setup and browser-assisted login guidance
|
||||
|
||||
Adds step-by-step guidance for creating a Desktop OAuth client in Google Cloud Console,
|
||||
where to place `client_secret.json`, and how humans/agents can complete browser consent
|
||||
(including unverified app and scope-selection prompts).
|
||||
|
||||
- ee2e216: Narrow default OAuth scopes to avoid `Error 403: restricted_client` on unverified apps and add a `--full` flag for broader access (fixes #25). Replace the cryptic non-interactive setup error with actionable step-by-step OAuth console instructions (fixes #24).
|
||||
- de2787e: feat(error): detect disabled APIs and guide users to enable them
|
||||
|
||||
When the Google API returns a 403 `accessNotConfigured` error (i.e., the
|
||||
required API has not been enabled for the GCP project), `gws` now:
|
||||
|
||||
- Extracts the GCP Console enable URL from the error message body.
|
||||
- Prints the original error JSON to stdout (machine-readable, unchanged shape
|
||||
except for an optional new `enable_url` field added to the error object).
|
||||
- Prints a human-readable hint with the direct enable URL to stderr, along
|
||||
with instructions to retry after enabling.
|
||||
|
||||
This prevents a dead-end experience where users see a raw 403 JSON blob
|
||||
with no guidance. The JSON output is backward-compatible; only an optional
|
||||
`enable_url` field is added when the URL is parseable from the message.
|
||||
|
||||
Fixes #31
|
||||
|
||||
- 9935dde: ci: auto-generate and commit skills on PR branch pushes
|
||||
- 4b868c7: docs: add community guidance to gws-shared skill and gws --help output
|
||||
|
||||
Encourages agents and users to star the repository and directs bug reports
|
||||
and feature requests to GitHub Issues, with guidance to check for existing
|
||||
issues before opening new ones.
|
||||
|
||||
- 0603bce: fix: atomic credential file writes to prevent corruption on crash or Ctrl-C
|
||||
- 666f9a8: fix(auth): support --help / -h flag on auth subcommand
|
||||
- bcd2401: fix: flatten nested objects in table output and fix multi-byte char truncation panic
|
||||
- ee35e4a: fix: warn to stderr when unknown --format value is provided
|
||||
- e094b02: fix: YAML block scalar for strings with `#`/`:`, and repeated CSV/table headers with `--page-all`
|
||||
|
||||
**Bug 1 — YAML output: `drive#file` rendered as block scalar**
|
||||
|
||||
Strings containing `#` or `:` (e.g. `drive#file`, `https://…`) were
|
||||
incorrectly emitted as YAML block scalars (`|`), producing output like:
|
||||
|
||||
```yaml
|
||||
kind: |
|
||||
drive#file
|
||||
```
|
||||
|
||||
Block scalars add an implicit trailing newline which changes the string
|
||||
value and produces invalid-looking output. The fix restricts block
|
||||
scalar to strings that genuinely contain newlines; all other strings
|
||||
are double-quoted, which is safe for any character sequence.
|
||||
|
||||
**Bug 2 — `--page-all` with `--format csv` / `--format table` repeats headers**
|
||||
|
||||
When paginating with `--page-all`, each page printed its own header row,
|
||||
making the combined output unusable for downstream processing:
|
||||
|
||||
```
|
||||
id,kind,name ← page 1 header
|
||||
1,drive#file,foo.txt
|
||||
id,kind,name ← page 2 header (unexpected!)
|
||||
2,drive#file,bar.txt
|
||||
```
|
||||
|
||||
Column headers (and the table separator line) are now emitted only for
|
||||
the first page; continuation pages contain data rows only.
|
||||
|
||||
- 173d155: fix: add YAML document separators (---) when paginating with --page-all --format yaml
|
||||
- 214fc18: ci: skip smoketest on fork pull requests
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6ae7427: fix(auth): stabilize encrypted credential key fallback across sessions
|
||||
|
||||
When the OS keyring returned `NoEntry`, the previous code could generate
|
||||
a fresh random key on each process invocation instead of reusing one.
|
||||
This caused `credentials.enc` written by `gws auth login` to be
|
||||
unreadable by subsequent commands.
|
||||
|
||||
Changes:
|
||||
|
||||
- Always prefer an existing `.encryption_key` file before generating a new key
|
||||
- When generating a new key, persist it to `.encryption_key` as a stable fallback
|
||||
- Best-effort write new keys into the keyring as well
|
||||
- Fix `OnceLock` race: return the already-cached key if `set` loses a race
|
||||
|
||||
Fixes #27
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- b0d0b95: Add workflow helpers, personas, and 50 consumer-focused recipes
|
||||
|
||||
- Add `gws workflow` subcommand with 5 built-in helpers: `+standup-report`, `+meeting-prep`, `+email-to-task`, `+weekly-digest`, `+file-announce`
|
||||
- Add 10 agent personas (exec-assistant, project-manager, sales-ops, etc.) with curated skill sets
|
||||
- Add `docs/skills.md` skills index and `registry/recipes.yaml` with 50 multi-step recipes for Gmail, Drive, Docs, Calendar, and Sheets
|
||||
- Update README with skills index link and accurate skill count
|
||||
- Fix lefthook pre-commit to run fmt and clippy sequentially
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 90adcb4: fix: percent-encode path parameters to prevent path traversal
|
||||
- e71ce29: Fix Gemini extension installation issue by removing redundant authentication settings and update the documentation.
|
||||
- 90adcb4: fix: harden input validation for AI/LLM callers
|
||||
|
||||
- Add `src/validate.rs` with `validate_safe_output_dir`, `validate_msg_format`, and `validate_safe_dir_path` helpers
|
||||
- Validate `--output-dir` against path traversal in `gmail +watch` and `events +subscribe`
|
||||
- Validate `--msg-format` against allowlist (full, metadata, minimal, raw) in `gmail +watch`
|
||||
- Validate `--dir` against path traversal in `script +push`
|
||||
- Add clap `value_parser` constraint for `--msg-format`
|
||||
- Document input validation patterns in `AGENTS.md`
|
||||
|
||||
- 90adcb4: Security: Harden validate_resource_name and fix Gmail watch path traversal
|
||||
- 90adcb4: Replace manual `urlencoded()` with reqwest `.query()` builder for safer URL encoding
|
||||
- c11d3c4: Added test coverage for `EncryptedTokenStorage::new` initialization.
|
||||
- 7664357: Add test for missing error path in load_client_config
|
||||
- 90adcb4: fix: add shared URL safety helpers for path params (`encode_path_segment`, `validate_resource_name`)
|
||||
- 90adcb4: fix: warn on stderr when API calls fail silently
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d29f41e: Fix README typography and spacing
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- adb2cfa: Fix OAuth login failing with "no refresh token" error by decrypting the token cache before parsing and supporting the HashMap token format used by EncryptedTokenStorage
|
||||
- d990dcc: Improve README branding by making the hero banner full-width.
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c714f4b: Fix npm package name to publish as @googleworkspace/cli instead of gws
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3cd4d52: Fix release pipeline to sync Cargo.toml version with changesets and create git tags for private packages
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a0ad089: Speed up CI builds with Swatinem/rust-cache, sccache, and build artifact reuse for smoketests
|
||||
- 30d929b: Optimize demo GIF and improve README
|
||||
@@ -0,0 +1 @@
|
||||
When contributing to this repository, you must strictly follow all guidelines outlined in the AGENTS.md file.
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
# Google Workspace CLI (`gws`) Context
|
||||
|
||||
The `gws` CLI provides dynamic access to Google Workspace APIs (Drive, Gmail, Calendar, Sheets, Admin, etc.) by parsing Discovery Documents at runtime.
|
||||
|
||||
## Rules of Engagement for Agents
|
||||
|
||||
* **Schema Discovery:** *If you don't know the exact JSON payload structure, run `gws schema <resource>.<method>` first to inspect the schema before executing.*
|
||||
* **Context Window Protection:** *Workspace APIs (like Drive and Gmail) return massive JSON blobs. ALWAYS use field masks when listing or getting resources by appending `--params '{"fields": "id,name"}'` to avoid overwhelming your context window.*
|
||||
* **Dry-Run Safety:** *Always use the `--dry-run` flag for mutating operations (create, update, delete) to validate your JSON payload before actual execution.*
|
||||
|
||||
## Core Syntax
|
||||
|
||||
```bash
|
||||
gws <service> <resource> [sub-resource] <method> [flags]
|
||||
```
|
||||
|
||||
Use `--help` to get help on the available commands.
|
||||
|
||||
```bash
|
||||
gws --help
|
||||
gws <service> --help
|
||||
gws <service> <resource> --help
|
||||
gws <service> <resource> <method> --help
|
||||
```
|
||||
|
||||
### Key Flags
|
||||
|
||||
- `--params '<JSON>'`: URL/query parameters (e.g., `id`, `q`, `pageSize`).
|
||||
- `--json '<JSON>'`: Request body for POST/PUT/PATCH methods.
|
||||
- `--page-all`: Auto-paginates results and outputs NDJSON (one JSON object per line).
|
||||
- `--fields '<MASK>'`: Limits the response fields (critical for AI context window efficiency).
|
||||
- `--upload <PATH>`: Files for multipart uploads (e.g., `drive files create`).
|
||||
- `--output <PATH>`: Destination for binary downloads (e.g., `drive files get`).
|
||||
- `--sanitize <TEMPLATE>`: Sanitizes output using Google Cloud Model Armor.
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### 1. Reading Data (GET/LIST)
|
||||
Always use `--fields` to minimize tokens.
|
||||
|
||||
```bash
|
||||
# List Drive files (efficient)
|
||||
gws drive files list --params '{"q": "name contains \"Report\"", "pageSize": 10}' --fields "files(id,name,mimeType)"
|
||||
|
||||
# Get Gmail message details
|
||||
gws gmail users messages get --params '{"userId": "me", "id": "MSG_123"}'
|
||||
```
|
||||
|
||||
### 2. Writing Data (POST/PUT/PATCH)
|
||||
Use `--json` for the request body.
|
||||
|
||||
```bash
|
||||
# Send Email
|
||||
gws gmail users messages send --params '{"userId": "me"}' --json '{"raw": "BASE64..."}'
|
||||
|
||||
# Create Spreadsheet
|
||||
gws sheets spreadsheets create --json '{"properties": {"title": "Q4 Budget"}}'
|
||||
```
|
||||
|
||||
### 3. Pagination (NDJSON)
|
||||
Use `--page-all` for listing large collections. The output is Newline Delimited JSON.
|
||||
|
||||
```bash
|
||||
# Stream all users
|
||||
gws admin users list --params '{"domain": "example.com"}' --page-all
|
||||
```
|
||||
|
||||
### 4. Schema Introspection
|
||||
If unsure about parameters or body structure, check the schema:
|
||||
|
||||
```bash
|
||||
gws schema drive.files.list
|
||||
gws schema sheets.spreadsheets.create
|
||||
```
|
||||
Generated
+3948
File diff suppressed because it is too large
Load Diff
+22
@@ -0,0 +1,22 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
[workspace]
|
||||
members = ["crates/google-workspace-cli", "crates/google-workspace"]
|
||||
resolver = "2"
|
||||
|
||||
# The profile that 'cargo dist' will build with
|
||||
[profile.dist]
|
||||
inherits = "release"
|
||||
lto = "thin"
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,510 @@
|
||||
<h1 align="center">gws</h1>
|
||||
|
||||
**One CLI for all of Google Workspace — built for humans and AI agents.**<br>
|
||||
Drive, Gmail, Calendar, and every Workspace API. Zero boilerplate. Structured JSON output. 40+ agent skills included.
|
||||
|
||||
> [!NOTE]
|
||||
> This is **not** an officially supported Google product.
|
||||
|
||||
<p>
|
||||
<a href="https://www.npmjs.com/package/@googleworkspace/cli"><img src="https://img.shields.io/npm/v/@googleworkspace/cli" alt="npm version"></a>
|
||||
<a href="https://github.com/googleworkspace/cli/blob/main/LICENSE"><img src="https://img.shields.io/github/license/googleworkspace/cli" alt="license"></a>
|
||||
<a href="https://github.com/googleworkspace/cli/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/googleworkspace/cli/ci.yml?branch=main&label=CI" alt="CI status"></a>
|
||||
<a href="https://www.npmjs.com/package/@googleworkspace/cli"><img src="https://img.shields.io/npm/unpacked-size/@googleworkspace/cli" alt="install size"></a>
|
||||
</p>
|
||||
<br>
|
||||
|
||||
⬇️ **[Download the latest release for your OS](https://github.com/googleworkspace/cli/releases)**
|
||||
|
||||
`gws` doesn't ship a static list of commands. It reads Google's own [Discovery Service](https://developers.google.com/discovery) at runtime and builds its entire command surface dynamically. When Google Workspace adds an API endpoint or method, `gws` picks it up automatically.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This project is under active development. Expect breaking changes as we march toward v1.0.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation](#installation)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Why gws?](#why-gws)
|
||||
- [Authentication](#authentication)
|
||||
- [AI Agent Skills](#ai-agent-skills)
|
||||
- [Advanced Usage](#advanced-usage)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Exit Codes](#exit-codes)
|
||||
- [Architecture](#architecture)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Development](#development)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js 18+** — for `npm install` (or download a pre-built binary from [GitHub Releases](https://github.com/googleworkspace/cli/releases))
|
||||
- **A Google Cloud project** — required for OAuth credentials. You can create one via the [Google Cloud Console](https://console.cloud.google.com/) or with the [`gcloud` CLI](https://cloud.google.com/sdk/docs/install) or with the `gws auth setup` command.
|
||||
- **A Google account** with access to Google Workspace
|
||||
|
||||
## Installation
|
||||
|
||||
The recommended way to install `gws` is to download the pre-built binary for your OS and architecture from the **[GitHub Releases](https://github.com/googleworkspace/cli/releases)** page. Extract the archive and place the `gws` binary in your `$PATH`.
|
||||
|
||||
For convenience, you can also use `npm` to automate downloading the appropriate binary from GitHub Releases:
|
||||
|
||||
```bash
|
||||
npm install -g @googleworkspace/cli
|
||||
```
|
||||
|
||||
Or build from source:
|
||||
|
||||
```bash
|
||||
cargo install --git https://github.com/googleworkspace/cli --locked
|
||||
```
|
||||
|
||||
A Nix flake is also available at `github:googleworkspace/cli`
|
||||
|
||||
```bash
|
||||
nix run github:googleworkspace/cli
|
||||
```
|
||||
|
||||
On macOS and Linux, you can also install via [Homebrew](https://brew.sh/):
|
||||
|
||||
```bash
|
||||
brew install googleworkspace-cli
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
gws auth setup # walks you through Google Cloud project config
|
||||
gws auth login # subsequent OAuth login
|
||||
gws drive files list --params '{"pageSize": 5}'
|
||||
```
|
||||
|
||||
## Why gws?
|
||||
|
||||
**For humans** — stop writing `curl` calls against REST docs. `gws` gives you `--help` on every resource, `--dry-run` to preview requests, and auto‑pagination.
|
||||
|
||||
**For AI agents** — every response is structured JSON. Pair it with the included agent skills and your LLM can manage Workspace without custom tooling.
|
||||
|
||||
```bash
|
||||
# List the 10 most recent files
|
||||
gws drive files list --params '{"pageSize": 10}'
|
||||
|
||||
# Create a spreadsheet
|
||||
gws sheets spreadsheets create --json '{"properties": {"title": "Q1 Budget"}}'
|
||||
|
||||
# Send a Chat message
|
||||
gws chat spaces messages create \
|
||||
--params '{"parent": "spaces/xyz"}' \
|
||||
--json '{"text": "Deploy complete."}' \
|
||||
--dry-run
|
||||
|
||||
# Introspect any method's request/response schema
|
||||
gws schema drive.files.list
|
||||
|
||||
# Stream paginated results as NDJSON
|
||||
gws drive files list --params '{"pageSize": 100}' --page-all | jq -r '.files[].name'
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
The CLI supports multiple auth workflows so it works on your laptop, in CI, and on a server.
|
||||
|
||||
### Which setup should I use?
|
||||
|
||||
| I have… | Use |
|
||||
|---|---|
|
||||
| `gcloud` installed and authenticated | [`gws auth setup`](#interactive-local-desktop) (fastest) |
|
||||
| A GCP project but no `gcloud` | [Manual OAuth setup](#manual-oauth-setup-google-cloud-console) |
|
||||
| An existing OAuth access token | [`GOOGLE_WORKSPACE_CLI_TOKEN`](#pre-obtained-access-token) |
|
||||
| Existing Credentials | [`GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE`](#service-account-server-to-server) |
|
||||
|
||||
### Interactive (local desktop)
|
||||
|
||||
Credentials are encrypted at rest (AES-256-GCM) with the key stored in your OS keyring (or `~/.config/gws/.encryption_key` when `GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file`).
|
||||
|
||||
```bash
|
||||
gws auth setup # one-time: creates a Cloud project, enables APIs, logs you in
|
||||
gws auth login # subsequent scope selection and login
|
||||
```
|
||||
|
||||
> `gws auth setup` requires the [`gcloud` CLI](https://cloud.google.com/sdk/docs/install). If you don't have `gcloud`, use the [manual setup](#manual-oauth-setup-google-cloud-console) below instead.
|
||||
|
||||
> [!WARNING]
|
||||
> **Scope limits in testing mode:** If your OAuth app is unverified (testing mode),
|
||||
> Google limits consent to ~25 scopes. The `recommended` scope preset includes 85+
|
||||
> scopes and **will fail** for unverified apps (especially for `@gmail.com` accounts).
|
||||
> Choose individual services instead to filter the scope picker:
|
||||
> ```bash
|
||||
> gws auth login -s drive,gmail,sheets
|
||||
> ```
|
||||
|
||||
|
||||
### Manual OAuth setup (Google Cloud Console)
|
||||
|
||||
Use this when `gws auth setup` cannot automate project/client creation, or when you want explicit control.
|
||||
|
||||
1. Open Google Cloud Console in the target project:
|
||||
- OAuth consent screen: `https://console.cloud.google.com/apis/credentials/consent?project=<PROJECT_ID>`
|
||||
- Credentials: `https://console.cloud.google.com/apis/credentials?project=<PROJECT_ID>`
|
||||
2. Configure OAuth branding/audience if prompted:
|
||||
- App type: **External** (testing mode is fine)
|
||||
3. Add your account under **Test users**
|
||||
4. Create an OAuth client:
|
||||
- Type: **Desktop app**
|
||||
5. Download the client JSON and save it to:
|
||||
- `~/.config/gws/client_secret.json`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **You must add yourself as a test user.** In the OAuth consent screen, click
|
||||
> **Test users → Add users** and enter your Google account email. Without this,
|
||||
> login will fail with a generic "Access blocked" error.
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
gws auth login
|
||||
```
|
||||
|
||||
### Browser-assisted auth (human or agent)
|
||||
|
||||
You can complete OAuth either manually or with browser automation.
|
||||
|
||||
- **Human flow**: run `gws auth login`, open the printed URL, approve scopes.
|
||||
- **Agent-assisted flow**: the agent opens the URL, selects account, handles consent prompts, and returns control once the localhost callback succeeds.
|
||||
|
||||
If consent shows **"Google hasn't verified this app"** (testing mode), click **Continue**.
|
||||
If scope checkboxes appear, select required scopes (or **Select all**) before continuing.
|
||||
|
||||
### Headless / CI (export flow)
|
||||
|
||||
1. Complete interactive auth on a machine with a browser.
|
||||
2. Export credentials:
|
||||
```bash
|
||||
gws auth export --unmasked > credentials.json
|
||||
```
|
||||
3. On the headless machine:
|
||||
```bash
|
||||
export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=/path/to/credentials.json
|
||||
gws drive files list # just works
|
||||
```
|
||||
|
||||
### Service Account (server-to-server)
|
||||
|
||||
Point to your key file; no login needed.
|
||||
|
||||
```bash
|
||||
export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=/path/to/service-account.json
|
||||
gws drive files list
|
||||
```
|
||||
|
||||
### Pre-obtained Access Token
|
||||
|
||||
Useful when another tool (e.g. `gcloud`) already mints tokens for your environment.
|
||||
|
||||
```bash
|
||||
export GOOGLE_WORKSPACE_CLI_TOKEN=$(gcloud auth print-access-token)
|
||||
```
|
||||
|
||||
### Precedence
|
||||
|
||||
| Priority | Source | Set via |
|
||||
| -------- | ---------------------- | --------------------------------------- |
|
||||
| 1 | Access token | `GOOGLE_WORKSPACE_CLI_TOKEN` |
|
||||
| 2 | Credentials file | `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` |
|
||||
| 3 | Encrypted credentials | `gws auth login` |
|
||||
| 4 | Plaintext credentials | `~/.config/gws/credentials.json` |
|
||||
|
||||
Environment variables can also live in a `.env` file.
|
||||
|
||||
## AI Agent Skills
|
||||
|
||||
The repo ships 100+ Agent Skills (`SKILL.md` files) — one for every supported API, plus higher-level helpers for common workflows and 50 curated recipes for Gmail, Drive, Docs, Calendar, and Sheets. See the full [Skills Index](docs/skills.md) for the complete list.
|
||||
|
||||
```bash
|
||||
# Install all skills at once
|
||||
npx skills add https://github.com/googleworkspace/cli
|
||||
|
||||
# Or pick only what you need
|
||||
npx skills add https://github.com/googleworkspace/cli/tree/main/skills/gws-drive
|
||||
npx skills add https://github.com/googleworkspace/cli/tree/main/skills/gws-gmail
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>OpenClaw setup</summary>
|
||||
|
||||
```bash
|
||||
# Symlink all skills (stays in sync with repo)
|
||||
ln -s $(pwd)/skills/gws-* ~/.openclaw/skills/
|
||||
|
||||
# Or copy specific skills
|
||||
cp -r skills/gws-drive skills/gws-gmail ~/.openclaw/skills/
|
||||
```
|
||||
|
||||
The `gws-shared` skill includes an `install` block so OpenClaw auto-installs the CLI via `npm` if `gws` isn't on PATH.
|
||||
|
||||
</details>
|
||||
|
||||
## Gemini CLI Extension
|
||||
|
||||
1. Authenticate the CLI first:
|
||||
|
||||
```bash
|
||||
gws auth setup
|
||||
```
|
||||
|
||||
2. Install the extension into the Gemini CLI:
|
||||
```bash
|
||||
gemini extensions install https://github.com/googleworkspace/cli
|
||||
```
|
||||
|
||||
Installing this extension gives your Gemini CLI agent direct access to all `gws` commands and Google Workspace agent skills. Because `gws` handles its own authentication securely, you simply need to authenticate your terminal once prior to using the agent, and the extension will automatically inherit your credentials.
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Multipart Uploads
|
||||
|
||||
```bash
|
||||
gws drive files create --json '{"name": "report.pdf"}' --upload ./report.pdf
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
| Flag | Description | Default |
|
||||
| ------------------- | ---------------------------------------------- | ------- |
|
||||
| `--page-all` | Auto-paginate, one JSON line per page (NDJSON) | off |
|
||||
| `--page-limit <N>` | Max pages to fetch | 10 |
|
||||
| `--page-delay <MS>` | Delay between pages | 100 ms |
|
||||
|
||||
### Google Sheets — Shell Escaping
|
||||
|
||||
Sheets ranges use `!` which bash interprets as history expansion. Always wrap values in **single quotes**:
|
||||
|
||||
```bash
|
||||
# Read cells A1:C10 from "Sheet1"
|
||||
gws sheets spreadsheets values get \
|
||||
--params '{"spreadsheetId": "SPREADSHEET_ID", "range": "Sheet1!A1:C10"}'
|
||||
|
||||
# Append rows
|
||||
gws sheets spreadsheets values append \
|
||||
--params '{"spreadsheetId": "ID", "range": "Sheet1!A1", "valueInputOption": "USER_ENTERED"}' \
|
||||
--json '{"values": [["Name", "Score"], ["Alice", 95]]}'
|
||||
```
|
||||
|
||||
### Helper Commands
|
||||
|
||||
Some services ship hand-crafted helper commands alongside the auto-generated Discovery surface. Helper commands are prefixed with `+` so they are visually distinct and never collide with Discovery-generated method names.
|
||||
|
||||
Time-aware helpers (`+agenda`, `+standup-report`, `+weekly-digest`, `+meeting-prep`) automatically use your **Google account timezone** (fetched from Calendar Settings API and cached for 24 hours). Override with `--timezone`/`--tz` on `+agenda`, or set the `--timezone` flag for explicit control.
|
||||
|
||||
Run `gws <service> --help` to see both Discovery methods and helper commands together.
|
||||
|
||||
```bash
|
||||
gws gmail --help # shows +send, +reply, +reply-all, +forward, +triage, +watch …
|
||||
gws calendar --help # shows +insert, +agenda …
|
||||
gws drive --help # shows +upload …
|
||||
```
|
||||
|
||||
**Full helper reference:**
|
||||
|
||||
| Service | Command | Description |
|
||||
|---------|---------|-------------|
|
||||
| `gmail` | `+send` | Send an email |
|
||||
| `gmail` | `+reply` | Reply to a message (handles threading automatically) |
|
||||
| `gmail` | `+reply-all` | Reply-all to a message |
|
||||
| `gmail` | `+forward` | Forward a message to new recipients |
|
||||
| `gmail` | `+triage` | Show unread inbox summary (sender, subject, date) |
|
||||
| `gmail` | `+watch` | Watch for new emails and stream them as NDJSON |
|
||||
| `sheets` | `+append` | Append a row to a spreadsheet |
|
||||
| `sheets` | `+read` | Read values from a spreadsheet |
|
||||
| `docs` | `+write` | Append text to a document |
|
||||
| `chat` | `+send` | Send a message to a space |
|
||||
| `drive` | `+upload` | Upload a file with automatic metadata |
|
||||
| `calendar` | `+insert` | Create a new event |
|
||||
| `calendar` | `+agenda` | Show upcoming events (uses Google account timezone; override with `--timezone`) |
|
||||
| `script` | `+push` | Replace all files in an Apps Script project with local files |
|
||||
| `workflow` | `+standup-report` | Today's meetings + open tasks as a standup summary |
|
||||
| `workflow` | `+meeting-prep` | Prepare for your next meeting: agenda, attendees, and linked docs |
|
||||
| `workflow` | `+email-to-task` | Convert a Gmail message into a Google Tasks entry |
|
||||
| `workflow` | `+weekly-digest` | Weekly summary: this week's meetings + unread email count |
|
||||
| `workflow` | `+file-announce` | Announce a Drive file in a Chat space |
|
||||
| `events` | `+subscribe` | Subscribe to Workspace events and stream them as NDJSON |
|
||||
| `events` | `+renew` | Renew/reactivate Workspace Events subscriptions |
|
||||
| `modelarmor` | `+sanitize-prompt` | Sanitize a user prompt through a Model Armor template |
|
||||
| `modelarmor` | `+sanitize-response` | Sanitize a model response through a Model Armor template |
|
||||
| `modelarmor` | `+create-template` | Create a new Model Armor template |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Send an email
|
||||
gws gmail +send --to alice@example.com --subject "Hello" --body "Hi there"
|
||||
|
||||
# Reply to a message
|
||||
gws gmail +reply --message-id MESSAGE_ID --body "Thanks!"
|
||||
|
||||
# Append a row to a spreadsheet
|
||||
gws sheets +append --spreadsheet SPREADSHEET_ID --values "Alice,95"
|
||||
|
||||
# Show today's calendar agenda
|
||||
gws calendar +agenda
|
||||
|
||||
# Upload a file to Drive
|
||||
gws drive +upload ./report.pdf --name "Q1 Report"
|
||||
|
||||
# Morning standup summary
|
||||
gws workflow +standup-report
|
||||
|
||||
# Show today's agenda in a specific timezone
|
||||
gws calendar +agenda --today --timezone America/New_York
|
||||
```
|
||||
|
||||
### Model Armor (Response Sanitization)
|
||||
|
||||
Integrate [Google Cloud Model Armor](https://cloud.google.com/security/products/model-armor) to scan API responses for prompt injection before they reach your agent.
|
||||
|
||||
```bash
|
||||
gws gmail users messages get --params '...' \
|
||||
--sanitize "projects/P/locations/L/templates/T"
|
||||
```
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------------------------------- | ---------------------------- |
|
||||
| `GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE` | Default Model Armor template |
|
||||
| `GOOGLE_WORKSPACE_CLI_SANITIZE_MODE` | `warn` (default) or `block` |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
All variables are optional. See [`.env.example`](.env.example) for a copy-paste template.
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `GOOGLE_WORKSPACE_CLI_TOKEN` | Pre-obtained OAuth2 access token (highest priority) |
|
||||
| `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` | Path to OAuth credentials JSON (user or service account) |
|
||||
| `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID (alternative to `client_secret.json`) |
|
||||
| `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret (paired with `CLIENT_ID`) |
|
||||
| `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override config directory (default: `~/.config/gws`) |
|
||||
| `GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE` | Default Model Armor template |
|
||||
| `GOOGLE_WORKSPACE_CLI_SANITIZE_MODE` | `warn` (default) or `block` |
|
||||
| `GOOGLE_WORKSPACE_CLI_LOG` | Log level for stderr (e.g., `gws=debug`). Off by default. |
|
||||
| `GOOGLE_WORKSPACE_CLI_LOG_FILE` | Directory for JSON log files with daily rotation. Off by default. |
|
||||
| `GOOGLE_WORKSPACE_PROJECT_ID` | GCP project ID override for quota/billing and fallback for helper commands |
|
||||
|
||||
Environment variables can also be set in a `.env` file (loaded via [dotenvy](https://crates.io/crates/dotenvy)).
|
||||
|
||||
## Exit Codes
|
||||
|
||||
`gws` uses structured exit codes so scripts can branch on the failure type without parsing error output.
|
||||
|
||||
| Code | Meaning | Example cause |
|
||||
|------|---------|---------------|
|
||||
| `0` | Success | Command completed normally |
|
||||
| `1` | API error | Google returned a 4xx/5xx response |
|
||||
| `2` | Auth error | Credentials missing, expired, or invalid |
|
||||
| `3` | Validation error | Bad arguments, unknown service, invalid flag |
|
||||
| `4` | Discovery error | Could not fetch the API schema document |
|
||||
| `5` | Internal error | Unexpected failure |
|
||||
|
||||
```bash
|
||||
gws drive files list --params '{"fileId": "bad"}'
|
||||
echo $? # 1 — API error
|
||||
|
||||
gws unknown-service files list
|
||||
echo $? # 3 — validation error (unknown service)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
`gws` uses a **two-phase parsing** strategy:
|
||||
|
||||
1. Read `argv[1]` to identify the service (e.g. `drive`)
|
||||
2. Fetch the service's Discovery Document (cached 24 h)
|
||||
3. Build a `clap::Command` tree from the document's resources and methods
|
||||
4. Re-parse the remaining arguments
|
||||
5. Authenticate, build the HTTP request, execute
|
||||
|
||||
All output — success, errors, download metadata — is structured JSON.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Access blocked" or 403 during login
|
||||
|
||||
Your OAuth app is in **testing mode** and your account is not listed as a test user.
|
||||
|
||||
**Fix:** Open the [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) in your GCP project → **Test users** → **Add users** → enter your Google account email. Then retry `gws auth login`.
|
||||
|
||||
### "Google hasn't verified this app"
|
||||
|
||||
Expected when your app is in testing mode. Click **Advanced** → **Go to \<app name\> (unsafe)** to proceed. This is safe for personal use; verification is only required to publish the app to other users.
|
||||
|
||||
### Too many scopes / consent screen error
|
||||
|
||||
Unverified (testing mode) apps are limited to ~25 OAuth scopes. The `recommended` scope preset includes many scopes and will exceed this limit.
|
||||
|
||||
**Fix:** Select only the scopes you need:
|
||||
|
||||
```bash
|
||||
gws auth login --scopes drive,gmail,calendar
|
||||
```
|
||||
|
||||
### `gcloud` CLI not found
|
||||
|
||||
`gws auth setup` requires the `gcloud` CLI to automate project creation. You have three options:
|
||||
|
||||
1. [Install gcloud](https://cloud.google.com/sdk/docs/install) and use `gcloud` directly.
|
||||
2. Re-run `gws auth setup` which wraps `gcloud` calls.
|
||||
3. Skip `gcloud` entirely — set up OAuth credentials manually in the [Cloud Console](#manual-oauth-setup-google-cloud-console)
|
||||
|
||||
### `redirect_uri_mismatch`
|
||||
|
||||
The OAuth client was not created as a **Desktop app** type. In the [Credentials](https://console.cloud.google.com/apis/credentials) page, delete the existing client, create a new one with type **Desktop app**, and download the new JSON.
|
||||
|
||||
### API not enabled — `accessNotConfigured`
|
||||
|
||||
If a required Google API is not enabled for your GCP project, you will see a
|
||||
403 error with reason `accessNotConfigured`:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": 403,
|
||||
"message": "Gmail API has not been used in project 549352339482 ...",
|
||||
"reason": "accessNotConfigured",
|
||||
"enable_url": "https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`gws` also prints an actionable hint to **stderr**:
|
||||
|
||||
```
|
||||
💡 API not enabled for your GCP project.
|
||||
Enable it at: https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482
|
||||
After enabling, wait a few seconds and retry your command.
|
||||
```
|
||||
|
||||
**Steps to fix:**
|
||||
|
||||
1. Click the `enable_url` link (or copy it from the `enable_url` JSON field).
|
||||
2. In the GCP Console, click **Enable**.
|
||||
3. Wait ~10 seconds, then retry your `gws` command.
|
||||
|
||||
> [!TIP]
|
||||
> You can also run `gws auth setup` which walks you through enabling all required
|
||||
> APIs for your project automatically.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cargo build # dev build
|
||||
cargo clippy -- -D warnings # lint
|
||||
cargo test # unit tests
|
||||
./scripts/coverage.sh # HTML coverage report → target/llvm-cov/html/
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
|
||||
## Disclaimer
|
||||
|
||||
> [!CAUTION]
|
||||
> This is **not** an officially supported Google product.
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`googleworkspace/cli`
|
||||
- 原始仓库:https://github.com/googleworkspace/cli
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,6 @@
|
||||
# Report a security issue
|
||||
|
||||
To report a security issue, please use [https://g.co/vulnz](https://g.co/vulnz). We use
|
||||
[https://g.co/vulnz](https://g.co/vulnz) for our intake, and do coordination and disclosure here on
|
||||
GitHub (including using GitHub Security Advisory). The Google Security Team will
|
||||
respond within 5 working days of your report on [https://g.co/vulnz](https://g.co/vulnz).
|
||||
@@ -0,0 +1,12 @@
|
||||
|
||||
╔════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ ✨ Feature Roll ✨ ║
|
||||
║ ║
|
||||
║ ✅ Compatible with Headless Envs ║
|
||||
║ 🔒 Can be Scoped to Read-Only ║
|
||||
║ 🦀 Zero Runtime (Static Binary) ║
|
||||
║ 📄 Auto-Pagination (NDJSON) ║
|
||||
║ 🧠 Type-Safe Discovery Schemas ║
|
||||
║ ║
|
||||
╚════════════════════════════════════════════════╝
|
||||
@@ -0,0 +1,29 @@
|
||||
|
||||
╔════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ ██████╗ ██╗ ██╗███████╗ ║
|
||||
║ ██╔════╝ ██║ ██║██╔════╝ ║
|
||||
║ ██║ ███╗██║█╗ ██║███████╗ ║
|
||||
║ ██║ ██║██║███╗ ██║╚════██║ ║
|
||||
║ ╚██████╔╝╚███╔ ███╔╝███████║ ║
|
||||
║ ╚═════╝ ╚══╝ ╚══╝ ╚══════╝ ║
|
||||
║ ║
|
||||
║ Google Workspace CLI ║
|
||||
║ ───────────────────── ║
|
||||
║ One tool. Every API. ║
|
||||
║ ║
|
||||
╚════════════════════════════════════════════════╝
|
||||
|
||||
⭐ github.com/googleworkspace/cli
|
||||
|
||||
npm i -g @googleworkspace/cli
|
||||
|
||||
────────────────────────────────
|
||||
Built with Rust. 🦀
|
||||
────────────────────────────────
|
||||
|
||||
🚀 Drive 📧 Gmail
|
||||
📅 Calendar 📊 Sheets
|
||||
📝 Docs 🎨 Slides
|
||||
💬 Chat 👥 Admin
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
|
||||
╔════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ ██████╗ ██╗ ██╗███████╗ ║
|
||||
║ ██╔════╝ ██║ ██║██╔════╝ ║
|
||||
║ ██║ ███╗██║█╗ ██║███████╗ ║
|
||||
║ ██║ ██║██║███╗ ██║╚════██║ ║
|
||||
║ ╚██████╔╝╚███╔ ███╔╝███████║ ║
|
||||
║ ╚═════╝ ╚══╝ ╚══╝ ╚══════╝ ║
|
||||
║ ║
|
||||
║ Google Workspace CLI ║
|
||||
║ ───────────────────── ║
|
||||
║ One tool. Every API. ║
|
||||
║ ║
|
||||
╚════════════════════════════════════════════════╝
|
||||
|
||||
⭐ github.com/googleworkspace/cli
|
||||
|
||||
npm i -g @googleworkspace/cli
|
||||
|
||||
────────────────────────────────
|
||||
Built with Rust. 🦀
|
||||
────────────────────────────────
|
||||
|
||||
🚀 Drive 📧 Gmail
|
||||
📅 Calendar 📊 Sheets
|
||||
📝 Docs 🎨 Slides
|
||||
💬 Chat 👥 Admin
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m
|
||||
[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m
|
||||
[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m
|
||||
[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m
|
||||
[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[40m [0m[47m [0m[47m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[40m [0m[47m [0m[40m [0m[47m [0m[47m [0m
|
||||
[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m[47m [0m
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
🤖 WHAT IS GWS?
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
A single CLI for ALL Google Workspace APIs.
|
||||
|
||||
Perfect for:
|
||||
|
||||
🤖 AI agents
|
||||
📜 Shell scripts
|
||||
⚡ Power users
|
||||
📊 Automation
|
||||
@@ -0,0 +1,3 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📂 EXPLORE SERVICES
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
@@ -0,0 +1,3 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
🔍 INSPECT DRIVE
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
@@ -0,0 +1,7 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
🔍 INTROSPECT APIS
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
What params does an API
|
||||
method accept? Just ask.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📊 JSON SCHEMAS
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
@@ -0,0 +1,4 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
🗂️ List files in a folder
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📤 Upload to Drive
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📧 Send an email
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📅 Schedule a meeting
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📊 Log data to Sheets
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
♾️ Paginate all pages
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
--page-all streams NDJSON
|
||||
from every page.
|
||||
Pipe to jq for processing.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
[package]
|
||||
name = "google-workspace-cli"
|
||||
version = "0.22.5"
|
||||
edition = "2021"
|
||||
description = "Google Workspace CLI — dynamic command surface from Discovery Service"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/googleworkspace/cli"
|
||||
homepage = "https://github.com/googleworkspace/cli"
|
||||
readme = "README.md"
|
||||
authors = ["Justin Poehnelt"]
|
||||
keywords = ["cli", "google-workspace", "google", "drive", "gmail"]
|
||||
categories = ["command-line-utilities", "web-programming"]
|
||||
|
||||
[[bin]]
|
||||
name = "gws"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
google-workspace = { version = "0.22.5", path = "../google-workspace" }
|
||||
tempfile = "3"
|
||||
aes-gcm = "0.10"
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive", "string"] }
|
||||
dirs = "5"
|
||||
dotenvy = "0.15"
|
||||
hostname = "0.4"
|
||||
reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls-native-roots", "socks"], default-features = false }
|
||||
rand = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
yup-oauth2 = "12"
|
||||
futures-util = "0.3"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
bytes = "1"
|
||||
base64 = "0.22.1"
|
||||
derive_builder = "0.20.2"
|
||||
ratatui = "0.30.0"
|
||||
crossterm = "0.29.0"
|
||||
chrono = "0.4.44"
|
||||
chrono-tz = "0.10"
|
||||
iana-time-zone = "0.1"
|
||||
mail-builder = "0.4"
|
||||
async-trait = "0.1.89"
|
||||
toml = "0.8"
|
||||
percent-encoding = "2.3.2"
|
||||
zeroize = { version = "1.8.2", features = ["derive"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
tracing-appender = "0.2"
|
||||
uuid = { version = "1.22.0", features = ["v4", "v5"] }
|
||||
mime_guess2 = "2.3.1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
keyring = { version = "3.6.3", features = ["apple-native"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
keyring = { version = "3.6.3", features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(not(any(target_os = "macos", target_os = "windows")))'.dependencies]
|
||||
keyring = "3.6.3"
|
||||
|
||||
[dev-dependencies]
|
||||
serial_test = "3.4.0"
|
||||
@@ -0,0 +1,33 @@
|
||||
# google-workspace-cli
|
||||
|
||||
**One CLI for all of Google Workspace — built for humans and AI agents.**
|
||||
|
||||
`gws` dynamically generates its command surface at runtime by reading Google's [Discovery Service](https://developers.google.com/discovery). Drive, Gmail, Calendar, and every Workspace API — zero boilerplate, structured JSON output, 40+ agent skills included.
|
||||
|
||||
## Install
|
||||
|
||||
Download the pre-built binary for your OS and architecture from the **[GitHub Releases](https://github.com/googleworkspace/cli/releases)** page.
|
||||
|
||||
Alternatively, you can use package managers as a convenience layer:
|
||||
|
||||
```bash
|
||||
npm install -g @googleworkspace/cli # npm (downloads GitHub release binary)
|
||||
cargo install google-workspace-cli # crates.io
|
||||
nix run github:googleworkspace/cli # nix
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
gws auth login
|
||||
gws drive files list --params '{"pageSize": 5}'
|
||||
gws gmail users.messages list --params '{"maxResults": 3}'
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
See the [full README](https://github.com/googleworkspace/cli#readme) for authentication setup, helper commands, agent skills, and more.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 — see [LICENSE](https://github.com/googleworkspace/cli/blob/main/LICENSE).
|
||||
@@ -0,0 +1,190 @@
|
||||
[[personas]]
|
||||
name = "exec-assistant"
|
||||
title = "Executive Assistant"
|
||||
description = "Manage an executive's schedule, inbox, and communications."
|
||||
services = [ "gmail", "calendar", "drive", "chat" ]
|
||||
workflows = [ "+standup-report", "+meeting-prep", "+weekly-digest" ]
|
||||
instructions = [
|
||||
"Start each day with `gws workflow +standup-report` to get the executive's agenda and open tasks.",
|
||||
"Before each meeting, run `gws workflow +meeting-prep` to see attendees, description, and linked docs.",
|
||||
"Triage the inbox with `gws gmail +triage --max 10` — prioritize emails from direct reports and leadership.",
|
||||
"Schedule meetings with `gws calendar +insert` — always check for conflicts first using `gws calendar +agenda`.",
|
||||
"Draft replies with `gws gmail +send` — keep tone professional and concise."
|
||||
]
|
||||
tips = [
|
||||
"Always confirm calendar changes with the executive before committing.",
|
||||
"Use `--format table` for quick visual scans of agenda and triage output.",
|
||||
"Check `gws calendar +agenda --week` on Monday mornings for weekly planning."
|
||||
]
|
||||
|
||||
[[personas]]
|
||||
name = "project-manager"
|
||||
title = "Project Manager"
|
||||
description = "Coordinate projects — track tasks, schedule meetings, and share docs."
|
||||
services = [ "drive", "sheets", "calendar", "gmail", "chat" ]
|
||||
workflows = [ "+standup-report", "+weekly-digest", "+file-announce" ]
|
||||
instructions = [
|
||||
"Start the week with `gws workflow +weekly-digest` for a snapshot of upcoming meetings and unread items.",
|
||||
"Track project status in Sheets using `gws sheets +append` to log updates.",
|
||||
"Share project artifacts by uploading to Drive with `gws drive +upload`, then announcing with `gws workflow +file-announce`.",
|
||||
"Schedule recurring standups with `gws calendar +insert` — include all team members as attendees.",
|
||||
"Send status update emails to stakeholders with `gws gmail +send`."
|
||||
]
|
||||
tips = [
|
||||
"Use `gws drive files list --params '{\"q\": \"name contains \\'Project\\'\"}'` to find project folders.",
|
||||
"Pipe triage output through `jq` for filtering by sender or subject.",
|
||||
"Use `--dry-run` before any write operations to preview what will happen."
|
||||
]
|
||||
|
||||
[[personas]]
|
||||
name = "hr-coordinator"
|
||||
title = "HR Coordinator"
|
||||
description = "Handle HR workflows — onboarding, announcements, and employee comms."
|
||||
services = [ "gmail", "calendar", "drive", "chat" ]
|
||||
workflows = [ "+email-to-task", "+file-announce" ]
|
||||
instructions = [
|
||||
"For new hire onboarding, create calendar events for orientation sessions with `gws calendar +insert`.",
|
||||
"Upload onboarding docs to a shared Drive folder with `gws drive +upload`.",
|
||||
"Announce new hires in Chat spaces with `gws workflow +file-announce` to share their profile doc.",
|
||||
"Convert email requests into tracked tasks with `gws workflow +email-to-task`.",
|
||||
"Send bulk announcements with `gws gmail +send` — use clear subject lines."
|
||||
]
|
||||
tips = [
|
||||
"Always use `--sanitize` for PII-sensitive operations.",
|
||||
"Create a dedicated 'HR Onboarding' calendar for tracking orientation schedules."
|
||||
]
|
||||
|
||||
[[personas]]
|
||||
name = "sales-ops"
|
||||
title = "Sales Operations"
|
||||
description = "Manage sales workflows — track deals, schedule calls, client comms."
|
||||
services = [ "gmail", "calendar", "sheets", "drive" ]
|
||||
workflows = [ "+meeting-prep", "+email-to-task", "+weekly-digest" ]
|
||||
instructions = [
|
||||
"Prepare for client calls with `gws workflow +meeting-prep` to review attendees and agenda.",
|
||||
"Log deal updates in a tracking spreadsheet with `gws sheets +append`.",
|
||||
"Convert follow-up emails into tasks with `gws workflow +email-to-task`.",
|
||||
"Share proposals by uploading to Drive with `gws drive +upload`.",
|
||||
"Get a weekly sales pipeline summary with `gws workflow +weekly-digest`."
|
||||
]
|
||||
tips = [
|
||||
"Use `gws gmail +triage --query 'from:client-domain.com'` to filter client emails.",
|
||||
"Schedule follow-up calls immediately after meetings to maintain momentum.",
|
||||
"Keep all client-facing documents in a dedicated shared Drive folder."
|
||||
]
|
||||
|
||||
[[personas]]
|
||||
name = "it-admin"
|
||||
title = "IT Administrator"
|
||||
description = "Administer IT — monitor security and configure Workspace."
|
||||
services = [ "gmail", "drive", "calendar" ]
|
||||
workflows = [ "+standup-report" ]
|
||||
instructions = [
|
||||
"Start the day with `gws workflow +standup-report` to review any pending IT requests.",
|
||||
"Monitor suspicious login activity and review audit logs.",
|
||||
"Configure Drive sharing policies to enforce organizational security."
|
||||
]
|
||||
tips = [
|
||||
"Always use `--dry-run` before bulk operations.",
|
||||
"Review `gws auth status` regularly to verify service account permissions."
|
||||
]
|
||||
|
||||
[[personas]]
|
||||
name = "content-creator"
|
||||
title = "Content Creator"
|
||||
description = "Create, organize, and distribute content across Workspace."
|
||||
services = [ "docs", "drive", "gmail", "chat", "slides" ]
|
||||
workflows = [ "+file-announce" ]
|
||||
instructions = [
|
||||
"Draft content in Google Docs with `gws docs +write`.",
|
||||
"Organize content assets in Drive folders — use `gws drive files list` to browse.",
|
||||
"Share finished content by announcing in Chat with `gws workflow +file-announce`.",
|
||||
"Send content review requests via email with `gws gmail +send`.",
|
||||
"Upload media assets to Drive with `gws drive +upload`."
|
||||
]
|
||||
tips = [
|
||||
"Use `gws docs +write` for quick content updates — it handles the Docs API formatting.",
|
||||
"Keep a 'Content Calendar' in a shared Sheet for tracking publication schedules.",
|
||||
"Use `--format yaml` for human-readable output when debugging API responses."
|
||||
]
|
||||
|
||||
[[personas]]
|
||||
name = "customer-support"
|
||||
title = "Customer Support Agent"
|
||||
description = "Manage customer support — track tickets, respond, escalate issues."
|
||||
services = [ "gmail", "sheets", "chat", "calendar" ]
|
||||
workflows = [ "+email-to-task", "+standup-report" ]
|
||||
instructions = [
|
||||
"Triage the support inbox with `gws gmail +triage --query 'label:support'`.",
|
||||
"Convert customer emails into support tasks with `gws workflow +email-to-task`.",
|
||||
"Log ticket status updates in a tracking sheet with `gws sheets +append`.",
|
||||
"Escalate urgent issues to the team Chat space.",
|
||||
"Schedule follow-up calls with customers using `gws calendar +insert`."
|
||||
]
|
||||
tips = [
|
||||
"Use `gws gmail +triage --labels` to see email categories at a glance.",
|
||||
"Set up Gmail filters for auto-labeling support requests.",
|
||||
"Use `--format table` for quick status dashboard views."
|
||||
]
|
||||
|
||||
[[personas]]
|
||||
name = "event-coordinator"
|
||||
title = "Event Coordinator"
|
||||
description = "Plan and manage events — scheduling, invitations, and logistics."
|
||||
services = [ "calendar", "gmail", "drive", "chat", "sheets" ]
|
||||
workflows = [ "+meeting-prep", "+file-announce", "+weekly-digest" ]
|
||||
instructions = [
|
||||
"Create event calendar entries with `gws calendar +insert` — include location and attendee lists.",
|
||||
"Prepare event materials and upload to Drive with `gws drive +upload`.",
|
||||
"Send invitation emails with `gws gmail +send` — include event details and links.",
|
||||
"Announce updates in Chat spaces with `gws workflow +file-announce`.",
|
||||
"Track RSVPs and logistics in Sheets with `gws sheets +append`."
|
||||
]
|
||||
tips = [
|
||||
"Use `gws calendar +agenda --days 30` for long-range event planning.",
|
||||
"Create a dedicated calendar for each major event series.",
|
||||
"Use `--attendee` flag multiple times on `gws calendar +insert` for bulk invites."
|
||||
]
|
||||
|
||||
[[personas]]
|
||||
name = "team-lead"
|
||||
title = "Team Lead"
|
||||
description = "Lead a team — run standups, coordinate tasks, and communicate."
|
||||
services = [ "calendar", "gmail", "chat", "drive", "sheets" ]
|
||||
workflows = [
|
||||
"+standup-report",
|
||||
"+meeting-prep",
|
||||
"+weekly-digest",
|
||||
"+email-to-task"
|
||||
]
|
||||
instructions = [
|
||||
"Run daily standups with `gws workflow +standup-report` — share output in team Chat.",
|
||||
"Prepare for 1:1s with `gws workflow +meeting-prep`.",
|
||||
"Get weekly snapshots with `gws workflow +weekly-digest`.",
|
||||
"Delegate email action items with `gws workflow +email-to-task`.",
|
||||
"Track team OKRs in a shared Sheet with `gws sheets +append`."
|
||||
]
|
||||
tips = [
|
||||
"Use `gws calendar +agenda --week --format table` for weekly team calendar views.",
|
||||
"Pipe standup reports to Chat with `gws chat spaces messages create`.",
|
||||
"Use `--sanitize` for any operations involving sensitive team data."
|
||||
]
|
||||
|
||||
[[personas]]
|
||||
name = "researcher"
|
||||
title = "Researcher"
|
||||
description = "Organize research — manage references, notes, and collaboration."
|
||||
services = [ "drive", "docs", "sheets", "gmail" ]
|
||||
workflows = [ "+file-announce" ]
|
||||
instructions = [
|
||||
"Organize research papers and notes in Drive folders.",
|
||||
"Write research notes and summaries with `gws docs +write`.",
|
||||
"Track research data in Sheets — use `gws sheets +append` for data logging.",
|
||||
"Share findings with collaborators via `gws workflow +file-announce`.",
|
||||
"Request peer reviews via `gws gmail +send`."
|
||||
]
|
||||
tips = [
|
||||
"Use `gws drive files list` with search queries to find specific documents.",
|
||||
"Keep a running log of experiments and findings in a shared Sheet.",
|
||||
"Use `--format csv` when exporting data for analysis tools."
|
||||
]
|
||||
@@ -0,0 +1,496 @@
|
||||
[[recipes]]
|
||||
name = "label-and-archive-emails"
|
||||
title = "Label and Archive Gmail Threads"
|
||||
description = "Apply Gmail labels to matching messages and archive them to keep your inbox clean."
|
||||
category = "productivity"
|
||||
services = [ "gmail" ]
|
||||
steps = [
|
||||
"Search for matching emails: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"from:notifications@service.com\"}' --format table`",
|
||||
"Apply a label: `gws gmail users messages modify --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}' --json '{\"addLabelIds\": [\"LABEL_ID\"]}'`",
|
||||
"Archive (remove from inbox): `gws gmail users messages modify --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}' --json '{\"removeLabelIds\": [\"INBOX\"]}'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "draft-email-from-doc"
|
||||
title = "Draft a Gmail Message from a Google Doc"
|
||||
description = "Read content from a Google Doc and use it as the body of a Gmail message."
|
||||
category = "productivity"
|
||||
services = [ "docs", "gmail" ]
|
||||
steps = [
|
||||
"Get the document content: `gws docs documents get --params '{\"documentId\": \"DOC_ID\"}'`",
|
||||
"Copy the text from the body content",
|
||||
"Send the email: `gws gmail +send --to recipient@example.com --subject 'Newsletter Update' --body 'CONTENT_FROM_DOC'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "organize-drive-folder"
|
||||
title = "Organize Files into Google Drive Folders"
|
||||
description = "Create a Google Drive folder structure and move files into the right locations."
|
||||
category = "productivity"
|
||||
services = [ "drive" ]
|
||||
steps = [
|
||||
"Create a project folder: `gws drive files create --json '{\"name\": \"Q2 Project\", \"mimeType\": \"application/vnd.google-apps.folder\"}'`",
|
||||
"Create sub-folders: `gws drive files create --json '{\"name\": \"Documents\", \"mimeType\": \"application/vnd.google-apps.folder\", \"parents\": [\"PARENT_FOLDER_ID\"]}'`",
|
||||
"Move existing files into folder: `gws drive files update --params '{\"fileId\": \"FILE_ID\", \"addParents\": \"FOLDER_ID\", \"removeParents\": \"OLD_PARENT_ID\"}'`",
|
||||
"Verify structure: `gws drive files list --params '{\"q\": \"FOLDER_ID in parents\"}' --format table`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "share-folder-with-team"
|
||||
title = "Share a Google Drive Folder with a Team"
|
||||
description = "Share a Google Drive folder and all its contents with a list of collaborators."
|
||||
category = "productivity"
|
||||
services = [ "drive" ]
|
||||
steps = [
|
||||
"Find the folder: `gws drive files list --params '{\"q\": \"name = '\\''Project X'\\'' and mimeType = '\\''application/vnd.google-apps.folder'\\''\"}'`",
|
||||
"Share as editor: `gws drive permissions create --params '{\"fileId\": \"FOLDER_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"colleague@company.com\"}'`",
|
||||
"Share as viewer: `gws drive permissions create --params '{\"fileId\": \"FOLDER_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"stakeholder@company.com\"}'`",
|
||||
"Verify permissions: `gws drive permissions list --params '{\"fileId\": \"FOLDER_ID\"}' --format table`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "email-drive-link"
|
||||
title = "Email a Google Drive File Link"
|
||||
description = "Share a Google Drive file and email the link with a message to recipients."
|
||||
category = "productivity"
|
||||
services = [ "drive", "gmail" ]
|
||||
steps = [
|
||||
"Find the file: `gws drive files list --params '{\"q\": \"name = '\\''Quarterly Report'\\''\"}'`",
|
||||
"Share the file: `gws drive permissions create --params '{\"fileId\": \"FILE_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"client@example.com\"}'`",
|
||||
"Email the link: `gws gmail +send --to client@example.com --subject 'Quarterly Report' --body 'Hi, please find the report here: https://docs.google.com/document/d/FILE_ID'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "create-doc-from-template"
|
||||
title = "Create a Google Doc from a Template"
|
||||
description = "Copy a Google Docs template, fill in content, and share with collaborators."
|
||||
category = "productivity"
|
||||
services = [ "drive", "docs" ]
|
||||
steps = [
|
||||
"Copy the template: `gws drive files copy --params '{\"fileId\": \"TEMPLATE_DOC_ID\"}' --json '{\"name\": \"Project Brief - Q2 Launch\"}'`",
|
||||
"Get the new doc ID from the response",
|
||||
"Add content: `gws docs +write --document-id NEW_DOC_ID --text '## Project: Q2 Launch\n\n### Objective\nLaunch the new feature by end of Q2.'`",
|
||||
"Share with team: `gws drive permissions create --params '{\"fileId\": \"NEW_DOC_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"team@company.com\"}'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "create-expense-tracker"
|
||||
title = "Create a Google Sheets Expense Tracker"
|
||||
description = "Set up a Google Sheets spreadsheet for tracking expenses with headers and initial entries."
|
||||
category = "productivity"
|
||||
services = [ "sheets", "drive" ]
|
||||
steps = [
|
||||
"Create spreadsheet: `gws drive files create --json '{\"name\": \"Expense Tracker 2025\", \"mimeType\": \"application/vnd.google-apps.spreadsheet\"}'`",
|
||||
"Add headers: `gws sheets +append --spreadsheet SHEET_ID --range 'Sheet1' --values '[\"Date\", \"Category\", \"Description\", \"Amount\"]'`",
|
||||
"Add first entry: `gws sheets +append --spreadsheet SHEET_ID --range 'Sheet1' --values '[\"2025-01-15\", \"Travel\", \"Flight to NYC\", \"450.00\"]'`",
|
||||
"Share with manager: `gws drive permissions create --params '{\"fileId\": \"SHEET_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"manager@company.com\"}'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "copy-sheet-for-new-month"
|
||||
title = "Copy a Google Sheet for a New Month"
|
||||
description = "Duplicate a Google Sheets template tab for a new month of tracking."
|
||||
category = "productivity"
|
||||
services = [ "sheets" ]
|
||||
steps = [
|
||||
"Get spreadsheet details: `gws sheets spreadsheets get --params '{\"spreadsheetId\": \"SHEET_ID\"}'`",
|
||||
"Copy the template sheet: `gws sheets spreadsheets sheets copyTo --params '{\"spreadsheetId\": \"SHEET_ID\", \"sheetId\": 0}' --json '{\"destinationSpreadsheetId\": \"SHEET_ID\"}'`",
|
||||
"Rename the new tab: `gws sheets spreadsheets batchUpdate --params '{\"spreadsheetId\": \"SHEET_ID\"}' --json '{\"requests\": [{\"updateSheetProperties\": {\"properties\": {\"sheetId\": 123, \"title\": \"February 2025\"}, \"fields\": \"title\"}}]}'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "block-focus-time"
|
||||
title = "Block Focus Time on Google Calendar"
|
||||
description = "Create recurring focus time blocks on Google Calendar to protect deep work hours."
|
||||
category = "scheduling"
|
||||
services = [ "calendar" ]
|
||||
steps = [
|
||||
"Create recurring focus block: `gws calendar events insert --params '{\"calendarId\": \"primary\"}' --json '{\"summary\": \"Focus Time\", \"description\": \"Protected deep work block\", \"start\": {\"dateTime\": \"2025-01-20T09:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2025-01-20T11:00:00\", \"timeZone\": \"America/New_York\"}, \"recurrence\": [\"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR\"], \"transparency\": \"opaque\"}'`",
|
||||
"Verify it shows as busy: `gws calendar +agenda`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "reschedule-meeting"
|
||||
title = "Reschedule a Google Calendar Meeting"
|
||||
description = "Move a Google Calendar event to a new time and automatically notify all attendees."
|
||||
category = "scheduling"
|
||||
services = [ "calendar" ]
|
||||
steps = [
|
||||
"Find the event: `gws calendar +agenda`",
|
||||
"Get event details: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`",
|
||||
"Update the time: `gws calendar events patch --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\", \"sendUpdates\": \"all\"}' --json '{\"start\": {\"dateTime\": \"2025-01-22T14:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2025-01-22T15:00:00\", \"timeZone\": \"America/New_York\"}}'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "create-gmail-filter"
|
||||
title = "Create a Gmail Filter"
|
||||
description = "Create a Gmail filter to automatically label, star, or categorize incoming messages."
|
||||
category = "productivity"
|
||||
services = [ "gmail" ]
|
||||
steps = [
|
||||
"List existing labels: `gws gmail users labels list --params '{\"userId\": \"me\"}' --format table`",
|
||||
"Create a new label: `gws gmail users labels create --params '{\"userId\": \"me\"}' --json '{\"name\": \"Receipts\"}'`",
|
||||
"Create a filter: `gws gmail users settings filters create --params '{\"userId\": \"me\"}' --json '{\"criteria\": {\"from\": \"receipts@example.com\"}, \"action\": {\"addLabelIds\": [\"LABEL_ID\"], \"removeLabelIds\": [\"INBOX\"]}}'`",
|
||||
"Verify filter: `gws gmail users settings filters list --params '{\"userId\": \"me\"}' --format table`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "schedule-recurring-event"
|
||||
title = "Schedule a Recurring Meeting"
|
||||
description = "Create a recurring Google Calendar event with attendees."
|
||||
category = "scheduling"
|
||||
services = [ "calendar" ]
|
||||
steps = [
|
||||
"Create recurring event: `gws calendar events insert --params '{\"calendarId\": \"primary\"}' --json '{\"summary\": \"Weekly Standup\", \"start\": {\"dateTime\": \"2024-03-18T09:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2024-03-18T09:30:00\", \"timeZone\": \"America/New_York\"}, \"recurrence\": [\"RRULE:FREQ=WEEKLY;BYDAY=MO\"], \"attendees\": [{\"email\": \"team@company.com\"}]}'`",
|
||||
"Verify it was created: `gws calendar +agenda --days 14 --format table`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "find-free-time"
|
||||
title = "Find Free Time Across Calendars"
|
||||
description = "Query Google Calendar free/busy status for multiple users to find a meeting slot."
|
||||
category = "scheduling"
|
||||
services = [ "calendar" ]
|
||||
steps = [
|
||||
"Query free/busy: `gws calendar freebusy query --json '{\"timeMin\": \"2024-03-18T08:00:00Z\", \"timeMax\": \"2024-03-18T18:00:00Z\", \"items\": [{\"id\": \"user1@company.com\"}, {\"id\": \"user2@company.com\"}]}'`",
|
||||
"Review the output to find overlapping free slots",
|
||||
"Create event in the free slot: `gws calendar +insert --summary 'Meeting' --attendee user1@company.com --attendee user2@company.com --start '2024-03-18T14:00:00' --end '2024-03-18T14:30:00'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "bulk-download-folder"
|
||||
title = "Bulk Download Drive Folder"
|
||||
description = "List and download all files from a Google Drive folder."
|
||||
category = "productivity"
|
||||
services = [ "drive" ]
|
||||
steps = [
|
||||
"List files in folder: `gws drive files list --params '{\"q\": \"'\\''FOLDER_ID'\\'' in parents\"}' --format json`",
|
||||
"Download each file: `gws drive files get --params '{\"fileId\": \"FILE_ID\", \"alt\": \"media\"}' -o filename.ext`",
|
||||
"Export Google Docs as PDF: `gws drive files export --params '{\"fileId\": \"FILE_ID\", \"mimeType\": \"application/pdf\"}' -o document.pdf`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "find-large-files"
|
||||
title = "Find Largest Files in Drive"
|
||||
description = "Identify large Google Drive files consuming storage quota."
|
||||
category = "productivity"
|
||||
services = [ "drive" ]
|
||||
steps = [
|
||||
"List files sorted by size: `gws drive files list --params '{\"orderBy\": \"quotaBytesUsed desc\", \"pageSize\": 20, \"fields\": \"files(id,name,size,mimeType,owners)\"}' --format table`",
|
||||
"Review the output and identify files to archive or move"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "create-shared-drive"
|
||||
title = "Create and Configure a Shared Drive"
|
||||
description = "Create a Google Shared Drive and add members with appropriate roles."
|
||||
category = "productivity"
|
||||
services = [ "drive" ]
|
||||
steps = [
|
||||
"Create shared drive: `gws drive drives create --params '{\"requestId\": \"unique-id-123\"}' --json '{\"name\": \"Project X\"}'`",
|
||||
"Add a member: `gws drive permissions create --params '{\"fileId\": \"DRIVE_ID\", \"supportsAllDrives\": true}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"member@company.com\"}'`",
|
||||
"List members: `gws drive permissions list --params '{\"fileId\": \"DRIVE_ID\", \"supportsAllDrives\": true}'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "log-deal-update"
|
||||
title = "Log Deal Update to Sheet"
|
||||
description = "Append a deal status update to a Google Sheets sales tracking spreadsheet."
|
||||
category = "sales"
|
||||
services = [ "sheets", "drive" ]
|
||||
steps = [
|
||||
"Find the tracking sheet: `gws drive files list --params '{\"q\": \"name = '\\''Sales Pipeline'\\'' and mimeType = '\\''application/vnd.google-apps.spreadsheet'\\''\"}'`",
|
||||
"Read current data: `gws sheets +read --spreadsheet SHEET_ID --range \"Pipeline!A1:F\"`",
|
||||
"Append new row: `gws sheets +append --spreadsheet SHEET_ID --range 'Pipeline' --values '[\"2024-03-15\", \"Acme Corp\", \"Proposal Sent\", \"$50,000\", \"Q2\", \"jdoe\"]'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "collect-form-responses"
|
||||
title = "Check Form Responses"
|
||||
description = "Retrieve and review responses from a Google Form."
|
||||
category = "productivity"
|
||||
services = [ "forms" ]
|
||||
steps = [
|
||||
"List forms: `gws forms forms list` (if you don't have the form ID)",
|
||||
"Get form details: `gws forms forms get --params '{\"formId\": \"FORM_ID\"}'`",
|
||||
"Get responses: `gws forms forms responses list --params '{\"formId\": \"FORM_ID\"}' --format table`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "post-mortem-setup"
|
||||
title = "Set Up Post-Mortem"
|
||||
description = "Create a Google Docs post-mortem, schedule a Google Calendar review, and notify via Chat."
|
||||
category = "engineering"
|
||||
services = [ "docs", "calendar", "chat" ]
|
||||
steps = [
|
||||
"Create post-mortem doc: `gws docs +write --title 'Post-Mortem: [Incident]' --body '## Summary\\n\\n## Timeline\\n\\n## Root Cause\\n\\n## Action Items'`",
|
||||
"Schedule review meeting: `gws calendar +insert --summary 'Post-Mortem Review: [Incident]' --attendee team@company.com --start '2026-03-16T14:00:00' --end '2026-03-16T15:00:00'`",
|
||||
"Notify in Chat: `gws chat +send --space spaces/ENG_SPACE --text '🔍 Post-mortem scheduled for [Incident].'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "create-task-list"
|
||||
title = "Create a Task List and Add Tasks"
|
||||
description = "Set up a new Google Tasks list with initial tasks."
|
||||
category = "productivity"
|
||||
services = [ "tasks" ]
|
||||
steps = [
|
||||
"Create task list: `gws tasks tasklists insert --json '{\"title\": \"Q2 Goals\"}'`",
|
||||
"Add a task: `gws tasks tasks insert --params '{\"tasklist\": \"TASKLIST_ID\"}' --json '{\"title\": \"Review Q1 metrics\", \"notes\": \"Pull data from analytics dashboard\", \"due\": \"2024-04-01T00:00:00Z\"}'`",
|
||||
"Add another task: `gws tasks tasks insert --params '{\"tasklist\": \"TASKLIST_ID\"}' --json '{\"title\": \"Draft Q2 OKRs\"}'`",
|
||||
"List tasks: `gws tasks tasks list --params '{\"tasklist\": \"TASKLIST_ID\"}' --format table`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "review-overdue-tasks"
|
||||
title = "Review Overdue Tasks"
|
||||
description = "Find Google Tasks that are past due and need attention."
|
||||
category = "productivity"
|
||||
services = [ "tasks" ]
|
||||
steps = [
|
||||
"List task lists: `gws tasks tasklists list --format table`",
|
||||
"List tasks with status: `gws tasks tasks list --params '{\"tasklist\": \"TASKLIST_ID\", \"showCompleted\": false}' --format table`",
|
||||
"Review due dates and prioritize overdue items"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "watch-drive-changes"
|
||||
title = "Watch for Drive Changes"
|
||||
description = "Subscribe to change notifications on a Google Drive file or folder."
|
||||
category = "engineering"
|
||||
services = [ "events" ]
|
||||
steps = [
|
||||
"Create subscription: `gws events subscriptions create --json '{\"targetResource\": \"//drive.googleapis.com/drives/DRIVE_ID\", \"eventTypes\": [\"google.workspace.drive.file.v1.updated\"], \"notificationEndpoint\": {\"pubsubTopic\": \"projects/PROJECT/topics/TOPIC\"}, \"payloadOptions\": {\"includeResource\": true}}'`",
|
||||
"List active subscriptions: `gws events subscriptions list`",
|
||||
"Renew before expiry: `gws events +renew --subscription SUBSCRIPTION_ID`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "create-classroom-course"
|
||||
title = "Create a Google Classroom Course"
|
||||
description = "Create a Google Classroom course and invite students."
|
||||
category = "education"
|
||||
services = [ "classroom" ]
|
||||
steps = [
|
||||
"Create the course: `gws classroom courses create --json '{\"name\": \"Introduction to CS\", \"section\": \"Period 1\", \"room\": \"Room 101\", \"ownerId\": \"me\"}'`",
|
||||
"Invite a student: `gws classroom invitations create --json '{\"courseId\": \"COURSE_ID\", \"userId\": \"student@school.edu\", \"role\": \"STUDENT\"}'`",
|
||||
"List enrolled students: `gws classroom courses students list --params '{\"courseId\": \"COURSE_ID\"}' --format table`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "create-meet-space"
|
||||
title = "Create a Google Meet Conference"
|
||||
description = "Create a Google Meet meeting space and share the join link."
|
||||
category = "scheduling"
|
||||
services = [ "meet", "gmail" ]
|
||||
steps = [
|
||||
"Create meeting space: `gws meet spaces create --json '{\"config\": {\"accessType\": \"OPEN\"}}'`",
|
||||
"Copy the meeting URI from the response",
|
||||
"Email the link: `gws gmail +send --to team@company.com --subject 'Join the meeting' --body 'Join here: MEETING_URI'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "review-meet-participants"
|
||||
title = "Review Google Meet Attendance"
|
||||
description = "Review who attended a Google Meet conference and for how long."
|
||||
category = "productivity"
|
||||
services = [ "meet" ]
|
||||
steps = [
|
||||
"List recent conferences: `gws meet conferenceRecords list --format table`",
|
||||
"List participants: `gws meet conferenceRecords participants list --params '{\"parent\": \"conferenceRecords/CONFERENCE_ID\"}' --format table`",
|
||||
"Get session details: `gws meet conferenceRecords participants participantSessions list --params '{\"parent\": \"conferenceRecords/CONFERENCE_ID/participants/PARTICIPANT_ID\"}' --format table`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "create-presentation"
|
||||
title = "Create a Google Slides Presentation"
|
||||
description = "Create a new Google Slides presentation and add initial slides."
|
||||
category = "productivity"
|
||||
services = [ "slides" ]
|
||||
steps = [
|
||||
"Create presentation: `gws slides presentations create --json '{\"title\": \"Quarterly Review Q2\"}'`",
|
||||
"Get the presentation ID from the response",
|
||||
"Share with team: `gws drive permissions create --params '{\"fileId\": \"PRESENTATION_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"team@company.com\"}'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "save-email-attachments"
|
||||
title = "Save Gmail Attachments to Google Drive"
|
||||
description = "Find Gmail messages with attachments and save them to a Google Drive folder."
|
||||
category = "productivity"
|
||||
services = [ "gmail", "drive" ]
|
||||
steps = [
|
||||
"Search for emails with attachments: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"has:attachment from:client@example.com\"}' --format table`",
|
||||
"Get message details: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}'`",
|
||||
"Download attachment: `gws gmail users messages attachments get --params '{\"userId\": \"me\", \"messageId\": \"MESSAGE_ID\", \"id\": \"ATTACHMENT_ID\"}'`",
|
||||
"Upload to Drive folder: `gws drive +upload --file ./attachment.pdf --parent FOLDER_ID`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "send-team-announcement"
|
||||
title = "Announce via Gmail and Google Chat"
|
||||
description = "Send a team announcement via both Gmail and a Google Chat space."
|
||||
category = "communication"
|
||||
services = [ "gmail", "chat" ]
|
||||
steps = [
|
||||
"Send email: `gws gmail +send --to team@company.com --subject 'Important Update' --body 'Please review the attached policy changes.'`",
|
||||
"Post in Chat: `gws chat +send --space spaces/TEAM_SPACE --text '📢 Important Update: Please check your email for policy changes.'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "create-feedback-form"
|
||||
title = "Create and Share a Google Form"
|
||||
description = "Create a Google Form for feedback and share it via Gmail."
|
||||
category = "productivity"
|
||||
services = [ "forms", "gmail" ]
|
||||
steps = [
|
||||
"Create form: `gws forms forms create --json '{\"info\": {\"title\": \"Event Feedback\", \"documentTitle\": \"Event Feedback Form\"}}'`",
|
||||
"Get the form URL from the response (responderUri field)",
|
||||
"Email the form: `gws gmail +send --to attendees@company.com --subject 'Please share your feedback' --body 'Fill out the form: FORM_URL'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "sync-contacts-to-sheet"
|
||||
title = "Export Google Contacts to Sheets"
|
||||
description = "Export Google Contacts directory to a Google Sheets spreadsheet."
|
||||
category = "productivity"
|
||||
services = [ "people", "sheets" ]
|
||||
steps = [
|
||||
"List contacts: `gws people people listDirectoryPeople --params '{\"readMask\": \"names,emailAddresses,phoneNumbers\", \"sources\": [\"DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE\"], \"pageSize\": 100}' --format json`",
|
||||
"Create a sheet: `gws sheets +append --spreadsheet SHEET_ID --range 'Contacts' --values '[\"Name\", \"Email\", \"Phone\"]'`",
|
||||
"Append each contact row: `gws sheets +append --spreadsheet SHEET_ID --range 'Contacts' --values '[\"Jane Doe\", \"jane@company.com\", \"+1-555-0100\"]'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "share-event-materials"
|
||||
title = "Share Files with Meeting Attendees"
|
||||
description = "Share Google Drive files with all attendees of a Google Calendar event."
|
||||
category = "productivity"
|
||||
services = [ "calendar", "drive" ]
|
||||
steps = [
|
||||
"Get event attendees: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`",
|
||||
"Share file with each attendee: `gws drive permissions create --params '{\"fileId\": \"FILE_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"attendee@company.com\"}'`",
|
||||
"Verify sharing: `gws drive permissions list --params '{\"fileId\": \"FILE_ID\"}' --format table`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "create-vacation-responder"
|
||||
title = "Set Up a Gmail Vacation Responder"
|
||||
description = "Enable a Gmail out-of-office auto-reply with a custom message and date range."
|
||||
category = "productivity"
|
||||
services = [ "gmail" ]
|
||||
steps = [
|
||||
"Enable vacation responder: `gws gmail users settings updateVacation --params '{\"userId\": \"me\"}' --json '{\"enableAutoReply\": true, \"responseSubject\": \"Out of Office\", \"responseBodyPlainText\": \"I am out of the office until Jan 20. For urgent matters, contact backup@company.com.\", \"restrictToContacts\": false, \"restrictToDomain\": false}'`",
|
||||
"Verify settings: `gws gmail users settings getVacation --params '{\"userId\": \"me\"}'`",
|
||||
"Disable when back: `gws gmail users settings updateVacation --params '{\"userId\": \"me\"}' --json '{\"enableAutoReply\": false}'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "create-events-from-sheet"
|
||||
title = "Create Google Calendar Events from a Sheet"
|
||||
description = "Read event data from a Google Sheets spreadsheet and create Google Calendar entries for each row."
|
||||
category = "productivity"
|
||||
services = [ "sheets", "calendar" ]
|
||||
steps = [
|
||||
"Read event data: `gws sheets +read --spreadsheet SHEET_ID --range \"Events!A2:D\"`",
|
||||
"For each row, create a calendar event: `gws calendar +insert --summary 'Team Standup' --start '2026-01-20T09:00:00' --end '2026-01-20T09:30:00' --attendee alice@company.com --attendee bob@company.com`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "plan-weekly-schedule"
|
||||
title = "Plan Your Weekly Google Calendar Schedule"
|
||||
description = "Review your Google Calendar week, identify gaps, and add events to fill them."
|
||||
category = "scheduling"
|
||||
services = [ "calendar" ]
|
||||
steps = [
|
||||
"Check this week's agenda: `gws calendar +agenda`",
|
||||
"Check free/busy for the week: `gws calendar freebusy query --json '{\"timeMin\": \"2025-01-20T00:00:00Z\", \"timeMax\": \"2025-01-25T00:00:00Z\", \"items\": [{\"id\": \"primary\"}]}'`",
|
||||
"Add a new event: `gws calendar +insert --summary 'Deep Work Block' --start '2026-01-21T14:00:00' --end '2026-01-21T16:00:00'`",
|
||||
"Review updated schedule: `gws calendar +agenda`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "share-doc-and-notify"
|
||||
title = "Share a Google Doc and Notify Collaborators"
|
||||
description = "Share a Google Docs document with edit access and email collaborators the link."
|
||||
category = "productivity"
|
||||
services = [ "drive", "docs", "gmail" ]
|
||||
steps = [
|
||||
"Find the doc: `gws drive files list --params '{\"q\": \"name contains '\\''Project Brief'\\'' and mimeType = '\\''application/vnd.google-apps.document'\\''\"}'`",
|
||||
"Share with editor access: `gws drive permissions create --params '{\"fileId\": \"DOC_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"reviewer@company.com\"}'`",
|
||||
"Email the link: `gws gmail +send --to reviewer@company.com --subject 'Please review: Project Brief' --body 'I have shared the project brief with you: https://docs.google.com/document/d/DOC_ID'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "backup-sheet-as-csv"
|
||||
title = "Export a Google Sheet as CSV"
|
||||
description = "Export a Google Sheets spreadsheet as a CSV file for local backup or processing."
|
||||
category = "productivity"
|
||||
services = [ "sheets", "drive" ]
|
||||
steps = [
|
||||
"Get spreadsheet details: `gws sheets spreadsheets get --params '{\"spreadsheetId\": \"SHEET_ID\"}'`",
|
||||
"Export as CSV: `gws drive files export --params '{\"fileId\": \"SHEET_ID\", \"mimeType\": \"text/csv\"}'`",
|
||||
"Or read values directly: `gws sheets +read --spreadsheet SHEET_ID --range 'Sheet1' --format csv`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "save-email-to-doc"
|
||||
title = "Save a Gmail Message to Google Docs"
|
||||
description = "Save a Gmail message body into a Google Doc for archival or reference."
|
||||
category = "productivity"
|
||||
services = [ "gmail", "docs" ]
|
||||
steps = [
|
||||
"Find the message: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"subject:important from:boss@company.com\"}' --format table`",
|
||||
"Get message content: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MSG_ID\"}'`",
|
||||
"Create a doc with the content: `gws docs documents create --json '{\"title\": \"Saved Email - Important Update\"}'`",
|
||||
"Write the email body: `gws docs +write --document-id DOC_ID --text 'From: boss@company.com\nSubject: Important Update\n\n[EMAIL BODY]'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "compare-sheet-tabs"
|
||||
title = "Compare Two Google Sheets Tabs"
|
||||
description = "Read data from two tabs in a Google Sheet to compare and identify differences."
|
||||
category = "productivity"
|
||||
services = [ "sheets" ]
|
||||
steps = [
|
||||
"Read the first tab: `gws sheets +read --spreadsheet SHEET_ID --range \"January!A1:D\"`",
|
||||
"Read the second tab: `gws sheets +read --spreadsheet SHEET_ID --range \"February!A1:D\"`",
|
||||
"Compare the data and identify changes"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "batch-invite-to-event"
|
||||
title = "Add Multiple Attendees to a Calendar Event"
|
||||
description = "Add a list of attendees to an existing Google Calendar event and send notifications."
|
||||
category = "scheduling"
|
||||
services = [ "calendar" ]
|
||||
steps = [
|
||||
"Get the event: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`",
|
||||
"Add attendees: `gws calendar events patch --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\", \"sendUpdates\": \"all\"}' --json '{\"attendees\": [{\"email\": \"alice@company.com\"}, {\"email\": \"bob@company.com\"}, {\"email\": \"carol@company.com\"}]}'`",
|
||||
"Verify attendees: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "forward-labeled-emails"
|
||||
title = "Forward Labeled Gmail Messages"
|
||||
description = "Find Gmail messages with a specific label and forward them to another address."
|
||||
category = "productivity"
|
||||
services = [ "gmail" ]
|
||||
steps = [
|
||||
"Find labeled messages: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"label:needs-review\"}' --format table`",
|
||||
"Get message content: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MSG_ID\"}'`",
|
||||
"Forward via new email: `gws gmail +send --to manager@company.com --subject 'FW: [Original Subject]' --body 'Forwarding for your review:\n\n[Original Message Body]'`"
|
||||
]
|
||||
|
||||
[[recipes]]
|
||||
name = "generate-report-from-sheet"
|
||||
title = "Generate a Google Docs Report from Sheet Data"
|
||||
description = "Read data from a Google Sheet and create a formatted Google Docs report."
|
||||
category = "productivity"
|
||||
services = [ "sheets", "docs", "drive" ]
|
||||
steps = [
|
||||
"Read the data: `gws sheets +read --spreadsheet SHEET_ID --range \"Sales!A1:D\"`",
|
||||
"Create the report doc: `gws docs documents create --json '{\"title\": \"Sales Report - January 2025\"}'`",
|
||||
"Write the report: `gws docs +write --document-id DOC_ID --text '## Sales Report - January 2025\n\n### Summary\nTotal deals: 45\nRevenue: $125,000\n\n### Top Deals\n1. Acme Corp - $25,000\n2. Widget Inc - $18,000'`",
|
||||
"Share with stakeholders: `gws drive permissions create --params '{\"fileId\": \"DOC_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"cfo@company.com\"}'`"
|
||||
]
|
||||
@@ -0,0 +1,995 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Authentication and Credential Management
|
||||
//!
|
||||
//! Handles obtaining OAuth 2.0 access tokens and Service Account tokens.
|
||||
//! Supports local user flow (via a loopback server) and Application Default Credentials,
|
||||
//! with token caching to minimize repeated authentication overhead.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::credential_store;
|
||||
|
||||
const PROXY_ENV_VARS: &[&str] = &[
|
||||
"http_proxy",
|
||||
"HTTP_PROXY",
|
||||
"https_proxy",
|
||||
"HTTPS_PROXY",
|
||||
"all_proxy",
|
||||
"ALL_PROXY",
|
||||
];
|
||||
|
||||
/// Response from Google's token endpoint
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
#[allow(dead_code)]
|
||||
expires_in: u64,
|
||||
#[allow(dead_code)]
|
||||
token_type: String,
|
||||
}
|
||||
|
||||
/// Refresh an access token using reqwest (supports HTTP proxy via environment variables).
|
||||
/// This is used as a fallback when yup-oauth2's hyper-based client fails due to proxy issues.
|
||||
async fn refresh_token_with_reqwest(
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
refresh_token: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let client = crate::client::shared_client().map_err(anyhow::Error::from)?;
|
||||
let params = [
|
||||
("client_id", client_id),
|
||||
("client_secret", client_secret),
|
||||
("refresh_token", refresh_token),
|
||||
("grant_type", "refresh_token"),
|
||||
];
|
||||
|
||||
let response = client
|
||||
.post("https://oauth2.googleapis.com/token")
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send token refresh request")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response_text_or_placeholder(response.text().await);
|
||||
anyhow::bail!("Token refresh failed with status {}: {}", status, body);
|
||||
}
|
||||
|
||||
let token_response: TokenResponse = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse token response")?;
|
||||
|
||||
Ok(token_response.access_token)
|
||||
}
|
||||
|
||||
/// Returns the project ID to be used for quota and billing (sets the `x-goog-user-project` header).
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. `GOOGLE_WORKSPACE_PROJECT_ID` environment variable.
|
||||
/// 2. `project_id` from the OAuth client configuration (`client_secret.json`).
|
||||
/// 3. `quota_project_id` from Application Default Credentials (ADC).
|
||||
pub fn get_quota_project() -> Option<String> {
|
||||
// 1. Explicit environment variable (highest priority)
|
||||
if let Ok(project_id) = std::env::var("GOOGLE_WORKSPACE_PROJECT_ID") {
|
||||
if !project_id.is_empty() {
|
||||
return Some(project_id);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Project ID from the OAuth client configuration (set via `gws auth setup`)
|
||||
if let Ok(config) = crate::oauth_config::load_client_config() {
|
||||
if !config.project_id.is_empty() {
|
||||
return Some(config.project_id);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to Application Default Credentials (ADC)
|
||||
let path = std::env::var("GOOGLE_APPLICATION_CREDENTIALS")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
.or_else(adc_well_known_path)?;
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||
json.get("quota_project_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Returns the well-known Application Default Credentials path:
|
||||
/// `~/.config/gcloud/application_default_credentials.json`.
|
||||
///
|
||||
/// Note: `dirs::config_dir()` returns `~/Library/Application Support` on macOS, which is
|
||||
/// wrong for gcloud. The Google Cloud SDK always uses `~/.config/gcloud` regardless of OS.
|
||||
fn adc_well_known_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|d| {
|
||||
d.join(".config")
|
||||
.join("gcloud")
|
||||
.join("application_default_credentials.json")
|
||||
})
|
||||
}
|
||||
|
||||
/// Types of credentials we support
|
||||
#[derive(Debug)]
|
||||
enum Credential {
|
||||
AuthorizedUser(yup_oauth2::authorized_user::AuthorizedUserSecret),
|
||||
ServiceAccount(yup_oauth2::ServiceAccountKey),
|
||||
}
|
||||
|
||||
/// Fetches access tokens for a fixed set of scopes.
|
||||
///
|
||||
/// Long-running helpers use this trait so they can request a fresh token before
|
||||
/// each API call instead of holding a single token string until it expires.
|
||||
#[async_trait::async_trait]
|
||||
pub trait AccessTokenProvider: Send + Sync {
|
||||
async fn access_token(&self) -> anyhow::Result<String>;
|
||||
}
|
||||
|
||||
/// A token provider backed by [`get_token`].
|
||||
///
|
||||
/// This keeps the scope list in one place so call sites can ask for a fresh
|
||||
/// token whenever they need to make another request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScopedTokenProvider {
|
||||
scopes: Vec<String>,
|
||||
}
|
||||
|
||||
impl ScopedTokenProvider {
|
||||
pub fn new(scopes: &[&str]) -> Self {
|
||||
Self {
|
||||
scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AccessTokenProvider for ScopedTokenProvider {
|
||||
async fn access_token(&self) -> anyhow::Result<String> {
|
||||
let scopes: Vec<&str> = self.scopes.iter().map(String::as_str).collect();
|
||||
get_token(&scopes).await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn token_provider(scopes: &[&str]) -> ScopedTokenProvider {
|
||||
ScopedTokenProvider::new(scopes)
|
||||
}
|
||||
|
||||
/// A fake [`AccessTokenProvider`] for tests that returns tokens from a queue.
|
||||
#[cfg(test)]
|
||||
pub struct FakeTokenProvider {
|
||||
tokens: std::sync::Arc<tokio::sync::Mutex<std::collections::VecDeque<String>>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl FakeTokenProvider {
|
||||
pub fn new(tokens: impl IntoIterator<Item = &'static str>) -> Self {
|
||||
Self {
|
||||
tokens: std::sync::Arc::new(tokio::sync::Mutex::new(
|
||||
tokens.into_iter().map(|t| t.to_string()).collect(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[async_trait::async_trait]
|
||||
impl AccessTokenProvider for FakeTokenProvider {
|
||||
async fn access_token(&self) -> anyhow::Result<String> {
|
||||
self.tokens
|
||||
.lock()
|
||||
.await
|
||||
.pop_front()
|
||||
.ok_or_else(|| anyhow::anyhow!("no test token remaining"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an OAuth2 authenticator and returns an access token.
|
||||
///
|
||||
/// Tries credentials in order:
|
||||
/// 0. `GOOGLE_WORKSPACE_CLI_TOKEN` env var (raw access token, highest priority)
|
||||
/// 1. `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` env var (plaintext JSON, can be User or Service Account)
|
||||
/// 2. Encrypted credentials at `~/.config/gws/credentials.enc`
|
||||
/// 3. Plaintext credentials at `~/.config/gws/credentials.json` (User only)
|
||||
/// 4. Application Default Credentials (ADC):
|
||||
/// - `GOOGLE_APPLICATION_CREDENTIALS` env var (path to a JSON credentials file), then
|
||||
/// - Well-known ADC path: `~/.config/gcloud/application_default_credentials.json`
|
||||
/// (populated by `gcloud auth application-default login`)
|
||||
pub async fn get_token(scopes: &[&str]) -> anyhow::Result<String> {
|
||||
// 0. Direct token from env var (highest priority, bypasses all credential loading)
|
||||
if let Ok(token) = std::env::var("GOOGLE_WORKSPACE_CLI_TOKEN") {
|
||||
if !token.is_empty() {
|
||||
return Ok(token);
|
||||
}
|
||||
}
|
||||
|
||||
let creds_file = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE").ok();
|
||||
let config_dir = crate::auth_commands::config_dir();
|
||||
let enc_path = credential_store::encrypted_credentials_path();
|
||||
let default_path = config_dir.join("credentials.json");
|
||||
let token_cache = config_dir.join("token_cache.json");
|
||||
|
||||
let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?;
|
||||
get_token_inner(scopes, creds, &token_cache).await
|
||||
}
|
||||
|
||||
/// Check if HTTP proxy environment variables are set
|
||||
pub(crate) fn has_proxy_env() -> bool {
|
||||
PROXY_ENV_VARS
|
||||
.iter()
|
||||
.any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()))
|
||||
}
|
||||
|
||||
pub(crate) fn response_text_or_placeholder<E>(result: Result<String, E>) -> String {
|
||||
result.unwrap_or_else(|_| "(could not read error response body)".to_string())
|
||||
}
|
||||
|
||||
async fn get_token_inner(
|
||||
scopes: &[&str],
|
||||
creds: Credential,
|
||||
token_cache_path: &std::path::Path,
|
||||
) -> anyhow::Result<String> {
|
||||
match creds {
|
||||
Credential::AuthorizedUser(ref secret) => {
|
||||
// If proxy env vars are set, use reqwest directly (it supports proxy)
|
||||
// This avoids waiting for yup-oauth2's hyper client to timeout
|
||||
if has_proxy_env() {
|
||||
return refresh_token_with_reqwest(
|
||||
&secret.client_id,
|
||||
&secret.client_secret,
|
||||
&secret.refresh_token,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// No proxy - use yup-oauth2 (faster, has token caching)
|
||||
let auth = yup_oauth2::AuthorizedUserAuthenticator::builder(secret.clone())
|
||||
.with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new(
|
||||
token_cache_path.to_path_buf(),
|
||||
)))
|
||||
.build()
|
||||
.await
|
||||
.context("Failed to build authorized user authenticator")?;
|
||||
|
||||
let token = auth.token(scopes).await.context("Failed to get token")?;
|
||||
Ok(token
|
||||
.token()
|
||||
.ok_or_else(|| anyhow::anyhow!("Token response contained no access token"))?
|
||||
.to_string())
|
||||
}
|
||||
Credential::ServiceAccount(key) => {
|
||||
let tc_filename = token_cache_path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "token_cache.json".to_string());
|
||||
let sa_cache = token_cache_path.with_file_name(format!("sa_{tc_filename}"));
|
||||
let builder = yup_oauth2::ServiceAccountAuthenticator::builder(key).with_storage(
|
||||
Box::new(crate::token_storage::EncryptedTokenStorage::new(sa_cache)),
|
||||
);
|
||||
|
||||
let auth = builder
|
||||
.build()
|
||||
.await
|
||||
.context("Failed to build service account authenticator")?;
|
||||
|
||||
let token = auth.token(scopes).await.context("Failed to get token")?;
|
||||
Ok(token
|
||||
.token()
|
||||
.ok_or_else(|| anyhow::anyhow!("Token response contained no access token"))?
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a plaintext JSON credential file into a [`Credential`].
|
||||
///
|
||||
/// Determines the credential type from the `"type"` field:
|
||||
/// - `"service_account"` → [`Credential::ServiceAccount`]
|
||||
/// - anything else (including `"authorized_user"`) → [`Credential::AuthorizedUser`]
|
||||
///
|
||||
/// Uses the already-parsed `serde_json::Value` to avoid a second string parse.
|
||||
async fn parse_credential_file(
|
||||
path: &std::path::Path,
|
||||
content: &str,
|
||||
) -> anyhow::Result<Credential> {
|
||||
let json: serde_json::Value = serde_json::from_str(content)
|
||||
.with_context(|| format!("Failed to parse credentials JSON at {}", path.display()))?;
|
||||
|
||||
if json.get("type").and_then(|v| v.as_str()) == Some("service_account") {
|
||||
let key = yup_oauth2::parse_service_account_key(content).with_context(|| {
|
||||
format!(
|
||||
"Failed to parse service account key from {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
return Ok(Credential::ServiceAccount(key));
|
||||
}
|
||||
|
||||
// Deserialize from the Value we already have — avoids a second string parse.
|
||||
let secret: yup_oauth2::authorized_user::AuthorizedUserSecret = serde_json::from_value(json)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to parse authorized user credentials from {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(Credential::AuthorizedUser(secret))
|
||||
}
|
||||
|
||||
async fn load_credentials_inner(
|
||||
env_file: Option<&str>,
|
||||
enc_path: &std::path::Path,
|
||||
default_path: &std::path::Path,
|
||||
) -> anyhow::Result<Credential> {
|
||||
// 1. Explicit env var — plaintext file (User or Service Account)
|
||||
if let Some(path) = env_file {
|
||||
let p = PathBuf::from(path);
|
||||
if p.exists() {
|
||||
let content = tokio::fs::read_to_string(&p)
|
||||
.await
|
||||
.with_context(|| format!("Failed to read credentials from {path}"))?;
|
||||
return parse_credential_file(&p, &content).await;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE points to {path}, but file does not exist"
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Encrypted credentials
|
||||
if enc_path.exists() {
|
||||
match credential_store::load_encrypted_from_path(enc_path) {
|
||||
Ok(json_str) => {
|
||||
return parse_credential_file(enc_path, &json_str).await;
|
||||
}
|
||||
Err(e) => {
|
||||
// Decryption failed — the encryption key likely changed (e.g. after
|
||||
// an upgrade that migrated keys between keyring and file storage).
|
||||
// Remove the stale file so the next `gws auth login` starts fresh,
|
||||
// and fall through to other credential sources (plaintext, ADC).
|
||||
eprintln!(
|
||||
"Warning: removing undecryptable credentials file ({}): {e:#}",
|
||||
enc_path.display()
|
||||
);
|
||||
if let Err(err) = tokio::fs::remove_file(enc_path).await {
|
||||
eprintln!(
|
||||
"Warning: failed to remove stale credentials file '{}': {err}",
|
||||
enc_path.display()
|
||||
);
|
||||
}
|
||||
// Also remove stale token caches that used the old key.
|
||||
for cache_file in ["token_cache.json", "sa_token_cache.json"] {
|
||||
let path = enc_path.with_file_name(cache_file);
|
||||
if let Err(err) = tokio::fs::remove_file(&path).await {
|
||||
if err.kind() != std::io::ErrorKind::NotFound {
|
||||
eprintln!(
|
||||
"Warning: failed to remove stale token cache '{}': {err}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall through to remaining credential sources below.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Plaintext credentials at default path (AuthorizedUser)
|
||||
if default_path.exists() {
|
||||
return Ok(Credential::AuthorizedUser(
|
||||
yup_oauth2::read_authorized_user_secret(default_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("Failed to read credentials from {}", default_path.display())
|
||||
})?,
|
||||
));
|
||||
}
|
||||
|
||||
// 4a. GOOGLE_APPLICATION_CREDENTIALS env var (explicit path — hard error if missing)
|
||||
if let Ok(adc_env) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") {
|
||||
let adc_path = PathBuf::from(&adc_env);
|
||||
if adc_path.exists() {
|
||||
let content = tokio::fs::read_to_string(&adc_path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to read ADC from {adc_env}"))?;
|
||||
return parse_credential_file(&adc_path, &content).await;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"GOOGLE_APPLICATION_CREDENTIALS points to {adc_env}, but file does not exist"
|
||||
);
|
||||
}
|
||||
|
||||
// 4b. Well-known ADC path: ~/.config/gcloud/application_default_credentials.json
|
||||
// (populated by `gcloud auth application-default login`). Silent if absent.
|
||||
if let Some(well_known) = adc_well_known_path() {
|
||||
if well_known.exists() {
|
||||
let content = tokio::fs::read_to_string(&well_known)
|
||||
.await
|
||||
.with_context(|| format!("Failed to read ADC from {}", well_known.display()))?;
|
||||
return parse_credential_file(&well_known, &content).await;
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"No credentials found. Run `gws auth setup` to configure, \
|
||||
`gws auth login` to authenticate, or set GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE.\n\
|
||||
Tip: Application Default Credentials (ADC) are also supported — run \
|
||||
`gcloud auth application-default login` or set GOOGLE_APPLICATION_CREDENTIALS."
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
/// RAII guard that saves the current value of an environment variable and
|
||||
/// restores it when dropped. This ensures cleanup even if a test panics.
|
||||
struct EnvVarGuard {
|
||||
name: String,
|
||||
original: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
/// Save the current value of `name`, then set it to `value`.
|
||||
fn set(name: &str, value: impl AsRef<std::ffi::OsStr>) -> Self {
|
||||
let original = std::env::var_os(name);
|
||||
std::env::set_var(name, value);
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
original,
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the current value of `name`, then remove it.
|
||||
fn remove(name: &str) -> Self {
|
||||
let original = std::env::var_os(name);
|
||||
std::env::remove_var(name);
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
original,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.original {
|
||||
Some(v) => std::env::set_var(&self.name, v),
|
||||
None => std::env::remove_var(&self.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_proxy_env() -> Vec<EnvVarGuard> {
|
||||
PROXY_ENV_VARS
|
||||
.iter()
|
||||
.map(|key| EnvVarGuard::remove(key))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn has_proxy_env_returns_false_when_unset() {
|
||||
let _guards = clear_proxy_env();
|
||||
assert!(!has_proxy_env());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn has_proxy_env_returns_true_when_set() {
|
||||
let mut guards = clear_proxy_env();
|
||||
guards.push(EnvVarGuard::set(
|
||||
"HTTPS_PROXY",
|
||||
"http://proxy.internal:8080",
|
||||
));
|
||||
assert!(has_proxy_env());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_text_or_placeholder_returns_body() {
|
||||
let body = response_text_or_placeholder(Result::<String, ()>::Ok("error body".to_string()));
|
||||
assert_eq!(body, "error body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_text_or_placeholder_returns_placeholder_on_error() {
|
||||
let body = response_text_or_placeholder(Result::<String, ()>::Err(()));
|
||||
assert_eq!(body, "(could not read error response body)");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_load_credentials_no_options() {
|
||||
// Isolate from host ADC: override HOME so adc_well_known_path()
|
||||
// resolves to a non-existent directory, and clear the env var.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let _home_guard = EnvVarGuard::set("HOME", tmp.path());
|
||||
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
|
||||
|
||||
let err = load_credentials_inner(
|
||||
None,
|
||||
&PathBuf::from("/does/not/exist1"),
|
||||
&PathBuf::from("/does/not/exist2"),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(err.is_err());
|
||||
assert!(err
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("No credentials found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_load_credentials_adc_env_var_authorized_user() {
|
||||
let mut file = NamedTempFile::new().unwrap();
|
||||
let json = r#"{
|
||||
"client_id": "adc_id",
|
||||
"client_secret": "adc_secret",
|
||||
"refresh_token": "adc_refresh",
|
||||
"type": "authorized_user"
|
||||
}"#;
|
||||
file.write_all(json.as_bytes()).unwrap();
|
||||
|
||||
let _adc_guard = EnvVarGuard::set(
|
||||
"GOOGLE_APPLICATION_CREDENTIALS",
|
||||
file.path().to_str().unwrap(),
|
||||
);
|
||||
|
||||
let res = load_credentials_inner(
|
||||
None,
|
||||
&PathBuf::from("/missing/enc"),
|
||||
&PathBuf::from("/missing/plain"),
|
||||
)
|
||||
.await;
|
||||
|
||||
match res.unwrap() {
|
||||
Credential::AuthorizedUser(secret) => {
|
||||
assert_eq!(secret.client_id, "adc_id");
|
||||
assert_eq!(secret.refresh_token, "adc_refresh");
|
||||
}
|
||||
_ => panic!("Expected AuthorizedUser from ADC"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_load_credentials_adc_env_var_service_account() {
|
||||
let mut file = NamedTempFile::new().unwrap();
|
||||
let json = r#"{
|
||||
"type": "service_account",
|
||||
"project_id": "test-project",
|
||||
"private_key_id": "adc-key-id",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASC\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "adc-sa@test-project.iam.gserviceaccount.com",
|
||||
"client_id": "456",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token"
|
||||
}"#;
|
||||
file.write_all(json.as_bytes()).unwrap();
|
||||
|
||||
let _adc_guard = EnvVarGuard::set(
|
||||
"GOOGLE_APPLICATION_CREDENTIALS",
|
||||
file.path().to_str().unwrap(),
|
||||
);
|
||||
|
||||
let res = load_credentials_inner(
|
||||
None,
|
||||
&PathBuf::from("/missing/enc"),
|
||||
&PathBuf::from("/missing/plain"),
|
||||
)
|
||||
.await;
|
||||
|
||||
match res.unwrap() {
|
||||
Credential::ServiceAccount(key) => {
|
||||
assert_eq!(
|
||||
key.client_email,
|
||||
"adc-sa@test-project.iam.gserviceaccount.com"
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected ServiceAccount from ADC"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_load_credentials_adc_env_var_missing_file() {
|
||||
let _adc_guard = EnvVarGuard::set("GOOGLE_APPLICATION_CREDENTIALS", "/does/not/exist.json");
|
||||
|
||||
// When GOOGLE_APPLICATION_CREDENTIALS points to a missing file, we error immediately
|
||||
// rather than falling through — the user explicitly asked for this file.
|
||||
let err = load_credentials_inner(
|
||||
None,
|
||||
&PathBuf::from("/missing/enc"),
|
||||
&PathBuf::from("/missing/plain"),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(err.is_err());
|
||||
let msg = err.unwrap_err().to_string();
|
||||
assert!(
|
||||
msg.contains("does not exist"),
|
||||
"Should hard-error when GOOGLE_APPLICATION_CREDENTIALS points to missing file, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_credentials_env_file_missing() {
|
||||
let err = load_credentials_inner(
|
||||
Some("/does/not/exist"),
|
||||
&PathBuf::from("/also/missing"),
|
||||
&PathBuf::from("/still/missing"),
|
||||
)
|
||||
.await;
|
||||
assert!(err.is_err());
|
||||
assert!(err.unwrap_err().to_string().contains("does not exist"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_credentials_env_file_authorized_user() {
|
||||
let mut file = NamedTempFile::new().unwrap();
|
||||
let json = r#"{
|
||||
"client_id": "test_id",
|
||||
"client_secret": "test_secret",
|
||||
"refresh_token": "test_refresh",
|
||||
"type": "authorized_user"
|
||||
}"#;
|
||||
file.write_all(json.as_bytes()).unwrap();
|
||||
|
||||
let res = load_credentials_inner(
|
||||
Some(file.path().to_str().unwrap()),
|
||||
&PathBuf::from("/also/missing"),
|
||||
&PathBuf::from("/still/missing"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
match res {
|
||||
Credential::AuthorizedUser(secret) => {
|
||||
assert_eq!(secret.client_id, "test_id");
|
||||
assert_eq!(secret.refresh_token, "test_refresh");
|
||||
}
|
||||
_ => panic!("Expected AuthorizedUser"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_credentials_env_file_service_account() {
|
||||
let mut file = NamedTempFile::new().unwrap();
|
||||
let json = r#"{
|
||||
"type": "service_account",
|
||||
"project_id": "test",
|
||||
"private_key_id": "test-key-id",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASC\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "test@test.iam.gserviceaccount.com",
|
||||
"client_id": "123",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token"
|
||||
}"#;
|
||||
file.write_all(json.as_bytes()).unwrap();
|
||||
|
||||
let res = load_credentials_inner(
|
||||
Some(file.path().to_str().unwrap()),
|
||||
&PathBuf::from("/also/missing"),
|
||||
&PathBuf::from("/still/missing"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
match res {
|
||||
Credential::ServiceAccount(key) => {
|
||||
assert_eq!(key.client_email, "test@test.iam.gserviceaccount.com");
|
||||
}
|
||||
_ => panic!("Expected ServiceAccount"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_credentials_default_path_authorized_user() {
|
||||
let mut file = NamedTempFile::new().unwrap();
|
||||
let json = r#"{
|
||||
"client_id": "default_id",
|
||||
"client_secret": "default_secret",
|
||||
"refresh_token": "default_refresh",
|
||||
"type": "authorized_user"
|
||||
}"#;
|
||||
file.write_all(json.as_bytes()).unwrap();
|
||||
|
||||
let res = load_credentials_inner(None, &PathBuf::from("/also/missing"), file.path())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
match res {
|
||||
Credential::AuthorizedUser(secret) => {
|
||||
assert_eq!(secret.client_id, "default_id");
|
||||
}
|
||||
_ => panic!("Expected AuthorizedUser"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_get_token_from_env_var() {
|
||||
let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", "my-test-token");
|
||||
|
||||
let result = get_token(&["https://www.googleapis.com/auth/drive"]).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "my-test-token");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_scoped_token_provider_uses_get_token() {
|
||||
let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", "provider-token");
|
||||
let provider = token_provider(&["https://www.googleapis.com/auth/drive"]);
|
||||
|
||||
let first = provider.access_token().await.unwrap();
|
||||
let second = provider.access_token().await.unwrap();
|
||||
|
||||
assert_eq!(first, "provider-token");
|
||||
assert_eq!(second, "provider-token");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_load_credentials_encrypted_file() {
|
||||
// Simulate an encrypted credentials file
|
||||
let json = r#"{
|
||||
"client_id": "enc_test_id",
|
||||
"client_secret": "enc_test_secret",
|
||||
"refresh_token": "enc_test_refresh",
|
||||
"type": "authorized_user"
|
||||
}"#;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let enc_path = dir.path().join("credentials.enc");
|
||||
|
||||
// Isolate global config dir to prevent races with other tests
|
||||
std::env::set_var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path());
|
||||
|
||||
// Encrypt and write
|
||||
let encrypted = crate::credential_store::encrypt(json.as_bytes()).unwrap();
|
||||
std::fs::write(&enc_path, &encrypted).unwrap();
|
||||
|
||||
let res = load_credentials_inner(None, &enc_path, &PathBuf::from("/does/not/exist"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
match res {
|
||||
Credential::AuthorizedUser(secret) => {
|
||||
assert_eq!(secret.client_id, "enc_test_id");
|
||||
assert_eq!(secret.client_secret, "enc_test_secret");
|
||||
assert_eq!(secret.refresh_token, "enc_test_refresh");
|
||||
}
|
||||
_ => panic!("Expected AuthorizedUser from encrypted credentials"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_credentials_encrypted_takes_priority_over_default() {
|
||||
// Encrypted credentials should be loaded before the default plaintext path
|
||||
let enc_json = r#"{
|
||||
"client_id": "encrypted_id",
|
||||
"client_secret": "encrypted_secret",
|
||||
"refresh_token": "encrypted_refresh",
|
||||
"type": "authorized_user"
|
||||
}"#;
|
||||
let plain_json = r#"{
|
||||
"client_id": "plaintext_id",
|
||||
"client_secret": "plaintext_secret",
|
||||
"refresh_token": "plaintext_refresh",
|
||||
"type": "authorized_user"
|
||||
}"#;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let enc_path = dir.path().join("credentials.enc");
|
||||
let plain_path = dir.path().join("credentials.json");
|
||||
|
||||
let encrypted = crate::credential_store::encrypt(enc_json.as_bytes()).unwrap();
|
||||
std::fs::write(&enc_path, &encrypted).unwrap();
|
||||
std::fs::write(&plain_path, plain_json).unwrap();
|
||||
|
||||
let res = load_credentials_inner(None, &enc_path, &plain_path)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
match res {
|
||||
Credential::AuthorizedUser(secret) => {
|
||||
assert_eq!(
|
||||
secret.client_id, "encrypted_id",
|
||||
"Encrypted credentials should take priority over plaintext"
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected AuthorizedUser"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_load_credentials_corrupt_encrypted_file_is_removed() {
|
||||
// When credentials.enc cannot be decrypted, the file should be removed
|
||||
// automatically and the function should fall through to other sources.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let _home_guard = EnvVarGuard::set("HOME", tmp.path());
|
||||
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let enc_path = dir.path().join("credentials.enc");
|
||||
|
||||
// Write garbage data that cannot be decrypted.
|
||||
tokio::fs::write(&enc_path, b"not-valid-encrypted-data-at-all-1234567890")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(enc_path.exists());
|
||||
|
||||
let result =
|
||||
load_credentials_inner(None, &enc_path, &PathBuf::from("/does/not/exist")).await;
|
||||
|
||||
// Should fall through to "No credentials found" (not a decryption error).
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
msg.contains("No credentials found"),
|
||||
"Should fall through to final error, got: {msg}"
|
||||
);
|
||||
assert!(
|
||||
!enc_path.exists(),
|
||||
"Stale credentials.enc must be removed after decryption failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_load_credentials_corrupt_encrypted_falls_through_to_plaintext() {
|
||||
// When credentials.enc is corrupt but a valid plaintext file exists,
|
||||
// the function should fall through and use the plaintext credentials.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let enc_path = dir.path().join("credentials.enc");
|
||||
let plain_path = dir.path().join("credentials.json");
|
||||
|
||||
// Write garbage encrypted data.
|
||||
tokio::fs::write(&enc_path, b"not-valid-encrypted-data-at-all-1234567890")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Write valid plaintext credentials.
|
||||
let plain_json = r#"{
|
||||
"client_id": "fallback_id",
|
||||
"client_secret": "fallback_secret",
|
||||
"refresh_token": "fallback_refresh",
|
||||
"type": "authorized_user"
|
||||
}"#;
|
||||
tokio::fs::write(&plain_path, plain_json).await.unwrap();
|
||||
|
||||
let res = load_credentials_inner(None, &enc_path, &plain_path)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
match res {
|
||||
Credential::AuthorizedUser(secret) => {
|
||||
assert_eq!(
|
||||
secret.client_id, "fallback_id",
|
||||
"Should fall through to plaintext credentials"
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected AuthorizedUser from plaintext fallback"),
|
||||
}
|
||||
assert!(!enc_path.exists(), "Stale credentials.enc must be removed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn test_get_token_env_var_empty_falls_through() {
|
||||
// An empty token should not short-circuit — it should be ignored
|
||||
// and fall through to normal credential loading.
|
||||
// Isolate from host ADC so the well-known path doesn't match.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let _home_guard = EnvVarGuard::set("HOME", tmp.path());
|
||||
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
|
||||
let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", "");
|
||||
|
||||
let result = load_credentials_inner(
|
||||
None,
|
||||
&PathBuf::from("/does/not/exist1"),
|
||||
&PathBuf::from("/does/not/exist2"),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Should fall through to normal credential loading, which fails
|
||||
// because we pointed at non-existent paths
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("No credentials found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_get_quota_project_priority_env_var() {
|
||||
let _env_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_PROJECT_ID", "priority-env");
|
||||
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
|
||||
let _config_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_CLI_CONFIG_DIR");
|
||||
let _home_guard = EnvVarGuard::set("HOME", "/missing/home");
|
||||
|
||||
assert_eq!(get_quota_project(), Some("priority-env".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_get_quota_project_priority_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let _config_guard = EnvVarGuard::set(
|
||||
"GOOGLE_WORKSPACE_CLI_CONFIG_DIR",
|
||||
tmp.path().to_str().unwrap(),
|
||||
);
|
||||
let _env_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_PROJECT_ID");
|
||||
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
|
||||
let _home_guard = EnvVarGuard::set("HOME", "/missing/home");
|
||||
|
||||
// Save a client config with a project ID
|
||||
crate::oauth_config::save_client_config("id", "secret", "config-project").unwrap();
|
||||
|
||||
assert_eq!(get_quota_project(), Some("config-project".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_get_quota_project_priority_adc_fallback() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let adc_dir = tmp.path().join(".config").join("gcloud");
|
||||
std::fs::create_dir_all(&adc_dir).unwrap();
|
||||
std::fs::write(
|
||||
adc_dir.join("application_default_credentials.json"),
|
||||
r#"{"quota_project_id": "adc-project"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let _home_guard = EnvVarGuard::set("HOME", tmp.path());
|
||||
let _env_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_PROJECT_ID");
|
||||
let _config_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_CLI_CONFIG_DIR");
|
||||
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
|
||||
|
||||
assert_eq!(get_quota_project(), Some("adc-project".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_get_quota_project_reads_adc() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let adc_dir = tmp.path().join(".config").join("gcloud");
|
||||
std::fs::create_dir_all(&adc_dir).unwrap();
|
||||
std::fs::write(
|
||||
adc_dir.join("application_default_credentials.json"),
|
||||
r#"{"quota_project_id": "my-project-123"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let _home_guard = EnvVarGuard::set("HOME", tmp.path());
|
||||
let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS");
|
||||
// Isolate from local environment
|
||||
let _env_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_PROJECT_ID");
|
||||
let _config_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_CLI_CONFIG_DIR");
|
||||
|
||||
assert_eq!(get_quota_project(), Some("my-project-123".to_string()));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! HTTP client — re-exports from `google_workspace` library crate.
|
||||
|
||||
pub use google_workspace::client::*;
|
||||
@@ -0,0 +1,282 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use clap::{Arg, Command};
|
||||
|
||||
use crate::discovery::{RestDescription, RestResource};
|
||||
|
||||
/// Builds the full CLI command tree from a Discovery Document.
|
||||
pub fn build_cli(doc: &RestDescription) -> Command {
|
||||
let about_text = doc
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Google Workspace CLI".to_string());
|
||||
let mut root = Command::new("gws")
|
||||
.about(about_text)
|
||||
.subcommand_required(true)
|
||||
.arg_required_else_help(true)
|
||||
.arg(
|
||||
clap::Arg::new("sanitize")
|
||||
.long("sanitize")
|
||||
.help("Sanitize API responses through a Model Armor template. Requires cloud-platform scope. Format: projects/PROJECT/locations/LOCATION/templates/TEMPLATE. Also reads GWS_SANITIZE_TEMPLATE env var.")
|
||||
.value_name("TEMPLATE")
|
||||
.global(true),
|
||||
)
|
||||
.arg(
|
||||
clap::Arg::new("dry-run")
|
||||
.long("dry-run")
|
||||
.help("Validate the request locally without sending it to the API")
|
||||
.action(clap::ArgAction::SetTrue)
|
||||
.global(true),
|
||||
)
|
||||
.arg(
|
||||
clap::Arg::new("format")
|
||||
.long("format")
|
||||
.help("Output format: json (default), table, yaml, csv")
|
||||
.value_name("FORMAT")
|
||||
.global(true),
|
||||
);
|
||||
|
||||
// Inject helper commands
|
||||
let helper = crate::helpers::get_helper(&doc.name);
|
||||
if let Some(ref helper) = helper {
|
||||
root = helper.inject_commands(root, doc);
|
||||
}
|
||||
|
||||
// Add resource subcommands (unless helper suppresses them)
|
||||
let skip_resources = helper.as_ref().is_some_and(|h| h.helper_only());
|
||||
if !skip_resources {
|
||||
let mut resource_names: Vec<_> = doc.resources.keys().collect();
|
||||
resource_names.sort();
|
||||
for name in resource_names {
|
||||
let resource = &doc.resources[name];
|
||||
if let Some(cmd) = build_resource_command(name, resource) {
|
||||
root = root.subcommand(cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
root
|
||||
}
|
||||
|
||||
/// Recursively builds a Command for a resource.
|
||||
/// Returns None if the resource has no methods or sub-resources.
|
||||
fn build_resource_command(name: &str, resource: &RestResource) -> Option<Command> {
|
||||
let mut cmd = Command::new(name.to_string())
|
||||
.about(format!("Operations on the '{name}' resource"))
|
||||
.subcommand_required(true)
|
||||
.arg_required_else_help(true);
|
||||
|
||||
let mut has_children = false;
|
||||
|
||||
// Add method subcommands
|
||||
let mut method_names: Vec<_> = resource.methods.keys().collect();
|
||||
method_names.sort();
|
||||
for method_name in method_names {
|
||||
let method = &resource.methods[method_name];
|
||||
|
||||
has_children = true;
|
||||
|
||||
let about = crate::text::truncate_description(
|
||||
method.description.as_deref().unwrap_or(""),
|
||||
crate::text::CLI_DESCRIPTION_LIMIT,
|
||||
true,
|
||||
);
|
||||
|
||||
let mut method_cmd = Command::new(method_name.to_string())
|
||||
.about(about)
|
||||
.arg(
|
||||
Arg::new("params")
|
||||
.long("params")
|
||||
.help("JSON string for URL/Query parameters")
|
||||
.value_name("JSON"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("output")
|
||||
.long("output")
|
||||
.short('o')
|
||||
.help("Output file path for binary responses")
|
||||
.value_name("PATH"),
|
||||
);
|
||||
|
||||
// Only add --json flag if the method accepts a request body
|
||||
if method.request.is_some() {
|
||||
method_cmd = method_cmd.arg(
|
||||
Arg::new("json")
|
||||
.long("json")
|
||||
.help("JSON string for the request body")
|
||||
.value_name("JSON"),
|
||||
);
|
||||
}
|
||||
|
||||
// Add --upload flag if the method supports media upload
|
||||
if method.supports_media_upload {
|
||||
method_cmd = method_cmd
|
||||
.arg(
|
||||
Arg::new("upload")
|
||||
.long("upload")
|
||||
.help("Local file path to upload as media content (multipart upload)")
|
||||
.value_name("PATH"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("upload-content-type")
|
||||
.long("upload-content-type")
|
||||
.help("MIME type of the uploaded file content (e.g. text/markdown). If omitted, detected from file extension or metadata mimeType")
|
||||
.value_name("MIME"),
|
||||
);
|
||||
}
|
||||
|
||||
// Pagination flags
|
||||
method_cmd = method_cmd
|
||||
.arg(
|
||||
Arg::new("page-all")
|
||||
.long("page-all")
|
||||
.help("Auto-paginate through all results, outputting one JSON line per page (NDJSON)")
|
||||
.action(clap::ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("page-limit")
|
||||
.long("page-limit")
|
||||
.help("Maximum number of pages to fetch when using --page-all (default: 10)")
|
||||
.value_name("N")
|
||||
.value_parser(clap::value_parser!(u32)),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("page-delay")
|
||||
.long("page-delay")
|
||||
.help("Delay in milliseconds between page fetches (default: 100)")
|
||||
.value_name("MS")
|
||||
.value_parser(clap::value_parser!(u64)),
|
||||
);
|
||||
|
||||
cmd = cmd.subcommand(method_cmd);
|
||||
}
|
||||
|
||||
// Add sub-resource subcommands (recursive)
|
||||
let mut sub_names: Vec<_> = resource.resources.keys().collect();
|
||||
sub_names.sort();
|
||||
for sub_name in sub_names {
|
||||
let sub_resource = &resource.resources[sub_name];
|
||||
if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource) {
|
||||
has_children = true;
|
||||
cmd = cmd.subcommand(sub_cmd);
|
||||
}
|
||||
}
|
||||
|
||||
if has_children {
|
||||
Some(cmd)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::discovery::{RestMethod, RestResource};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn make_doc() -> RestDescription {
|
||||
let mut methods = HashMap::new();
|
||||
methods.insert(
|
||||
"list".to_string(),
|
||||
RestMethod {
|
||||
id: None,
|
||||
description: None,
|
||||
http_method: "GET".to_string(),
|
||||
path: "list".to_string(),
|
||||
parameters: HashMap::new(),
|
||||
parameter_order: vec![],
|
||||
request: None,
|
||||
response: None,
|
||||
scopes: vec!["https://www.googleapis.com/auth/drive.readonly".to_string()],
|
||||
flat_path: None,
|
||||
supports_media_download: false,
|
||||
supports_media_upload: false,
|
||||
media_upload: None,
|
||||
},
|
||||
);
|
||||
|
||||
methods.insert(
|
||||
"delete".to_string(),
|
||||
RestMethod {
|
||||
id: None,
|
||||
description: None,
|
||||
http_method: "DELETE".to_string(),
|
||||
path: "delete".to_string(),
|
||||
parameters: HashMap::new(),
|
||||
parameter_order: vec![],
|
||||
request: None,
|
||||
response: None,
|
||||
scopes: vec!["https://www.googleapis.com/auth/drive".to_string()],
|
||||
flat_path: None,
|
||||
supports_media_download: false,
|
||||
supports_media_upload: false,
|
||||
media_upload: None,
|
||||
},
|
||||
);
|
||||
|
||||
let mut resources = HashMap::new();
|
||||
resources.insert(
|
||||
"files".to_string(),
|
||||
RestResource {
|
||||
methods,
|
||||
resources: HashMap::new(),
|
||||
},
|
||||
);
|
||||
|
||||
RestDescription {
|
||||
name: "drive".to_string(),
|
||||
version: "v3".to_string(),
|
||||
title: None,
|
||||
description: None,
|
||||
root_url: "".to_string(),
|
||||
service_path: "".to_string(),
|
||||
base_url: None,
|
||||
schemas: HashMap::new(),
|
||||
resources,
|
||||
parameters: HashMap::new(),
|
||||
auth: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_commands_always_shown() {
|
||||
let doc = make_doc();
|
||||
let cmd = build_cli(&doc);
|
||||
|
||||
// Should have "files" subcommand
|
||||
let files_cmd = cmd
|
||||
.find_subcommand("files")
|
||||
.expect("files resource missing");
|
||||
|
||||
// All methods should always be visible regardless of auth state
|
||||
assert!(files_cmd.find_subcommand("list").is_some());
|
||||
assert!(files_cmd.find_subcommand("delete").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_arg_present() {
|
||||
let doc = make_doc();
|
||||
let cmd = build_cli(&doc);
|
||||
|
||||
// The --sanitize global arg should be available
|
||||
let args: Vec<_> = cmd.get_arguments().collect();
|
||||
let sanitize_arg = args.iter().find(|a| a.get_id() == "sanitize");
|
||||
assert!(
|
||||
sanitize_arg.is_some(),
|
||||
"--sanitize arg should be present on root command"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Discovery Document types and fetching.
|
||||
//!
|
||||
//! Types are re-exported from the `google_workspace` library crate.
|
||||
//! The CLI wrapper provides default caching via `config_dir()`.
|
||||
|
||||
pub use google_workspace::discovery::*;
|
||||
|
||||
/// Fetches and caches a Google Discovery Document using the CLI's config directory.
|
||||
///
|
||||
/// This is a convenience wrapper around
|
||||
/// [`google_workspace::discovery::fetch_discovery_document`] that automatically
|
||||
/// uses the CLI's cache directory (`~/.config/gws/cache/`).
|
||||
pub async fn fetch_discovery_document(
|
||||
service: &str,
|
||||
version: &str,
|
||||
) -> anyhow::Result<RestDescription> {
|
||||
let cache_dir = crate::auth_commands::config_dir().join("cache");
|
||||
google_workspace::discovery::fetch_discovery_document(service, version, Some(&cache_dir)).await
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Structured error types and CLI error output.
|
||||
//!
|
||||
//! Core error types are re-exported from the `google_workspace` library crate.
|
||||
//! CLI-specific error formatting (colored terminal output) is defined here.
|
||||
|
||||
pub use google_workspace::error::*;
|
||||
|
||||
use crate::output::{colorize, sanitize_for_terminal};
|
||||
|
||||
/// Human-readable exit code table, keyed by (code, description).
|
||||
///
|
||||
/// Used by `print_usage()` so the help text stays in sync with the
|
||||
/// constants defined below without requiring manual updates in two places.
|
||||
pub const EXIT_CODE_DOCUMENTATION: &[(i32, &str)] = &[
|
||||
(0, "Success"),
|
||||
(
|
||||
GwsError::EXIT_CODE_API,
|
||||
"API error — Google returned an error response",
|
||||
),
|
||||
(
|
||||
GwsError::EXIT_CODE_AUTH,
|
||||
"Auth error — credentials missing or invalid",
|
||||
),
|
||||
(
|
||||
GwsError::EXIT_CODE_VALIDATION,
|
||||
"Validation — bad arguments or input",
|
||||
),
|
||||
(
|
||||
GwsError::EXIT_CODE_DISCOVERY,
|
||||
"Discovery — could not fetch API schema",
|
||||
),
|
||||
(GwsError::EXIT_CODE_OTHER, "Internal — unexpected failure"),
|
||||
];
|
||||
|
||||
/// Format a colored error label for the given error variant.
|
||||
fn error_label(err: &GwsError) -> String {
|
||||
match err {
|
||||
GwsError::Api { .. } => colorize("error[api]:", "31"), // red
|
||||
GwsError::Auth(_) => colorize("error[auth]:", "31"), // red
|
||||
GwsError::Validation(_) => colorize("error[validation]:", "33"), // yellow
|
||||
GwsError::Discovery(_) => colorize("error[discovery]:", "31"), // red
|
||||
GwsError::Other(_) => colorize("error:", "31"), // red
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats any error as a JSON object and prints to stdout.
|
||||
///
|
||||
/// A human-readable colored label is printed to stderr when connected to a
|
||||
/// TTY. For `accessNotConfigured` errors (HTTP 403, reason
|
||||
/// `accessNotConfigured`), additional guidance is printed to stderr.
|
||||
/// The JSON output on stdout is unchanged (machine-readable).
|
||||
pub fn print_error_json(err: &GwsError) {
|
||||
let json = err.to_json();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json).unwrap_or_default()
|
||||
);
|
||||
|
||||
// Print a colored summary to stderr. For accessNotConfigured errors,
|
||||
// print specialized guidance instead of the generic message to avoid
|
||||
// redundant output (the full API error already appears in the JSON).
|
||||
if let GwsError::Api {
|
||||
reason, enable_url, ..
|
||||
} = err
|
||||
{
|
||||
if reason == "accessNotConfigured" {
|
||||
eprintln!();
|
||||
let hint = colorize("hint:", "36"); // cyan
|
||||
eprintln!(
|
||||
"{} {hint} API not enabled for your GCP project.",
|
||||
error_label(err)
|
||||
);
|
||||
if let Some(url) = enable_url {
|
||||
eprintln!(" Enable it at: {url}");
|
||||
} else {
|
||||
eprintln!(" Visit the GCP Console → APIs & Services → Library to enable the required API.");
|
||||
}
|
||||
eprintln!(" After enabling, wait a few seconds and retry your command.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"{} {}",
|
||||
error_label(err),
|
||||
sanitize_for_terminal(&err.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_colorize_respects_no_color_env() {
|
||||
std::env::set_var("NO_COLOR", "1");
|
||||
let result = colorize("hello", "31");
|
||||
std::env::remove_var("NO_COLOR");
|
||||
assert_eq!(result, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_label_contains_variant_name() {
|
||||
let api_err = GwsError::Api {
|
||||
code: 400,
|
||||
message: "bad".to_string(),
|
||||
reason: "r".to_string(),
|
||||
enable_url: None,
|
||||
};
|
||||
let label = error_label(&api_err);
|
||||
assert!(label.contains("error[api]:"));
|
||||
|
||||
let auth_err = GwsError::Auth("fail".to_string());
|
||||
assert!(error_label(&auth_err).contains("error[auth]:"));
|
||||
|
||||
let val_err = GwsError::Validation("bad input".to_string());
|
||||
assert!(error_label(&val_err).contains("error[validation]:"));
|
||||
|
||||
let disc_err = GwsError::Discovery("missing".to_string());
|
||||
assert!(error_label(&disc_err).contains("error[discovery]:"));
|
||||
|
||||
let other_err = GwsError::Other(anyhow::anyhow!("oops"));
|
||||
assert!(error_label(&other_err).contains("error:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_for_terminal_strips_control_chars() {
|
||||
let input = "normal \x1b[31mred text\x1b[0m end";
|
||||
let sanitized = sanitize_for_terminal(input);
|
||||
assert_eq!(sanitized, "normal [31mred text[0m end");
|
||||
assert!(!sanitized.contains('\x1b'));
|
||||
|
||||
let input2 = "line1\nline2\ttab";
|
||||
assert_eq!(sanitize_for_terminal(input2), "line1\nline2\ttab");
|
||||
|
||||
let input3 = "hello\x07bell\x08backspace";
|
||||
assert_eq!(sanitize_for_terminal(input3), "hellobellbackspace");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,780 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Output Formatting
|
||||
//!
|
||||
//! Transforms JSON API responses into human-readable formats (table, YAML, CSV).
|
||||
|
||||
use serde_json::Value;
|
||||
use std::fmt::Write;
|
||||
|
||||
/// Supported output formats.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub enum OutputFormat {
|
||||
/// Pretty-printed JSON (default).
|
||||
#[default]
|
||||
Json,
|
||||
/// Aligned text table.
|
||||
Table,
|
||||
/// YAML.
|
||||
Yaml,
|
||||
/// Comma-separated values.
|
||||
Csv,
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
/// Parse from a string argument.
|
||||
///
|
||||
/// Returns `Ok(format)` for known values, or `Err(unknown_value)` if the
|
||||
/// string is not recognised. Call sites should warn the user on `Err` and
|
||||
/// decide whether to fall back to JSON or surface an error.
|
||||
pub fn parse(s: &str) -> Result<Self, String> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"json" => Ok(Self::Json),
|
||||
"table" => Ok(Self::Table),
|
||||
"yaml" | "yml" => Ok(Self::Yaml),
|
||||
"csv" => Ok(Self::Csv),
|
||||
other => Err(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse from a string argument, falling back to JSON for unknown values.
|
||||
///
|
||||
/// Prefer `parse()` at call sites where you want to surface a warning.
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
Self::parse(s).unwrap_or(Self::Json)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a JSON value according to the specified output format.
|
||||
pub fn format_value(value: &Value, format: &OutputFormat) -> String {
|
||||
match format {
|
||||
OutputFormat::Json => serde_json::to_string_pretty(value).unwrap_or_default(),
|
||||
OutputFormat::Table => format_table(value),
|
||||
OutputFormat::Yaml => format_yaml(value),
|
||||
OutputFormat::Csv => format_csv(value),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a JSON value for a paginated page.
|
||||
///
|
||||
/// When auto-paginating with `--page-all`, CSV and table formats should only
|
||||
/// emit column headers on the **first** page so that each subsequent page
|
||||
/// contains only data rows, making the combined output machine-parseable.
|
||||
///
|
||||
/// For JSON the output is compact (one JSON object per line / NDJSON).
|
||||
/// For YAML each page is prefixed with a `---` document separator so the
|
||||
/// combined stream is a valid YAML multi-document file.
|
||||
pub fn format_value_paginated(value: &Value, format: &OutputFormat, is_first_page: bool) -> String {
|
||||
match format {
|
||||
OutputFormat::Json => serde_json::to_string(value).unwrap_or_default(),
|
||||
OutputFormat::Csv => format_csv_page(value, is_first_page),
|
||||
OutputFormat::Table => format_table_page(value, is_first_page),
|
||||
// Prefix every page with a YAML document separator so that the
|
||||
// concatenated stream is parseable as a multi-document YAML file.
|
||||
OutputFormat::Yaml => format!("---\n{}", format_yaml(value)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a "data array" from a typical Google API list response.
|
||||
/// Google APIs return lists as `{ "files": [...], "nextPageToken": "..." }`
|
||||
/// where the array key varies by resource type.
|
||||
fn extract_items(value: &Value) -> Option<(&str, &Vec<Value>)> {
|
||||
if let Value::Object(obj) = value {
|
||||
for (key, val) in obj {
|
||||
if key == "nextPageToken" || key == "kind" || key.starts_with('_') {
|
||||
continue;
|
||||
}
|
||||
if let Value::Array(arr) = val {
|
||||
if !arr.is_empty() {
|
||||
return Some((key, arr));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn format_table(value: &Value) -> String {
|
||||
format_table_page(value, true)
|
||||
}
|
||||
|
||||
/// Recursively flatten a JSON object into `(dot.notation.key, string_value)` pairs.
|
||||
///
|
||||
/// Nested objects become `parent.child` key names so that `--format table` can
|
||||
/// render them as individual columns instead of raw JSON blobs.
|
||||
fn flatten_object(obj: &serde_json::Map<String, Value>, prefix: &str) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for (key, val) in obj {
|
||||
let full_key = if prefix.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{prefix}.{key}")
|
||||
};
|
||||
match val {
|
||||
Value::Object(nested) => {
|
||||
out.extend(flatten_object(nested, &full_key));
|
||||
}
|
||||
_ => {
|
||||
out.push((full_key, value_to_cell(val)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Format as a text table, optionally omitting the header row.
|
||||
///
|
||||
/// Pass `emit_header = false` for continuation pages when using `--page-all`
|
||||
/// so the combined terminal output doesn't repeat column names and separator
|
||||
/// lines between pages.
|
||||
fn format_table_page(value: &Value, emit_header: bool) -> String {
|
||||
// Try to extract a list of items from standard Google API response
|
||||
let items = extract_items(value);
|
||||
|
||||
if let Some((_key, arr)) = items {
|
||||
format_array_as_table(arr, emit_header)
|
||||
} else if let Value::Array(arr) = value {
|
||||
format_array_as_table(arr, emit_header)
|
||||
} else if let Value::Object(obj) = value {
|
||||
// Single object: key/value table — flatten nested objects first
|
||||
let mut output = String::new();
|
||||
let flat = flatten_object(obj, "");
|
||||
let max_key_len = flat.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
|
||||
for (key, val_str) in &flat {
|
||||
let _ = writeln!(output, "{:width$} {}", key, val_str, width = max_key_len);
|
||||
}
|
||||
output
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_array_as_table(arr: &[Value], emit_header: bool) -> String {
|
||||
if arr.is_empty() {
|
||||
return "(empty)\n".to_string();
|
||||
}
|
||||
|
||||
// Flatten each row so nested objects become dot-notation columns.
|
||||
let flat_rows: Vec<Vec<(String, String)>> = arr
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
Value::Object(obj) => flatten_object(obj, ""),
|
||||
_ => vec![(String::new(), value_to_cell(item))],
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Collect all unique column names (preserving insertion order).
|
||||
let mut columns: Vec<String> = Vec::new();
|
||||
for row in &flat_rows {
|
||||
for (key, _) in row {
|
||||
if !columns.contains(key) {
|
||||
columns.push(key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if columns.is_empty() {
|
||||
// Array of non-objects
|
||||
let mut output = String::new();
|
||||
for item in arr {
|
||||
let _ = writeln!(output, "{}", value_to_cell(item));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// Build lookup: row_index -> column_name -> cell_value
|
||||
let row_maps: Vec<std::collections::HashMap<&str, &str>> = flat_rows
|
||||
.iter()
|
||||
.map(|pairs| {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Calculate column widths (char-count, not byte-count).
|
||||
let mut widths: Vec<usize> = columns.iter().map(|c| c.chars().count()).collect();
|
||||
let rows: Vec<Vec<String>> = row_maps
|
||||
.iter()
|
||||
.map(|row| {
|
||||
columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, col)| {
|
||||
let cell = row.get(col.as_str()).copied().unwrap_or("").to_string();
|
||||
let char_len = cell.chars().count();
|
||||
if char_len > widths[i] {
|
||||
widths[i] = char_len;
|
||||
}
|
||||
// Cap column width at 60 chars
|
||||
if widths[i] > 60 {
|
||||
widths[i] = 60;
|
||||
}
|
||||
cell
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
if emit_header {
|
||||
// Header
|
||||
let header: Vec<String> = columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| format!("{:width$}", c, width = widths[i]))
|
||||
.collect();
|
||||
let _ = writeln!(output, "{}", header.join(" "));
|
||||
|
||||
// Separator
|
||||
let sep: Vec<String> = widths.iter().map(|w| "─".repeat(*w)).collect();
|
||||
let _ = writeln!(output, "{}", sep.join(" "));
|
||||
}
|
||||
|
||||
// Rows — truncate by char count to avoid panicking on multi-byte UTF-8.
|
||||
for row in &rows {
|
||||
let cells: Vec<String> = row
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| {
|
||||
let char_len = c.chars().count();
|
||||
let truncated = if char_len > widths[i] {
|
||||
// Safe char-boundary slice: take widths[i]-1 chars, then append ellipsis.
|
||||
let truncated_str: String = c.chars().take(widths[i] - 1).collect();
|
||||
format!("{truncated_str}…")
|
||||
} else {
|
||||
c.clone()
|
||||
};
|
||||
// Pad to column width (by char count)
|
||||
let pad = widths[i].saturating_sub(truncated.chars().count());
|
||||
format!("{truncated}{}", " ".repeat(pad))
|
||||
})
|
||||
.collect();
|
||||
let _ = writeln!(output, "{}", cells.join(" "));
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn format_yaml(value: &Value) -> String {
|
||||
json_to_yaml(value, 0)
|
||||
}
|
||||
|
||||
fn json_to_yaml(value: &Value, indent: usize) -> String {
|
||||
let prefix = " ".repeat(indent);
|
||||
match value {
|
||||
Value::Null => "null".to_string(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::String(s) => {
|
||||
if s.contains('\n') {
|
||||
// Genuine multi-line content: block scalar is the most readable choice.
|
||||
format!(
|
||||
"|\n{}",
|
||||
s.lines()
|
||||
.map(|l| format!("{prefix} {l}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
)
|
||||
} else {
|
||||
// Single-line strings: always double-quote so that characters like
|
||||
// `#` (comment marker) and `:` (mapping indicator) are never
|
||||
// misinterpreted by YAML parsers. Escape backslashes and double
|
||||
// quotes to keep the output valid.
|
||||
let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
format!("\"{escaped}\"")
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
if arr.is_empty() {
|
||||
return "[]".to_string();
|
||||
}
|
||||
let mut out = String::new();
|
||||
for item in arr {
|
||||
let val_str = json_to_yaml(item, indent + 1);
|
||||
let _ = write!(out, "\n{prefix}- {val_str}");
|
||||
}
|
||||
out
|
||||
}
|
||||
Value::Object(obj) => {
|
||||
if obj.is_empty() {
|
||||
return "{}".to_string();
|
||||
}
|
||||
let mut out = String::new();
|
||||
for (key, val) in obj {
|
||||
match val {
|
||||
Value::Object(_) | Value::Array(_) => {
|
||||
let val_str = json_to_yaml(val, indent + 1);
|
||||
let _ = write!(out, "\n{prefix}{key}:{val_str}");
|
||||
}
|
||||
_ => {
|
||||
let val_str = json_to_yaml(val, indent);
|
||||
let _ = write!(out, "\n{prefix}{key}: {val_str}");
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_csv(value: &Value) -> String {
|
||||
format_csv_page(value, true)
|
||||
}
|
||||
|
||||
/// Format as CSV, optionally omitting the header row.
|
||||
///
|
||||
/// Pass `emit_header = false` for all pages after the first when using
|
||||
/// `--page-all`, so the combined output has a single header line.
|
||||
fn format_csv_page(value: &Value, emit_header: bool) -> String {
|
||||
let items = extract_items(value);
|
||||
|
||||
let arr = if let Some((_key, arr)) = items {
|
||||
arr.as_slice()
|
||||
} else if let Value::Array(arr) = value {
|
||||
arr.as_slice()
|
||||
} else {
|
||||
// Single value — just output it
|
||||
return value_to_cell(value);
|
||||
};
|
||||
|
||||
if arr.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// Array of non-objects
|
||||
if !arr.iter().any(|v| v.is_object()) {
|
||||
let mut output = String::new();
|
||||
for item in arr {
|
||||
if let Value::Array(inner) = item {
|
||||
let cells: Vec<String> = inner
|
||||
.iter()
|
||||
.map(|v| csv_escape(&value_to_cell(v)))
|
||||
.collect();
|
||||
let _ = writeln!(output, "{}", cells.join(","));
|
||||
} else {
|
||||
let _ = writeln!(output, "{}", csv_escape(&value_to_cell(item)));
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// Collect columns
|
||||
let mut columns: Vec<String> = Vec::new();
|
||||
for item in arr {
|
||||
if let Value::Object(obj) = item {
|
||||
for key in obj.keys() {
|
||||
if !columns.contains(key) {
|
||||
columns.push(key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
// Header (omitted on continuation pages)
|
||||
if emit_header {
|
||||
let _ = writeln!(output, "{}", columns.join(","));
|
||||
}
|
||||
|
||||
// Rows
|
||||
for item in arr {
|
||||
let cells: Vec<String> = columns
|
||||
.iter()
|
||||
.map(|col| {
|
||||
if let Value::Object(obj) = item {
|
||||
csv_escape(&value_to_cell(obj.get(col).unwrap_or(&Value::Null)))
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let _ = writeln!(output, "{}", cells.join(","));
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn csv_escape(s: &str) -> String {
|
||||
if s.contains(',') || s.contains('"') || s.contains('\n') {
|
||||
format!("\"{}\"", s.replace('"', "\"\""))
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn value_to_cell(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Null => String::new(),
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Array(arr) => {
|
||||
let items: Vec<String> = arr.iter().map(value_to_cell).collect();
|
||||
items.join(", ")
|
||||
}
|
||||
Value::Object(_) => serde_json::to_string(value).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_output_format_from_str() {
|
||||
assert_eq!(OutputFormat::from_str("json"), OutputFormat::Json);
|
||||
assert_eq!(OutputFormat::from_str("table"), OutputFormat::Table);
|
||||
assert_eq!(OutputFormat::from_str("yaml"), OutputFormat::Yaml);
|
||||
assert_eq!(OutputFormat::from_str("yml"), OutputFormat::Yaml);
|
||||
assert_eq!(OutputFormat::from_str("csv"), OutputFormat::Csv);
|
||||
assert_eq!(OutputFormat::from_str("unknown"), OutputFormat::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_format_parse_known() {
|
||||
assert_eq!(OutputFormat::parse("json"), Ok(OutputFormat::Json));
|
||||
assert_eq!(OutputFormat::parse("table"), Ok(OutputFormat::Table));
|
||||
assert_eq!(OutputFormat::parse("yaml"), Ok(OutputFormat::Yaml));
|
||||
assert_eq!(OutputFormat::parse("yml"), Ok(OutputFormat::Yaml));
|
||||
assert_eq!(OutputFormat::parse("csv"), Ok(OutputFormat::Csv));
|
||||
// Case-insensitive
|
||||
assert_eq!(OutputFormat::parse("JSON"), Ok(OutputFormat::Json));
|
||||
assert_eq!(OutputFormat::parse("TABLE"), Ok(OutputFormat::Table));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_format_parse_unknown_returns_err() {
|
||||
assert!(OutputFormat::parse("bogus").is_err());
|
||||
assert_eq!(OutputFormat::parse("bogus").unwrap_err(), "bogus");
|
||||
assert!(OutputFormat::parse("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_json() {
|
||||
let val = json!({"name": "test"});
|
||||
let output = format_value(&val, &OutputFormat::Json);
|
||||
assert!(output.contains("\"name\""));
|
||||
assert!(output.contains("\"test\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_table_array_of_objects() {
|
||||
let val = json!({
|
||||
"files": [
|
||||
{"id": "1", "name": "hello.txt"},
|
||||
{"id": "2", "name": "world.txt"}
|
||||
]
|
||||
});
|
||||
let output = format_value(&val, &OutputFormat::Table);
|
||||
assert!(output.contains("id"));
|
||||
assert!(output.contains("name"));
|
||||
assert!(output.contains("hello.txt"));
|
||||
assert!(output.contains("world.txt"));
|
||||
// Check separator line
|
||||
assert!(output.contains("──"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_table_single_object() {
|
||||
let val = json!({"id": "abc", "name": "test"});
|
||||
let output = format_value(&val, &OutputFormat::Table);
|
||||
assert!(output.contains("id"));
|
||||
assert!(output.contains("abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_table_nested_object_flattened() {
|
||||
// Nested objects should become dot-notation columns, not raw JSON blobs.
|
||||
let val = json!({
|
||||
"user": {
|
||||
"displayName": "Alice",
|
||||
"emailAddress": "alice@example.com"
|
||||
},
|
||||
"storageQuota": {
|
||||
"limit": "1000",
|
||||
"usage": "500"
|
||||
}
|
||||
});
|
||||
let output = format_value(&val, &OutputFormat::Table);
|
||||
// Should contain dot-notation keys
|
||||
assert!(
|
||||
output.contains("user.displayName"),
|
||||
"expected flattened key in output:\n{output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("user.emailAddress"),
|
||||
"expected flattened key in output:\n{output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("Alice"),
|
||||
"expected value in output:\n{output}"
|
||||
);
|
||||
// Should NOT contain raw JSON blobs
|
||||
assert!(
|
||||
!output.contains("{\"displayName"),
|
||||
"should not have raw JSON blob:\n{output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_table_nested_objects_in_array() {
|
||||
let val = json!([
|
||||
{"id": "1", "owner": {"name": "Alice"}},
|
||||
{"id": "2", "owner": {"name": "Bob"}}
|
||||
]);
|
||||
let output = format_value(&val, &OutputFormat::Table);
|
||||
assert!(
|
||||
output.contains("owner.name"),
|
||||
"expected flattened column:\n{output}"
|
||||
);
|
||||
assert!(output.contains("Alice"), "expected value:\n{output}");
|
||||
assert!(output.contains("Bob"), "expected value:\n{output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_table_multibyte_truncation_does_not_panic() {
|
||||
// Column width cap is 60 chars, so a long string with multi-byte chars
|
||||
// must be safely truncated without a byte-boundary panic.
|
||||
let long_emoji = "😀".repeat(70); // each emoji is 4 bytes
|
||||
let val = json!([{"col": long_emoji}]);
|
||||
// Should not panic
|
||||
let output = format_value(&val, &OutputFormat::Table);
|
||||
assert!(output.contains("col"), "column name must appear:\n{output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_table_multibyte_exact_boundary() {
|
||||
// Multi-byte chars at various positions must not panic or produce garbled output.
|
||||
let val = json!([{"name": "café résumé naïve"}]);
|
||||
let output = format_value(&val, &OutputFormat::Table);
|
||||
assert!(output.contains("name"), "column must appear:\n{output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_csv() {
|
||||
let val = json!({
|
||||
"files": [
|
||||
{"id": "1", "name": "hello"},
|
||||
{"id": "2", "name": "world"}
|
||||
]
|
||||
});
|
||||
let output = format_value(&val, &OutputFormat::Csv);
|
||||
assert!(output.contains("id,name"));
|
||||
assert!(output.contains("1,hello"));
|
||||
assert!(output.contains("2,world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_csv_array_of_arrays() {
|
||||
// Sheets API returns {"values": [["col1","col2"], ["a","b"]]}
|
||||
let val = json!({
|
||||
"values": [
|
||||
["Student Name", "Gender", "Class Level"],
|
||||
["Alexandra", "Female", "4. Senior"],
|
||||
["Andrew", "Male", "1. Freshman"]
|
||||
]
|
||||
});
|
||||
let output = format_value(&val, &OutputFormat::Csv);
|
||||
let lines: Vec<&str> = output.lines().collect();
|
||||
assert_eq!(lines[0], "Student Name,Gender,Class Level");
|
||||
assert_eq!(lines[1], "Alexandra,Female,4. Senior");
|
||||
assert_eq!(lines[2], "Andrew,Male,1. Freshman");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_csv_flat_scalars() {
|
||||
// Flat array of non-object, non-array values → one value per line
|
||||
let val = json!(["apple", "banana", "cherry"]);
|
||||
let output = format_value(&val, &OutputFormat::Csv);
|
||||
let lines: Vec<&str> = output.lines().collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines[0], "apple");
|
||||
assert_eq!(lines[1], "banana");
|
||||
assert_eq!(lines[2], "cherry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_csv_flat_scalars_with_escaping() {
|
||||
// Scalars that contain commas/quotes must be CSV-escaped
|
||||
let val = json!(["plain", "has,comma", "has\"quote"]);
|
||||
let output = format_value(&val, &OutputFormat::Csv);
|
||||
let lines: Vec<&str> = output.lines().collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines[0], "plain");
|
||||
assert_eq!(lines[1], "\"has,comma\"");
|
||||
assert_eq!(lines[2], "\"has\"\"quote\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_csv_escape() {
|
||||
assert_eq!(csv_escape("simple"), "simple");
|
||||
assert_eq!(csv_escape("has,comma"), "\"has,comma\"");
|
||||
assert_eq!(csv_escape("has\"quote"), "\"has\"\"quote\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml() {
|
||||
let val = json!({"name": "test", "count": 42});
|
||||
let output = format_value(&val, &OutputFormat::Yaml);
|
||||
assert!(output.contains("name: \"test\""));
|
||||
assert!(output.contains("count: 42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_table_empty_array() {
|
||||
let val = json!({"files": []});
|
||||
// No items to extract, falls back to single-object table
|
||||
let output = format_value(&val, &OutputFormat::Table);
|
||||
assert!(output.contains("files"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_items() {
|
||||
let val = json!({"files": [{"id": "1"}], "nextPageToken": "abc"});
|
||||
let (key, items) = extract_items(&val).unwrap();
|
||||
assert_eq!(key, "files");
|
||||
assert_eq!(items.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_items_none() {
|
||||
let val = json!({"status": "ok"});
|
||||
assert!(extract_items(&val).is_none());
|
||||
}
|
||||
|
||||
// --- YAML block-scalar regression tests ---
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_hash_in_string_is_quoted_not_block() {
|
||||
// `drive#file` contains `#` which is a YAML comment marker; the
|
||||
// serialiser must quote it rather than emit a block scalar.
|
||||
let val = json!({"kind": "drive#file", "id": "123"});
|
||||
let output = format_value(&val, &OutputFormat::Yaml);
|
||||
// Must be a double-quoted string, not a block scalar (`|`).
|
||||
assert!(
|
||||
output.contains("kind: \"drive#file\""),
|
||||
"expected double-quoted kind, got:\n{output}"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("kind: |"),
|
||||
"kind must not use block scalar, got:\n{output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_colon_in_string_is_quoted() {
|
||||
let val = json!({"url": "https://example.com/path"});
|
||||
let output = format_value(&val, &OutputFormat::Yaml);
|
||||
assert!(
|
||||
output.contains("url: \"https://example.com/path\""),
|
||||
"expected double-quoted url, got:\n{output}"
|
||||
);
|
||||
assert!(!output.contains("url: |"), "url must not use block scalar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_multiline_still_uses_block() {
|
||||
let val = json!({"body": "line one\nline two"});
|
||||
let output = format_value(&val, &OutputFormat::Yaml);
|
||||
// Multi-line content should still use block scalar.
|
||||
assert!(
|
||||
output.contains("body: |"),
|
||||
"multiline string must use block scalar, got:\n{output}"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Paginated format tests ---
|
||||
|
||||
#[test]
|
||||
fn test_format_value_paginated_csv_first_page_has_header() {
|
||||
let val = json!({
|
||||
"files": [
|
||||
{"id": "1", "name": "a.txt"},
|
||||
{"id": "2", "name": "b.txt"}
|
||||
]
|
||||
});
|
||||
let output = format_value_paginated(&val, &OutputFormat::Csv, true);
|
||||
let lines: Vec<&str> = output.lines().collect();
|
||||
assert_eq!(lines[0], "id,name", "first page must start with header");
|
||||
assert_eq!(lines[1], "1,a.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_value_paginated_csv_continuation_no_header() {
|
||||
let val = json!({
|
||||
"files": [
|
||||
{"id": "3", "name": "c.txt"}
|
||||
]
|
||||
});
|
||||
let output = format_value_paginated(&val, &OutputFormat::Csv, false);
|
||||
let lines: Vec<&str> = output.lines().collect();
|
||||
// The first (and only) line must be a data row, not the header.
|
||||
assert_eq!(lines[0], "3,c.txt", "continuation page must have no header");
|
||||
assert!(
|
||||
!output.contains("id,name"),
|
||||
"header must be absent on continuation pages"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_value_paginated_table_first_page_has_header() {
|
||||
let val = json!({
|
||||
"items": [
|
||||
{"id": "1", "name": "foo"}
|
||||
]
|
||||
});
|
||||
let output = format_value_paginated(&val, &OutputFormat::Table, true);
|
||||
assert!(
|
||||
output.contains("id"),
|
||||
"table header must appear on first page"
|
||||
);
|
||||
assert!(output.contains("──"), "separator must appear on first page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_value_paginated_table_continuation_no_header() {
|
||||
let val = json!({
|
||||
"items": [
|
||||
{"id": "2", "name": "bar"}
|
||||
]
|
||||
});
|
||||
let output = format_value_paginated(&val, &OutputFormat::Table, false);
|
||||
assert!(output.contains("bar"), "data row must be present");
|
||||
assert!(
|
||||
!output.contains("──"),
|
||||
"separator must be absent on continuation pages"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_value_paginated_yaml_has_document_separator() {
|
||||
let val = json!({"files": [{"id": "1", "name": "foo"}]});
|
||||
let first = format_value_paginated(&val, &OutputFormat::Yaml, true);
|
||||
let second = format_value_paginated(&val, &OutputFormat::Yaml, false);
|
||||
assert!(
|
||||
first.starts_with("---\n"),
|
||||
"first YAML page must start with ---"
|
||||
);
|
||||
assert!(
|
||||
second.starts_with("---\n"),
|
||||
"continuation YAML pages must also start with ---"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! File-system utilities.
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
|
||||
/// Write `data` to `path` atomically.
|
||||
///
|
||||
/// This implementation uses `tempfile::NamedTempFile` to create a temporary
|
||||
/// file with a random name, `O_EXCL` flags (preventing symlink attacks),
|
||||
/// and secure 0600 permissions from the moment of creation.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an `io::Error` if the temporary file cannot be created/written or if the
|
||||
/// final rename fails.
|
||||
pub fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> {
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "path has no parent directory")
|
||||
})?;
|
||||
|
||||
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
|
||||
tmp.write_all(data)?;
|
||||
tmp.as_file().sync_all()?;
|
||||
tmp.persist(path)
|
||||
.map_err(|e| io::Error::new(e.error.kind(), e.error))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Async variant of [`atomic_write`] for use with tokio.
|
||||
///
|
||||
/// This implementation uses `create_new(true)` (O_EXCL) and `mode(0o600)` to
|
||||
/// prevent TOCTOU/symlink race conditions.
|
||||
pub async fn atomic_write_async(path: &Path, data: &[u8]) -> io::Result<()> {
|
||||
use rand::Rng;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "path has no parent directory")
|
||||
})?;
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?
|
||||
.to_string_lossy();
|
||||
|
||||
let mut retries = 0;
|
||||
let mut file: tokio::fs::File;
|
||||
let mut tmp_path;
|
||||
|
||||
loop {
|
||||
let suffix: String = rand::thread_rng()
|
||||
.sample_iter(&rand::distributions::Alphanumeric)
|
||||
.take(8)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
let tmp_name = format!("{}.tmp.{}", file_name, suffix);
|
||||
tmp_path = parent.join(tmp_name);
|
||||
|
||||
let mut opts = tokio::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
opts.mode(0o600);
|
||||
}
|
||||
|
||||
match opts.open(&tmp_path).await {
|
||||
Ok(f) => {
|
||||
file = f;
|
||||
break;
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists && retries < 10 => {
|
||||
retries += 1;
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
let write_result = async {
|
||||
file.write_all(data).await?;
|
||||
file.sync_all().await?;
|
||||
drop(file);
|
||||
tokio::fs::rename(&tmp_path, path).await
|
||||
}
|
||||
.await;
|
||||
|
||||
if write_result.is_err() {
|
||||
let _ = tokio::fs::remove_file(&tmp_path).await;
|
||||
}
|
||||
|
||||
write_result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn test_atomic_write_creates_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("credentials.enc");
|
||||
atomic_write(&path, b"hello").unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), b"hello");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let meta = fs::metadata(&path).unwrap();
|
||||
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_atomic_write_overwrites_existing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("credentials.enc");
|
||||
fs::write(&path, b"old").unwrap();
|
||||
atomic_write(&path, b"new").unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), b"new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_atomic_write_leaves_no_tmp_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("credentials.enc");
|
||||
atomic_write(&path, b"data").unwrap();
|
||||
// Since we use random names, we just check that no .tmp files remain in the dir
|
||||
let files: Vec<_> = fs::read_dir(dir.path())
|
||||
.unwrap()
|
||||
.map(|res| res.unwrap().file_name())
|
||||
.collect();
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(files[0], "credentials.enc");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_atomic_write_async_creates_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("token_cache.json");
|
||||
atomic_write_async(&path, b"async hello").await.unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), b"async hello");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let meta = fs::metadata(&path).unwrap();
|
||||
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
# Helper Commands (`+verb`) — Guidelines
|
||||
|
||||
## Design Principle
|
||||
|
||||
The core design of `gws` is **schema-driven**: commands are dynamically generated from Google Discovery Documents at runtime. This avoids maintaining a hardcoded, unbounded argument surface. **Helpers must complement this design, not duplicate it.**
|
||||
|
||||
## When a Helper is Justified
|
||||
|
||||
A `+helper` command should exist only when it provides value that Discovery-based commands **cannot**:
|
||||
|
||||
| Justification | Example | Why Discovery Can't Do It |
|
||||
|---|---|---|
|
||||
| **Multi-step orchestration** | `+subscribe` | Creates Pub/Sub topic → subscription → Workspace Events subscription (3 APIs) |
|
||||
| **Format translation** | `+write` | Transforms Markdown → Docs `batchUpdate` JSON |
|
||||
| **Multi-API composition** | `+triage` | Lists messages then fetches N metadata payloads concurrently |
|
||||
| **Complex body construction** | `+send`, `+reply` | Builds RFC 2822 MIME from simple flags |
|
||||
| **Multipart upload** | `+upload` | Handles resumable upload protocol with progress |
|
||||
| **Workflow recipes** | `+standup-report` | Chains calls across multiple services |
|
||||
|
||||
**Litmus test:** Can the user achieve the same result with `gws <service> <resource> <method> --params '{...}'`? If yes, don't add a helper.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### ❌ Anti-pattern 1: Single API Call Wrapper
|
||||
|
||||
If a helper wraps one API call that Discovery already exposes, reject it.
|
||||
|
||||
**Real example:** `+revisions` (PR #563) wrapped `gws drive files-revisions list` — same single API call, zero added value.
|
||||
|
||||
### ❌ Anti-pattern 2: Unbounded Flag Accumulation
|
||||
|
||||
Adding flags to expose data that is already in the API response creates unbounded surface area.
|
||||
|
||||
**Real example:** `--thread-id`, `--delivered-to`, `--sent-last` on `+triage` (PR #597) — all three values are already present in the Gmail API response. Agents and users should extract them with `--format` or `jq`, not new flags.
|
||||
|
||||
**Why this is harmful:** Every API response contains dozens of fields. If we add a flag for each one, helpers become unbounded maintenance burdens — the exact problem Discovery-driven design solves.
|
||||
|
||||
### ❌ Anti-pattern 3: Duplicating Discovery Parameters
|
||||
|
||||
Don't re-expose Discovery-defined parameters (e.g., `pageSize`, `fields`, `orderBy`) as custom helper flags. Use `--params` passthrough instead.
|
||||
|
||||
## Flag Design Rules
|
||||
|
||||
Helper flags must control **orchestration logic**, not API parameters or output fields.
|
||||
|
||||
### ✅ Good Flags (control orchestration)
|
||||
|
||||
| Flag | Helper | Why It's Good |
|
||||
|---|---|---|
|
||||
| `--spreadsheet`, `--range` | `+read` | Identifies which resource to operate on |
|
||||
| `--to`, `--subject`, `--body` | `+send` | Inputs to MIME construction (format translation) |
|
||||
| `--dry-run` | `+subscribe` | Controls whether API calls are actually made |
|
||||
| `--subscription` | `+subscribe` | Switches between "create new" vs. "use existing" orchestration path |
|
||||
| `--target`, `--project` | `+subscribe` | Required for multi-service resource creation |
|
||||
|
||||
### ❌ Bad Flags (expose API response data)
|
||||
|
||||
| Flag | Why It's Bad | Alternative |
|
||||
|---|---|---|
|
||||
| `--thread-id` | Already in API response | `jq '.threadId'` |
|
||||
| `--delivered-to` | Already in response headers | `jq '.payload.headers[] | ...'` |
|
||||
| `--include-labels` | Output field filtering | `--format` or `jq` |
|
||||
|
||||
### Decision Checklist for New Flags
|
||||
|
||||
1. Does this flag control **what API call to make** or **how to orchestrate** multiple calls? → ✅ Add it
|
||||
2. Does this flag control **what data appears in output**? → ❌ Use `--format`/`jq`
|
||||
3. Does this flag duplicate a Discovery parameter? → ❌ Use `--params`
|
||||
4. Could the user achieve this with existing flags + post-processing? → ❌ Don't add it
|
||||
|
||||
## Architecture
|
||||
|
||||
Helpers are implemented using the `Helper` trait defined in `mod.rs`.
|
||||
|
||||
- **`inject_commands`**: Adds subcommands to the main service command. All helper commands are always shown regardless of authentication state.
|
||||
- **`handle`**: Implementation of the command logic. Returns `Ok(true)` if the command was handled, or `Ok(false)` to let the default raw resource handler attempt to handle it.
|
||||
|
||||
## Adding a New Helper — Checklist
|
||||
|
||||
1. **Passes the litmus test** — cannot be done with a single Discovery command
|
||||
2. **Flags are bounded** — only flags controlling orchestration, not API params/output
|
||||
3. **Uses shared infrastructure:**
|
||||
- `crate::client::build_client()` for HTTP
|
||||
- `crate::validate::validate_resource_name()` for user-supplied resource IDs
|
||||
- `crate::validate::encode_path_segment()` for URL path segments
|
||||
- `crate::output::sanitize_for_terminal()` for error messages
|
||||
4. **Has tests** — at minimum: command registration, required args, happy path
|
||||
5. **Supports `--dry-run`** where the helper creates or mutates resources
|
||||
|
||||
### Development Steps
|
||||
|
||||
1. Create `src/helpers/<service>.rs`
|
||||
2. Implement the `Helper` trait
|
||||
3. Register it in `src/helpers/mod.rs`
|
||||
4. **Prefix** the command with `+` (e.g., `+create`)
|
||||
@@ -0,0 +1,772 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::Helper;
|
||||
use crate::auth;
|
||||
use crate::error::GwsError;
|
||||
use crate::executor;
|
||||
use clap::{Arg, ArgAction, ArgMatches, Command};
|
||||
use serde_json::json;
|
||||
use serde_json::Value;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
pub struct CalendarHelper;
|
||||
|
||||
impl Helper for CalendarHelper {
|
||||
fn inject_commands(
|
||||
&self,
|
||||
mut cmd: Command,
|
||||
_doc: &crate::discovery::RestDescription,
|
||||
) -> Command {
|
||||
cmd = cmd.subcommand(
|
||||
Command::new("+insert")
|
||||
.about("[Helper] create a new event")
|
||||
.arg(
|
||||
Arg::new("calendar")
|
||||
.long("calendar")
|
||||
.help("Calendar ID (default: primary)")
|
||||
.default_value("primary")
|
||||
.value_name("ID"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("summary")
|
||||
.long("summary")
|
||||
.help("Event summary/title")
|
||||
.required(true)
|
||||
.value_name("TEXT"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("start")
|
||||
.long("start")
|
||||
.help("Start time (ISO 8601, e.g., 2024-01-01T10:00:00Z)")
|
||||
.required(true)
|
||||
.value_name("TIME"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("end")
|
||||
.long("end")
|
||||
.help("End time (ISO 8601)")
|
||||
.required(true)
|
||||
.value_name("TIME"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("location")
|
||||
.long("location")
|
||||
.help("Event location")
|
||||
.value_name("TEXT"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("description")
|
||||
.long("description")
|
||||
.help("Event description/body")
|
||||
.value_name("TEXT"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("attendee")
|
||||
.long("attendee")
|
||||
.help("Attendee email (can be used multiple times)")
|
||||
.value_name("EMAIL")
|
||||
.action(ArgAction::Append),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("meet")
|
||||
.long("meet")
|
||||
.help("Add a Google Meet video conference link")
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.after_help("\
|
||||
EXAMPLES:
|
||||
gws calendar +insert --summary 'Standup' --start '2026-06-17T09:00:00-07:00' --end '2026-06-17T09:30:00-07:00'
|
||||
gws calendar +insert --summary 'Review' --start ... --end ... --attendee alice@example.com
|
||||
gws calendar +insert --summary 'Meet' --start ... --end ... --meet
|
||||
|
||||
TIPS:
|
||||
Use RFC3339 format for times (e.g. 2026-06-17T09:00:00-07:00).
|
||||
The --meet flag automatically adds a Google Meet link to the event."),
|
||||
);
|
||||
cmd = cmd.subcommand(
|
||||
Command::new("+agenda")
|
||||
.about("[Helper] Show upcoming events across all calendars")
|
||||
.arg(
|
||||
Arg::new("today")
|
||||
.long("today")
|
||||
.help("Show today's events")
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("tomorrow")
|
||||
.long("tomorrow")
|
||||
.help("Show tomorrow's events")
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("week")
|
||||
.long("week")
|
||||
.help("Show this week's events")
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("days")
|
||||
.long("days")
|
||||
.help("Number of days ahead to show")
|
||||
.value_name("N"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("calendar")
|
||||
.long("calendar")
|
||||
.help("Filter to specific calendar name or ID")
|
||||
.value_name("NAME"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("timezone")
|
||||
.long("timezone")
|
||||
.alias("tz")
|
||||
.help("IANA timezone override (e.g. America/Denver). Defaults to Google account timezone.")
|
||||
.value_name("TZ"),
|
||||
)
|
||||
.after_help(
|
||||
"\
|
||||
EXAMPLES:
|
||||
gws calendar +agenda
|
||||
gws calendar +agenda --today
|
||||
gws calendar +agenda --week --format table
|
||||
gws calendar +agenda --days 3 --calendar 'Work'
|
||||
gws calendar +agenda --today --timezone America/New_York
|
||||
|
||||
TIPS:
|
||||
Read-only — never modifies events.
|
||||
Queries all calendars by default; use --calendar to filter.
|
||||
Uses your Google account timezone by default; override with --timezone.",
|
||||
),
|
||||
);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn handle<'a>(
|
||||
&'a self,
|
||||
doc: &'a crate::discovery::RestDescription,
|
||||
matches: &'a ArgMatches,
|
||||
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
|
||||
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
if let Some(matches) = matches.subcommand_matches("+insert") {
|
||||
let (params_str, body_str, scopes) = build_insert_request(matches, doc)?;
|
||||
|
||||
let scopes_str: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
|
||||
let (token, auth_method) = match auth::get_token(&scopes_str).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!("Calendar auth failed: {e}"))),
|
||||
};
|
||||
|
||||
let events_res = doc.resources.get("events").ok_or_else(|| {
|
||||
GwsError::Discovery("Resource 'events' not found".to_string())
|
||||
})?;
|
||||
let insert_method = events_res.methods.get("insert").ok_or_else(|| {
|
||||
GwsError::Discovery("Method 'events.insert' not found".to_string())
|
||||
})?;
|
||||
|
||||
executor::execute_method(
|
||||
doc,
|
||||
insert_method,
|
||||
Some(¶ms_str),
|
||||
Some(&body_str),
|
||||
token.as_deref(),
|
||||
auth_method,
|
||||
None,
|
||||
None,
|
||||
matches.get_flag("dry-run"),
|
||||
&executor::PaginationConfig::default(),
|
||||
None,
|
||||
&crate::helpers::modelarmor::SanitizeMode::Warn,
|
||||
&crate::formatter::OutputFormat::default(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(matches) = matches.subcommand_matches("+agenda") {
|
||||
handle_agenda(matches).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
let cal_scope = "https://www.googleapis.com/auth/calendar.readonly";
|
||||
let token = auth::get_token(&[cal_scope])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Calendar auth failed: {e}")))?;
|
||||
|
||||
let output_format = matches
|
||||
.get_one::<String>("format")
|
||||
.map(|s| crate::formatter::OutputFormat::from_str(s))
|
||||
.unwrap_or(crate::formatter::OutputFormat::Table);
|
||||
|
||||
let client = crate::client::build_client()?;
|
||||
let tz_override = matches.get_one::<String>("timezone").map(|s| s.as_str());
|
||||
let tz = crate::timezone::resolve_account_timezone(&client, &token, tz_override).await?;
|
||||
|
||||
// Determine time range using the account timezone so that --today and
|
||||
// --tomorrow align with the user's Google account day, not the machine.
|
||||
let now_in_tz = chrono::Utc::now().with_timezone(&tz);
|
||||
let today_start_tz = crate::timezone::start_of_today(tz)?;
|
||||
|
||||
let days: i64 = if matches.get_flag("tomorrow") {
|
||||
1
|
||||
} else if matches.get_flag("week") {
|
||||
7
|
||||
} else {
|
||||
matches
|
||||
.get_one::<String>("days")
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(1)
|
||||
};
|
||||
|
||||
let (time_min_dt, time_max_dt) = if matches.get_flag("today") {
|
||||
// Today: account tz midnight to midnight+1
|
||||
let end = today_start_tz + chrono::Duration::days(1);
|
||||
(today_start_tz, end)
|
||||
} else if matches.get_flag("tomorrow") {
|
||||
// Tomorrow: account tz midnight+1 to midnight+2
|
||||
let start = today_start_tz + chrono::Duration::days(1);
|
||||
let end = today_start_tz + chrono::Duration::days(2);
|
||||
(start, end)
|
||||
} else {
|
||||
// From now, N days ahead
|
||||
let end = now_in_tz + chrono::Duration::days(days);
|
||||
(now_in_tz, end)
|
||||
};
|
||||
|
||||
let time_min = time_min_dt.to_rfc3339();
|
||||
let time_max = time_max_dt.to_rfc3339();
|
||||
|
||||
// client already built above for timezone resolution
|
||||
let calendar_filter = matches.get_one::<String>("calendar");
|
||||
|
||||
// 1. List all calendars
|
||||
let list_url = "https://www.googleapis.com/calendar/v3/users/me/calendarList";
|
||||
let list_resp = client
|
||||
.get(list_url)
|
||||
.bearer_auth(&token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to list calendars: {e}")))?;
|
||||
|
||||
if !list_resp.status().is_success() {
|
||||
let err = list_resp.text().await.unwrap_or_default();
|
||||
return Err(GwsError::Api {
|
||||
code: 0,
|
||||
message: err,
|
||||
reason: "calendarList_failed".to_string(),
|
||||
enable_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
let list_json: Value = list_resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse calendar list: {e}")))?;
|
||||
|
||||
let calendars = list_json
|
||||
.get("items")
|
||||
.and_then(|i| i.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// 2. For each calendar, fetch events concurrently
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
|
||||
// Pre-filter calendars and collect owned data to avoid lifetime issues
|
||||
struct CalInfo {
|
||||
id: String,
|
||||
summary: String,
|
||||
}
|
||||
let filtered_calendars: Vec<CalInfo> = calendars
|
||||
.iter()
|
||||
.filter_map(|cal| {
|
||||
let cal_id = cal.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let cal_summary = cal
|
||||
.get("summary")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(cal_id);
|
||||
|
||||
// Apply calendar filter
|
||||
if let Some(filter) = calendar_filter {
|
||||
if !cal_summary.contains(filter.as_str()) && cal_id != filter.as_str() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
Some(CalInfo {
|
||||
id: cal_id.to_string(),
|
||||
summary: cal_summary.to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut all_events: Vec<Value> = stream::iter(filtered_calendars)
|
||||
.map(|cal| {
|
||||
let client = &client;
|
||||
let token = &token;
|
||||
let time_min = &time_min;
|
||||
let time_max = &time_max;
|
||||
async move {
|
||||
let events_url = format!(
|
||||
"https://www.googleapis.com/calendar/v3/calendars/{}/events",
|
||||
crate::validate::encode_path_segment(&cal.id),
|
||||
);
|
||||
|
||||
let resp = crate::client::send_with_retry(|| {
|
||||
client
|
||||
.get(&events_url)
|
||||
.query(&[
|
||||
("timeMin", time_min.as_str()),
|
||||
("timeMax", time_max.as_str()),
|
||||
("singleEvents", "true"),
|
||||
("orderBy", "startTime"),
|
||||
("maxResults", "50"),
|
||||
])
|
||||
.bearer_auth(token)
|
||||
})
|
||||
.await;
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
_ => return vec![],
|
||||
};
|
||||
|
||||
let events_json: Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
|
||||
let mut events = Vec::new();
|
||||
if let Some(items) = events_json.get("items").and_then(|i| i.as_array()) {
|
||||
for event in items {
|
||||
let start = event
|
||||
.get("start")
|
||||
.and_then(|s| s.get("dateTime").or_else(|| s.get("date")))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let end = event
|
||||
.get("end")
|
||||
.and_then(|s| s.get("dateTime").or_else(|| s.get("date")))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let summary = event
|
||||
.get("summary")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("(No title)")
|
||||
.to_string();
|
||||
let location = event
|
||||
.get("location")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
events.push(json!({
|
||||
"start": start,
|
||||
"end": end,
|
||||
"summary": summary,
|
||||
"calendar": cal.summary,
|
||||
"location": location,
|
||||
}));
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
})
|
||||
.buffer_unordered(5)
|
||||
.flat_map(stream::iter)
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
// 3. Sort by start time
|
||||
all_events.sort_by(|a, b| {
|
||||
let a_start = a.get("start").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let b_start = b.get("start").and_then(|v| v.as_str()).unwrap_or("");
|
||||
a_start.cmp(b_start)
|
||||
});
|
||||
|
||||
let output = json!({
|
||||
"events": all_events,
|
||||
"count": all_events.len(),
|
||||
"timeMin": time_min,
|
||||
"timeMax": time_max,
|
||||
});
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
crate::formatter::format_value(&output, &output_format)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_insert_request(
|
||||
matches: &ArgMatches,
|
||||
doc: &crate::discovery::RestDescription,
|
||||
) -> Result<(String, String, Vec<String>), GwsError> {
|
||||
let calendar_id = matches.get_one::<String>("calendar").unwrap();
|
||||
let summary = matches.get_one::<String>("summary").unwrap();
|
||||
let start = matches.get_one::<String>("start").unwrap();
|
||||
let end = matches.get_one::<String>("end").unwrap();
|
||||
let location = matches.get_one::<String>("location");
|
||||
let description = matches.get_one::<String>("description");
|
||||
let attendees_vals = matches.get_many::<String>("attendee");
|
||||
|
||||
// Find method: events.insert checks
|
||||
let events_res = doc
|
||||
.resources
|
||||
.get("events")
|
||||
.ok_or_else(|| GwsError::Discovery("Resource 'events' not found".to_string()))?;
|
||||
let insert_method = events_res
|
||||
.methods
|
||||
.get("insert")
|
||||
.ok_or_else(|| GwsError::Discovery("Method 'events.insert' not found".to_string()))?;
|
||||
|
||||
// Build body
|
||||
let mut body = json!({
|
||||
"summary": summary,
|
||||
"start": { "dateTime": start },
|
||||
"end": { "dateTime": end },
|
||||
});
|
||||
|
||||
if let Some(loc) = location {
|
||||
body["location"] = json!(loc);
|
||||
}
|
||||
if let Some(desc) = description {
|
||||
body["description"] = json!(desc);
|
||||
}
|
||||
|
||||
if let Some(atts) = attendees_vals {
|
||||
let attendees_list: Vec<_> = atts.map(|email| json!({ "email": email })).collect();
|
||||
body["attendees"] = json!(attendees_list);
|
||||
}
|
||||
|
||||
let mut params = json!({
|
||||
"calendarId": calendar_id
|
||||
});
|
||||
|
||||
if matches.get_flag("meet") {
|
||||
let namespace = uuid::Uuid::NAMESPACE_DNS;
|
||||
|
||||
let mut attendees: Vec<_> = matches
|
||||
.get_many::<String>("attendee")
|
||||
.map(|vals| vals.cloned().collect())
|
||||
.unwrap_or_default();
|
||||
attendees.sort();
|
||||
|
||||
let seed_payload = {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("v".to_string(), json!(1));
|
||||
map.insert("summary".to_string(), json!(summary));
|
||||
map.insert("start".to_string(), json!(start));
|
||||
map.insert("end".to_string(), json!(end));
|
||||
if let Some(loc) = location {
|
||||
map.insert("location".to_string(), json!(loc));
|
||||
}
|
||||
if let Some(desc) = description {
|
||||
map.insert("description".to_string(), json!(desc));
|
||||
}
|
||||
if !attendees.is_empty() {
|
||||
let attendees_list_for_seed: Vec<_> = attendees
|
||||
.iter()
|
||||
.map(|email| json!({ "email": email }))
|
||||
.collect();
|
||||
map.insert("attendees".to_string(), json!(attendees_list_for_seed));
|
||||
}
|
||||
serde_json::Value::Object(map)
|
||||
};
|
||||
|
||||
let seed_data = serde_json::to_vec(&seed_payload).map_err(|e| {
|
||||
GwsError::Other(anyhow::anyhow!(
|
||||
"Failed to serialize seed payload for idempotency key: {e}"
|
||||
))
|
||||
})?;
|
||||
let request_id = uuid::Uuid::new_v5(&namespace, &seed_data).to_string();
|
||||
|
||||
body["conferenceData"] = json!({
|
||||
"createRequest": {
|
||||
"requestId": request_id,
|
||||
"conferenceSolutionKey": { "type": "hangoutsMeet" }
|
||||
}
|
||||
});
|
||||
params["conferenceDataVersion"] = json!(1);
|
||||
}
|
||||
let body_str = body.to_string();
|
||||
let scopes: Vec<String> = insert_method.scopes.iter().map(|s| s.to_string()).collect();
|
||||
|
||||
// events.insert requires 'calendarId' path parameter
|
||||
let params_str = params.to_string();
|
||||
|
||||
Ok((params_str, body_str, scopes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_mock_doc() -> crate::discovery::RestDescription {
|
||||
let mut doc = crate::discovery::RestDescription::default();
|
||||
let mut events_res = crate::discovery::RestResource::default();
|
||||
let mut insert_method = crate::discovery::RestMethod::default();
|
||||
insert_method.scopes.push("https://scope".to_string());
|
||||
events_res
|
||||
.methods
|
||||
.insert("insert".to_string(), insert_method);
|
||||
doc.resources.insert("events".to_string(), events_res);
|
||||
doc
|
||||
}
|
||||
|
||||
fn make_matches_insert(args: &[&str]) -> ArgMatches {
|
||||
let cmd = Command::new("test")
|
||||
.arg(
|
||||
Arg::new("calendar")
|
||||
.long("calendar")
|
||||
.default_value("primary"),
|
||||
)
|
||||
.arg(Arg::new("summary").long("summary").required(true))
|
||||
.arg(Arg::new("start").long("start").required(true))
|
||||
.arg(Arg::new("end").long("end").required(true))
|
||||
.arg(Arg::new("location").long("location"))
|
||||
.arg(Arg::new("description").long("description"))
|
||||
.arg(
|
||||
Arg::new("attendee")
|
||||
.long("attendee")
|
||||
.action(ArgAction::Append),
|
||||
)
|
||||
.arg(Arg::new("meet").long("meet").action(ArgAction::SetTrue));
|
||||
cmd.try_get_matches_from(args).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_insert_request() {
|
||||
let doc = make_mock_doc();
|
||||
let matches = make_matches_insert(&[
|
||||
"test",
|
||||
"--summary",
|
||||
"Meeting",
|
||||
"--start",
|
||||
"2024-01-01T10:00:00Z",
|
||||
"--end",
|
||||
"2024-01-01T11:00:00Z",
|
||||
]);
|
||||
let (params, body, scopes) = build_insert_request(&matches, &doc).unwrap();
|
||||
|
||||
assert!(params.contains("primary"));
|
||||
assert!(body.contains("Meeting"));
|
||||
assert!(body.contains("2024-01-01T10:00:00Z"));
|
||||
assert_eq!(scopes[0], "https://scope");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_insert_request_with_meet() {
|
||||
let doc = make_mock_doc();
|
||||
let matches = make_matches_insert(&[
|
||||
"test",
|
||||
"--summary",
|
||||
"Meeting",
|
||||
"--start",
|
||||
"2024-01-01T10:00:00Z",
|
||||
"--end",
|
||||
"2024-01-01T11:00:00Z",
|
||||
"--meet",
|
||||
]);
|
||||
let (params, body, _) = build_insert_request(&matches, &doc).unwrap();
|
||||
|
||||
let params_json: serde_json::Value = serde_json::from_str(¶ms).unwrap();
|
||||
assert_eq!(params_json["conferenceDataVersion"], 1);
|
||||
|
||||
let body_json: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
let create_req = &body_json["conferenceData"]["createRequest"];
|
||||
assert_eq!(create_req["conferenceSolutionKey"]["type"], "hangoutsMeet");
|
||||
assert!(uuid::Uuid::parse_str(create_req["requestId"].as_str().unwrap()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_insert_request_with_meet_is_idempotent() {
|
||||
let doc = make_mock_doc();
|
||||
let args = &[
|
||||
"test",
|
||||
"--summary",
|
||||
"Idempotent Meeting",
|
||||
"--start",
|
||||
"2024-01-01T10:00:00Z",
|
||||
"--end",
|
||||
"2024-01-01T11:00:00Z",
|
||||
"--meet",
|
||||
];
|
||||
let matches1 = make_matches_insert(args);
|
||||
let (_, body1, _) = build_insert_request(&matches1, &doc).unwrap();
|
||||
|
||||
let matches2 = make_matches_insert(args);
|
||||
let (_, body2, _) = build_insert_request(&matches2, &doc).unwrap();
|
||||
|
||||
let b1: serde_json::Value = serde_json::from_str(&body1).unwrap();
|
||||
let b2: serde_json::Value = serde_json::from_str(&body2).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
b1["conferenceData"]["createRequest"]["requestId"],
|
||||
b2["conferenceData"]["createRequest"]["requestId"],
|
||||
"requestId should be deterministic for the same event details"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_insert_request_with_meet_idempotency_robust() {
|
||||
let doc = make_mock_doc();
|
||||
|
||||
// Base case
|
||||
let args_base = &[
|
||||
"test",
|
||||
"--summary",
|
||||
"S",
|
||||
"--start",
|
||||
"2024-01-01T10:00:00Z",
|
||||
"--end",
|
||||
"2024-01-01T11:00:00Z",
|
||||
"--meet",
|
||||
"--attendee",
|
||||
"a@b.com",
|
||||
"--attendee",
|
||||
"c@d.com",
|
||||
];
|
||||
let (_, body_base, _) =
|
||||
build_insert_request(&make_matches_insert(args_base), &doc).unwrap();
|
||||
let b_base: serde_json::Value = serde_json::from_str(&body_base).unwrap();
|
||||
let id_base = b_base["conferenceData"]["createRequest"]["requestId"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
|
||||
// Same but different attendee order
|
||||
let args_reordered = &[
|
||||
"test",
|
||||
"--summary",
|
||||
"S",
|
||||
"--start",
|
||||
"2024-01-01T10:00:00Z",
|
||||
"--end",
|
||||
"2024-01-01T11:00:00Z",
|
||||
"--meet",
|
||||
"--attendee",
|
||||
"c@d.com",
|
||||
"--attendee",
|
||||
"a@b.com",
|
||||
];
|
||||
let (_, body_reordered, _) =
|
||||
build_insert_request(&make_matches_insert(args_reordered), &doc).unwrap();
|
||||
let b_reordered: serde_json::Value = serde_json::from_str(&body_reordered).unwrap();
|
||||
let id_reordered = b_reordered["conferenceData"]["createRequest"]["requestId"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
id_base, id_reordered,
|
||||
"Attendee order should not change requestId"
|
||||
);
|
||||
|
||||
// Different summary -> different ID
|
||||
let args_diff = &[
|
||||
"test",
|
||||
"--summary",
|
||||
"Diff",
|
||||
"--start",
|
||||
"2024-01-01T10:00:00Z",
|
||||
"--end",
|
||||
"2024-01-01T11:00:00Z",
|
||||
"--meet",
|
||||
"--attendee",
|
||||
"a@b.com",
|
||||
"--attendee",
|
||||
"c@d.com",
|
||||
];
|
||||
let (_, body_diff, _) =
|
||||
build_insert_request(&make_matches_insert(args_diff), &doc).unwrap();
|
||||
let b_diff: serde_json::Value = serde_json::from_str(&body_diff).unwrap();
|
||||
let id_diff = b_diff["conferenceData"]["createRequest"]["requestId"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(
|
||||
id_base, id_diff,
|
||||
"Different summary should produce different requestId"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_insert_request_with_optional_fields() {
|
||||
let doc = make_mock_doc();
|
||||
let matches = make_matches_insert(&[
|
||||
"test",
|
||||
"--summary",
|
||||
"Meeting",
|
||||
"--start",
|
||||
"2024-01-01T10:00:00Z",
|
||||
"--end",
|
||||
"2024-01-01T11:00:00Z",
|
||||
"--location",
|
||||
"Room 1",
|
||||
"--description",
|
||||
"Discuss stuff",
|
||||
"--attendee",
|
||||
"a@b.com",
|
||||
"--attendee",
|
||||
"c@d.com",
|
||||
]);
|
||||
let (_, body, _) = build_insert_request(&matches, &doc).unwrap();
|
||||
|
||||
assert!(body.contains("Room 1"));
|
||||
assert!(body.contains("Discuss stuff"));
|
||||
assert!(body.contains("a@b.com"));
|
||||
assert!(body.contains("c@d.com"));
|
||||
}
|
||||
|
||||
/// Verify that agenda day boundaries use a specific timezone, not UTC.
|
||||
#[test]
|
||||
fn agenda_day_boundaries_use_account_timezone() {
|
||||
use chrono::{NaiveTime, TimeZone, Utc};
|
||||
|
||||
// Simulate using a known account timezone (America/Denver = UTC-7 / UTC-6 DST)
|
||||
let tz = chrono_tz::America::Denver;
|
||||
let now_in_tz = Utc::now().with_timezone(&tz);
|
||||
let today_start = now_in_tz
|
||||
.date_naive()
|
||||
.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap());
|
||||
let today_start_tz = tz
|
||||
.from_local_datetime(&today_start)
|
||||
.earliest()
|
||||
.expect("midnight should resolve");
|
||||
|
||||
let today_rfc = today_start_tz.to_rfc3339();
|
||||
let tomorrow_start = today_start_tz + chrono::Duration::days(1);
|
||||
let tomorrow_rfc = tomorrow_start.to_rfc3339();
|
||||
|
||||
// The Denver offset should appear in the RFC3339 string (-07:00 or -06:00 for DST).
|
||||
// Crucially, it should NOT be +00:00 (UTC).
|
||||
assert!(
|
||||
today_rfc.contains("-07:00") || today_rfc.contains("-06:00"),
|
||||
"today boundary should carry Denver offset, got {today_rfc}"
|
||||
);
|
||||
assert!(
|
||||
tomorrow_rfc.contains("-07:00") || tomorrow_rfc.contains("-06:00"),
|
||||
"tomorrow boundary should carry Denver offset, got {tomorrow_rfc}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::Helper;
|
||||
use crate::auth;
|
||||
use crate::error::GwsError;
|
||||
use crate::executor;
|
||||
use clap::{Arg, ArgMatches, Command};
|
||||
use serde_json::json;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
pub struct ChatHelper;
|
||||
|
||||
impl Helper for ChatHelper {
|
||||
fn inject_commands(
|
||||
&self,
|
||||
mut cmd: Command,
|
||||
_doc: &crate::discovery::RestDescription,
|
||||
) -> Command {
|
||||
cmd = cmd.subcommand(
|
||||
Command::new("+send")
|
||||
.about("[Helper] Send a message to a space")
|
||||
.arg(
|
||||
Arg::new("space")
|
||||
.long("space")
|
||||
.help("Space name (e.g. spaces/AAAA...)")
|
||||
.required(true)
|
||||
.value_name("NAME"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("text")
|
||||
.long("text")
|
||||
.help("Message text (plain text)")
|
||||
.required(true)
|
||||
.value_name("TEXT"),
|
||||
)
|
||||
.after_help(
|
||||
"\
|
||||
EXAMPLES:
|
||||
gws chat +send --space spaces/AAAAxxxx --text 'Hello team!'
|
||||
|
||||
TIPS:
|
||||
Use 'gws chat spaces list' to find space names.
|
||||
For cards or threaded replies, use the raw API instead.",
|
||||
),
|
||||
);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn handle<'a>(
|
||||
&'a self,
|
||||
doc: &'a crate::discovery::RestDescription,
|
||||
matches: &'a ArgMatches,
|
||||
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
|
||||
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
|
||||
// We use `Box::pin` to create a pinned future on the heap.
|
||||
// This is necessary because the `Helper` trait returns a generic `Future`,
|
||||
// and async blocks in Rust are anonymous types that need to be erased
|
||||
// (via `dyn Future`) to be returned from a trait method.
|
||||
Box::pin(async move {
|
||||
if let Some(matches) = matches.subcommand_matches("+send") {
|
||||
// Parse arguments into our config struct config
|
||||
let config = parse_send_args(matches)?;
|
||||
// The `?` operator here will propagate any errors from `build_send_request`
|
||||
// immediately, returning `Err(GwsError)` from the async block.
|
||||
let (params_str, body_str, scopes) = build_send_request(&config, 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!("Chat auth failed: {e}"))),
|
||||
};
|
||||
|
||||
// Method: spaces.messages.create
|
||||
let spaces_res = doc.resources.get("spaces").ok_or_else(|| {
|
||||
GwsError::Discovery("Resource 'spaces' not found".to_string())
|
||||
})?;
|
||||
let messages_res = spaces_res.resources.get("messages").ok_or_else(|| {
|
||||
GwsError::Discovery("Resource 'spaces.messages' not found".to_string())
|
||||
})?;
|
||||
let create_method = messages_res.methods.get("create").ok_or_else(|| {
|
||||
GwsError::Discovery("Method 'spaces.messages.create' not found".to_string())
|
||||
})?;
|
||||
|
||||
let pagination = executor::PaginationConfig {
|
||||
page_all: false,
|
||||
page_limit: 10,
|
||||
page_delay_ms: 100,
|
||||
};
|
||||
|
||||
executor::execute_method(
|
||||
doc,
|
||||
create_method,
|
||||
Some(¶ms_str),
|
||||
Some(&body_str),
|
||||
token.as_deref(),
|
||||
auth_method,
|
||||
None,
|
||||
None,
|
||||
matches.get_flag("dry-run"),
|
||||
&pagination,
|
||||
None,
|
||||
&crate::helpers::modelarmor::SanitizeMode::Warn,
|
||||
&crate::formatter::OutputFormat::default(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_send_request(
|
||||
config: &SendConfig,
|
||||
doc: &crate::discovery::RestDescription,
|
||||
) -> Result<(String, String, Vec<String>), GwsError> {
|
||||
let spaces_res = doc
|
||||
.resources
|
||||
.get("spaces")
|
||||
.ok_or_else(|| GwsError::Discovery("Resource 'spaces' not found".to_string()))?;
|
||||
let messages_res = spaces_res
|
||||
.resources
|
||||
.get("messages")
|
||||
.ok_or_else(|| GwsError::Discovery("Resource 'spaces.messages' not found".to_string()))?;
|
||||
let create_method = messages_res.methods.get("create").ok_or_else(|| {
|
||||
GwsError::Discovery("Method 'spaces.messages.create' not found".to_string())
|
||||
})?;
|
||||
|
||||
let params = json!({
|
||||
"parent": config.space
|
||||
});
|
||||
|
||||
let body = json!({
|
||||
"text": config.text
|
||||
});
|
||||
|
||||
let scopes: Vec<String> = create_method.scopes.iter().map(|s| s.to_string()).collect();
|
||||
|
||||
Ok((params.to_string(), body.to_string(), scopes))
|
||||
}
|
||||
|
||||
/// Configuration for sending a chat message.
|
||||
///
|
||||
/// This struct holds the parsed arguments for the `+send` command.
|
||||
/// We use `String` here to own the data, as it will be used to construct
|
||||
/// the JSON body for the API request.
|
||||
pub struct SendConfig {
|
||||
/// The space to send the message to (e.g., "spaces/AAAA...").
|
||||
pub space: String,
|
||||
/// The text content of the message.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Parses the command line arguments into a `SendConfig` struct.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `matches` - The `ArgMatches` from `clap` containing the parsed arguments.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `SendConfig` - The populated configuration struct.
|
||||
pub fn parse_send_args(matches: &ArgMatches) -> Result<SendConfig, GwsError> {
|
||||
let space = matches.get_one::<String>("space").unwrap().clone();
|
||||
crate::validate::validate_resource_name(&space)?;
|
||||
|
||||
Ok(SendConfig {
|
||||
space,
|
||||
text: matches.get_one::<String>("text").unwrap().clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::discovery::{RestDescription, RestMethod, RestResource};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn make_mock_doc() -> RestDescription {
|
||||
let mut methods = HashMap::new();
|
||||
methods.insert(
|
||||
"create".to_string(),
|
||||
RestMethod {
|
||||
scopes: vec!["https://scope".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let mut messages_res = RestResource::default();
|
||||
messages_res.methods = methods;
|
||||
|
||||
let mut spaces_res = RestResource::default();
|
||||
spaces_res
|
||||
.resources
|
||||
.insert("messages".to_string(), messages_res);
|
||||
|
||||
let mut resources = HashMap::new();
|
||||
resources.insert("spaces".to_string(), spaces_res);
|
||||
|
||||
RestDescription {
|
||||
resources,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_matches_send(args: &[&str]) -> ArgMatches {
|
||||
let cmd = Command::new("test")
|
||||
.arg(Arg::new("space").long("space"))
|
||||
.arg(Arg::new("text").long("text"));
|
||||
cmd.try_get_matches_from(args).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_send_request() {
|
||||
let doc = make_mock_doc();
|
||||
let config = SendConfig {
|
||||
space: "spaces/123".to_string(),
|
||||
text: "hello chat".to_string(),
|
||||
};
|
||||
let (params, body, scopes) = build_send_request(&config, &doc).unwrap();
|
||||
|
||||
assert!(params.contains("spaces/123"));
|
||||
assert!(body.contains("hello chat"));
|
||||
assert_eq!(scopes[0], "https://scope");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_send_args() {
|
||||
let matches = make_matches_send(&["test", "--space", "valid-space", "--text", "t"]);
|
||||
let config = parse_send_args(&matches).unwrap();
|
||||
assert_eq!(config.space, "valid-space");
|
||||
assert_eq!(config.text, "t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_send_args_rejects_traversal_in_space() {
|
||||
let matches = make_matches_send(&["test", "--space", "../etc/passwd", "--text", "t"]);
|
||||
let result = parse_send_args(&matches);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"space with path traversal should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_send_args_rejects_query_injection_in_space() {
|
||||
let matches =
|
||||
make_matches_send(&["test", "--space", "spaces/AAA?key=injected", "--text", "t"]);
|
||||
let result = parse_send_args(&matches);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"space with query characters should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_commands() {
|
||||
let helper = ChatHelper;
|
||||
let cmd = Command::new("test");
|
||||
let doc = crate::discovery::RestDescription::default();
|
||||
|
||||
let cmd = helper.inject_commands(cmd, &doc);
|
||||
let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
|
||||
assert!(subcommands.contains(&"+send"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::Helper;
|
||||
use crate::auth;
|
||||
use crate::error::GwsError;
|
||||
use crate::executor;
|
||||
use clap::{Arg, ArgMatches, Command};
|
||||
use serde_json::json;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
pub struct DocsHelper;
|
||||
|
||||
impl Helper for DocsHelper {
|
||||
fn inject_commands(
|
||||
&self,
|
||||
mut cmd: Command,
|
||||
_doc: &crate::discovery::RestDescription,
|
||||
) -> Command {
|
||||
cmd = cmd.subcommand(
|
||||
Command::new("+write")
|
||||
.about("[Helper] Append text to a document")
|
||||
.arg(
|
||||
Arg::new("document")
|
||||
.long("document")
|
||||
.help("Document ID")
|
||||
.required(true)
|
||||
.value_name("ID"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("text")
|
||||
.long("text")
|
||||
.help("Text to append (plain text)")
|
||||
.required(true)
|
||||
.value_name("TEXT"),
|
||||
)
|
||||
.after_help(
|
||||
"\
|
||||
EXAMPLES:
|
||||
gws docs +write --document DOC_ID --text 'Hello, world!'
|
||||
|
||||
TIPS:
|
||||
Text is inserted at the end of the document body.
|
||||
For rich formatting, use the raw batchUpdate API instead.",
|
||||
),
|
||||
);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn handle<'a>(
|
||||
&'a self,
|
||||
doc: &'a crate::discovery::RestDescription,
|
||||
matches: &'a ArgMatches,
|
||||
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
|
||||
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
if let Some(matches) = matches.subcommand_matches("+write") {
|
||||
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}"))),
|
||||
};
|
||||
|
||||
// Method: documents.batchUpdate
|
||||
let documents_res = doc.resources.get("documents").ok_or_else(|| {
|
||||
GwsError::Discovery("Resource 'documents' not found".to_string())
|
||||
})?;
|
||||
let batch_update_method =
|
||||
documents_res.methods.get("batchUpdate").ok_or_else(|| {
|
||||
GwsError::Discovery("Method 'documents.batchUpdate' not found".to_string())
|
||||
})?;
|
||||
|
||||
let pagination = executor::PaginationConfig {
|
||||
page_all: false,
|
||||
page_limit: 10,
|
||||
page_delay_ms: 100,
|
||||
};
|
||||
|
||||
executor::execute_method(
|
||||
doc,
|
||||
batch_update_method,
|
||||
Some(¶ms_str),
|
||||
Some(&body_str),
|
||||
token.as_deref(),
|
||||
auth_method,
|
||||
None,
|
||||
None,
|
||||
matches.get_flag("dry-run"),
|
||||
&pagination,
|
||||
None,
|
||||
&crate::helpers::modelarmor::SanitizeMode::Warn,
|
||||
&crate::formatter::OutputFormat::default(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_write_request(
|
||||
matches: &ArgMatches,
|
||||
doc: &crate::discovery::RestDescription,
|
||||
) -> Result<(String, String, Vec<String>), GwsError> {
|
||||
let document_id = matches.get_one::<String>("document").unwrap();
|
||||
let text = matches.get_one::<String>("text").unwrap();
|
||||
|
||||
let documents_res = doc
|
||||
.resources
|
||||
.get("documents")
|
||||
.ok_or_else(|| GwsError::Discovery("Resource 'documents' not found".to_string()))?;
|
||||
let batch_update_method = documents_res.methods.get("batchUpdate").ok_or_else(|| {
|
||||
GwsError::Discovery("Method 'documents.batchUpdate' not found".to_string())
|
||||
})?;
|
||||
|
||||
let params = json!({
|
||||
"documentId": document_id
|
||||
});
|
||||
|
||||
let body = json!({
|
||||
"requests": [
|
||||
{
|
||||
"insertText": {
|
||||
"text": text,
|
||||
"endOfSegmentLocation": {
|
||||
"segmentId": "" // Empty means body
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let scopes: Vec<String> = batch_update_method
|
||||
.scopes
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
Ok((params.to_string(), body.to_string(), scopes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::discovery::{RestDescription, RestMethod, RestResource};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn make_mock_doc() -> RestDescription {
|
||||
let mut methods = HashMap::new();
|
||||
methods.insert(
|
||||
"batchUpdate".to_string(),
|
||||
RestMethod {
|
||||
scopes: vec!["https://scope".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let mut documents_res = RestResource::default();
|
||||
documents_res.methods = methods;
|
||||
|
||||
let mut resources = HashMap::new();
|
||||
resources.insert("documents".to_string(), documents_res);
|
||||
|
||||
RestDescription {
|
||||
resources,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_matches_write(args: &[&str]) -> ArgMatches {
|
||||
let cmd = Command::new("test")
|
||||
.arg(Arg::new("document").long("document"))
|
||||
.arg(Arg::new("text").long("text"));
|
||||
cmd.try_get_matches_from(args).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_write_request() {
|
||||
let doc = make_mock_doc();
|
||||
let matches = make_matches_write(&["test", "--document", "123", "--text", "hello world"]);
|
||||
let (params, body, scopes) = build_write_request(&matches, &doc).unwrap();
|
||||
|
||||
assert!(params.contains("123"));
|
||||
assert!(body.contains("hello world"));
|
||||
assert!(body.contains("endOfSegmentLocation"));
|
||||
assert_eq!(scopes[0], "https://scope");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::Helper;
|
||||
use crate::auth;
|
||||
use crate::error::GwsError;
|
||||
use crate::executor;
|
||||
use clap::{Arg, ArgMatches, Command};
|
||||
use serde_json::{json, Value};
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
pub struct DriveHelper;
|
||||
|
||||
impl Helper for DriveHelper {
|
||||
fn inject_commands(
|
||||
&self,
|
||||
mut cmd: Command,
|
||||
_doc: &crate::discovery::RestDescription,
|
||||
) -> Command {
|
||||
cmd = cmd.subcommand(
|
||||
Command::new("+upload")
|
||||
.about("[Helper] Upload a file with automatic metadata")
|
||||
.arg(
|
||||
Arg::new("file")
|
||||
.help("Path to file to upload")
|
||||
.required(true)
|
||||
.index(1),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("parent")
|
||||
.long("parent")
|
||||
.help("Parent folder ID")
|
||||
.value_name("ID"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("name")
|
||||
.long("name")
|
||||
.help("Target filename (defaults to source filename)")
|
||||
.value_name("NAME"),
|
||||
)
|
||||
.after_help(
|
||||
"\
|
||||
EXAMPLES:
|
||||
gws drive +upload ./report.pdf
|
||||
gws drive +upload ./report.pdf --parent FOLDER_ID
|
||||
gws drive +upload ./data.csv --name 'Sales Data.csv'
|
||||
|
||||
TIPS:
|
||||
MIME type is detected automatically.
|
||||
Filename is inferred from the local path unless --name is given.",
|
||||
),
|
||||
);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn handle<'a>(
|
||||
&'a self,
|
||||
doc: &'a crate::discovery::RestDescription,
|
||||
matches: &'a ArgMatches,
|
||||
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
|
||||
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
if let Some(matches) = matches.subcommand_matches("+upload") {
|
||||
let file_path = matches.get_one::<String>("file").unwrap();
|
||||
let parent_id = matches.get_one::<String>("parent");
|
||||
let name_arg = matches.get_one::<String>("name");
|
||||
|
||||
// Determine filename
|
||||
let filename = determine_filename(file_path, name_arg.map(|s| s.as_str()))?;
|
||||
|
||||
// Find method: files.create
|
||||
let files_res = doc
|
||||
.resources
|
||||
.get("files")
|
||||
.ok_or_else(|| GwsError::Discovery("Resource 'files' not found".to_string()))?;
|
||||
let create_method = files_res.methods.get("create").ok_or_else(|| {
|
||||
GwsError::Discovery("Method 'files.create' not found".to_string())
|
||||
})?;
|
||||
|
||||
// Build metadata
|
||||
let metadata = build_metadata(&filename, parent_id.map(|s| s.as_str()));
|
||||
|
||||
let body_str = metadata.to_string();
|
||||
|
||||
let scopes: Vec<&str> = create_method.scopes.iter().map(|s| s.as_str()).collect();
|
||||
let (token, auth_method) = match auth::get_token(&scopes).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!("Drive auth failed: {e}"))),
|
||||
};
|
||||
|
||||
executor::execute_method(
|
||||
doc,
|
||||
create_method,
|
||||
None,
|
||||
Some(&body_str),
|
||||
token.as_deref(),
|
||||
auth_method,
|
||||
None,
|
||||
Some(executor::UploadSource::File {
|
||||
path: file_path,
|
||||
content_type: None,
|
||||
}),
|
||||
matches.get_flag("dry-run"),
|
||||
&executor::PaginationConfig::default(),
|
||||
None,
|
||||
&crate::helpers::modelarmor::SanitizeMode::Warn,
|
||||
&crate::formatter::OutputFormat::default(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn determine_filename(file_path: &str, name_arg: Option<&str>) -> Result<String, GwsError> {
|
||||
if let Some(n) = name_arg {
|
||||
Ok(n.to_string())
|
||||
} else {
|
||||
Path::new(file_path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| GwsError::Validation("Invalid file path".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_metadata(filename: &str, parent_id: Option<&str>) -> Value {
|
||||
let mut metadata = json!({
|
||||
"name": filename
|
||||
});
|
||||
|
||||
if let Some(parent) = parent_id {
|
||||
metadata["parents"] = json!([parent]);
|
||||
}
|
||||
|
||||
metadata
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_determine_filename_explicit() {
|
||||
assert_eq!(
|
||||
determine_filename("path/to/file.txt", Some("custom.txt")).unwrap(),
|
||||
"custom.txt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_filename_from_path() {
|
||||
assert_eq!(
|
||||
determine_filename("path/to/file.txt", None).unwrap(),
|
||||
"file.txt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_filename_invalid_path() {
|
||||
assert!(determine_filename("", None).is_err());
|
||||
assert!(determine_filename("/", None).is_err()); // Root has no filename component usually
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_metadata_no_parent() {
|
||||
let meta = build_metadata("file.txt", None);
|
||||
assert_eq!(meta["name"], "file.txt");
|
||||
assert!(meta.get("parents").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_metadata_with_parent() {
|
||||
let meta = build_metadata("file.txt", Some("folder123"));
|
||||
assert_eq!(meta["name"], "file.txt");
|
||||
assert_eq!(meta["parents"][0], "folder123");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::Helper;
|
||||
pub mod renew;
|
||||
pub mod subscribe;
|
||||
|
||||
use renew::handle_renew;
|
||||
use subscribe::handle_subscribe;
|
||||
|
||||
pub(super) use crate::auth;
|
||||
pub(super) use crate::error::GwsError;
|
||||
pub(super) use anyhow::Context;
|
||||
pub(super) use clap::{Arg, ArgAction, ArgMatches, Command};
|
||||
pub(super) use derive_builder::Builder;
|
||||
pub(super) use serde_json::{json, Value};
|
||||
pub(super) use std::future::Future;
|
||||
pub(super) use std::pin::Pin;
|
||||
|
||||
pub struct EventsHelper;
|
||||
pub(super) const PUBSUB_SCOPE: &str = "https://www.googleapis.com/auth/pubsub";
|
||||
pub(super) const WORKSPACE_EVENTS_SCOPE: &str =
|
||||
"https://www.googleapis.com/auth/chat.spaces.readonly";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProjectId(pub String);
|
||||
impl std::fmt::Display for ProjectId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubscriptionName(pub String);
|
||||
impl std::fmt::Display for SubscriptionName {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Helper for EventsHelper {
|
||||
fn inject_commands(
|
||||
&self,
|
||||
mut cmd: Command,
|
||||
_doc: &crate::discovery::RestDescription,
|
||||
) -> Command {
|
||||
cmd = cmd.subcommand(
|
||||
Command::new("+subscribe")
|
||||
.about("[Helper] Subscribe to Workspace events and stream them as NDJSON")
|
||||
.arg(
|
||||
Arg::new("target")
|
||||
.long("target")
|
||||
.help(
|
||||
"Workspace resource URI (e.g., //chat.googleapis.com/spaces/SPACE_ID)",
|
||||
)
|
||||
.value_name("URI"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("event-types")
|
||||
.long("event-types")
|
||||
.help("Comma-separated CloudEvents types to subscribe to")
|
||||
.value_name("TYPES"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("project")
|
||||
.long("project")
|
||||
.help("GCP project ID for Pub/Sub resources")
|
||||
.value_name("PROJECT"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("subscription")
|
||||
.long("subscription")
|
||||
.help("Existing Pub/Sub subscription name (skip setup)")
|
||||
.value_name("NAME"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("max-messages")
|
||||
.long("max-messages")
|
||||
.help("Max messages per pull batch (default: 10)")
|
||||
.value_name("N")
|
||||
.default_value("10"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("poll-interval")
|
||||
.long("poll-interval")
|
||||
.help("Seconds between pulls (default: 5)")
|
||||
.value_name("SECS")
|
||||
.default_value("5"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("once")
|
||||
.long("once")
|
||||
.help("Pull once and exit")
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("cleanup")
|
||||
.long("cleanup")
|
||||
.help("Delete created Pub/Sub resources on exit")
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("no-ack")
|
||||
.long("no-ack")
|
||||
.help("Don't auto-acknowledge messages")
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("output-dir")
|
||||
.long("output-dir")
|
||||
.help("Write each event to a separate JSON file in this directory")
|
||||
.value_name("DIR"),
|
||||
)
|
||||
.after_help("\
|
||||
EXAMPLES:
|
||||
gws events +subscribe --target '//chat.googleapis.com/spaces/SPACE' --event-types 'google.workspace.chat.message.v1.created' --project my-project
|
||||
gws events +subscribe --subscription projects/p/subscriptions/my-sub --once
|
||||
gws events +subscribe ... --cleanup --output-dir ./events
|
||||
|
||||
TIPS:
|
||||
Without --cleanup, Pub/Sub resources persist for reconnection.
|
||||
Press Ctrl-C to stop gracefully."),
|
||||
);
|
||||
|
||||
cmd = cmd.subcommand(
|
||||
Command::new("+renew")
|
||||
.about("[Helper] Renew/reactivate Workspace Events subscriptions")
|
||||
.arg(
|
||||
Arg::new("name")
|
||||
.long("name")
|
||||
.help("Subscription name to reactivate (e.g., subscriptions/SUB_ID)")
|
||||
.value_name("NAME"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("all")
|
||||
.long("all")
|
||||
.help("Renew all subscriptions expiring within --within window")
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("within")
|
||||
.long("within")
|
||||
.help("Time window for --all (e.g., 1h, 30m, 2d)")
|
||||
.value_name("DURATION")
|
||||
.default_value("1h"),
|
||||
)
|
||||
.after_help(
|
||||
"\
|
||||
EXAMPLES:
|
||||
gws events +renew --name subscriptions/SUB_ID
|
||||
gws events +renew --all --within 2d
|
||||
|
||||
TIPS:
|
||||
Subscriptions expire if not renewed periodically.
|
||||
Use --all with a cron job to keep subscriptions alive.",
|
||||
),
|
||||
);
|
||||
|
||||
cmd
|
||||
}
|
||||
|
||||
fn handle<'a>(
|
||||
&'a self,
|
||||
doc: &'a crate::discovery::RestDescription,
|
||||
matches: &'a ArgMatches,
|
||||
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
|
||||
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
if let Some(sub_matches) = matches.subcommand_matches("+subscribe") {
|
||||
handle_subscribe(doc, sub_matches).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if let Some(renew_matches) = matches.subcommand_matches("+renew") {
|
||||
handle_renew(doc, renew_matches).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_inject_commands() {
|
||||
let helper = EventsHelper;
|
||||
let cmd = Command::new("test");
|
||||
let doc = crate::discovery::RestDescription::default();
|
||||
|
||||
let cmd = helper.inject_commands(cmd, &doc);
|
||||
let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
|
||||
assert!(subcommands.contains(&"+subscribe"));
|
||||
assert!(subcommands.contains(&"+renew"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct RenewConfig {
|
||||
pub name: Option<String>,
|
||||
pub all: bool,
|
||||
pub within: String,
|
||||
}
|
||||
|
||||
fn parse_renew_args(matches: &ArgMatches) -> Result<RenewConfig, GwsError> {
|
||||
let name = matches.get_one::<String>("name").cloned();
|
||||
let all = matches.get_flag("all");
|
||||
let within = matches
|
||||
.get_one::<String>("within")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "1h".to_string());
|
||||
|
||||
if name.is_none() && !all {
|
||||
return Err(GwsError::Validation(
|
||||
"Either --name or --all is required for +renew".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(RenewConfig { name, all, within })
|
||||
}
|
||||
|
||||
/// Handles the `+renew` command.
|
||||
pub(super) async fn handle_renew(
|
||||
_doc: &crate::discovery::RestDescription,
|
||||
matches: &ArgMatches,
|
||||
) -> Result<(), GwsError> {
|
||||
let config = parse_renew_args(matches)?;
|
||||
let dry_run = matches.get_flag("dry-run");
|
||||
|
||||
if dry_run {
|
||||
eprintln!("🏃 DRY RUN — no changes will be made\n");
|
||||
|
||||
// Handle dry-run case and exit early
|
||||
let result = if let Some(name) = config.name {
|
||||
let name = crate::validate::validate_resource_name(&name)?;
|
||||
eprintln!("Reactivating subscription: {name}");
|
||||
json!({
|
||||
"dry_run": true,
|
||||
"action": "Would reactivate subscription",
|
||||
"name": name,
|
||||
"note": "Run without --dry-run to actually reactivate the subscription"
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"dry_run": true,
|
||||
"action": "Would list and renew subscriptions expiring within",
|
||||
"within": config.within,
|
||||
"note": "Run without --dry-run to actually renew subscriptions"
|
||||
})
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&result).context("Failed to serialize dry-run output")?
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Real run logic
|
||||
let client = crate::client::build_client()?;
|
||||
let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to get token: {e}")))?;
|
||||
|
||||
if let Some(name) = config.name {
|
||||
let name = crate::validate::validate_resource_name(&name)?;
|
||||
eprintln!("Reactivating subscription: {name}");
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"https://workspaceevents.googleapis.com/v1/{name}:reactivate"
|
||||
))
|
||||
.bearer_auth(&ws_token)
|
||||
.header("Content-Type", "application/json")
|
||||
.body("{}")
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reactivate subscription")?;
|
||||
|
||||
let body: Value = resp.json().await.context("Failed to parse response")?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&body).context("Failed to serialize response body")?
|
||||
);
|
||||
} else {
|
||||
let within_secs = parse_duration(&config.within)?;
|
||||
let resp = client
|
||||
.get("https://workspaceevents.googleapis.com/v1/subscriptions")
|
||||
.bearer_auth(&ws_token)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to list subscriptions")?;
|
||||
|
||||
let body: Value = resp.json().await.context("Failed to parse response")?;
|
||||
|
||||
let mut renewed = 0;
|
||||
if let Some(subs) = body.get("subscriptions").and_then(|s| s.as_array()) {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let to_renew = filter_subscriptions_to_renew(subs, now, within_secs);
|
||||
|
||||
for name in to_renew {
|
||||
let name = crate::validate::validate_resource_name(&name)?;
|
||||
eprintln!("Renewing {name}...");
|
||||
let _ = client
|
||||
.post(format!(
|
||||
"https://workspaceevents.googleapis.com/v1/{name}:reactivate"
|
||||
))
|
||||
.bearer_auth(&ws_token)
|
||||
.header("Content-Type", "application/json")
|
||||
.body("{}")
|
||||
.send()
|
||||
.await;
|
||||
renewed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let result = json!({
|
||||
"status": "success",
|
||||
"renewed": renewed,
|
||||
});
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&result).context("Failed to serialize result")?
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn filter_subscriptions_to_renew(subs: &[Value], now_secs: u64, within_secs: u64) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
for sub in subs {
|
||||
if let Some(expire_time) = sub.get("expireTime").and_then(|e| e.as_str()) {
|
||||
if let Some(expire_secs) = parse_rfc3339_rough(expire_time) {
|
||||
let remaining = expire_secs.saturating_sub(now_secs);
|
||||
if remaining < within_secs {
|
||||
if let Some(name) = sub.get("name").and_then(|n| n.as_str()) {
|
||||
result.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Parses a duration string like "1h", "30m", "2d" into seconds.
|
||||
fn parse_duration(s: &str) -> Result<u64, GwsError> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return Err(GwsError::Validation("Empty duration".to_string()));
|
||||
}
|
||||
|
||||
let (num_str, unit) = s.split_at(s.len() - 1);
|
||||
let num: u64 = num_str
|
||||
.parse()
|
||||
.map_err(|_| GwsError::Validation(format!("Invalid duration: {s}")))?;
|
||||
|
||||
match unit {
|
||||
"s" => Ok(num),
|
||||
"m" => Ok(num * 60),
|
||||
"h" => Ok(num * 3600),
|
||||
"d" => Ok(num * 86400),
|
||||
_ => Err(GwsError::Validation(format!(
|
||||
"Unknown duration unit '{unit}'. Use s, m, h, or d."
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an RFC 3339 timestamp to Unix seconds.
|
||||
fn parse_rfc3339_rough(s: &str) -> Option<u64> {
|
||||
chrono::DateTime::parse_from_rfc3339(s)
|
||||
.ok()
|
||||
.map(|dt| dt.timestamp() as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_matches_renew(args: &[&str]) -> ArgMatches {
|
||||
let cmd = Command::new("test")
|
||||
.arg(Arg::new("name").long("name"))
|
||||
.arg(Arg::new("all").long("all").action(ArgAction::SetTrue))
|
||||
.arg(Arg::new("within").long("within").default_value("1h"));
|
||||
cmd.try_get_matches_from(args).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_hours() {
|
||||
assert_eq!(parse_duration("1h").unwrap(), 3600);
|
||||
assert_eq!(parse_duration("2h").unwrap(), 7200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_minutes() {
|
||||
assert_eq!(parse_duration("30m").unwrap(), 1800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_days() {
|
||||
assert_eq!(parse_duration("1d").unwrap(), 86400);
|
||||
assert_eq!(parse_duration("7d").unwrap(), 604800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_invalid() {
|
||||
assert!(parse_duration("").is_err());
|
||||
assert!(parse_duration("abc").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_rfc3339_rough() {
|
||||
// 2026-02-13T10:00:00Z
|
||||
let ts = parse_rfc3339_rough("2026-02-13T10:00:00Z").unwrap();
|
||||
assert!(ts > 0);
|
||||
|
||||
// Check simple calculation logic (not verifying exact epoch seconds against a full library)
|
||||
// just consistency
|
||||
let ts2 = parse_rfc3339_rough("2026-02-13T10:00:01Z").unwrap();
|
||||
assert_eq!(ts2, ts + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_renew_args_name() {
|
||||
let matches = make_matches_renew(&["test", "--name", "subs/123"]);
|
||||
let config = parse_renew_args(&matches).unwrap();
|
||||
assert_eq!(config.name, Some("subs/123".to_string()));
|
||||
assert!(!config.all);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_renew_args_all() {
|
||||
let matches = make_matches_renew(&["test", "--all", "--within", "2h"]);
|
||||
let config = parse_renew_args(&matches).unwrap();
|
||||
assert!(config.name.is_none());
|
||||
assert!(config.all);
|
||||
assert_eq!(config.within, "2h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_renew_args_missing() {
|
||||
let matches = make_matches_renew(&["test"]);
|
||||
assert!(parse_renew_args(&matches).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_subscriptions_to_renew() {
|
||||
// Let's use `parse_rfc3339_rough` to get a baseline
|
||||
let base_ts = parse_rfc3339_rough("2026-02-13T10:00:00Z").unwrap();
|
||||
|
||||
// sub1 expires in 30m (1800s from base)
|
||||
let sub1 = json!({
|
||||
"name": "subs/1",
|
||||
"expireTime": "2026-02-13T10:30:00Z"
|
||||
});
|
||||
|
||||
// sub2 expires in 2h (7200s from base)
|
||||
let sub2 = json!({
|
||||
"name": "subs/2",
|
||||
"expireTime": "2026-02-13T12:00:00Z"
|
||||
});
|
||||
|
||||
let subs = vec![sub1, sub2];
|
||||
|
||||
// within 1h (3600s) -> should catch sub1 (1800s < 3600s) but not sub2 (7200s > 3600s)
|
||||
let to_renew = filter_subscriptions_to_renew(&subs, base_ts, 3600);
|
||||
|
||||
assert_eq!(to_renew.len(), 1);
|
||||
assert_eq!(to_renew[0], "subs/1");
|
||||
|
||||
// within 3h (10800s) -> should catch both
|
||||
let to_renew_all = filter_subscriptions_to_renew(&subs, base_ts, 10800);
|
||||
assert_eq!(to_renew_all.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,945 @@
|
||||
use super::*;
|
||||
use crate::auth::AccessTokenProvider;
|
||||
use crate::helpers::PUBSUB_API_BASE;
|
||||
use crate::output::sanitize_for_terminal;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Default, Builder)]
|
||||
#[builder(setter(into))]
|
||||
pub struct SubscribeConfig {
|
||||
#[builder(default)]
|
||||
target: Option<String>,
|
||||
#[builder(default)]
|
||||
event_types: Vec<String>,
|
||||
#[builder(default)]
|
||||
project: Option<ProjectId>,
|
||||
#[builder(default)]
|
||||
subscription: Option<SubscriptionName>,
|
||||
#[builder(default = "10")]
|
||||
max_messages: u32,
|
||||
#[builder(default = "2")]
|
||||
poll_interval: u64,
|
||||
#[builder(default)]
|
||||
once: bool,
|
||||
#[builder(default)]
|
||||
cleanup: bool,
|
||||
#[builder(default)]
|
||||
no_ack: bool,
|
||||
#[builder(default)]
|
||||
output_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn parse_subscribe_args(matches: &ArgMatches) -> Result<SubscribeConfig, GwsError> {
|
||||
let mut builder = SubscribeConfigBuilder::default();
|
||||
|
||||
if let Some(target) = matches.get_one::<String>("target") {
|
||||
builder.target(Some(target.clone()));
|
||||
}
|
||||
if let Some(event_types) = matches.get_one::<String>("event-types") {
|
||||
builder.event_types(
|
||||
event_types
|
||||
.split(',')
|
||||
.map(|t| t.trim().to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
if let Some(project) = matches
|
||||
.get_one::<String>("project")
|
||||
.cloned()
|
||||
.or_else(|| std::env::var("GOOGLE_WORKSPACE_PROJECT_ID").ok())
|
||||
{
|
||||
builder.project(Some(ProjectId(project)));
|
||||
}
|
||||
if let Some(subscription) = matches.get_one::<String>("subscription") {
|
||||
crate::validate::validate_resource_name(subscription)?;
|
||||
builder.subscription(Some(SubscriptionName(subscription.clone())));
|
||||
}
|
||||
if let Some(max_messages) = matches
|
||||
.get_one::<String>("max-messages")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
{
|
||||
builder.max_messages(max_messages);
|
||||
}
|
||||
if let Some(poll_interval) = matches
|
||||
.get_one::<String>("poll-interval")
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
{
|
||||
builder.poll_interval(poll_interval);
|
||||
}
|
||||
builder.once(matches.get_flag("once"));
|
||||
builder.cleanup(matches.get_flag("cleanup"));
|
||||
builder.no_ack(matches.get_flag("no-ack"));
|
||||
if let Some(output_dir) = matches.get_one::<String>("output-dir") {
|
||||
builder.output_dir(Some(crate::validate::validate_safe_output_dir(output_dir)?));
|
||||
}
|
||||
|
||||
let config = builder
|
||||
.build()
|
||||
.map_err(|e| GwsError::Validation(e.to_string()))?;
|
||||
validate_subscribe_config(&config)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn validate_subscribe_config(config: &SubscribeConfig) -> Result<(), GwsError> {
|
||||
if config.subscription.is_none() {
|
||||
if config.target.is_none() {
|
||||
return Err(GwsError::Validation(
|
||||
"--target is required when not using --subscription".to_string(),
|
||||
));
|
||||
}
|
||||
if config.event_types.is_empty() {
|
||||
return Err(GwsError::Validation(
|
||||
"--event-types is required when not using --subscription".to_string(),
|
||||
));
|
||||
}
|
||||
if config.project.is_none() {
|
||||
return Err(GwsError::Validation(
|
||||
"--project is required when not using --subscription (or set GOOGLE_WORKSPACE_PROJECT_ID)".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handles the `+subscribe` command.
|
||||
pub(super) async fn handle_subscribe(
|
||||
_doc: &crate::discovery::RestDescription,
|
||||
matches: &ArgMatches,
|
||||
) -> Result<(), GwsError> {
|
||||
let config = parse_subscribe_args(matches)?;
|
||||
let dry_run = matches.get_flag("dry-run");
|
||||
|
||||
if dry_run {
|
||||
eprintln!("🏃 DRY RUN — no changes will be made\n");
|
||||
}
|
||||
|
||||
if let Some(ref dir) = config.output_dir {
|
||||
if !dry_run {
|
||||
std::fs::create_dir_all(dir).context("Failed to create output dir")?;
|
||||
}
|
||||
}
|
||||
|
||||
let client = crate::client::build_client()?;
|
||||
let pubsub_token_provider = auth::token_provider(&[PUBSUB_SCOPE]);
|
||||
|
||||
let (pubsub_subscription, topic_name, ws_subscription_name, created_resources) =
|
||||
if let Some(ref sub_name) = config.subscription {
|
||||
// Use existing subscription — no setup needed
|
||||
// (don't fetch Pub/Sub token since we won't need it for existing subscriptions)
|
||||
if dry_run {
|
||||
eprintln!("Would listen to existing subscription: {}", sub_name.0);
|
||||
let result = json!({
|
||||
"dry_run": true,
|
||||
"action": "Would listen to existing subscription",
|
||||
"subscription": sub_name.0,
|
||||
"note": "Run without --dry-run to actually start listening"
|
||||
});
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&result)
|
||||
.context("Failed to serialize dry-run output")?
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
(sub_name.0.clone(), None, None, false)
|
||||
} else {
|
||||
// Get Pub/Sub token only when creating new subscription
|
||||
let pubsub_token = if dry_run {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
auth::get_token(&[PUBSUB_SCOPE])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to get Pub/Sub token: {e}")))?,
|
||||
)
|
||||
};
|
||||
|
||||
// Full setup: create Pub/Sub topic + subscription + Workspace Events subscription
|
||||
// Validate target before use in both dry-run and actual execution paths
|
||||
let target = crate::validate::validate_resource_name(&config.target.clone().unwrap())?
|
||||
.to_string();
|
||||
let project =
|
||||
crate::validate::validate_resource_name(&config.project.clone().unwrap().0)?
|
||||
.to_string();
|
||||
let event_types_str: Vec<&str> =
|
||||
config.event_types.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
// Generate descriptive names from event types
|
||||
// e.g. "google.workspace.drive.file.v1.updated" -> "drive-file-updated"
|
||||
let slug = derive_slug_from_event_types(&event_types_str);
|
||||
let suffix = format!("{:08x}", rand::random::<u32>());
|
||||
let topic = format!("projects/{project}/topics/gws-{slug}-{suffix}");
|
||||
let sub = format!("projects/{project}/subscriptions/gws-{slug}-{suffix}");
|
||||
|
||||
// Dry-run: print what would be created and exit
|
||||
if dry_run {
|
||||
eprintln!("Would create Pub/Sub topic: {topic}");
|
||||
eprintln!("Would create Pub/Sub subscription: {sub}");
|
||||
eprintln!("Would create Workspace Events subscription for target: {target}");
|
||||
eprintln!(
|
||||
"Would listen for event types: {}",
|
||||
config.event_types.join(", ")
|
||||
);
|
||||
|
||||
let result = json!({
|
||||
"dry_run": true,
|
||||
"action": "Would create Workspace Events subscription",
|
||||
"pubsub_topic": topic,
|
||||
"pubsub_subscription": sub,
|
||||
"target": target,
|
||||
"event_types": config.event_types,
|
||||
"note": "Run without --dry-run to actually create subscription"
|
||||
});
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&result)
|
||||
.context("Failed to serialize dry-run output")?
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 1. Create Pub/Sub topic
|
||||
eprintln!("Creating Pub/Sub topic: {topic}");
|
||||
let token = pubsub_token.as_ref().ok_or_else(|| {
|
||||
GwsError::Auth(
|
||||
"Token unavailable in non-dry-run mode. This indicates a bug.".to_string(),
|
||||
)
|
||||
})?;
|
||||
let resp = client
|
||||
.put(format!("{PUBSUB_API_BASE}/{topic}"))
|
||||
.bearer_auth(token)
|
||||
.header("Content-Type", "application/json")
|
||||
.body("{}")
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create topic")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(GwsError::Api {
|
||||
code: 400,
|
||||
message: format!("Failed to create Pub/Sub topic: {body}"),
|
||||
reason: "pubsubError".to_string(),
|
||||
enable_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Create Pub/Sub subscription
|
||||
eprintln!("Creating Pub/Sub subscription: {sub}");
|
||||
let sub_body = json!({
|
||||
"topic": topic,
|
||||
"ackDeadlineSeconds": 60,
|
||||
});
|
||||
let resp = client
|
||||
.put(format!("{PUBSUB_API_BASE}/{sub}"))
|
||||
.bearer_auth(token)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&sub_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create subscription")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(GwsError::Api {
|
||||
code: 400,
|
||||
message: format!("Failed to create Pub/Sub subscription: {body}"),
|
||||
reason: "pubsubError".to_string(),
|
||||
enable_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Create Workspace Events subscription
|
||||
eprintln!("Creating Workspace Events subscription...");
|
||||
let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE])
|
||||
.await
|
||||
.map_err(|e| {
|
||||
GwsError::Auth(format!("Failed to get Workspace Events token: {e}"))
|
||||
})?;
|
||||
|
||||
let ws_body = json!({
|
||||
"targetResource": target,
|
||||
"eventTypes": config.event_types,
|
||||
"notificationEndpoint": {
|
||||
"pubsubTopic": topic,
|
||||
},
|
||||
"payloadOptions": {
|
||||
"includeResource": true,
|
||||
},
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post("https://workspaceevents.googleapis.com/v1/subscriptions")
|
||||
.bearer_auth(&ws_token)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&ws_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create Workspace Events subscription")?;
|
||||
|
||||
let resp_body: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse subscription response")?;
|
||||
|
||||
let ws_sub_name = resp_body
|
||||
// Direct subscription response
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| s.starts_with("subscriptions/"))
|
||||
.or_else(|| {
|
||||
// LRO response — check response.name
|
||||
resp_body
|
||||
.get("response")
|
||||
.and_then(|r| r.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
})
|
||||
.or_else(|| {
|
||||
// LRO response — check metadata.subscription
|
||||
resp_body
|
||||
.get("metadata")
|
||||
.and_then(|m| m.get("subscription"))
|
||||
.and_then(|v| v.as_str())
|
||||
})
|
||||
.or_else(|| {
|
||||
// Fall back to the operation name itself
|
||||
resp_body.get("name").and_then(|v| v.as_str())
|
||||
})
|
||||
.unwrap_or("pending")
|
||||
.to_string();
|
||||
|
||||
eprintln!("Workspace Events subscription: {ws_sub_name}");
|
||||
eprintln!("Listening for events...\n");
|
||||
|
||||
(sub, Some(topic), Some(ws_sub_name), true)
|
||||
};
|
||||
|
||||
// Pull loop
|
||||
let result = pull_loop(
|
||||
&client,
|
||||
&pubsub_token_provider,
|
||||
&pubsub_subscription,
|
||||
config.clone(),
|
||||
PUBSUB_API_BASE,
|
||||
)
|
||||
.await;
|
||||
|
||||
// On exit, print reconnection info or cleanup
|
||||
if created_resources {
|
||||
if config.cleanup {
|
||||
eprintln!("\nCleaning up Pub/Sub resources...");
|
||||
// Delete Pub/Sub subscription
|
||||
if let Ok(pubsub_token) = pubsub_token_provider.access_token().await {
|
||||
let _ = client
|
||||
.delete(format!("{PUBSUB_API_BASE}/{pubsub_subscription}"))
|
||||
.bearer_auth(&pubsub_token)
|
||||
.send()
|
||||
.await;
|
||||
// Delete Pub/Sub topic
|
||||
if let Some(ref topic) = topic_name {
|
||||
let _ = client
|
||||
.delete(format!("{PUBSUB_API_BASE}/{topic}"))
|
||||
.bearer_auth(&pubsub_token)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
eprintln!("Cleanup complete.");
|
||||
} else {
|
||||
eprintln!("Warning: failed to refresh token for cleanup. Resources may need manual deletion.");
|
||||
}
|
||||
} else {
|
||||
eprintln!("\n--- Reconnection Info ---");
|
||||
eprintln!(
|
||||
"To reconnect later:\n gws events +subscribe --subscription {}",
|
||||
pubsub_subscription
|
||||
);
|
||||
if let Some(ref ws_name) = ws_subscription_name {
|
||||
eprintln!("Workspace Events subscription: {ws_name}");
|
||||
}
|
||||
if let Some(ref topic) = topic_name {
|
||||
eprintln!("Pub/Sub topic: {topic}");
|
||||
}
|
||||
eprintln!("Pub/Sub subscription: {pubsub_subscription}");
|
||||
eprintln!("To clean up manually:");
|
||||
if let Some(ref topic) = topic_name {
|
||||
eprintln!(
|
||||
" gcloud pubsub subscriptions delete {}",
|
||||
pubsub_subscription
|
||||
);
|
||||
eprintln!(" gcloud pubsub topics delete {topic}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Pulls messages from a Pub/Sub subscription in a loop.
|
||||
async fn pull_loop(
|
||||
client: &reqwest::Client,
|
||||
token_provider: &dyn auth::AccessTokenProvider,
|
||||
subscription: &str,
|
||||
config: SubscribeConfig,
|
||||
pubsub_api_base: &str,
|
||||
) -> Result<(), GwsError> {
|
||||
let mut file_counter: u64 = 0;
|
||||
loop {
|
||||
let token = token_provider
|
||||
.access_token()
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to get Pub/Sub token: {e}")))?;
|
||||
let pull_body = json!({
|
||||
"maxMessages": config.max_messages,
|
||||
});
|
||||
|
||||
let pull_future = client
|
||||
.post(format!("{pubsub_api_base}/{subscription}:pull"))
|
||||
.bearer_auth(&token)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&pull_body)
|
||||
.timeout(std::time::Duration::from_secs(config.poll_interval.max(10)))
|
||||
.send();
|
||||
|
||||
let resp = tokio::select! {
|
||||
result = pull_future => {
|
||||
match result {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.is_timeout() => continue,
|
||||
Err(e) => return Err(anyhow::anyhow!("Pub/Sub pull failed: {e}").into()),
|
||||
}
|
||||
}
|
||||
_ = super::super::shutdown_signal() => {
|
||||
eprintln!("\nReceived shutdown signal, stopping...");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(GwsError::Api {
|
||||
code: 400,
|
||||
message: format!("Pub/Sub pull failed: {body}"),
|
||||
reason: "pubsubError".to_string(),
|
||||
enable_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
let pull_response: Value = resp.json().await.context("Failed to parse pull response")?;
|
||||
|
||||
let (ack_ids, events) = process_events_pull_response(&pull_response);
|
||||
|
||||
for event in events {
|
||||
let json_str =
|
||||
serde_json::to_string_pretty(&event).unwrap_or_else(|_| "{}".to_string());
|
||||
if let Some(ref dir) = config.output_dir {
|
||||
file_counter += 1;
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
let path = dir.join(format!("{ts}_{file_counter}.json"));
|
||||
if let Err(e) = std::fs::write(&path, &json_str) {
|
||||
eprintln!(
|
||||
"Warning: failed to write {}: {}",
|
||||
path.display(),
|
||||
sanitize_for_terminal(&e.to_string())
|
||||
);
|
||||
} else {
|
||||
eprintln!("Wrote {}", path.display());
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&event).unwrap_or_else(|_| "{}".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Acknowledge messages
|
||||
if !config.no_ack && !ack_ids.is_empty() {
|
||||
let ack_body = json!({
|
||||
"ackIds": ack_ids,
|
||||
});
|
||||
|
||||
let _ = client
|
||||
.post(format!("{pubsub_api_base}/{subscription}:acknowledge"))
|
||||
.bearer_auth(&token)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&ack_body)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
if config.once {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for SIGINT/SIGTERM between polls
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {},
|
||||
_ = super::super::shutdown_signal() => {
|
||||
eprintln!("\nReceived shutdown signal, stopping...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_events_pull_response(response: &Value) -> (Vec<String>, Vec<Value>) {
|
||||
let mut ack_ids = Vec::new();
|
||||
let mut events = Vec::new();
|
||||
|
||||
if let Some(messages) = response.get("receivedMessages").and_then(|m| m.as_array()) {
|
||||
for msg in messages {
|
||||
if let Some(ack_id) = msg.get("ackId").and_then(|a| a.as_str()) {
|
||||
ack_ids.push(ack_id.to_string());
|
||||
}
|
||||
|
||||
if let Some(pubsub_msg) = msg.get("message") {
|
||||
events.push(decode_cloud_event(pubsub_msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(ack_ids, events)
|
||||
}
|
||||
|
||||
/// Decodes a Pub/Sub message containing a CloudEvent.
|
||||
fn decode_cloud_event(pubsub_msg: &Value) -> Value {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
|
||||
let attributes = pubsub_msg.get("attributes").cloned().unwrap_or(json!({}));
|
||||
|
||||
let event_type = attributes
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
let source = attributes
|
||||
.get("source")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
let time = attributes
|
||||
.get("time")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Decode base64 data
|
||||
let data = pubsub_msg
|
||||
.get("data")
|
||||
.and_then(|d| d.as_str())
|
||||
.and_then(|d| STANDARD.decode(d).ok())
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||||
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
|
||||
.unwrap_or(json!(null));
|
||||
|
||||
json!({
|
||||
"type": event_type,
|
||||
"source": source,
|
||||
"time": time,
|
||||
"attributes": attributes,
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
/// Derives a readable slug from event types for Pub/Sub resource naming.
|
||||
/// e.g. ["google.workspace.drive.file.v1.updated"] -> "drive-file-updated"
|
||||
/// Multiple types are joined: ["...drive.file.v1.updated", "...drive.file.v1.created"] -> "drive-file-updated-created"
|
||||
fn derive_slug_from_event_types(event_types: &[&str]) -> String {
|
||||
let parts: Vec<String> = event_types
|
||||
.iter()
|
||||
.map(|et| {
|
||||
// Strip "google.workspace." prefix and version segment
|
||||
let stripped = et.strip_prefix("google.workspace.").unwrap_or(et);
|
||||
// Split by '.', remove version-like segments (e.g. "v1")
|
||||
let segments: Vec<&str> = stripped
|
||||
.split('.')
|
||||
.filter(|s| {
|
||||
!s.starts_with('v')
|
||||
|| s.len() > 3
|
||||
|| !s[1..].chars().all(|c| c.is_ascii_digit())
|
||||
})
|
||||
.collect();
|
||||
segments.join("-")
|
||||
})
|
||||
.collect();
|
||||
|
||||
let slug = if parts.len() == 1 {
|
||||
parts[0].clone()
|
||||
} else {
|
||||
// Find common prefix across event types, then append distinct suffixes
|
||||
let first_segments: Vec<&str> = parts[0].split('-').collect();
|
||||
let mut common_len = 0;
|
||||
'outer: for i in 0..first_segments.len() {
|
||||
for p in &parts[1..] {
|
||||
let segs: Vec<&str> = p.split('-').collect();
|
||||
if i >= segs.len() || segs[i] != first_segments[i] {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
common_len = i + 1;
|
||||
}
|
||||
let prefix = first_segments[..common_len].join("-");
|
||||
let suffixes: Vec<String> = parts
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let segs: Vec<&str> = p.split('-').collect();
|
||||
segs[common_len..].join("-")
|
||||
})
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
if suffixes.is_empty() {
|
||||
prefix
|
||||
} else {
|
||||
format!("{}-{}", prefix, suffixes.join("-"))
|
||||
}
|
||||
};
|
||||
|
||||
// Truncate to keep Pub/Sub resource names within limits
|
||||
let slug = if slug.len() > 40 { &slug[..40] } else { &slug };
|
||||
slug.trim_end_matches('-').to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::FakeTokenProvider;
|
||||
use base64::Engine as _;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
async fn spawn_subscribe_server() -> (
|
||||
String,
|
||||
Arc<Mutex<Vec<(String, String)>>>,
|
||||
tokio::task::JoinHandle<()>,
|
||||
) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let recorded_requests = Arc::clone(&requests);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
for _ in 0..2 {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0_u8; 8192];
|
||||
let bytes_read = stream.read(&mut buf).await.unwrap();
|
||||
let request = String::from_utf8_lossy(&buf[..bytes_read]);
|
||||
let path = request
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let auth_header = request
|
||||
.lines()
|
||||
.find(|line| line.to_ascii_lowercase().starts_with("authorization:"))
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
recorded_requests
|
||||
.lock()
|
||||
.await
|
||||
.push((path.clone(), auth_header));
|
||||
|
||||
let body = match path.as_str() {
|
||||
"/v1/projects/test/subscriptions/demo:pull" => json!({
|
||||
"receivedMessages": [{
|
||||
"ackId": "ack-1",
|
||||
"message": {
|
||||
"attributes": {
|
||||
"type": "google.workspace.chat.message.v1.created",
|
||||
"source": "//chat/spaces/A"
|
||||
},
|
||||
"data": base64::engine::general_purpose::STANDARD
|
||||
.encode(json!({ "id": "evt-1" }).to_string())
|
||||
}
|
||||
}]
|
||||
})
|
||||
.to_string(),
|
||||
"/v1/projects/test/subscriptions/demo:acknowledge" => json!({}).to_string(),
|
||||
other => panic!("unexpected request path: {other}"),
|
||||
};
|
||||
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
(format!("http://{addr}/v1"), requests, handle)
|
||||
}
|
||||
|
||||
fn make_matches_subscribe(args: &[&str]) -> ArgMatches {
|
||||
let cmd = Command::new("test")
|
||||
.arg(Arg::new("target").long("target"))
|
||||
.arg(Arg::new("event-types").long("event-types"))
|
||||
.arg(Arg::new("project").long("project"))
|
||||
.arg(Arg::new("subscription").long("subscription"))
|
||||
.arg(Arg::new("max-messages").long("max-messages"))
|
||||
.arg(Arg::new("poll-interval").long("poll-interval"))
|
||||
.arg(Arg::new("once").long("once").action(ArgAction::SetTrue))
|
||||
.arg(
|
||||
Arg::new("cleanup")
|
||||
.long("cleanup")
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.arg(Arg::new("no-ack").long("no-ack").action(ArgAction::SetTrue))
|
||||
.arg(Arg::new("output-dir").long("output-dir"));
|
||||
cmd.try_get_matches_from(args).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subscribe_args_invalid_output_dir() {
|
||||
let matches = make_matches_subscribe(&["test", "--output-dir", "../../etc"]);
|
||||
let result = parse_subscribe_args(&matches);
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(msg.contains("outside the current directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subscribe_args() {
|
||||
let matches = make_matches_subscribe(&[
|
||||
"test",
|
||||
"--target",
|
||||
"//chat/spaces/A",
|
||||
"--event-types",
|
||||
"type1,type2",
|
||||
"--project",
|
||||
"my-project",
|
||||
"--max-messages",
|
||||
"20",
|
||||
"--once",
|
||||
]);
|
||||
let config = parse_subscribe_args(&matches).unwrap();
|
||||
|
||||
assert_eq!(config.target, Some("//chat/spaces/A".to_string()));
|
||||
assert_eq!(config.event_types, vec!["type1", "type2"]);
|
||||
assert_eq!(config.project, Some(ProjectId("my-project".to_string())));
|
||||
assert_eq!(config.max_messages, 20);
|
||||
assert!(config.once);
|
||||
assert!(!config.cleanup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subscribe_args_subscription() {
|
||||
let matches = make_matches_subscribe(&["test", "--subscription", "subs/my-sub"]);
|
||||
let config = parse_subscribe_args(&matches).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
config.subscription,
|
||||
Some(SubscriptionName("subs/my-sub".to_string()))
|
||||
);
|
||||
// Others defaults
|
||||
assert_eq!(config.max_messages, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slug_single_event_type() {
|
||||
let types = vec!["google.workspace.drive.file.v1.updated"];
|
||||
assert_eq!(derive_slug_from_event_types(&types), "drive-file-updated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slug_single_event_type_chat() {
|
||||
let types = vec!["google.workspace.chat.message.v1.created"];
|
||||
assert_eq!(derive_slug_from_event_types(&types), "chat-message-created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slug_multiple_event_types_common_prefix() {
|
||||
let types = vec![
|
||||
"google.workspace.drive.file.v1.updated",
|
||||
"google.workspace.drive.file.v1.created",
|
||||
];
|
||||
let slug = derive_slug_from_event_types(&types);
|
||||
assert_eq!(slug, "drive-file-updated-created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slug_non_workspace_prefix() {
|
||||
let types = vec!["custom.event.type"];
|
||||
let slug = derive_slug_from_event_types(&types);
|
||||
assert_eq!(slug, "custom-event-type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slug_truncation() {
|
||||
// Very long event type should be truncated to 40 chars
|
||||
let types = vec!["google.workspace.very.long.service.name.with.many.segments.v1.updated"];
|
||||
let slug = derive_slug_from_event_types(&types);
|
||||
assert!(slug.len() <= 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_cloud_event() {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
|
||||
let data = json!({"foo": "bar"}).to_string();
|
||||
let encoded = STANDARD.encode(data);
|
||||
|
||||
let msg = json!({
|
||||
"attributes": {
|
||||
"type": "google.workspace.chat.message.v1.created",
|
||||
"source": "//chat.googleapis.com/spaces/AAA",
|
||||
"time": "2026-02-13T10:00:00Z"
|
||||
},
|
||||
"data": encoded
|
||||
});
|
||||
|
||||
let event = decode_cloud_event(&msg);
|
||||
|
||||
assert_eq!(event["type"], "google.workspace.chat.message.v1.created");
|
||||
assert_eq!(event["source"], "//chat.googleapis.com/spaces/AAA");
|
||||
assert_eq!(event["data"]["foo"], "bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_events_pull_response() {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
|
||||
// Mock a Pub/Sub response with two messages
|
||||
let data1 = json!({"id": "1", "content": "hello"}).to_string();
|
||||
let encoded1 = STANDARD.encode(data1);
|
||||
|
||||
let data2 = json!({"id": "2", "content": "world"}).to_string();
|
||||
let encoded2 = STANDARD.encode(data2);
|
||||
|
||||
let response = json!({
|
||||
"receivedMessages": [
|
||||
{
|
||||
"ackId": "ack1",
|
||||
"message": {
|
||||
"attributes": {
|
||||
"type": "google.workspace.chat.message.v1.created",
|
||||
"source": "//chat/spaces/A"
|
||||
},
|
||||
"data": encoded1,
|
||||
"messageId": "msg1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ackId": "ack2",
|
||||
"message": {
|
||||
"attributes": {
|
||||
"type": "google.workspace.drive.file.v1.updated",
|
||||
"source": "//drive/files/B"
|
||||
},
|
||||
"data": encoded2,
|
||||
"messageId": "msg2"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let (ack_ids, events) = process_events_pull_response(&response);
|
||||
|
||||
assert_eq!(ack_ids.len(), 2);
|
||||
assert_eq!(ack_ids[0], "ack1");
|
||||
assert_eq!(ack_ids[1], "ack2");
|
||||
|
||||
assert_eq!(events.len(), 2);
|
||||
assert_eq!(
|
||||
events[0]["type"],
|
||||
"google.workspace.chat.message.v1.created"
|
||||
);
|
||||
assert_eq!(events[0]["data"]["id"], "1");
|
||||
|
||||
assert_eq!(events[1]["type"], "google.workspace.drive.file.v1.updated");
|
||||
assert_eq!(events[1]["data"]["id"], "2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_events_pull_response_empty() {
|
||||
let response = json!({});
|
||||
let (ack_ids, events) = process_events_pull_response(&response);
|
||||
assert!(ack_ids.is_empty());
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_subscribe_validation_missing_target() {
|
||||
let config = SubscribeConfigBuilder::default()
|
||||
.event_types(vec!["type1".to_string()])
|
||||
.project(Some(ProjectId("p1".to_string())))
|
||||
.build()
|
||||
.unwrap();
|
||||
let result = validate_subscribe_config(&config);
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(err_msg.contains("--target is required"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_subscribe_validation_missing_events() {
|
||||
let config = SubscribeConfigBuilder::default()
|
||||
.target(Some("target1".to_string()))
|
||||
.project(Some(ProjectId("p1".to_string())))
|
||||
.build()
|
||||
.unwrap();
|
||||
let result = validate_subscribe_config(&config);
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(err_msg.contains("--event-types is required"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_subscribe_validation_missing_project() {
|
||||
let config = SubscribeConfigBuilder::default()
|
||||
.target(Some("target1".to_string()))
|
||||
.event_types(vec!["type1".to_string()])
|
||||
.build()
|
||||
.unwrap();
|
||||
let result = validate_subscribe_config(&config);
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(err_msg.contains("--project is required"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pull_loop_refreshes_pubsub_token_between_requests() {
|
||||
let client = reqwest::Client::new();
|
||||
let token_provider = FakeTokenProvider::new(["pubsub-token"]);
|
||||
let (pubsub_base, requests, server) = spawn_subscribe_server().await;
|
||||
let config = SubscribeConfigBuilder::default()
|
||||
.subscription(Some(SubscriptionName(
|
||||
"projects/test/subscriptions/demo".to_string(),
|
||||
)))
|
||||
.max_messages(1_u32)
|
||||
.poll_interval(1_u64)
|
||||
.once(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
pull_loop(
|
||||
&client,
|
||||
&token_provider,
|
||||
"projects/test/subscriptions/demo",
|
||||
config,
|
||||
&pubsub_base,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
server.await.unwrap();
|
||||
|
||||
let requests = requests.lock().await;
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[0].0, "/v1/projects/test/subscriptions/demo:pull");
|
||||
assert_eq!(requests[0].1, "authorization: Bearer pubsub-token");
|
||||
assert_eq!(
|
||||
requests[1].0,
|
||||
"/v1/projects/test/subscriptions/demo:acknowledge"
|
||||
);
|
||||
assert_eq!(requests[1].1, "authorization: Bearer pubsub-token");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use std::io::{self, Write};
|
||||
|
||||
/// Handle the `+read` subcommand.
|
||||
pub(super) async fn handle_read(
|
||||
_doc: &crate::discovery::RestDescription,
|
||||
matches: &ArgMatches,
|
||||
) -> Result<(), GwsError> {
|
||||
let message_id = matches.get_one::<String>("id").unwrap();
|
||||
|
||||
let dry_run = matches.get_flag("dry-run");
|
||||
|
||||
let original = if dry_run {
|
||||
OriginalMessage::dry_run_placeholder(message_id)
|
||||
} else {
|
||||
let t = auth::get_token(&[GMAIL_READONLY_SCOPE])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
|
||||
|
||||
let client = crate::client::build_client()?;
|
||||
fetch_message_metadata(&client, &t, message_id).await?
|
||||
};
|
||||
|
||||
let format = matches.get_one::<String>("format").unwrap();
|
||||
let show_headers = matches.get_flag("headers");
|
||||
let use_html = matches.get_flag("html");
|
||||
|
||||
let mut stdout = io::stdout().lock();
|
||||
|
||||
if format == "json" {
|
||||
let json_output = serde_json::to_string_pretty(&original)
|
||||
.context("Failed to serialize message to JSON")?;
|
||||
writeln!(stdout, "{}", json_output).context("Failed to write JSON output")?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if show_headers {
|
||||
// Format structured fields into display strings for header output.
|
||||
let from_str = original.from.to_string();
|
||||
let to_str = format_mailbox_list(&original.to);
|
||||
let cc_str = original
|
||||
.cc
|
||||
.as_ref()
|
||||
.map(|cc| format_mailbox_list(cc))
|
||||
.unwrap_or_default();
|
||||
|
||||
let headers_to_show: [(&str, &str); 5] = [
|
||||
("From", &from_str),
|
||||
("To", &to_str),
|
||||
("Cc", &cc_str),
|
||||
("Subject", &original.subject),
|
||||
("Date", original.date.as_deref().unwrap_or_default()),
|
||||
];
|
||||
for (name, value) in headers_to_show {
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Replace newlines to prevent header spoofing in the output, then sanitize.
|
||||
let sanitized_value = sanitize_for_terminal(&value.replace(['\r', '\n'], " "));
|
||||
writeln!(stdout, "{}: {}", name, sanitized_value)
|
||||
.with_context(|| format!("Failed to write '{name}' header"))?;
|
||||
}
|
||||
writeln!(stdout, "---").context("Failed to write header separator")?;
|
||||
}
|
||||
|
||||
let body = if use_html {
|
||||
original
|
||||
.body_html
|
||||
.as_deref()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or(&original.body_text)
|
||||
} else {
|
||||
&original.body_text
|
||||
};
|
||||
|
||||
writeln!(stdout, "{}", sanitize_for_terminal(body)).context("Failed to write message body")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Format a slice of Mailbox as a displayable comma-separated string.
|
||||
fn format_mailbox_list(mailboxes: &[Mailbox]) -> String {
|
||||
mailboxes
|
||||
.iter()
|
||||
.map(|m| m.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
use crate::output::sanitize_for_terminal;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_for_terminal() {
|
||||
let malicious = "Subject: \x1b]0;MALICIOUS\x07Hello\nWorld\r\t";
|
||||
let sanitized = sanitize_for_terminal(malicious);
|
||||
// ANSI escape sequences (control chars) should be removed
|
||||
assert!(!sanitized.contains('\x1b'));
|
||||
assert!(!sanitized.contains('\x07'));
|
||||
// CR is also stripped (can be abused for terminal overwrite attacks)
|
||||
assert!(!sanitized.contains('\r'));
|
||||
// Newline and tab should be preserved
|
||||
assert!(sanitized.contains("Hello"));
|
||||
assert!(sanitized.contains('\n'));
|
||||
assert!(sanitized.contains('\t'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_mailbox_list_empty() {
|
||||
assert_eq!(format_mailbox_list(&[]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_mailbox_list_single() {
|
||||
let mailboxes = Mailbox::parse_list("alice@example.com");
|
||||
let result = format_mailbox_list(&mailboxes);
|
||||
assert!(result.contains("alice@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_mailbox_list_multiple() {
|
||||
let mailboxes = Mailbox::parse_list("alice@example.com, Bob <bob@example.com>");
|
||||
let result = format_mailbox_list(&mailboxes);
|
||||
assert!(result.contains("alice@example.com"));
|
||||
assert!(result.contains("bob@example.com"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,461 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Handle the `+send` subcommand.
|
||||
pub(super) async fn handle_send(
|
||||
doc: &crate::discovery::RestDescription,
|
||||
matches: &ArgMatches,
|
||||
) -> Result<(), GwsError> {
|
||||
let mut config = parse_send_args(matches)?;
|
||||
let dry_run = matches.get_flag("dry-run");
|
||||
|
||||
let token = if dry_run {
|
||||
None
|
||||
} else {
|
||||
// Resolve the target method (send or draft) and use its discovery
|
||||
// doc scopes, so the token matches the operation. resolve_sender
|
||||
// gracefully degrades if the token doesn't cover the sendAs.list
|
||||
// endpoint.
|
||||
let method = super::resolve_mail_method(doc, matches.get_flag("draft"))?;
|
||||
let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect();
|
||||
let t = auth::get_token(&scopes)
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
|
||||
let client = crate::client::build_client()?;
|
||||
config.from = resolve_sender(&client, &t, config.from.as_deref()).await?;
|
||||
Some(t)
|
||||
};
|
||||
|
||||
let raw = create_send_raw_message(&config)?;
|
||||
|
||||
super::dispatch_raw_email(doc, matches, &raw, None, token.as_deref()).await
|
||||
}
|
||||
|
||||
pub(super) struct SendConfig {
|
||||
pub to: Vec<Mailbox>,
|
||||
pub subject: String,
|
||||
pub body: String,
|
||||
pub from: Option<Vec<Mailbox>>,
|
||||
pub cc: Option<Vec<Mailbox>>,
|
||||
pub bcc: Option<Vec<Mailbox>>,
|
||||
pub html: bool,
|
||||
pub attachments: Vec<Attachment>,
|
||||
}
|
||||
|
||||
fn create_send_raw_message(config: &SendConfig) -> Result<String, GwsError> {
|
||||
let mb = mail_builder::MessageBuilder::new()
|
||||
.to(to_mb_address_list(&config.to))
|
||||
.subject(&config.subject);
|
||||
|
||||
let mb = apply_optional_headers(
|
||||
mb,
|
||||
config.from.as_deref(),
|
||||
config.cc.as_deref(),
|
||||
config.bcc.as_deref(),
|
||||
);
|
||||
|
||||
finalize_message(mb, &config.body, config.html, &config.attachments)
|
||||
}
|
||||
|
||||
fn parse_send_args(matches: &ArgMatches) -> Result<SendConfig, GwsError> {
|
||||
let to = Mailbox::parse_list(matches.get_one::<String>("to").unwrap());
|
||||
if to.is_empty() {
|
||||
return Err(GwsError::Validation(
|
||||
"--to must specify at least one recipient".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(SendConfig {
|
||||
to,
|
||||
subject: matches.get_one::<String>("subject").unwrap().to_string(),
|
||||
body: matches.get_one::<String>("body").unwrap().to_string(),
|
||||
from: parse_optional_mailboxes(matches, "from"),
|
||||
cc: parse_optional_mailboxes(matches, "cc"),
|
||||
bcc: parse_optional_mailboxes(matches, "bcc"),
|
||||
html: matches.get_flag("html"),
|
||||
attachments: parse_attachments(matches)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::tests::{extract_header, strip_qp_soft_breaks};
|
||||
use super::*;
|
||||
|
||||
fn make_matches_send(args: &[&str]) -> ArgMatches {
|
||||
let cmd = Command::new("test")
|
||||
.arg(Arg::new("to").long("to"))
|
||||
.arg(Arg::new("subject").long("subject"))
|
||||
.arg(Arg::new("body").long("body"))
|
||||
.arg(Arg::new("from").long("from"))
|
||||
.arg(Arg::new("cc").long("cc"))
|
||||
.arg(Arg::new("bcc").long("bcc"))
|
||||
.arg(Arg::new("html").long("html").action(ArgAction::SetTrue))
|
||||
.arg(
|
||||
Arg::new("attach")
|
||||
.long("attach")
|
||||
.short('a')
|
||||
.action(ArgAction::Append),
|
||||
)
|
||||
.arg(Arg::new("draft").long("draft").action(ArgAction::SetTrue));
|
||||
cmd.try_get_matches_from(args).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_send_args() {
|
||||
let matches = make_matches_send(&[
|
||||
"test",
|
||||
"--to",
|
||||
"me@example.com",
|
||||
"--subject",
|
||||
"Hi",
|
||||
"--body",
|
||||
"Body",
|
||||
]);
|
||||
let config = parse_send_args(&matches).unwrap();
|
||||
assert_eq!(config.to.len(), 1);
|
||||
assert_eq!(config.to[0].email, "me@example.com");
|
||||
assert_eq!(config.subject, "Hi");
|
||||
assert_eq!(config.body, "Body");
|
||||
assert!(config.from.is_none());
|
||||
assert!(config.cc.is_none());
|
||||
assert!(config.bcc.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_send_args_with_from() {
|
||||
let matches = make_matches_send(&[
|
||||
"test",
|
||||
"--to",
|
||||
"me@example.com",
|
||||
"--subject",
|
||||
"Hi",
|
||||
"--body",
|
||||
"Body",
|
||||
"--from",
|
||||
"alias@example.com",
|
||||
]);
|
||||
let config = parse_send_args(&matches).unwrap();
|
||||
assert_eq!(config.from.as_ref().unwrap()[0].email, "alias@example.com");
|
||||
|
||||
// Whitespace-only --from becomes None
|
||||
let matches = make_matches_send(&[
|
||||
"test",
|
||||
"--to",
|
||||
"me@example.com",
|
||||
"--subject",
|
||||
"Hi",
|
||||
"--body",
|
||||
"Body",
|
||||
"--from",
|
||||
" ",
|
||||
]);
|
||||
let config = parse_send_args(&matches).unwrap();
|
||||
assert!(config.from.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_send_args_with_cc_and_bcc() {
|
||||
let matches = make_matches_send(&[
|
||||
"test",
|
||||
"--to",
|
||||
"me@example.com",
|
||||
"--subject",
|
||||
"Hi",
|
||||
"--body",
|
||||
"Body",
|
||||
"--cc",
|
||||
"carol@example.com",
|
||||
"--bcc",
|
||||
"secret@example.com",
|
||||
]);
|
||||
let config = parse_send_args(&matches).unwrap();
|
||||
assert_eq!(config.cc.as_ref().unwrap()[0].email, "carol@example.com");
|
||||
assert_eq!(config.bcc.as_ref().unwrap()[0].email, "secret@example.com");
|
||||
|
||||
// Whitespace-only values become None
|
||||
let matches = make_matches_send(&[
|
||||
"test",
|
||||
"--to",
|
||||
"me@example.com",
|
||||
"--subject",
|
||||
"Hi",
|
||||
"--body",
|
||||
"Body",
|
||||
"--cc",
|
||||
" ",
|
||||
"--bcc",
|
||||
"",
|
||||
]);
|
||||
let config = parse_send_args(&matches).unwrap();
|
||||
assert!(config.cc.is_none());
|
||||
assert!(config.bcc.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_send_args_html_flag() {
|
||||
let matches = make_matches_send(&[
|
||||
"test",
|
||||
"--to",
|
||||
"me@example.com",
|
||||
"--subject",
|
||||
"Hi",
|
||||
"--body",
|
||||
"<b>Bold</b>",
|
||||
"--html",
|
||||
]);
|
||||
let config = parse_send_args(&matches).unwrap();
|
||||
assert!(config.html);
|
||||
|
||||
// Default is false
|
||||
let matches = make_matches_send(&[
|
||||
"test",
|
||||
"--to",
|
||||
"me@example.com",
|
||||
"--subject",
|
||||
"Hi",
|
||||
"--body",
|
||||
"Plain",
|
||||
]);
|
||||
let config = parse_send_args(&matches).unwrap();
|
||||
assert!(!config.html);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_send_args_empty_to_returns_error() {
|
||||
let matches = make_matches_send(&["test", "--to", "", "--subject", "Hi", "--body", "Body"]);
|
||||
let err = parse_send_args(&matches).err().unwrap();
|
||||
assert!(
|
||||
err.to_string().contains("--to"),
|
||||
"error should mention --to"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_html_raw_message() {
|
||||
let config = SendConfig {
|
||||
to: Mailbox::parse_list("bob@example.com"),
|
||||
subject: "HTML test".to_string(),
|
||||
body: "<p>Hello <b>world</b></p>".to_string(),
|
||||
from: None,
|
||||
cc: None,
|
||||
bcc: None,
|
||||
html: true,
|
||||
attachments: vec![],
|
||||
};
|
||||
let raw = create_send_raw_message(&config).unwrap();
|
||||
let decoded = strip_qp_soft_breaks(&raw);
|
||||
|
||||
assert!(decoded.contains("text/html"));
|
||||
assert!(extract_header(&raw, "To")
|
||||
.unwrap()
|
||||
.contains("bob@example.com"));
|
||||
assert!(extract_header(&raw, "Subject")
|
||||
.unwrap()
|
||||
.contains("HTML test"));
|
||||
assert!(decoded.contains("<p>Hello <b>world</b></p>"));
|
||||
assert!(extract_header(&raw, "Cc").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_plain_text_raw_message() {
|
||||
let config = SendConfig {
|
||||
to: Mailbox::parse_list("bob@example.com"),
|
||||
subject: "Hello".to_string(),
|
||||
body: "World".to_string(),
|
||||
from: None,
|
||||
cc: None,
|
||||
bcc: None,
|
||||
html: false,
|
||||
attachments: vec![],
|
||||
};
|
||||
let raw = create_send_raw_message(&config).unwrap();
|
||||
|
||||
assert!(extract_header(&raw, "To")
|
||||
.unwrap()
|
||||
.contains("bob@example.com"));
|
||||
assert!(extract_header(&raw, "Subject").unwrap().contains("Hello"));
|
||||
assert!(raw.contains("text/plain"));
|
||||
assert!(raw.contains("World"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_with_cc_and_bcc() {
|
||||
let config = SendConfig {
|
||||
to: Mailbox::parse_list("alice@example.com"),
|
||||
subject: "Test".to_string(),
|
||||
body: "Body".to_string(),
|
||||
from: None,
|
||||
cc: Some(Mailbox::parse_list("carol@example.com")),
|
||||
bcc: Some(Mailbox::parse_list("secret@example.com")),
|
||||
html: false,
|
||||
attachments: vec![],
|
||||
};
|
||||
let raw = create_send_raw_message(&config).unwrap();
|
||||
|
||||
assert!(extract_header(&raw, "To")
|
||||
.unwrap()
|
||||
.contains("alice@example.com"));
|
||||
assert!(extract_header(&raw, "Cc")
|
||||
.unwrap()
|
||||
.contains("carol@example.com"));
|
||||
assert!(extract_header(&raw, "Bcc")
|
||||
.unwrap()
|
||||
.contains("secret@example.com"));
|
||||
// Verify no leakage between headers
|
||||
assert!(!extract_header(&raw, "To")
|
||||
.unwrap()
|
||||
.contains("carol@example.com"));
|
||||
assert!(!extract_header(&raw, "To")
|
||||
.unwrap()
|
||||
.contains("secret@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_with_from() {
|
||||
let config = SendConfig {
|
||||
to: Mailbox::parse_list("bob@example.com"),
|
||||
subject: "Test".to_string(),
|
||||
body: "Body".to_string(),
|
||||
from: Some(Mailbox::parse_list("alias@example.com")),
|
||||
cc: None,
|
||||
bcc: None,
|
||||
html: false,
|
||||
attachments: vec![],
|
||||
};
|
||||
let raw = create_send_raw_message(&config).unwrap();
|
||||
|
||||
assert!(extract_header(&raw, "From")
|
||||
.unwrap()
|
||||
.contains("alias@example.com"));
|
||||
assert!(extract_header(&raw, "To")
|
||||
.unwrap()
|
||||
.contains("bob@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_without_from_has_no_from_header() {
|
||||
let config = SendConfig {
|
||||
to: Mailbox::parse_list("bob@example.com"),
|
||||
subject: "Test".to_string(),
|
||||
body: "Body".to_string(),
|
||||
from: None,
|
||||
cc: None,
|
||||
bcc: None,
|
||||
html: false,
|
||||
attachments: vec![],
|
||||
};
|
||||
let raw = create_send_raw_message(&config).unwrap();
|
||||
|
||||
assert!(extract_header(&raw, "From").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_multiple_to_recipients() {
|
||||
let config = SendConfig {
|
||||
to: Mailbox::parse_list("alice@example.com, bob@example.com"),
|
||||
subject: "Group".to_string(),
|
||||
body: "Hi all".to_string(),
|
||||
from: None,
|
||||
cc: None,
|
||||
bcc: None,
|
||||
html: false,
|
||||
attachments: vec![],
|
||||
};
|
||||
let raw = create_send_raw_message(&config).unwrap();
|
||||
let to_header = extract_header(&raw, "To").unwrap();
|
||||
assert!(to_header.contains("alice@example.com"));
|
||||
assert!(to_header.contains("bob@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_crlf_injection_in_from_does_not_create_header() {
|
||||
let config = SendConfig {
|
||||
to: Mailbox::parse_list("alice@example.com"),
|
||||
subject: "Test".to_string(),
|
||||
body: "Body".to_string(),
|
||||
from: Some(Mailbox::parse_list(
|
||||
"sender@example.com\r\nBcc: evil@attacker.com",
|
||||
)),
|
||||
cc: None,
|
||||
bcc: None,
|
||||
html: false,
|
||||
attachments: vec![],
|
||||
};
|
||||
let raw = create_send_raw_message(&config).unwrap();
|
||||
|
||||
// The CRLF injection should not create a Bcc header
|
||||
assert!(
|
||||
extract_header(&raw, "Bcc").is_none(),
|
||||
"CRLF injection via --from should not create Bcc header"
|
||||
);
|
||||
// The From header should contain the sanitized email
|
||||
assert!(extract_header(&raw, "From")
|
||||
.unwrap()
|
||||
.contains("sender@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_crlf_injection_in_cc_does_not_create_header() {
|
||||
let config = SendConfig {
|
||||
to: Mailbox::parse_list("alice@example.com"),
|
||||
subject: "Test".to_string(),
|
||||
body: "Body".to_string(),
|
||||
from: None,
|
||||
cc: Some(Mailbox::parse_list("carol@example.com\r\nX-Injected: yes")),
|
||||
bcc: None,
|
||||
html: false,
|
||||
attachments: vec![],
|
||||
};
|
||||
let raw = create_send_raw_message(&config).unwrap();
|
||||
|
||||
// CRLF stripped → "X-Injected: yes" is concatenated into the email,
|
||||
// not emitted as a separate header line
|
||||
assert!(
|
||||
extract_header(&raw, "X-Injected").is_none(),
|
||||
"CRLF injection via --cc should not create X-Injected header"
|
||||
);
|
||||
assert!(extract_header(&raw, "Cc")
|
||||
.unwrap()
|
||||
.contains("carol@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_with_attachment_produces_multipart() {
|
||||
let config = SendConfig {
|
||||
to: Mailbox::parse_list("alice@example.com"),
|
||||
subject: "Report".to_string(),
|
||||
body: "See attached".to_string(),
|
||||
from: None,
|
||||
cc: None,
|
||||
bcc: None,
|
||||
html: false,
|
||||
attachments: vec![Attachment {
|
||||
filename: "report.pdf".to_string(),
|
||||
content_type: "application/pdf".to_string(),
|
||||
data: b"fake pdf".to_vec(),
|
||||
content_id: None,
|
||||
}],
|
||||
};
|
||||
let raw = create_send_raw_message(&config).unwrap();
|
||||
|
||||
assert!(raw.contains("multipart/mixed"));
|
||||
assert!(raw.contains("report.pdf"));
|
||||
assert!(raw.contains("See attached"));
|
||||
assert!(extract_header(&raw, "To")
|
||||
.unwrap()
|
||||
.contains("alice@example.com"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Gmail `+triage` helper — lists unread messages with sender, subject, date.
|
||||
//!
|
||||
//! Read-only: fetches unread message metadata (From, Subject, Date) and
|
||||
//! optionally includes label IDs. Supports custom Gmail search queries
|
||||
//! via `--query` and configurable result limits via `--max`.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Handle the `+triage` subcommand.
|
||||
pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
let max: u32 = matches
|
||||
.get_one::<String>("max")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(20);
|
||||
let query = matches
|
||||
.get_one::<String>("query")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("is:unread");
|
||||
let show_labels = matches.get_flag("labels");
|
||||
let output_format = matches
|
||||
.get_one::<String>("format")
|
||||
.map(|s| crate::formatter::OutputFormat::from_str(s))
|
||||
.unwrap_or(crate::formatter::OutputFormat::Table);
|
||||
|
||||
// Authenticate — use gmail.readonly instead of gmail.modify because triage
|
||||
// is read-only and the `q` query parameter is not supported under the
|
||||
// gmail.metadata scope. When a token carries both metadata and modify
|
||||
// scopes the API may resolve to the metadata path and reject `q` with 403.
|
||||
// gmail.readonly always supports `q`.
|
||||
let token = auth::get_token(&[GMAIL_READONLY_SCOPE])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
|
||||
|
||||
let client = crate::client::build_client()?;
|
||||
|
||||
// 1. List message IDs
|
||||
let list_url = "https://gmail.googleapis.com/gmail/v1/users/me/messages";
|
||||
|
||||
let list_resp = client
|
||||
.get(list_url)
|
||||
.query(&[("q", query), ("maxResults", &max.to_string())])
|
||||
.bearer_auth(&token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to list messages: {e}")))?;
|
||||
|
||||
if !list_resp.status().is_success() {
|
||||
let err = list_resp.text().await.unwrap_or_default();
|
||||
return Err(GwsError::Api {
|
||||
code: 0,
|
||||
message: err,
|
||||
reason: "list_failed".to_string(),
|
||||
enable_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
let list_json: Value = list_resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse list response: {e}")))?;
|
||||
|
||||
let messages = match list_json.get("messages").and_then(|m| m.as_array()) {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
eprintln!("{}", no_messages_msg(query));
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if messages.is_empty() {
|
||||
eprintln!("{}", no_messages_msg(query));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 2. Fetch metadata for each message concurrently
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
|
||||
// Collect message IDs upfront (owned) to avoid lifetime issues in async closures
|
||||
let msg_ids: Vec<String> = messages
|
||||
.iter()
|
||||
.filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()))
|
||||
.collect();
|
||||
|
||||
let results: Vec<Value> = stream::iter(msg_ids)
|
||||
.map(|msg_id| {
|
||||
let client = &client;
|
||||
let token = &token;
|
||||
async move {
|
||||
let get_url = format!(
|
||||
"https://gmail.googleapis.com/gmail/v1/users/me/messages/{}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date",
|
||||
msg_id
|
||||
);
|
||||
|
||||
let get_resp = crate::client::send_with_retry(|| {
|
||||
client.get(&get_url).bearer_auth(token)
|
||||
})
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
if !get_resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let msg_json: Value = get_resp.json().await.ok()?;
|
||||
|
||||
let headers = msg_json
|
||||
.get("payload")
|
||||
.and_then(|p| p.get("headers"))
|
||||
.and_then(|h| h.as_array());
|
||||
|
||||
let mut from = String::new();
|
||||
let mut subject = String::new();
|
||||
let mut date = String::new();
|
||||
|
||||
if let Some(headers) = headers {
|
||||
for h in headers {
|
||||
let name = h.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let value = h.get("value").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match name {
|
||||
"From" => from = value.to_string(),
|
||||
"Subject" => subject = value.to_string(),
|
||||
"Date" => date = value.to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut entry = json!({
|
||||
"id": msg_id,
|
||||
"from": from,
|
||||
"subject": subject,
|
||||
"date": date,
|
||||
});
|
||||
|
||||
if show_labels {
|
||||
let labels = msg_json
|
||||
.get("labelIds")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Array(vec![]));
|
||||
entry["labels"] = labels;
|
||||
}
|
||||
|
||||
Some(entry)
|
||||
}
|
||||
})
|
||||
.buffer_unordered(10)
|
||||
.filter_map(|r| async { r })
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
// 3. Output
|
||||
let result_count = results.len();
|
||||
let output = json!({
|
||||
"messages": results,
|
||||
"resultSizeEstimate": list_json.get("resultSizeEstimate").cloned().unwrap_or(json!(result_count)),
|
||||
"query": query,
|
||||
});
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
crate::formatter::format_value(&output, &output_format)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the human-readable "no messages" diagnostic string.
|
||||
/// Extracted so the test can reference the exact same message without duplication.
|
||||
fn no_messages_msg(query: &str) -> String {
|
||||
format!(
|
||||
"No messages found matching query: {}",
|
||||
crate::output::sanitize_for_terminal(query)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::no_messages_msg;
|
||||
use clap::{Arg, ArgAction, Command};
|
||||
|
||||
/// Build a clap command matching the +triage definition so we can
|
||||
/// unit-test argument parsing without needing a live GmailHelper.
|
||||
fn triage_cmd() -> Command {
|
||||
Command::new("triage")
|
||||
.arg(
|
||||
Arg::new("max")
|
||||
.long("max")
|
||||
.default_value("20")
|
||||
.value_name("N"),
|
||||
)
|
||||
.arg(Arg::new("query").long("query").value_name("QUERY"))
|
||||
.arg(Arg::new("labels").long("labels").action(ArgAction::SetTrue))
|
||||
.arg(Arg::new("format").long("format").value_name("FMT"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_max_to_20_and_query_to_unread() {
|
||||
let m = triage_cmd().try_get_matches_from(["triage"]).unwrap();
|
||||
let max: u32 = m
|
||||
.get_one::<String>("max")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(20);
|
||||
let query = m
|
||||
.get_one::<String>("query")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("is:unread");
|
||||
assert_eq!(max, 20);
|
||||
assert_eq!(query, "is:unread");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_max_overrides_default() {
|
||||
let m = triage_cmd()
|
||||
.try_get_matches_from(["triage", "--max", "5"])
|
||||
.unwrap();
|
||||
let max: u32 = m
|
||||
.get_one::<String>("max")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(20);
|
||||
assert_eq!(max, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_numeric_max_falls_back_to_20() {
|
||||
let m = triage_cmd()
|
||||
.try_get_matches_from(["triage", "--max", "abc"])
|
||||
.unwrap();
|
||||
let max: u32 = m
|
||||
.get_one::<String>("max")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(20);
|
||||
assert_eq!(max, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_query_overrides_default() {
|
||||
let m = triage_cmd()
|
||||
.try_get_matches_from(["triage", "--query", "from:boss"])
|
||||
.unwrap();
|
||||
let query = m
|
||||
.get_one::<String>("query")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("is:unread");
|
||||
assert_eq!(query, "from:boss");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_flag_defaults_to_false() {
|
||||
let m = triage_cmd().try_get_matches_from(["triage"]).unwrap();
|
||||
assert!(!m.get_flag("labels"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_flag_set_when_passed() {
|
||||
let m = triage_cmd()
|
||||
.try_get_matches_from(["triage", "--labels"])
|
||||
.unwrap();
|
||||
assert!(m.get_flag("labels"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_defaults_to_table_when_absent() {
|
||||
let m = triage_cmd().try_get_matches_from(["triage"]).unwrap();
|
||||
let fmt = m
|
||||
.get_one::<String>("format")
|
||||
.map(|s| crate::formatter::OutputFormat::from_str(s))
|
||||
.unwrap_or(crate::formatter::OutputFormat::Table);
|
||||
assert!(matches!(fmt, crate::formatter::OutputFormat::Table));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_json_when_specified() {
|
||||
let m = triage_cmd()
|
||||
.try_get_matches_from(["triage", "--format", "json"])
|
||||
.unwrap();
|
||||
let fmt = m
|
||||
.get_one::<String>("format")
|
||||
.map(|s| crate::formatter::OutputFormat::from_str(s))
|
||||
.unwrap_or(crate::formatter::OutputFormat::Table);
|
||||
assert!(matches!(fmt, crate::formatter::OutputFormat::Json));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_result_message_is_not_json() {
|
||||
// Verify that no_messages_msg() produces a human-readable string that
|
||||
// belongs on stderr, not stdout. If it were valid JSON it could corrupt
|
||||
// pipe workflows like `gws gmail +triage | jq`.
|
||||
let msg = no_messages_msg("label:inbox");
|
||||
assert!(serde_json::from_str::<serde_json::Value>(&msg).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,993 @@
|
||||
use super::*;
|
||||
use crate::auth::AccessTokenProvider;
|
||||
use crate::helpers::PUBSUB_API_BASE;
|
||||
use crate::output::colorize;
|
||||
use crate::output::sanitize_for_terminal;
|
||||
|
||||
const GMAIL_API_BASE: &str = "https://gmail.googleapis.com/gmail/v1";
|
||||
|
||||
/// Handles the `+watch` command — Gmail push notifications via Pub/Sub.
|
||||
pub(super) async fn handle_watch(
|
||||
matches: &ArgMatches,
|
||||
sanitize_config: &crate::helpers::modelarmor::SanitizeConfig,
|
||||
) -> Result<(), GwsError> {
|
||||
let config = parse_watch_args(matches)?;
|
||||
|
||||
if let Some(ref dir) = config.output_dir {
|
||||
std::fs::create_dir_all(dir).context("Failed to create output dir")?;
|
||||
}
|
||||
|
||||
let client = crate::client::build_client()?;
|
||||
let gmail_token_provider = auth::token_provider(&[GMAIL_SCOPE]);
|
||||
let pubsub_token_provider = auth::token_provider(&[PUBSUB_SCOPE]);
|
||||
|
||||
// Get tokens
|
||||
let gmail_token = auth::get_token(&[GMAIL_SCOPE])
|
||||
.await
|
||||
.context("Failed to get Gmail token")?;
|
||||
let pubsub_token = auth::get_token(&[PUBSUB_SCOPE])
|
||||
.await
|
||||
.context("Failed to get Pub/Sub token")?;
|
||||
|
||||
let (pubsub_subscription, topic_name, created_resources) = if let Some(ref sub_name) =
|
||||
config.subscription
|
||||
{
|
||||
(sub_name.clone(), None, false)
|
||||
} else {
|
||||
let project = config
|
||||
.project.clone()
|
||||
.or_else(|| std::env::var("GOOGLE_WORKSPACE_PROJECT_ID").ok())
|
||||
.ok_or_else(|| {
|
||||
GwsError::Validation(
|
||||
"--project is required when not using --subscription (or set GOOGLE_WORKSPACE_PROJECT_ID)".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let suffix = format!("{:08x}", rand::random::<u32>());
|
||||
let topic = if let Some(ref t) = config.topic {
|
||||
crate::validate::validate_resource_name(t)?.to_string()
|
||||
} else {
|
||||
let project = crate::validate::validate_resource_name(&project)?;
|
||||
let t = format!("projects/{project}/topics/gws-gmail-watch-{suffix}");
|
||||
// Create Pub/Sub topic
|
||||
eprintln!("Creating Pub/Sub topic: {t}");
|
||||
let resp = client
|
||||
.put(format!("{PUBSUB_API_BASE}/{t}"))
|
||||
.bearer_auth(&pubsub_token)
|
||||
.header("Content-Type", "application/json")
|
||||
.body("{}")
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create topic")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(GwsError::Api {
|
||||
code: 400,
|
||||
message: format!("Failed to create Pub/Sub topic: {body}"),
|
||||
reason: "pubsubError".to_string(),
|
||||
enable_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Grant Gmail publish permission on the topic
|
||||
eprintln!("Granting Gmail push permission on topic...");
|
||||
let iam_body = json!({
|
||||
"policy": {
|
||||
"bindings": [{
|
||||
"role": "roles/pubsub.publisher",
|
||||
"members": ["serviceAccount:gmail-api-push@system.gserviceaccount.com"]
|
||||
}]
|
||||
}
|
||||
});
|
||||
let resp = client
|
||||
.post(format!("{PUBSUB_API_BASE}/{t}:setIamPolicy"))
|
||||
.bearer_auth(&pubsub_token)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&iam_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to set topic IAM policy")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
eprintln!("Warning: Could not auto-grant Gmail push permission.");
|
||||
eprintln!("You may need to manually grant publisher access:");
|
||||
eprintln!(
|
||||
" gcloud pubsub topics add-iam-policy-binding {} \\",
|
||||
t.split('/').rfind(|s| !s.is_empty()).unwrap_or(&t)
|
||||
);
|
||||
eprintln!(
|
||||
" --member=serviceAccount:gmail-api-push@system.gserviceaccount.com \\"
|
||||
);
|
||||
eprintln!(" --role=roles/pubsub.publisher");
|
||||
eprintln!("Error: {}", sanitize_for_terminal(&body));
|
||||
}
|
||||
|
||||
t
|
||||
};
|
||||
|
||||
let project = crate::validate::validate_resource_name(&project)?;
|
||||
let sub = format!("projects/{project}/subscriptions/gws-gmail-watch-{suffix}");
|
||||
|
||||
// 3. Create Pub/Sub subscription
|
||||
eprintln!("Creating Pub/Sub subscription: {sub}");
|
||||
let sub_body = json!({
|
||||
"topic": topic,
|
||||
"ackDeadlineSeconds": 60,
|
||||
});
|
||||
let resp = client
|
||||
.put(format!("{PUBSUB_API_BASE}/{sub}"))
|
||||
.bearer_auth(&pubsub_token)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&sub_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create subscription")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(GwsError::Api {
|
||||
code: 400,
|
||||
message: format!("Failed to create Pub/Sub subscription: {body}"),
|
||||
reason: "pubsubError".to_string(),
|
||||
enable_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Call gmail.users.watch
|
||||
eprintln!("Setting up Gmail watch...");
|
||||
let mut watch_body = json!({
|
||||
"topicName": topic,
|
||||
});
|
||||
if let Some(ref label_ids) = config.label_ids {
|
||||
let labels: Vec<&str> = label_ids.split(',').map(|s| s.trim()).collect();
|
||||
watch_body["labelIds"] = json!(labels);
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.post(format!("{GMAIL_API_BASE}/users/me/watch"))
|
||||
.bearer_auth(&gmail_token)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&watch_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to call gmail.users.watch")?;
|
||||
|
||||
let watch_resp: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse watch response")?;
|
||||
|
||||
if let Some(err) = watch_resp.get("error") {
|
||||
return Err(GwsError::Api {
|
||||
code: err.get("code").and_then(|c| c.as_u64()).unwrap_or(400) as u16,
|
||||
message: format!(
|
||||
"gmail.users.watch failed: {}",
|
||||
serde_json::to_string(err).unwrap_or_default()
|
||||
),
|
||||
reason: "gmailError".to_string(),
|
||||
enable_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
let history_id = watch_resp
|
||||
.get("historyId")
|
||||
.and_then(|h| h.as_str())
|
||||
.unwrap_or("0");
|
||||
let expiration = watch_resp
|
||||
.get("expiration")
|
||||
.and_then(|e| e.as_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
eprintln!("Gmail watch active (historyId: {history_id}, expires: {expiration})");
|
||||
eprintln!("Listening for new emails...\n");
|
||||
|
||||
(sub, Some(topic), true)
|
||||
};
|
||||
|
||||
// Get initial historyId for tracking
|
||||
let profile_resp = client
|
||||
.get(format!("{GMAIL_API_BASE}/users/me/profile"))
|
||||
.bearer_auth(&gmail_token)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to get Gmail profile")?;
|
||||
|
||||
let profile: Value = profile_resp.json().await.unwrap_or(json!({}));
|
||||
let mut last_history_id: u64 = profile
|
||||
.get("historyId")
|
||||
.and_then(|h| h.as_str().or_else(|| h.as_u64().map(|_| "")))
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| profile.get("historyId").and_then(|h| h.as_u64()))
|
||||
.unwrap_or(0);
|
||||
|
||||
// Pull loop
|
||||
let runtime = WatchRuntime {
|
||||
client: &client,
|
||||
pubsub_token_provider: &pubsub_token_provider,
|
||||
gmail_token_provider: &gmail_token_provider,
|
||||
sanitize_config,
|
||||
pubsub_api_base: PUBSUB_API_BASE,
|
||||
gmail_api_base: GMAIL_API_BASE,
|
||||
};
|
||||
let result = watch_pull_loop(
|
||||
&runtime,
|
||||
&pubsub_subscription,
|
||||
&mut last_history_id,
|
||||
config.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Cleanup or print reconnection info
|
||||
if created_resources {
|
||||
if config.cleanup {
|
||||
eprintln!("\nCleaning up Pub/Sub resources...");
|
||||
if let Ok(pubsub_token) = pubsub_token_provider.access_token().await {
|
||||
let _ = client
|
||||
.delete(format!("{PUBSUB_API_BASE}/{}", pubsub_subscription))
|
||||
.bearer_auth(&pubsub_token)
|
||||
.send()
|
||||
.await;
|
||||
if let Some(ref topic) = topic_name {
|
||||
let _ = client
|
||||
.delete(format!("{PUBSUB_API_BASE}/{}", topic))
|
||||
.bearer_auth(&pubsub_token)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
eprintln!("Cleanup complete.");
|
||||
} else {
|
||||
eprintln!("Warning: failed to refresh token for cleanup. Resources may need manual deletion.");
|
||||
}
|
||||
} else {
|
||||
eprintln!("\n--- Reconnection Info ---");
|
||||
eprintln!(
|
||||
"To reconnect later:\n gws gmail +watch --subscription {}",
|
||||
pubsub_subscription
|
||||
);
|
||||
if let Some(ref topic) = topic_name {
|
||||
eprintln!("Pub/Sub topic: {}", topic);
|
||||
}
|
||||
eprintln!("Pub/Sub subscription: {}", pubsub_subscription);
|
||||
eprintln!("Note: Gmail watch expires after 7 days. Re-run +watch to renew.");
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Pull loop for Gmail watch — polls Pub/Sub, fetches messages via history API.
|
||||
async fn watch_pull_loop(
|
||||
runtime: &WatchRuntime<'_>,
|
||||
subscription: &str,
|
||||
last_history_id: &mut u64,
|
||||
config: WatchConfig,
|
||||
) -> Result<(), GwsError> {
|
||||
loop {
|
||||
let pubsub_token = runtime
|
||||
.pubsub_token_provider
|
||||
.access_token()
|
||||
.await
|
||||
.context("Failed to get Pub/Sub token")?;
|
||||
let pull_body = json!({ "maxMessages": config.max_messages });
|
||||
let pull_future = runtime
|
||||
.client
|
||||
.post(format!("{}/{subscription}:pull", runtime.pubsub_api_base))
|
||||
.bearer_auth(&pubsub_token)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&pull_body)
|
||||
.timeout(std::time::Duration::from_secs(config.poll_interval.max(10)))
|
||||
.send();
|
||||
|
||||
let resp = tokio::select! {
|
||||
result = pull_future => {
|
||||
match result {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.is_timeout() => continue,
|
||||
Err(e) => return Err(GwsError::Other(anyhow::anyhow!("Pub/Sub pull failed: {e}"))),
|
||||
}
|
||||
}
|
||||
_ = super::super::shutdown_signal() => {
|
||||
eprintln!("\nReceived shutdown signal, stopping...");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(GwsError::Api {
|
||||
code: 400,
|
||||
message: format!("Pub/Sub pull failed: {body}"),
|
||||
reason: "pubsubError".to_string(),
|
||||
enable_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
let pull_response: Value = resp.json().await.context("Failed to parse pull response")?;
|
||||
|
||||
let (ack_ids, max_history_id) = process_pull_response(&pull_response);
|
||||
|
||||
if max_history_id > *last_history_id && *last_history_id > 0 {
|
||||
// Fetch new messages via history API
|
||||
fetch_and_output_messages(
|
||||
runtime.client,
|
||||
runtime.gmail_token_provider,
|
||||
*last_history_id,
|
||||
&config.format,
|
||||
config.output_dir.as_ref(),
|
||||
runtime.sanitize_config,
|
||||
runtime.gmail_api_base,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if max_history_id > *last_history_id {
|
||||
*last_history_id = max_history_id;
|
||||
}
|
||||
|
||||
// Acknowledge messages
|
||||
if !ack_ids.is_empty() {
|
||||
let ack_body = json!({ "ackIds": ack_ids });
|
||||
let _ = runtime
|
||||
.client
|
||||
.post(format!(
|
||||
"{}/{subscription}:acknowledge",
|
||||
runtime.pubsub_api_base
|
||||
))
|
||||
.bearer_auth(&pubsub_token)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&ack_body)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
if config.once {
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {},
|
||||
_ = super::super::shutdown_signal() => {
|
||||
eprintln!("\nReceived shutdown signal, stopping...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_pull_response(response: &Value) -> (Vec<String>, u64) {
|
||||
let mut ack_ids = Vec::new();
|
||||
let mut max_history_id = 0;
|
||||
|
||||
if let Some(messages) = response.get("receivedMessages").and_then(|m| m.as_array()) {
|
||||
for msg in messages {
|
||||
if let Some(ack_id) = msg.get("ackId").and_then(|a| a.as_str()) {
|
||||
ack_ids.push(ack_id.to_string());
|
||||
}
|
||||
|
||||
// Extract historyId from the notification
|
||||
if let Some(pubsub_msg) = msg.get("message") {
|
||||
let data = pubsub_msg
|
||||
.get("data")
|
||||
.and_then(|d| d.as_str())
|
||||
.and_then(|d| base64::engine::general_purpose::STANDARD.decode(d).ok())
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||||
.and_then(|s| serde_json::from_str::<Value>(&s).ok());
|
||||
|
||||
if let Some(notification) = data {
|
||||
let notif_history_id = notification
|
||||
.get("historyId")
|
||||
.and_then(|h| h.as_u64().or_else(|| h.as_str()?.parse().ok()))
|
||||
.unwrap_or(0);
|
||||
|
||||
if notif_history_id > max_history_id {
|
||||
max_history_id = notif_history_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(ack_ids, max_history_id)
|
||||
}
|
||||
|
||||
/// Fetches new messages since `start_history_id` and outputs them as NDJSON.
|
||||
async fn fetch_and_output_messages(
|
||||
client: &reqwest::Client,
|
||||
gmail_token_provider: &dyn auth::AccessTokenProvider,
|
||||
start_history_id: u64,
|
||||
msg_format: &str,
|
||||
output_dir: Option<&std::path::PathBuf>,
|
||||
sanitize_config: &crate::helpers::modelarmor::SanitizeConfig,
|
||||
gmail_api_base: &str,
|
||||
) -> Result<(), GwsError> {
|
||||
let gmail_token = gmail_token_provider
|
||||
.access_token()
|
||||
.await
|
||||
.context("Failed to get Gmail token")?;
|
||||
let resp = client
|
||||
.get(format!("{gmail_api_base}/users/me/history"))
|
||||
.query(&[
|
||||
("startHistoryId", &start_history_id.to_string()),
|
||||
("historyTypes", &"messageAdded".to_string()),
|
||||
])
|
||||
.bearer_auth(&gmail_token)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to get history")?;
|
||||
|
||||
let body: Value = resp.json().await.unwrap_or(json!({}));
|
||||
|
||||
let msg_ids = extract_message_ids_from_history(&body);
|
||||
|
||||
for msg_id in msg_ids {
|
||||
let msg_url = format!(
|
||||
"{gmail_api_base}/users/me/messages/{}",
|
||||
crate::validate::encode_path_segment(&msg_id),
|
||||
);
|
||||
let msg_resp = client
|
||||
.get(&msg_url)
|
||||
.query(&[("format", msg_format)])
|
||||
.bearer_auth(&gmail_token)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if let Ok(resp) = msg_resp {
|
||||
if let Ok(mut full_msg) = resp.json::<Value>().await {
|
||||
// Apply sanitization if configured
|
||||
if let Some(ref template) = sanitize_config.template {
|
||||
let text_to_check = serde_json::to_string(&full_msg).unwrap_or_default();
|
||||
match crate::helpers::modelarmor::sanitize_text(template, &text_to_check).await
|
||||
{
|
||||
Ok(result) => {
|
||||
if let Some(sanitized_msg) = apply_sanitization_result(
|
||||
full_msg,
|
||||
sanitize_config,
|
||||
&result,
|
||||
&msg_id,
|
||||
) {
|
||||
full_msg = sanitized_msg;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Model Armor sanitization failed for message {msg_id}: {}",
|
||||
colorize("warning:", "33"),
|
||||
sanitize_for_terminal(&e.to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let json_str =
|
||||
serde_json::to_string_pretty(&full_msg).unwrap_or_else(|_| "{}".to_string());
|
||||
if let Some(dir) = output_dir {
|
||||
let path = dir.join(format!(
|
||||
"{}.json",
|
||||
crate::validate::encode_path_segment(&msg_id)
|
||||
));
|
||||
if let Err(e) = std::fs::write(&path, &json_str) {
|
||||
eprintln!(
|
||||
"Warning: failed to write {}: {}",
|
||||
path.display(),
|
||||
sanitize_for_terminal(&e.to_string())
|
||||
);
|
||||
} else {
|
||||
eprintln!("Wrote {}", path.display());
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&full_msg).unwrap_or_else(|_| "{}".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_sanitization_result(
|
||||
mut full_msg: Value,
|
||||
sanitize_config: &crate::helpers::modelarmor::SanitizeConfig,
|
||||
result: &crate::helpers::modelarmor::SanitizationResult,
|
||||
msg_id: &str,
|
||||
) -> Option<Value> {
|
||||
if result.filter_match_state == "MATCH_FOUND" {
|
||||
match sanitize_config.mode {
|
||||
crate::helpers::modelarmor::SanitizeMode::Block => {
|
||||
eprintln!(
|
||||
"{} Message {msg_id} blocked by Model Armor (match found)",
|
||||
colorize("blocked:", "31")
|
||||
);
|
||||
return None;
|
||||
}
|
||||
crate::helpers::modelarmor::SanitizeMode::Warn => {
|
||||
eprintln!(
|
||||
"{} Model Armor match found in message {msg_id}",
|
||||
colorize("warning:", "33")
|
||||
);
|
||||
full_msg["_sanitization"] = serde_json::json!({
|
||||
"filterMatchState": result.filter_match_state,
|
||||
"filterResults": result.filter_results,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(full_msg)
|
||||
}
|
||||
|
||||
fn extract_message_ids_from_history(history_body: &Value) -> Vec<String> {
|
||||
let mut seen_ids = std::collections::HashSet::new();
|
||||
let mut result = Vec::new();
|
||||
|
||||
if let Some(history) = history_body.get("history").and_then(|h| h.as_array()) {
|
||||
for entry in history {
|
||||
if let Some(added) = entry.get("messagesAdded").and_then(|m| m.as_array()) {
|
||||
for msg_entry in added {
|
||||
if let Some(msg_id) = msg_entry
|
||||
.get("message")
|
||||
.and_then(|m| m.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
{
|
||||
if seen_ids.insert(msg_id.to_string()) {
|
||||
result.push(msg_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct WatchConfig {
|
||||
project: Option<String>,
|
||||
subscription: Option<String>,
|
||||
topic: Option<String>,
|
||||
label_ids: Option<String>,
|
||||
max_messages: u32,
|
||||
poll_interval: u64,
|
||||
format: String,
|
||||
once: bool,
|
||||
cleanup: bool,
|
||||
output_dir: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
struct WatchRuntime<'a> {
|
||||
client: &'a reqwest::Client,
|
||||
pubsub_token_provider: &'a dyn auth::AccessTokenProvider,
|
||||
gmail_token_provider: &'a dyn auth::AccessTokenProvider,
|
||||
sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
|
||||
pubsub_api_base: &'a str,
|
||||
gmail_api_base: &'a str,
|
||||
}
|
||||
|
||||
fn parse_watch_args(matches: &ArgMatches) -> Result<WatchConfig, GwsError> {
|
||||
let format_str = matches
|
||||
.get_one::<String>("msg-format")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("full");
|
||||
// Note: msg-format is already constrained by clap's value_parser
|
||||
|
||||
let output_dir = matches
|
||||
.get_one::<String>("output-dir")
|
||||
.map(|dir| crate::validate::validate_safe_output_dir(dir))
|
||||
.transpose()?;
|
||||
|
||||
Ok(WatchConfig {
|
||||
project: matches.get_one::<String>("project").cloned(),
|
||||
subscription: matches
|
||||
.get_one::<String>("subscription")
|
||||
.map(|s| {
|
||||
crate::validate::validate_resource_name(s)?;
|
||||
Ok::<_, GwsError>(s.clone())
|
||||
})
|
||||
.transpose()?,
|
||||
topic: matches.get_one::<String>("topic").cloned(),
|
||||
label_ids: matches.get_one::<String>("label-ids").cloned(),
|
||||
max_messages: matches
|
||||
.get_one::<String>("max-messages")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(10),
|
||||
poll_interval: matches
|
||||
.get_one::<String>("poll-interval")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(5),
|
||||
format: format_str.to_string(),
|
||||
once: matches.get_flag("once"),
|
||||
cleanup: matches.get_flag("cleanup"),
|
||||
output_dir,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::FakeTokenProvider;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
async fn spawn_watch_server() -> (
|
||||
String,
|
||||
String,
|
||||
Arc<Mutex<Vec<(String, String)>>>,
|
||||
tokio::task::JoinHandle<()>,
|
||||
) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let recorded_requests = Arc::clone(&requests);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
for _ in 0..4 {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0_u8; 8192];
|
||||
let bytes_read = stream.read(&mut buf).await.unwrap();
|
||||
let request = String::from_utf8_lossy(&buf[..bytes_read]);
|
||||
let path = request
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let auth_header = request
|
||||
.lines()
|
||||
.find(|line| line.to_ascii_lowercase().starts_with("authorization:"))
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
recorded_requests
|
||||
.lock()
|
||||
.await
|
||||
.push((path.clone(), auth_header));
|
||||
|
||||
let body = match path.as_str() {
|
||||
"/v1/projects/test/subscriptions/demo:pull" => {
|
||||
let payload = base64::engine::general_purpose::STANDARD
|
||||
.encode(json!({ "historyId": 2 }).to_string());
|
||||
json!({
|
||||
"receivedMessages": [{
|
||||
"ackId": "ack-1",
|
||||
"message": {
|
||||
"data": payload,
|
||||
"messageId": "msg-1"
|
||||
}
|
||||
}]
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
"/gmail/v1/users/me/history?startHistoryId=1&historyTypes=messageAdded" => {
|
||||
json!({
|
||||
"history": [{
|
||||
"messagesAdded": [{
|
||||
"message": { "id": "msg-1" }
|
||||
}]
|
||||
}]
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
"/gmail/v1/users/me/messages/msg%2D1?format=full" => {
|
||||
json!({ "id": "msg-1" }).to_string()
|
||||
}
|
||||
"/v1/projects/test/subscriptions/demo:acknowledge" => json!({}).to_string(),
|
||||
other => panic!("unexpected request path: {other}"),
|
||||
};
|
||||
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
(
|
||||
format!("http://{addr}/v1"),
|
||||
format!("http://{addr}/gmail/v1"),
|
||||
requests,
|
||||
handle,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_message_ids_from_history() {
|
||||
let history = json!({
|
||||
"history": [
|
||||
{
|
||||
"messagesAdded": [
|
||||
{ "message": { "id": "msg1", "threadId": "t1" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"messagesAdded": [
|
||||
{ "message": { "id": "msg2", "threadId": "t2" } },
|
||||
{ "message": { "id": "msg1", "threadId": "t1" } } // duplicate
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let ids = extract_message_ids_from_history(&history);
|
||||
assert_eq!(ids.len(), 2);
|
||||
assert!(ids.contains(&"msg1".to_string()));
|
||||
assert!(ids.contains(&"msg2".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_message_ids_empty() {
|
||||
let history = json!({});
|
||||
let ids = extract_message_ids_from_history(&history);
|
||||
assert!(ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_pull_response() {
|
||||
let encoded_data = URL_SAFE
|
||||
.encode(json!({ "emailAddress": "me@example.com", "historyId": 12345 }).to_string());
|
||||
let response = json!({
|
||||
"receivedMessages": [
|
||||
{
|
||||
"ackId": "ack1",
|
||||
"message": {
|
||||
"data": encoded_data,
|
||||
"messageId": "msg1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ackId": "ack2",
|
||||
"message": {
|
||||
"data": URL_SAFE.encode(json!({ "historyId": 100 }).to_string()),
|
||||
"messageId": "msg2"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let (ack_ids, max_history) = process_pull_response(&response);
|
||||
assert_eq!(ack_ids.len(), 2);
|
||||
assert!(ack_ids.contains(&"ack1".to_string()));
|
||||
assert!(ack_ids.contains(&"ack2".to_string()));
|
||||
assert_eq!(max_history, 12345);
|
||||
}
|
||||
|
||||
fn make_matches_watch(args: &[&str]) -> ArgMatches {
|
||||
let cmd = Command::new("test")
|
||||
.arg(Arg::new("project").long("project"))
|
||||
.arg(Arg::new("subscription").long("subscription"))
|
||||
.arg(Arg::new("topic").long("topic"))
|
||||
.arg(Arg::new("label-ids").long("label-ids"))
|
||||
.arg(Arg::new("max-messages").long("max-messages"))
|
||||
.arg(Arg::new("poll-interval").long("poll-interval"))
|
||||
.arg(Arg::new("msg-format").long("msg-format"))
|
||||
.arg(Arg::new("once").long("once").action(ArgAction::SetTrue))
|
||||
.arg(
|
||||
Arg::new("cleanup")
|
||||
.long("cleanup")
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.arg(Arg::new("output-dir").long("output-dir"));
|
||||
cmd.try_get_matches_from(args).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_watch_args_invalid_format_rejected_by_clap() {
|
||||
// msg-format is constrained by clap's value_parser, so invalid values
|
||||
// are rejected at the clap level before parse_watch_args is called.
|
||||
// Verify the real command definition rejects bad formats:
|
||||
let helper = super::super::GmailHelper;
|
||||
let doc = crate::discovery::RestDescription::default();
|
||||
let cmd = helper.inject_commands(Command::new("test"), &doc);
|
||||
let watch_cmd = cmd
|
||||
.get_subcommands()
|
||||
.find(|c| c.get_name() == "+watch")
|
||||
.unwrap()
|
||||
.clone();
|
||||
let result =
|
||||
watch_cmd.try_get_matches_from(vec!["+watch", "--msg-format", "invalid-format"]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_watch_args_invalid_output_dir() {
|
||||
let matches = make_matches_watch(&["test", "--output-dir", "../../etc"]);
|
||||
let result = parse_watch_args(&matches);
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(msg.contains("outside the current directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_watch_args_rejects_traversal_subscription() {
|
||||
let matches = make_matches_watch(&["test", "--subscription", "../../evil"]);
|
||||
let result = parse_watch_args(&matches);
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(msg.contains("path traversal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_watch_args_full() {
|
||||
let matches = make_matches_watch(&[
|
||||
"test",
|
||||
"--project",
|
||||
"p1",
|
||||
"--subscription",
|
||||
"s1",
|
||||
"--max-messages",
|
||||
"20",
|
||||
"--once",
|
||||
]);
|
||||
let config = parse_watch_args(&matches).unwrap();
|
||||
assert_eq!(config.project.unwrap(), "p1");
|
||||
assert_eq!(config.subscription.unwrap(), "s1");
|
||||
assert_eq!(config.max_messages, 20);
|
||||
assert!(config.once);
|
||||
assert!(!config.cleanup);
|
||||
// Default check handled by unwrap_or
|
||||
assert_eq!(config.poll_interval, 5);
|
||||
assert_eq!(config.format, "full");
|
||||
assert_eq!(config.label_ids, None);
|
||||
assert_eq!(config.topic, None);
|
||||
assert_eq!(config.output_dir, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_watch_args_defaults() {
|
||||
let matches = make_matches_watch(&["test"]);
|
||||
let config = parse_watch_args(&matches).unwrap();
|
||||
assert_eq!(config.project, None);
|
||||
assert_eq!(config.subscription, None);
|
||||
assert_eq!(config.max_messages, 10);
|
||||
assert_eq!(config.poll_interval, 5);
|
||||
assert_eq!(config.format, "full");
|
||||
assert!(!config.once);
|
||||
assert!(!config.cleanup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_watch_args_invalid_numbers() {
|
||||
let matches = make_matches_watch(&[
|
||||
"test",
|
||||
"--max-messages",
|
||||
"not_a_number",
|
||||
"--poll-interval",
|
||||
"invalid",
|
||||
]);
|
||||
let config = parse_watch_args(&matches).unwrap();
|
||||
// Should fallback to defaults
|
||||
assert_eq!(config.max_messages, 10);
|
||||
assert_eq!(config.poll_interval, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_sanitization_result_block_mode() {
|
||||
let msg = json!({ "id": "msg1" });
|
||||
let config = crate::helpers::modelarmor::SanitizeConfig {
|
||||
template: Some("projects/x/locations/y/templates/z".to_string()),
|
||||
mode: crate::helpers::modelarmor::SanitizeMode::Block,
|
||||
};
|
||||
let result = crate::helpers::modelarmor::SanitizationResult {
|
||||
filter_match_state: "MATCH_FOUND".to_string(),
|
||||
filter_results: json!([]),
|
||||
invocation_result: "{}".to_string(),
|
||||
};
|
||||
|
||||
let output = apply_sanitization_result(msg, &config, &result, "msg1");
|
||||
assert!(output.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_sanitization_result_warn_mode() {
|
||||
let msg = json!({ "id": "msg1" });
|
||||
let config = crate::helpers::modelarmor::SanitizeConfig {
|
||||
template: Some("projects/x/locations/y/templates/z".to_string()),
|
||||
mode: crate::helpers::modelarmor::SanitizeMode::Warn,
|
||||
};
|
||||
let result = crate::helpers::modelarmor::SanitizationResult {
|
||||
filter_match_state: "MATCH_FOUND".to_string(),
|
||||
filter_results: json!([]),
|
||||
invocation_result: "{}".to_string(),
|
||||
};
|
||||
|
||||
let output = apply_sanitization_result(msg, &config, &result, "msg1").unwrap();
|
||||
// Warn mode adds the `_sanitization` metadata.
|
||||
assert!(output.get("_sanitization").is_some());
|
||||
assert_eq!(output["_sanitization"]["filterMatchState"], "MATCH_FOUND");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_sanitization_result_no_match() {
|
||||
let msg = json!({ "id": "msg1" });
|
||||
let config = crate::helpers::modelarmor::SanitizeConfig {
|
||||
template: Some("projects/x/locations/y/templates/z".to_string()),
|
||||
mode: crate::helpers::modelarmor::SanitizeMode::Block,
|
||||
};
|
||||
let result = crate::helpers::modelarmor::SanitizationResult {
|
||||
filter_match_state: "NO_MATCH_FOUND".to_string(),
|
||||
filter_results: json!([]),
|
||||
invocation_result: "{}".to_string(),
|
||||
};
|
||||
|
||||
let output = apply_sanitization_result(msg.clone(), &config, &result, "msg1").unwrap();
|
||||
// If no match found, block mode returns the exact input untouched.
|
||||
assert_eq!(output, msg);
|
||||
assert!(output.get("_sanitization").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_watch_pull_loop_refreshes_tokens_for_each_request() {
|
||||
let client = reqwest::Client::new();
|
||||
let pubsub_provider = FakeTokenProvider::new(["pubsub-token"]);
|
||||
let gmail_provider = FakeTokenProvider::new(["gmail-token"]);
|
||||
let (pubsub_base, gmail_base, requests, server) = spawn_watch_server().await;
|
||||
let mut last_history_id = 1;
|
||||
let config = WatchConfig {
|
||||
project: None,
|
||||
subscription: None,
|
||||
topic: None,
|
||||
label_ids: None,
|
||||
max_messages: 10,
|
||||
poll_interval: 1,
|
||||
format: "full".to_string(),
|
||||
once: true,
|
||||
cleanup: false,
|
||||
output_dir: None,
|
||||
};
|
||||
let sanitize_config = crate::helpers::modelarmor::SanitizeConfig {
|
||||
template: None,
|
||||
mode: crate::helpers::modelarmor::SanitizeMode::Warn,
|
||||
};
|
||||
|
||||
let runtime = WatchRuntime {
|
||||
client: &client,
|
||||
pubsub_token_provider: &pubsub_provider,
|
||||
gmail_token_provider: &gmail_provider,
|
||||
sanitize_config: &sanitize_config,
|
||||
pubsub_api_base: &pubsub_base,
|
||||
gmail_api_base: &gmail_base,
|
||||
};
|
||||
|
||||
watch_pull_loop(
|
||||
&runtime,
|
||||
"projects/test/subscriptions/demo",
|
||||
&mut last_history_id,
|
||||
config,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
server.await.unwrap();
|
||||
|
||||
let requests = requests.lock().await;
|
||||
assert_eq!(requests.len(), 4);
|
||||
assert_eq!(requests[0].0, "/v1/projects/test/subscriptions/demo:pull");
|
||||
assert_eq!(requests[0].1, "authorization: Bearer pubsub-token");
|
||||
assert_eq!(
|
||||
requests[1].0,
|
||||
"/gmail/v1/users/me/history?startHistoryId=1&historyTypes=messageAdded"
|
||||
);
|
||||
assert_eq!(requests[1].1, "authorization: Bearer gmail-token");
|
||||
assert_eq!(
|
||||
requests[2].0,
|
||||
"/gmail/v1/users/me/messages/msg%2D1?format=full"
|
||||
);
|
||||
assert_eq!(requests[2].1, "authorization: Bearer gmail-token");
|
||||
assert_eq!(
|
||||
requests[3].0,
|
||||
"/v1/projects/test/subscriptions/demo:acknowledge"
|
||||
);
|
||||
assert_eq!(requests[3].1, "authorization: Bearer pubsub-token");
|
||||
assert_eq!(last_history_id, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::error::GwsError;
|
||||
use clap::{ArgMatches, Command};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
pub mod calendar;
|
||||
pub mod chat;
|
||||
pub mod docs;
|
||||
pub mod drive;
|
||||
pub mod events;
|
||||
pub mod gmail;
|
||||
pub mod modelarmor;
|
||||
pub mod script;
|
||||
pub mod sheets;
|
||||
pub mod workflows;
|
||||
|
||||
/// Base URL for the Google Cloud Pub/Sub v1 API.
|
||||
///
|
||||
/// Shared across `events::subscribe` and `gmail::watch` so the constant
|
||||
/// is defined in a single place.
|
||||
pub(crate) const PUBSUB_API_BASE: &str = "https://pubsub.googleapis.com/v1";
|
||||
|
||||
/// Returns a future that completes when a shutdown signal is received.
|
||||
///
|
||||
/// On Unix this listens for both SIGINT (Ctrl+C) and SIGTERM; on other
|
||||
/// platforms only SIGINT is handled. Used by long-running pull loops
|
||||
/// (`gmail::watch`, `events::subscribe`) to exit cleanly under container
|
||||
/// orchestrators (Kubernetes, Docker, systemd) that send SIGTERM.
|
||||
///
|
||||
/// The signal handler is registered once in a background task on first call
|
||||
/// so it remains active for the lifetime of the process — no gap between
|
||||
/// loop iterations.
|
||||
pub(crate) async fn shutdown_signal() {
|
||||
use std::sync::OnceLock;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
static NOTIFY: OnceLock<std::sync::Arc<Notify>> = OnceLock::new();
|
||||
|
||||
let notify = NOTIFY.get_or_init(|| {
|
||||
let n = std::sync::Arc::new(Notify::new());
|
||||
let n2 = n.clone();
|
||||
tokio::spawn(async move {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
match signal(SignalKind::terminate()) {
|
||||
Ok(mut sigterm) => {
|
||||
tokio::select! {
|
||||
res = tokio::signal::ctrl_c() => {
|
||||
res.expect("failed to listen for SIGINT");
|
||||
}
|
||||
Some(_) = sigterm.recv() => {}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"warning: could not register SIGTERM handler: {e}. \
|
||||
Listening for Ctrl+C only."
|
||||
);
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to listen for SIGINT");
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to listen for SIGINT");
|
||||
}
|
||||
n2.notify_waiters();
|
||||
});
|
||||
n
|
||||
});
|
||||
|
||||
notify.notified().await;
|
||||
}
|
||||
|
||||
/// A trait for service-specific CLI helpers that inject custom commands.
|
||||
pub trait Helper: Send + Sync {
|
||||
/// Injects subcommands into the service command.
|
||||
fn inject_commands(&self, cmd: Command, doc: &crate::discovery::RestDescription) -> Command;
|
||||
|
||||
/// Attempts to handle a command. Returns Ok(Some(())) if handled,
|
||||
/// Ok(None) if not handled (should fall back to dynamic dispatch),
|
||||
/// or Err if handled but failed.
|
||||
fn handle<'a>(
|
||||
&'a self,
|
||||
doc: &'a crate::discovery::RestDescription,
|
||||
matches: &'a ArgMatches,
|
||||
sanitize_config: &'a modelarmor::SanitizeConfig,
|
||||
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>>;
|
||||
|
||||
/// If true, only helper commands are shown (discovery-generated commands are suppressed).
|
||||
fn helper_only(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_helper(service: &str) -> Option<Box<dyn Helper>> {
|
||||
match service {
|
||||
"gmail" => Some(Box::new(gmail::GmailHelper)),
|
||||
"sheets" => Some(Box::new(sheets::SheetsHelper)),
|
||||
"docs" => Some(Box::new(docs::DocsHelper)),
|
||||
"chat" => Some(Box::new(chat::ChatHelper)),
|
||||
"drive" => Some(Box::new(drive::DriveHelper)),
|
||||
"calendar" => Some(Box::new(calendar::CalendarHelper)),
|
||||
"script" | "apps-script" => Some(Box::new(script::ScriptHelper)),
|
||||
"workspaceevents" => Some(Box::new(events::EventsHelper)),
|
||||
"modelarmor" => Some(Box::new(modelarmor::ModelArmorHelper)),
|
||||
"workflow" => Some(Box::new(workflows::WorkflowHelper)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,780 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::Helper;
|
||||
use crate::auth;
|
||||
use crate::discovery::RestDescription;
|
||||
use crate::error::GwsError;
|
||||
use anyhow::Context;
|
||||
use clap::{Arg, ArgMatches, Command};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
/// Result of a Model Armor sanitization check.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SanitizationResult {
|
||||
/// The overall state of the match (e.g., "MATCH_FOUND", "NO_MATCH_FOUND").
|
||||
pub filter_match_state: String,
|
||||
/// Detailed results from specific filters (PI, Jailbreak, etc.).
|
||||
#[serde(default)]
|
||||
pub filter_results: serde_json::Value,
|
||||
/// The final decision based on the policy (e.g., "BLOCK", "ALLOW").
|
||||
#[serde(default)]
|
||||
pub invocation_result: String,
|
||||
}
|
||||
|
||||
/// Controls behavior when sanitization finds a match.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SanitizeMode {
|
||||
/// Log warning to stderr, annotate output with _sanitization field
|
||||
Warn,
|
||||
/// Suppress response output, exit non-zero
|
||||
Block,
|
||||
}
|
||||
|
||||
/// Configuration for Model Armor sanitization, threaded through the CLI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SanitizeConfig {
|
||||
pub template: Option<String>,
|
||||
pub mode: SanitizeMode,
|
||||
}
|
||||
|
||||
impl Default for SanitizeConfig {
|
||||
/// Provides default values for `SanitizeConfig`.
|
||||
///
|
||||
/// By default, no template is set (sanitization disabled) and the mode is `Warn`.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
template: None,
|
||||
mode: SanitizeMode::Warn,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SanitizeMode {
|
||||
/// Parses a string into a `SanitizeMode`.
|
||||
///
|
||||
/// * "block" (case-insensitive) -> `Block`
|
||||
/// * Any other value -> `Warn` (safe default)
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"block" => SanitizeMode::Block,
|
||||
_ => SanitizeMode::Warn,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ModelArmorHelper;
|
||||
|
||||
/// Build the regional base URL for Model Armor API.
|
||||
/// The discovery doc rootUrl (modelarmor.us.rep.googleapis.com) is incorrect —
|
||||
/// Model Armor requires region-specific endpoints: modelarmor.{region}.rep.googleapis.com
|
||||
fn regional_base_url(location: &str) -> String {
|
||||
format!("https://modelarmor.{location}.rep.googleapis.com/v1")
|
||||
}
|
||||
|
||||
/// Extract location from a full template resource name.
|
||||
/// e.g. "projects/my-project/locations/us-central1/templates/my-template" -> "us-central1"
|
||||
fn extract_location(resource_name: &str) -> Option<&str> {
|
||||
let parts: Vec<&str> = resource_name.split('/').collect();
|
||||
for i in 0..parts.len() {
|
||||
if parts[i] == "locations" && i + 1 < parts.len() {
|
||||
return Some(parts[i + 1]);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl Helper for ModelArmorHelper {
|
||||
fn inject_commands(&self, mut cmd: Command, _doc: &RestDescription) -> Command {
|
||||
cmd = cmd.subcommand(
|
||||
Command::new("+sanitize-prompt")
|
||||
.about("[Helper] Sanitize a user prompt through a Model Armor template")
|
||||
.arg(
|
||||
Arg::new("template")
|
||||
.long("template")
|
||||
.help("Full template resource name (projects/PROJECT/locations/LOCATION/templates/TEMPLATE)")
|
||||
.required(true)
|
||||
.value_name("NAME"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("text")
|
||||
.long("text")
|
||||
.help("Text content to sanitize")
|
||||
.value_name("TEXT"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("json")
|
||||
.long("json")
|
||||
.help("Full JSON request body (overrides --text)")
|
||||
.value_name("JSON"),
|
||||
)
|
||||
.after_help("\
|
||||
EXAMPLES:
|
||||
gws modelarmor +sanitize-prompt --template projects/P/locations/L/templates/T --text 'user input'
|
||||
echo 'prompt' | gws modelarmor +sanitize-prompt --template ...
|
||||
|
||||
TIPS:
|
||||
If neither --text nor --json is given, reads from stdin.
|
||||
For outbound safety, use +sanitize-response instead."),
|
||||
);
|
||||
|
||||
cmd = cmd.subcommand(
|
||||
Command::new("+sanitize-response")
|
||||
.about("[Helper] Sanitize a model response through a Model Armor template")
|
||||
.arg(
|
||||
Arg::new("template")
|
||||
.long("template")
|
||||
.help("Full template resource name (projects/PROJECT/locations/LOCATION/templates/TEMPLATE)")
|
||||
.required(true)
|
||||
.value_name("NAME"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("text")
|
||||
.long("text")
|
||||
.help("Text content to sanitize")
|
||||
.value_name("TEXT"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("json")
|
||||
.long("json")
|
||||
.help("Full JSON request body (overrides --text)")
|
||||
.value_name("JSON"),
|
||||
)
|
||||
.after_help("\
|
||||
EXAMPLES:
|
||||
gws modelarmor +sanitize-response --template projects/P/locations/L/templates/T --text 'model output'
|
||||
model_cmd | gws modelarmor +sanitize-response --template ...
|
||||
|
||||
TIPS:
|
||||
Use for outbound safety (model -> user).
|
||||
For inbound safety (user -> model), use +sanitize-prompt."),
|
||||
);
|
||||
|
||||
cmd = cmd.subcommand(
|
||||
Command::new("+create-template")
|
||||
.about("[Helper] Create a new Model Armor template")
|
||||
.arg(
|
||||
Arg::new("project")
|
||||
.long("project")
|
||||
.help("GCP project ID")
|
||||
.required(true)
|
||||
.value_name("PROJECT"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("location")
|
||||
.long("location")
|
||||
.help("GCP location (e.g. us-central1)")
|
||||
.required(true)
|
||||
.value_name("LOCATION"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("template-id")
|
||||
.long("template-id")
|
||||
.help("Template ID to create")
|
||||
.required(true)
|
||||
.value_name("ID"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("preset")
|
||||
.long("preset")
|
||||
.help("Use a preset template: jailbreak")
|
||||
.value_name("PRESET")
|
||||
.value_parser(["jailbreak"]),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("json")
|
||||
.long("json")
|
||||
.help("JSON body for the template configuration (overrides --preset)")
|
||||
.value_name("JSON"),
|
||||
)
|
||||
.after_help("\
|
||||
EXAMPLES:
|
||||
gws modelarmor +create-template --project P --location us-central1 --template-id my-tmpl --preset jailbreak
|
||||
gws modelarmor +create-template --project P --location us-central1 --template-id my-tmpl --json '{...}'
|
||||
|
||||
TIPS:
|
||||
Defaults to the jailbreak preset if neither --preset nor --json is given.
|
||||
Use the resulting template name with +sanitize-prompt and +sanitize-response."),
|
||||
);
|
||||
|
||||
cmd
|
||||
}
|
||||
|
||||
fn helper_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn handle<'a>(
|
||||
&'a self,
|
||||
_doc: &'a RestDescription,
|
||||
matches: &'a ArgMatches,
|
||||
_sanitize_config: &'a SanitizeConfig,
|
||||
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
if let Some(sub) = matches.subcommand_matches("+sanitize-prompt") {
|
||||
handle_sanitize(sub, "sanitizeUserPrompt", "userPromptData").await?;
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(sub) = matches.subcommand_matches("+sanitize-response") {
|
||||
handle_sanitize(sub, "sanitizeModelResponse", "modelResponseData").await?;
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(sub) = matches.subcommand_matches("+create-template") {
|
||||
handle_create_template(sub).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub const CLOUD_PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform";
|
||||
|
||||
/// Sanitize text through a Model Armor template and return the result.
|
||||
/// Template format: projects/PROJECT/locations/LOCATION/templates/TEMPLATE
|
||||
pub async fn sanitize_text(template: &str, text: &str) -> Result<SanitizationResult, GwsError> {
|
||||
let (body, url) = build_sanitize_request_data(template, text, "sanitizeUserPrompt")?;
|
||||
|
||||
let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE])
|
||||
.await
|
||||
.context("Failed to get auth token for Model Armor")?;
|
||||
|
||||
let client = crate::client::build_client()?;
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.context("Model Armor request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let resp_text = resp
|
||||
.text()
|
||||
.await
|
||||
.context("Failed to read Model Armor response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(GwsError::Other(anyhow::anyhow!(
|
||||
"Model Armor API returned status {status}: {resp_text}"
|
||||
)));
|
||||
}
|
||||
|
||||
parse_sanitize_response(&resp_text)
|
||||
}
|
||||
|
||||
/// Make a POST request to Model Armor's regional API endpoint.
|
||||
async fn model_armor_post(url: &str, body: &str) -> Result<(), GwsError> {
|
||||
let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE])
|
||||
.await
|
||||
.context("Failed to get auth token")?;
|
||||
|
||||
let client = crate::client::build_client()?;
|
||||
let resp = client
|
||||
.post(url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body.to_string())
|
||||
.send()
|
||||
.await
|
||||
.context("HTTP request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.context("Failed to read response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(GwsError::Other(anyhow::anyhow!(
|
||||
"API returned status {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
println!("{text}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle +sanitize-prompt and +sanitize-response
|
||||
async fn handle_sanitize(
|
||||
matches: &ArgMatches,
|
||||
method_name: &str,
|
||||
data_field: &str,
|
||||
) -> Result<(), GwsError> {
|
||||
let template_raw = matches.get_one::<String>("template").unwrap();
|
||||
let template = crate::validate::validate_resource_name(template_raw)?;
|
||||
|
||||
let location = extract_location(template).ok_or_else(|| {
|
||||
GwsError::Validation(
|
||||
"Cannot extract location from template name. Expected format: projects/PROJECT/locations/LOCATION/templates/TEMPLATE".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let body = parse_sanitize_args(matches, data_field)?;
|
||||
|
||||
let base = regional_base_url(location);
|
||||
let url = format!("{base}/{template}:{method_name}");
|
||||
|
||||
model_armor_post(&url, &body).await
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct CreateTemplateConfig {
|
||||
pub project: String,
|
||||
pub location: String,
|
||||
pub template_id: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
fn parse_create_template_args(matches: &ArgMatches) -> Result<CreateTemplateConfig, GwsError> {
|
||||
let project_raw = matches.get_one::<String>("project").unwrap();
|
||||
let project = crate::validate::validate_resource_name(project_raw)?.to_string();
|
||||
let location_raw = matches.get_one::<String>("location").unwrap();
|
||||
let location = crate::validate::validate_resource_name(location_raw)?.to_string();
|
||||
let template_id_raw = matches.get_one::<String>("template-id").unwrap();
|
||||
let template_id = crate::validate::validate_resource_name(template_id_raw)?.to_string();
|
||||
|
||||
let body = if let Some(json_str) = matches.get_one::<String>("json") {
|
||||
json_str.clone()
|
||||
} else {
|
||||
let preset = matches
|
||||
.get_one::<String>("preset")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("jailbreak");
|
||||
load_preset_template(preset)?
|
||||
};
|
||||
|
||||
Ok(CreateTemplateConfig {
|
||||
project,
|
||||
location,
|
||||
template_id,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_create_template_url(config: &CreateTemplateConfig) -> String {
|
||||
let base = regional_base_url(&config.location);
|
||||
let project = crate::validate::encode_path_segment(&config.project);
|
||||
let location = crate::validate::encode_path_segment(&config.location);
|
||||
let parent = format!("projects/{project}/locations/{location}");
|
||||
format!(
|
||||
"{base}/{parent}/templates?templateId={}",
|
||||
crate::validate::encode_path_segment(&config.template_id)
|
||||
)
|
||||
}
|
||||
|
||||
/// Handle +create-template
|
||||
async fn handle_create_template(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
let config = parse_create_template_args(matches)?;
|
||||
let url = build_create_template_url(&config);
|
||||
|
||||
eprintln!(
|
||||
"Creating template '{}' with preset: {}",
|
||||
config.template_id,
|
||||
matches
|
||||
.get_one::<String>("preset")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("jailbreak")
|
||||
);
|
||||
|
||||
model_armor_post(&url, &config.body).await
|
||||
}
|
||||
|
||||
/// Loads a preset template JSON file from the templates/modelarmor/ directory.
|
||||
/// Falls back to the embedded template if the file is not found.
|
||||
fn load_preset_template(name: &str) -> Result<String, GwsError> {
|
||||
// Try to find templates relative to the executable
|
||||
let exe_path = std::env::current_exe().ok();
|
||||
let search_dirs: Vec<std::path::PathBuf> = [
|
||||
// Relative to current directory
|
||||
Some(std::path::PathBuf::from("templates/modelarmor")),
|
||||
// Relative to executable
|
||||
exe_path
|
||||
.as_ref()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.join("../templates/modelarmor")),
|
||||
exe_path
|
||||
.as_ref()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.join("templates/modelarmor")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
|
||||
let filename = format!("{name}.json");
|
||||
|
||||
for dir in &search_dirs {
|
||||
let path = dir.join(&filename);
|
||||
if path.exists() {
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read template '{}'", path.display()))?;
|
||||
eprintln!("Using preset template from: {}", path.display());
|
||||
return Ok(content);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: embedded preset
|
||||
eprintln!("Template file not found, using embedded '{}' preset", name);
|
||||
Ok(include_str!("../../templates/modelarmor/jailbreak.json").to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_config_default() {
|
||||
let config = SanitizeConfig::default();
|
||||
assert!(config.template.is_none());
|
||||
assert_eq!(config.mode, SanitizeMode::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_config_with_template() {
|
||||
let config = SanitizeConfig {
|
||||
template: Some("projects/p/locations/us-central1/templates/t".to_string()),
|
||||
mode: SanitizeMode::Block,
|
||||
};
|
||||
assert_eq!(
|
||||
config.template.as_deref(),
|
||||
Some("projects/p/locations/us-central1/templates/t")
|
||||
);
|
||||
assert_eq!(config.mode, SanitizeMode::Block);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_mode_from_str_warn() {
|
||||
assert_eq!(SanitizeMode::from_str("warn"), SanitizeMode::Warn);
|
||||
assert_eq!(SanitizeMode::from_str("WARN"), SanitizeMode::Warn);
|
||||
assert_eq!(SanitizeMode::from_str("Warn"), SanitizeMode::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_mode_from_str_block() {
|
||||
assert_eq!(SanitizeMode::from_str("block"), SanitizeMode::Block);
|
||||
assert_eq!(SanitizeMode::from_str("BLOCK"), SanitizeMode::Block);
|
||||
assert_eq!(SanitizeMode::from_str("Block"), SanitizeMode::Block);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_mode_from_str_unknown_defaults_to_warn() {
|
||||
assert_eq!(SanitizeMode::from_str(""), SanitizeMode::Warn);
|
||||
assert_eq!(SanitizeMode::from_str("invalid"), SanitizeMode::Warn);
|
||||
assert_eq!(SanitizeMode::from_str("stop"), SanitizeMode::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_location_valid() {
|
||||
assert_eq!(
|
||||
extract_location("projects/my-project/locations/us-central1/templates/my-template"),
|
||||
Some("us-central1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_location_different_region() {
|
||||
assert_eq!(
|
||||
extract_location("projects/p/locations/europe-west1/templates/t"),
|
||||
Some("europe-west1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_location_no_locations() {
|
||||
assert_eq!(extract_location("projects/my-project/templates/t"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_location_empty() {
|
||||
assert_eq!(extract_location(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_location_trailing_locations() {
|
||||
// "locations" at the end with no value after
|
||||
assert_eq!(extract_location("projects/p/locations"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regional_base_url() {
|
||||
assert_eq!(
|
||||
regional_base_url("us-central1"),
|
||||
"https://modelarmor.us-central1.rep.googleapis.com/v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regional_base_url_different_region() {
|
||||
assert_eq!(
|
||||
regional_base_url("europe-west1"),
|
||||
"https://modelarmor.europe-west1.rep.googleapis.com/v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cloud_platform_scope_constant() {
|
||||
assert_eq!(
|
||||
CLOUD_PLATFORM_SCOPE,
|
||||
"https://www.googleapis.com/auth/cloud-platform"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_sanitize_request_data() {
|
||||
let template = "projects/p/locations/us-central1/templates/t";
|
||||
let (body, _) =
|
||||
build_sanitize_request_data(template, "some text", "sanitizeUserPrompt").unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(json["userPromptData"]["text"], "some text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sanitize_response_success() {
|
||||
let json_resp = json!({
|
||||
"sanitizationResult": {
|
||||
"filterMatchState": "MATCH_FOUND",
|
||||
"filterResults": {},
|
||||
"invocationResult": "SUCCESS"
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let res = parse_sanitize_response(&json_resp).unwrap();
|
||||
assert_eq!(res.filter_match_state, "MATCH_FOUND");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sanitize_response_missing_field() {
|
||||
let json_resp = json!({}).to_string();
|
||||
assert!(parse_sanitize_response(&json_resp).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_sanitize_request_data(
|
||||
template: &str,
|
||||
text: &str,
|
||||
method: &str,
|
||||
) -> Result<(String, String), GwsError> {
|
||||
let location = extract_location(template).ok_or_else(|| {
|
||||
GwsError::Validation(
|
||||
"Cannot extract location from --sanitize template. Expected format: projects/PROJECT/locations/LOCATION/templates/TEMPLATE".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let base = regional_base_url(location);
|
||||
let url = format!("{base}/{template}:{method}");
|
||||
|
||||
// Identify data field based on method
|
||||
let data_field = if method == "sanitizeUserPrompt" {
|
||||
"userPromptData"
|
||||
} else {
|
||||
"modelResponseData"
|
||||
};
|
||||
|
||||
let body = json!({data_field: {"text": text}}).to_string();
|
||||
Ok((body, url))
|
||||
}
|
||||
|
||||
pub fn parse_sanitize_response(resp_text: &str) -> Result<SanitizationResult, GwsError> {
|
||||
// Parse the response to extract sanitizationResult
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(resp_text).context("Failed to parse Model Armor response")?;
|
||||
|
||||
let result = parsed.get("sanitizationResult").ok_or_else(|| {
|
||||
GwsError::Other(anyhow::anyhow!(
|
||||
"No sanitizationResult in Model Armor response"
|
||||
))
|
||||
})?;
|
||||
|
||||
let res =
|
||||
serde_json::from_value(result.clone()).context("Failed to parse sanitization result")?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
fn parse_sanitize_args(matches: &ArgMatches, data_field: &str) -> Result<String, GwsError> {
|
||||
if let Some(json_str) = matches.get_one::<String>("json") {
|
||||
Ok(json_str.clone())
|
||||
} else if let Some(text) = matches.get_one::<String>("text") {
|
||||
let mut body = serde_json::Map::new();
|
||||
body.insert(data_field.to_string(), json!({"text": text}));
|
||||
Ok(serde_json::Value::Object(body).to_string())
|
||||
} else {
|
||||
// Try to read from stdin, but since we can't easily test stdin in unit tests,
|
||||
// we might check for TTY or empty stdin.
|
||||
// For simplicity here, we assume if we reach here without text/json, we try stdin.
|
||||
|
||||
// Note: We removed the TTY check to avoid adding 'atty' or 'is-terminal' dependency.
|
||||
// This means it will block on stdin if no input is provided, which is standard CLI behavior.
|
||||
|
||||
let stdin_text =
|
||||
std::io::read_to_string(std::io::stdin()).context("Failed to read stdin")?;
|
||||
|
||||
if stdin_text.trim().is_empty() {
|
||||
return Err(GwsError::Validation(
|
||||
"Provide text via --text, --json, or pipe to stdin".to_string(),
|
||||
));
|
||||
}
|
||||
let mut body = serde_json::Map::new();
|
||||
body.insert(data_field.to_string(), json!({"text": stdin_text.trim()}));
|
||||
Ok(serde_json::Value::Object(body).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod parsing_tests {
|
||||
use super::*;
|
||||
use clap::{Arg, Command};
|
||||
|
||||
fn make_matches(args: &[&str]) -> ArgMatches {
|
||||
let cmd = Command::new("test")
|
||||
.arg(Arg::new("json").long("json"))
|
||||
.arg(Arg::new("text").long("text"));
|
||||
cmd.try_get_matches_from(args).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sanitize_args_json() {
|
||||
let matches = make_matches(&["test", "--json", "{\"foo\":\"bar\"}"]);
|
||||
let body = parse_sanitize_args(&matches, "field").unwrap();
|
||||
assert_eq!(body, "{\"foo\":\"bar\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sanitize_args_text() {
|
||||
let matches = make_matches(&["test", "--text", "hello"]);
|
||||
let body = parse_sanitize_args(&matches, "field").unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(json["field"]["text"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_create_template_url() {
|
||||
let config = CreateTemplateConfig {
|
||||
project: "p".to_string(),
|
||||
location: "us-central1".to_string(),
|
||||
template_id: "t".to_string(),
|
||||
body: "{}".to_string(),
|
||||
};
|
||||
let url = build_create_template_url(&config);
|
||||
// encode_path_segment encodes hyphens ('-' → '%2D')
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://modelarmor.us-central1.rep.googleapis.com/v1/projects/p/locations/us%2Dcentral1/templates?templateId=t"
|
||||
);
|
||||
}
|
||||
|
||||
fn make_matches_create(args: &[&str]) -> ArgMatches {
|
||||
let cmd = Command::new("test")
|
||||
.arg(Arg::new("project").long("project").required(true))
|
||||
.arg(Arg::new("location").long("location").required(true))
|
||||
.arg(Arg::new("template-id").long("template-id").required(true))
|
||||
.arg(Arg::new("json").long("json"))
|
||||
.arg(Arg::new("preset").long("preset"));
|
||||
cmd.try_get_matches_from(args).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_create_template_args_json() {
|
||||
let matches = make_matches_create(&[
|
||||
"test",
|
||||
"--project",
|
||||
"p",
|
||||
"--location",
|
||||
"l",
|
||||
"--template-id",
|
||||
"t",
|
||||
"--json",
|
||||
"{\"a\":1}",
|
||||
]);
|
||||
let config = parse_create_template_args(&matches).unwrap();
|
||||
assert_eq!(config.project, "p");
|
||||
assert_eq!(config.location, "l");
|
||||
assert_eq!(config.template_id, "t");
|
||||
assert_eq!(config.body, "{\"a\":1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_create_template_args_preset() {
|
||||
let matches = make_matches_create(&[
|
||||
"test",
|
||||
"--project",
|
||||
"p",
|
||||
"--location",
|
||||
"l",
|
||||
"--template-id",
|
||||
"t",
|
||||
"--preset",
|
||||
"jailbreak",
|
||||
]);
|
||||
let config = parse_create_template_args(&matches).unwrap();
|
||||
assert_eq!(config.project, "p");
|
||||
assert_eq!(config.location, "l");
|
||||
assert_eq!(config.template_id, "t");
|
||||
assert!(config.body.contains("piAndJailbreakFilterSettings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_preset_template_fallback() {
|
||||
// Will test loading the built-in preset template
|
||||
let content = load_preset_template("jailbreak").unwrap();
|
||||
assert!(content.contains("piAndJailbreakFilterSettings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_commands() {
|
||||
let helper = ModelArmorHelper;
|
||||
let cmd = Command::new("test");
|
||||
let doc = crate::discovery::RestDescription::default();
|
||||
|
||||
let cmd = helper.inject_commands(cmd, &doc);
|
||||
let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
|
||||
assert!(subcommands.contains(&"+sanitize-prompt"));
|
||||
assert!(subcommands.contains(&"+sanitize-response"));
|
||||
assert!(subcommands.contains(&"+create-template"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_create_template_url_encodes_segments() {
|
||||
let config = CreateTemplateConfig {
|
||||
project: "my-project".to_string(),
|
||||
location: "us-central1".to_string(),
|
||||
template_id: "my-template".to_string(),
|
||||
body: "{}".to_string(),
|
||||
};
|
||||
let url = build_create_template_url(&config);
|
||||
assert!(url.contains("projects/my%2Dproject"));
|
||||
assert!(url.contains("locations/us%2Dcentral1"));
|
||||
assert!(url.contains("templateId=my%2Dtemplate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_create_template_args_rejects_traversal() {
|
||||
let matches = make_matches_create(&[
|
||||
"test",
|
||||
"--project",
|
||||
"../etc",
|
||||
"--location",
|
||||
"us-central1",
|
||||
"--template-id",
|
||||
"t",
|
||||
"--preset",
|
||||
"jailbreak",
|
||||
]);
|
||||
assert!(parse_create_template_args(&matches).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::Helper;
|
||||
use crate::auth;
|
||||
use crate::error::GwsError;
|
||||
use crate::executor;
|
||||
use anyhow::Context;
|
||||
use clap::{Arg, ArgMatches, Command};
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
|
||||
pub struct ScriptHelper;
|
||||
|
||||
impl Helper for ScriptHelper {
|
||||
fn inject_commands(
|
||||
&self,
|
||||
mut cmd: Command,
|
||||
_doc: &crate::discovery::RestDescription,
|
||||
) -> Command {
|
||||
cmd = cmd.subcommand(
|
||||
Command::new("+push")
|
||||
.about("[Helper] Upload local files to an Apps Script project")
|
||||
.arg(
|
||||
Arg::new("script")
|
||||
.long("script")
|
||||
.help("Script Project ID")
|
||||
.required(true)
|
||||
.value_name("ID"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("dir")
|
||||
.long("dir")
|
||||
.help("Directory containing script files (defaults to current dir)")
|
||||
.value_name("DIR"),
|
||||
)
|
||||
.after_help(
|
||||
"\
|
||||
EXAMPLES:
|
||||
gws script +push --script SCRIPT_ID
|
||||
gws script +push --script SCRIPT_ID --dir ./src
|
||||
|
||||
TIPS:
|
||||
Supports .gs, .js, .html, and appsscript.json files.
|
||||
Skips hidden files and node_modules automatically.
|
||||
This replaces ALL files in the project.",
|
||||
),
|
||||
);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn handle<'a>(
|
||||
&'a self,
|
||||
doc: &'a crate::discovery::RestDescription,
|
||||
matches: &'a ArgMatches,
|
||||
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
|
||||
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
if let Some(matches) = matches.subcommand_matches("+push") {
|
||||
let script_id = matches.get_one::<String>("script").unwrap();
|
||||
let dir_path = matches
|
||||
.get_one::<String>("dir")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(".");
|
||||
let safe_dir = crate::validate::validate_safe_dir_path(dir_path)?;
|
||||
|
||||
let mut files = Vec::new();
|
||||
visit_dirs(&safe_dir, &mut files)?;
|
||||
|
||||
if files.is_empty() {
|
||||
return Err(GwsError::Validation(format!(
|
||||
"No eligible files found in '{}'",
|
||||
dir_path
|
||||
)));
|
||||
}
|
||||
|
||||
// Find method: projects.updateContent
|
||||
let projects_res = doc.resources.get("projects").ok_or_else(|| {
|
||||
GwsError::Discovery("Resource 'projects' not found".to_string())
|
||||
})?;
|
||||
let update_method = projects_res.methods.get("updateContent").ok_or_else(|| {
|
||||
GwsError::Discovery("Method 'projects.updateContent' not found".to_string())
|
||||
})?;
|
||||
|
||||
// Build body
|
||||
let body = json!({
|
||||
"files": files
|
||||
});
|
||||
let body_str = body.to_string();
|
||||
|
||||
let scopes: Vec<&str> = update_method.scopes.iter().map(|s| s.as_str()).collect();
|
||||
let (token, auth_method) = match auth::get_token(&scopes).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!("Script auth failed: {e}"))),
|
||||
};
|
||||
|
||||
let params = json!({
|
||||
"scriptId": script_id
|
||||
});
|
||||
let params_str = params.to_string();
|
||||
|
||||
executor::execute_method(
|
||||
doc,
|
||||
update_method,
|
||||
Some(¶ms_str),
|
||||
Some(&body_str),
|
||||
token.as_deref(),
|
||||
auth_method,
|
||||
None,
|
||||
None,
|
||||
matches.get_flag("dry-run"),
|
||||
&executor::PaginationConfig::default(),
|
||||
None,
|
||||
&crate::helpers::modelarmor::SanitizeMode::Warn,
|
||||
&crate::formatter::OutputFormat::default(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_dirs(dir: &Path, files: &mut Vec<serde_json::Value>) -> Result<(), GwsError> {
|
||||
if dir.is_dir() {
|
||||
for entry in fs::read_dir(dir).context("Failed to read dir")? {
|
||||
let entry = entry.context("Failed to read entry")?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
visit_dirs(&path, files)?;
|
||||
} else if let Some(file_obj) = process_file(&path)? {
|
||||
files.push(file_obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_file(path: &Path) -> Result<Option<serde_json::Value>, GwsError> {
|
||||
let filename = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
|
||||
// Skip hidden files, node_modules, .git, etc. (basic filtering)
|
||||
if filename.starts_with('.') || path.components().any(|c| c.as_os_str() == "node_modules") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let (type_val, name_val) = match extension {
|
||||
"gs" | "js" => (
|
||||
"SERVER_JS",
|
||||
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);
|
||||
}
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let content = fs::read_to_string(path).map_err(|e| {
|
||||
GwsError::Validation(format!("Failed to read file '{}': {}", path.display(), e))
|
||||
})?;
|
||||
|
||||
Ok(Some(json!({
|
||||
"name": name_val,
|
||||
"type": type_val,
|
||||
"source": content
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_process_file_server_js() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("code.gs");
|
||||
let mut file = File::create(&file_path).unwrap();
|
||||
writeln!(file, "function foo() {{}}").unwrap();
|
||||
|
||||
let result = process_file(&file_path).unwrap().unwrap();
|
||||
assert_eq!(result["name"], "code");
|
||||
assert_eq!(result["type"], "SERVER_JS");
|
||||
assert_eq!(
|
||||
result["source"].as_str().unwrap().trim(),
|
||||
"function foo() {}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_file_html() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("index.html");
|
||||
let mut file = File::create(&file_path).unwrap();
|
||||
writeln!(file, "<html></html>").unwrap();
|
||||
|
||||
let result = process_file(&file_path).unwrap().unwrap();
|
||||
assert_eq!(result["name"], "index");
|
||||
assert_eq!(result["type"], "HTML");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_file_appsscript_json() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("appsscript.json");
|
||||
let mut file = File::create(&file_path).unwrap();
|
||||
writeln!(file, "{{}}").unwrap();
|
||||
|
||||
let result = process_file(&file_path).unwrap().unwrap();
|
||||
assert_eq!(result["name"], "appsscript");
|
||||
assert_eq!(result["type"], "JSON");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_file_ignored() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
// Random JSON
|
||||
let p1 = dir.path().join("other.json");
|
||||
File::create(&p1).unwrap();
|
||||
assert!(process_file(&p1).unwrap().is_none());
|
||||
|
||||
// Hidden file
|
||||
let p2 = dir.path().join(".hidden.gs");
|
||||
File::create(&p2).unwrap();
|
||||
assert!(process_file(&p2).unwrap().is_none());
|
||||
|
||||
// node_modules
|
||||
let node_modules = dir.path().join("node_modules");
|
||||
fs::create_dir(&node_modules).unwrap();
|
||||
let p3 = node_modules.join("dep.gs");
|
||||
File::create(&p3).unwrap();
|
||||
assert!(process_file(&p3).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visit_dirs() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
// Root file
|
||||
let f1 = dir.path().join("root.gs");
|
||||
File::create(&f1).unwrap();
|
||||
|
||||
// Subdir file
|
||||
let sub = dir.path().join("src");
|
||||
fs::create_dir(&sub).unwrap();
|
||||
let f2 = sub.join("utils.js");
|
||||
File::create(&f2).unwrap();
|
||||
|
||||
// Ignored file
|
||||
let f3 = dir.path().join("ignore.txt");
|
||||
File::create(&f3).unwrap();
|
||||
|
||||
let mut files = Vec::new();
|
||||
visit_dirs(dir.path(), &mut files).unwrap();
|
||||
|
||||
assert_eq!(files.len(), 2);
|
||||
|
||||
let names: Vec<&str> = files.iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
|
||||
assert!(names.contains(&"root"));
|
||||
assert!(names.contains(&"utils"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::Helper;
|
||||
use crate::auth;
|
||||
use crate::error::GwsError;
|
||||
use crate::executor;
|
||||
use clap::{Arg, ArgMatches, Command};
|
||||
use serde_json::json;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
pub struct SheetsHelper;
|
||||
|
||||
impl Helper for SheetsHelper {
|
||||
fn inject_commands(
|
||||
&self,
|
||||
mut cmd: Command,
|
||||
_doc: &crate::discovery::RestDescription,
|
||||
) -> Command {
|
||||
cmd = cmd.subcommand(
|
||||
Command::new("+append")
|
||||
.about("[Helper] Append a row to a spreadsheet")
|
||||
.arg(
|
||||
Arg::new("spreadsheet")
|
||||
.long("spreadsheet")
|
||||
.help("Spreadsheet ID")
|
||||
.required(true)
|
||||
.value_name("ID"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("values")
|
||||
.long("values")
|
||||
.help("Comma-separated values (simple strings)")
|
||||
.value_name("VALUES"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("json-values")
|
||||
.long("json-values")
|
||||
.help("JSON array of rows, e.g. '[[\"a\",\"b\"],[\"c\",\"d\"]]'")
|
||||
.value_name("JSON"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("range")
|
||||
.long("range")
|
||||
.help("Target range in A1 notation (e.g. 'Sheet2!A1'). Defaults to 'A1' (first sheet)")
|
||||
.value_name("RANGE"),
|
||||
)
|
||||
.after_help(
|
||||
r#"EXAMPLES:
|
||||
gws sheets +append --spreadsheet ID --values 'Alice,100,true'
|
||||
gws sheets +append --spreadsheet ID --json-values '[["a","b"],["c","d"]]'
|
||||
gws sheets +append --spreadsheet ID --range "Sheet2!A1" --values 'Alice,100'
|
||||
|
||||
TIPS:
|
||||
Use --values for simple single-row appends.
|
||||
Use --json-values for bulk multi-row inserts.
|
||||
Use --range to target a specific sheet tab (default: A1, i.e. first sheet)."#,
|
||||
),
|
||||
);
|
||||
|
||||
cmd = cmd.subcommand(
|
||||
Command::new("+read")
|
||||
.about("[Helper] Read values from a spreadsheet")
|
||||
.arg(
|
||||
Arg::new("spreadsheet")
|
||||
.long("spreadsheet")
|
||||
.help("Spreadsheet ID")
|
||||
.required(true)
|
||||
.value_name("ID"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("range")
|
||||
.long("range")
|
||||
.help("Range to read (e.g. 'Sheet1!A1:B2')")
|
||||
.required(true)
|
||||
.value_name("RANGE"),
|
||||
)
|
||||
.after_help(
|
||||
"\
|
||||
EXAMPLES:
|
||||
gws sheets +read --spreadsheet ID --range \"Sheet1!A1:D10\"
|
||||
gws sheets +read --spreadsheet ID --range Sheet1
|
||||
|
||||
TIPS:
|
||||
Read-only — never modifies the spreadsheet.
|
||||
For advanced options, use the raw values.get API.",
|
||||
),
|
||||
);
|
||||
|
||||
cmd
|
||||
}
|
||||
|
||||
fn handle<'a>(
|
||||
&'a self,
|
||||
doc: &'a crate::discovery::RestDescription,
|
||||
matches: &'a ArgMatches,
|
||||
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
|
||||
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
if let Some(matches) = matches.subcommand_matches("+append") {
|
||||
let config = parse_append_args(matches);
|
||||
let (params_str, body_str, scopes) = build_append_request(&config, 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!("Sheets auth failed: {e}"))),
|
||||
};
|
||||
|
||||
let spreadsheets_res = doc.resources.get("spreadsheets").ok_or_else(|| {
|
||||
GwsError::Discovery("Resource 'spreadsheets' not found".to_string())
|
||||
})?;
|
||||
let values_res = spreadsheets_res.resources.get("values").ok_or_else(|| {
|
||||
GwsError::Discovery("Resource 'spreadsheets.values' not found".to_string())
|
||||
})?;
|
||||
let append_method = values_res.methods.get("append").ok_or_else(|| {
|
||||
GwsError::Discovery("Method 'spreadsheets.values.append' not found".to_string())
|
||||
})?;
|
||||
|
||||
let pagination = executor::PaginationConfig {
|
||||
page_all: false,
|
||||
page_limit: 10,
|
||||
page_delay_ms: 100,
|
||||
};
|
||||
|
||||
executor::execute_method(
|
||||
doc,
|
||||
append_method,
|
||||
Some(¶ms_str),
|
||||
Some(&body_str),
|
||||
token.as_deref(),
|
||||
auth_method,
|
||||
None,
|
||||
None,
|
||||
matches.get_flag("dry-run"),
|
||||
&pagination,
|
||||
None,
|
||||
&crate::helpers::modelarmor::SanitizeMode::Warn,
|
||||
&crate::formatter::OutputFormat::default(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if let Some(matches) = matches.subcommand_matches("+read") {
|
||||
let config = parse_read_args(matches);
|
||||
let (params_str, scopes) = build_read_request(&config, doc)?;
|
||||
|
||||
// Re-find method
|
||||
let spreadsheets_res = doc.resources.get("spreadsheets").ok_or_else(|| {
|
||||
GwsError::Discovery("Resource 'spreadsheets' not found".to_string())
|
||||
})?;
|
||||
let values_res = spreadsheets_res.resources.get("values").ok_or_else(|| {
|
||||
GwsError::Discovery("Resource 'spreadsheets.values' not found".to_string())
|
||||
})?;
|
||||
let get_method = values_res.methods.get("get").ok_or_else(|| {
|
||||
GwsError::Discovery("Method 'spreadsheets.values.get' not found".to_string())
|
||||
})?;
|
||||
|
||||
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!("Sheets auth failed: {e}"))),
|
||||
};
|
||||
|
||||
executor::execute_method(
|
||||
doc,
|
||||
get_method,
|
||||
Some(¶ms_str),
|
||||
None,
|
||||
token.as_deref(),
|
||||
auth_method,
|
||||
None,
|
||||
None,
|
||||
matches.get_flag("dry-run"),
|
||||
&executor::PaginationConfig::default(),
|
||||
None,
|
||||
&crate::helpers::modelarmor::SanitizeMode::Warn,
|
||||
&crate::formatter::OutputFormat::default(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_append_request(
|
||||
config: &AppendConfig,
|
||||
doc: &crate::discovery::RestDescription,
|
||||
) -> Result<(String, String, Vec<String>), GwsError> {
|
||||
let spreadsheets_res = doc
|
||||
.resources
|
||||
.get("spreadsheets")
|
||||
.ok_or_else(|| GwsError::Discovery("Resource 'spreadsheets' not found".to_string()))?;
|
||||
let values_res = spreadsheets_res.resources.get("values").ok_or_else(|| {
|
||||
GwsError::Discovery("Resource 'spreadsheets.values' not found".to_string())
|
||||
})?;
|
||||
let append_method = values_res.methods.get("append").ok_or_else(|| {
|
||||
GwsError::Discovery("Method 'spreadsheets.values.append' not found".to_string())
|
||||
})?;
|
||||
|
||||
let params = json!({
|
||||
"spreadsheetId": config.spreadsheet_id,
|
||||
"range": config.range,
|
||||
"valueInputOption": "USER_ENTERED"
|
||||
});
|
||||
|
||||
let body = json!({
|
||||
"values": config.values
|
||||
});
|
||||
|
||||
// Map `&String` scope URLs to owned `String`s for the return value
|
||||
let scopes: Vec<String> = append_method.scopes.iter().map(|s| s.to_string()).collect();
|
||||
|
||||
Ok((params.to_string(), body.to_string(), scopes))
|
||||
}
|
||||
|
||||
fn build_read_request(
|
||||
config: &ReadConfig,
|
||||
doc: &crate::discovery::RestDescription,
|
||||
) -> Result<(String, Vec<String>), GwsError> {
|
||||
// ... resource lookup omitted for brevity ...
|
||||
let spreadsheets_res = doc
|
||||
.resources
|
||||
.get("spreadsheets")
|
||||
.ok_or_else(|| GwsError::Discovery("Resource 'spreadsheets' not found".to_string()))?;
|
||||
let values_res = spreadsheets_res.resources.get("values").ok_or_else(|| {
|
||||
GwsError::Discovery("Resource 'spreadsheets.values' not found".to_string())
|
||||
})?;
|
||||
let get_method = values_res.methods.get("get").ok_or_else(|| {
|
||||
GwsError::Discovery("Method 'spreadsheets.values.get' not found".to_string())
|
||||
})?;
|
||||
|
||||
let params = json!({
|
||||
"spreadsheetId": config.spreadsheet_id,
|
||||
"range": config.range
|
||||
});
|
||||
|
||||
let scopes: Vec<String> = get_method.scopes.iter().map(|s| s.to_string()).collect();
|
||||
|
||||
Ok((params.to_string(), scopes))
|
||||
}
|
||||
|
||||
/// Configuration for appending values to a spreadsheet.
|
||||
///
|
||||
/// Holds the parsed arguments for the `+append` subcommand.
|
||||
pub struct AppendConfig {
|
||||
/// The ID of the spreadsheet to append to.
|
||||
pub spreadsheet_id: String,
|
||||
/// Target range in A1 notation (e.g. "Sheet2!A1"). Defaults to "A1".
|
||||
pub range: String,
|
||||
/// The rows to append, where each inner Vec represents one row.
|
||||
pub values: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Parses arguments for the `+append` command.
|
||||
///
|
||||
/// Supports both `--values` (single row) and `--json-values` (single or multi-row).
|
||||
pub fn parse_append_args(matches: &ArgMatches) -> AppendConfig {
|
||||
let values = if let Some(json_str) = matches.get_one::<String>("json-values") {
|
||||
// Try parsing as array-of-arrays (multi-row) first
|
||||
if let Ok(parsed) = serde_json::from_str::<Vec<Vec<String>>>(json_str) {
|
||||
parsed
|
||||
} else if let Ok(parsed) = serde_json::from_str::<Vec<String>>(json_str) {
|
||||
// Single flat array — treat as one row
|
||||
vec![parsed]
|
||||
} else {
|
||||
eprintln!(
|
||||
"Warning: --json-values is not valid JSON; expected an array or array-of-arrays"
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
} else if let Some(values_str) = matches.get_one::<String>("values") {
|
||||
vec![values_str.split(',').map(|s| s.to_string()).collect()]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let range = matches
|
||||
.get_one::<String>("range")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "A1".to_string());
|
||||
|
||||
AppendConfig {
|
||||
spreadsheet_id: matches.get_one::<String>("spreadsheet").unwrap().clone(),
|
||||
range,
|
||||
values,
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for reading values from a spreadsheet.
|
||||
pub struct ReadConfig {
|
||||
pub spreadsheet_id: String,
|
||||
/// A1 notation range (e.g. "Sheet1!A1:B2").
|
||||
pub range: String,
|
||||
}
|
||||
|
||||
pub fn parse_read_args(matches: &ArgMatches) -> ReadConfig {
|
||||
ReadConfig {
|
||||
spreadsheet_id: matches.get_one::<String>("spreadsheet").unwrap().clone(),
|
||||
range: matches.get_one::<String>("range").unwrap().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::discovery::{RestDescription, RestMethod, RestResource};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn make_mock_doc() -> RestDescription {
|
||||
let mut methods = HashMap::new();
|
||||
methods.insert(
|
||||
"append".to_string(),
|
||||
RestMethod {
|
||||
scopes: vec!["https://scope".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
methods.insert(
|
||||
"get".to_string(),
|
||||
RestMethod {
|
||||
scopes: vec!["https://scope".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let mut values_res = RestResource::default();
|
||||
values_res.methods = methods;
|
||||
|
||||
let mut spreadsheets_res = RestResource::default();
|
||||
spreadsheets_res
|
||||
.resources
|
||||
.insert("values".to_string(), values_res);
|
||||
|
||||
let mut resources = HashMap::new();
|
||||
resources.insert("spreadsheets".to_string(), spreadsheets_res);
|
||||
|
||||
RestDescription {
|
||||
resources,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_matches_append(args: &[&str]) -> ArgMatches {
|
||||
let cmd = Command::new("test")
|
||||
.arg(Arg::new("spreadsheet").long("spreadsheet"))
|
||||
.arg(Arg::new("values").long("values"))
|
||||
.arg(Arg::new("json-values").long("json-values"))
|
||||
.arg(Arg::new("range").long("range"));
|
||||
cmd.try_get_matches_from(args).unwrap()
|
||||
}
|
||||
|
||||
fn make_matches_read(args: &[&str]) -> ArgMatches {
|
||||
let cmd = Command::new("test")
|
||||
.arg(Arg::new("spreadsheet").long("spreadsheet"))
|
||||
.arg(Arg::new("range").long("range"));
|
||||
cmd.try_get_matches_from(args).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_append_request() {
|
||||
let doc = make_mock_doc();
|
||||
let config = AppendConfig {
|
||||
spreadsheet_id: "123".to_string(),
|
||||
range: "A1".to_string(),
|
||||
values: vec![vec!["a".to_string(), "b".to_string(), "c".to_string()]],
|
||||
};
|
||||
let (params, body, scopes) = build_append_request(&config, &doc).unwrap();
|
||||
|
||||
assert!(params.contains("123"));
|
||||
assert!(params.contains("USER_ENTERED"));
|
||||
assert!(params.contains("A1"));
|
||||
assert!(body.contains("a"));
|
||||
assert!(body.contains("b"));
|
||||
assert_eq!(scopes[0], "https://scope");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_append_request_with_range() {
|
||||
let doc = make_mock_doc();
|
||||
let config = AppendConfig {
|
||||
spreadsheet_id: "123".to_string(),
|
||||
range: "Sheet2!A1".to_string(),
|
||||
values: vec![vec!["x".to_string()]],
|
||||
};
|
||||
let (params, _body, _scopes) = build_append_request(&config, &doc).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(¶ms).unwrap();
|
||||
assert_eq!(parsed["range"], "Sheet2!A1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_read_request() {
|
||||
let doc = make_mock_doc();
|
||||
let config = ReadConfig {
|
||||
spreadsheet_id: "123".to_string(),
|
||||
range: "A1:B2".to_string(),
|
||||
};
|
||||
let (params, scopes) = build_read_request(&config, &doc).unwrap();
|
||||
|
||||
assert!(params.contains("123"));
|
||||
assert!(params.contains("A1:B2"));
|
||||
assert_eq!(scopes[0], "https://scope");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_append_args_values() {
|
||||
let matches = make_matches_append(&["test", "--spreadsheet", "123", "--values", "a,b,c"]);
|
||||
let config = parse_append_args(&matches);
|
||||
assert_eq!(config.spreadsheet_id, "123");
|
||||
assert_eq!(config.range, "A1");
|
||||
assert_eq!(config.values, vec![vec!["a", "b", "c"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_append_args_with_range() {
|
||||
let matches = make_matches_append(&[
|
||||
"test",
|
||||
"--spreadsheet",
|
||||
"123",
|
||||
"--range",
|
||||
"Sheet2!A1",
|
||||
"--values",
|
||||
"a,b",
|
||||
]);
|
||||
let config = parse_append_args(&matches);
|
||||
assert_eq!(config.range, "Sheet2!A1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_append_args_default_range() {
|
||||
let matches = make_matches_append(&["test", "--spreadsheet", "123", "--values", "a"]);
|
||||
let config = parse_append_args(&matches);
|
||||
assert_eq!(config.range, "A1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_append_args_json_single_row() {
|
||||
let matches = make_matches_append(&[
|
||||
"test",
|
||||
"--spreadsheet",
|
||||
"123",
|
||||
"--json-values",
|
||||
r#"["a","b","c"]"#,
|
||||
]);
|
||||
let config = parse_append_args(&matches);
|
||||
assert_eq!(config.values, vec![vec!["a", "b", "c"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_append_args_json_multi_row() {
|
||||
let matches = make_matches_append(&[
|
||||
"test",
|
||||
"--spreadsheet",
|
||||
"123",
|
||||
"--json-values",
|
||||
r#"[["Alice","100"],["Bob","200"]]"#,
|
||||
]);
|
||||
let config = parse_append_args(&matches);
|
||||
assert_eq!(
|
||||
config.values,
|
||||
vec![vec!["Alice", "100"], vec!["Bob", "200"]]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_append_request_multi_row() {
|
||||
let doc = make_mock_doc();
|
||||
let config = AppendConfig {
|
||||
spreadsheet_id: "123".to_string(),
|
||||
range: "A1".to_string(),
|
||||
values: vec![
|
||||
vec!["Alice".to_string(), "100".to_string()],
|
||||
vec!["Bob".to_string(), "200".to_string()],
|
||||
],
|
||||
};
|
||||
let (_params, body, _scopes) = build_append_request(&config, &doc).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
let values = parsed["values"].as_array().unwrap();
|
||||
assert_eq!(values.len(), 2);
|
||||
assert_eq!(values[0], json!(["Alice", "100"]));
|
||||
assert_eq!(values[1], json!(["Bob", "200"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_read_args() {
|
||||
let matches = make_matches_read(&["test", "--spreadsheet", "123", "--range", "A1:B2"]);
|
||||
let config = parse_read_args(&matches);
|
||||
assert_eq!(config.spreadsheet_id, "123");
|
||||
assert_eq!(config.range, "A1:B2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_commands() {
|
||||
let helper = SheetsHelper;
|
||||
let cmd = Command::new("test");
|
||||
let doc = crate::discovery::RestDescription::default();
|
||||
|
||||
let cmd = helper.inject_commands(cmd, &doc);
|
||||
let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
|
||||
assert!(subcommands.contains(&"+append"));
|
||||
assert!(subcommands.contains(&"+read"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Cross-service workflow helpers that compose multiple Google Workspace API
|
||||
//! calls into high-level productivity actions.
|
||||
|
||||
use super::Helper;
|
||||
use crate::auth;
|
||||
use crate::error::GwsError;
|
||||
use crate::output::sanitize_for_terminal;
|
||||
use clap::{Arg, ArgMatches, Command};
|
||||
use serde_json::{json, Value};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
pub struct WorkflowHelper;
|
||||
|
||||
impl Helper for WorkflowHelper {
|
||||
fn inject_commands(
|
||||
&self,
|
||||
mut cmd: Command,
|
||||
_doc: &crate::discovery::RestDescription,
|
||||
) -> Command {
|
||||
cmd = cmd.subcommand(build_standup_report_cmd());
|
||||
cmd = cmd.subcommand(build_meeting_prep_cmd());
|
||||
cmd = cmd.subcommand(build_email_to_task_cmd());
|
||||
cmd = cmd.subcommand(build_weekly_digest_cmd());
|
||||
cmd = cmd.subcommand(build_file_announce_cmd());
|
||||
cmd
|
||||
}
|
||||
|
||||
fn handle<'a>(
|
||||
&'a self,
|
||||
_doc: &'a crate::discovery::RestDescription,
|
||||
matches: &'a ArgMatches,
|
||||
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
|
||||
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
if let Some(m) = matches.subcommand_matches("+standup-report") {
|
||||
handle_standup_report(m).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(m) = matches.subcommand_matches("+meeting-prep") {
|
||||
handle_meeting_prep(m).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(m) = matches.subcommand_matches("+email-to-task") {
|
||||
handle_email_to_task(m).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(m) = matches.subcommand_matches("+weekly-digest") {
|
||||
handle_weekly_digest(m).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(m) = matches.subcommand_matches("+file-announce") {
|
||||
handle_file_announce(m).await?;
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
})
|
||||
}
|
||||
|
||||
fn helper_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn build_standup_report_cmd() -> Command {
|
||||
Command::new("+standup-report")
|
||||
.about("[Helper] Today's meetings + open tasks as a standup summary")
|
||||
.arg(
|
||||
Arg::new("format")
|
||||
.long("format")
|
||||
.help("Output format: json (default), table, yaml, csv")
|
||||
.value_name("FORMAT")
|
||||
.global(true),
|
||||
)
|
||||
.after_help(
|
||||
"\
|
||||
EXAMPLES:
|
||||
gws workflow +standup-report
|
||||
gws workflow +standup-report --format table
|
||||
|
||||
TIPS:
|
||||
Read-only — never modifies data.
|
||||
Combines calendar agenda (today) with tasks list.",
|
||||
)
|
||||
}
|
||||
|
||||
fn build_meeting_prep_cmd() -> Command {
|
||||
Command::new("+meeting-prep")
|
||||
.about("[Helper] Prepare for your next meeting: agenda, attendees, and linked docs")
|
||||
.arg(
|
||||
Arg::new("calendar")
|
||||
.long("calendar")
|
||||
.help("Calendar ID (default: primary)")
|
||||
.default_value("primary")
|
||||
.value_name("ID"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("format")
|
||||
.long("format")
|
||||
.help("Output format: json (default), table, yaml, csv")
|
||||
.value_name("FORMAT")
|
||||
.global(true),
|
||||
)
|
||||
.after_help(
|
||||
"\
|
||||
EXAMPLES:
|
||||
gws workflow +meeting-prep
|
||||
gws workflow +meeting-prep --calendar Work
|
||||
|
||||
TIPS:
|
||||
Read-only — never modifies data.
|
||||
Shows the next upcoming event with attendees and description.",
|
||||
)
|
||||
}
|
||||
|
||||
fn build_email_to_task_cmd() -> Command {
|
||||
Command::new("+email-to-task")
|
||||
.about("[Helper] Convert a Gmail message into a Google Tasks entry")
|
||||
.arg(
|
||||
Arg::new("message-id")
|
||||
.long("message-id")
|
||||
.help("Gmail message ID to convert")
|
||||
.required(true)
|
||||
.value_name("ID"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("tasklist")
|
||||
.long("tasklist")
|
||||
.help("Task list ID (default: @default)")
|
||||
.default_value("@default")
|
||||
.value_name("ID"),
|
||||
)
|
||||
.after_help(
|
||||
"\
|
||||
EXAMPLES:
|
||||
gws workflow +email-to-task --message-id MSG_ID
|
||||
gws workflow +email-to-task --message-id MSG_ID --tasklist LIST_ID
|
||||
|
||||
TIPS:
|
||||
Reads the email subject as the task title and snippet as notes.
|
||||
Creates a new task — confirm with the user before executing.",
|
||||
)
|
||||
}
|
||||
|
||||
fn build_weekly_digest_cmd() -> Command {
|
||||
Command::new("+weekly-digest")
|
||||
.about("[Helper] Weekly summary: this week's meetings + unread email count")
|
||||
.arg(
|
||||
Arg::new("format")
|
||||
.long("format")
|
||||
.help("Output format: json (default), table, yaml, csv")
|
||||
.value_name("FORMAT")
|
||||
.global(true),
|
||||
)
|
||||
.after_help(
|
||||
"\
|
||||
EXAMPLES:
|
||||
gws workflow +weekly-digest
|
||||
gws workflow +weekly-digest --format table
|
||||
|
||||
TIPS:
|
||||
Read-only — never modifies data.
|
||||
Combines calendar agenda (week) with gmail triage summary.",
|
||||
)
|
||||
}
|
||||
|
||||
fn build_file_announce_cmd() -> Command {
|
||||
Command::new("+file-announce")
|
||||
.about("[Helper] Announce a Drive file in a Chat space")
|
||||
.arg(
|
||||
Arg::new("file-id")
|
||||
.long("file-id")
|
||||
.help("Drive file ID to announce")
|
||||
.required(true)
|
||||
.value_name("ID"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("space")
|
||||
.long("space")
|
||||
.help("Chat space name (e.g. spaces/SPACE_ID)")
|
||||
.required(true)
|
||||
.value_name("SPACE"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("message")
|
||||
.long("message")
|
||||
.help("Custom announcement message")
|
||||
.value_name("TEXT"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("format")
|
||||
.long("format")
|
||||
.help("Output format: json (default), table, yaml, csv")
|
||||
.value_name("FORMAT")
|
||||
.global(true),
|
||||
)
|
||||
.after_help(
|
||||
"\
|
||||
EXAMPLES:
|
||||
gws workflow +file-announce --file-id FILE_ID --space spaces/ABC123
|
||||
gws workflow +file-announce --file-id FILE_ID --space spaces/ABC123 --message 'Check this out!'
|
||||
|
||||
TIPS:
|
||||
This is a write command — sends a Chat message.
|
||||
Use `gws drive +upload` first to upload the file, then announce it here.
|
||||
Fetches the file name from Drive to build the announcement.",
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn get_json(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
token: &str,
|
||||
query: &[(&str, &str)],
|
||||
) -> Result<Value, GwsError> {
|
||||
let resp = client
|
||||
.get(url)
|
||||
.query(query)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| GwsError::Other(anyhow::anyhow!("HTTP request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(GwsError::Api {
|
||||
code: status.as_u16(),
|
||||
message: body,
|
||||
reason: "workflow_request_failed".to_string(),
|
||||
enable_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
resp.json::<Value>()
|
||||
.await
|
||||
.map_err(|e| GwsError::Other(anyhow::anyhow!("JSON parse failed: {e}")))
|
||||
}
|
||||
|
||||
fn format_and_print(value: &Value, matches: &ArgMatches) {
|
||||
let fmt = matches
|
||||
.get_one::<String>("format")
|
||||
.map(|s| crate::formatter::OutputFormat::from_str(s))
|
||||
.unwrap_or_default();
|
||||
println!("{}", crate::formatter::format_value(value, &fmt));
|
||||
}
|
||||
|
||||
async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
let cal_scope = "https://www.googleapis.com/auth/calendar.readonly";
|
||||
let tasks_scope = "https://www.googleapis.com/auth/tasks.readonly";
|
||||
let token = auth::get_token(&[cal_scope, tasks_scope])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
|
||||
|
||||
let client = crate::client::build_client()?;
|
||||
|
||||
// Resolve account timezone for day boundaries
|
||||
let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?;
|
||||
let now_in_tz = chrono::Utc::now().with_timezone(&tz);
|
||||
let today_start_tz = crate::timezone::start_of_today(tz)?;
|
||||
let today_end_tz = today_start_tz + chrono::Duration::days(1);
|
||||
let time_min = today_start_tz.to_rfc3339();
|
||||
let time_max = today_end_tz.to_rfc3339();
|
||||
|
||||
// Fetch today's events
|
||||
let events_json = get_json(
|
||||
&client,
|
||||
"https://www.googleapis.com/calendar/v3/calendars/primary/events",
|
||||
&token,
|
||||
&[
|
||||
("timeMin", time_min.as_str()),
|
||||
("timeMax", time_max.as_str()),
|
||||
("singleEvents", "true"),
|
||||
("orderBy", "startTime"),
|
||||
("maxResults", "25"),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
eprintln!(
|
||||
"Warning: Failed to fetch calendar events: {}",
|
||||
sanitize_for_terminal(&e.to_string())
|
||||
);
|
||||
})
|
||||
.unwrap_or(json!({}));
|
||||
let events = events_json
|
||||
.get("items")
|
||||
.and_then(|i| i.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let meetings: Vec<Value> = events
|
||||
.iter()
|
||||
.map(|e| {
|
||||
json!({
|
||||
"summary": e.get("summary").and_then(|v| v.as_str()).unwrap_or("(No title)"),
|
||||
"start": e.get("start").and_then(|s| s.get("dateTime").or(s.get("date"))).and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"end": e.get("end").and_then(|s| s.get("dateTime").or(s.get("date"))).and_then(|v| v.as_str()).unwrap_or(""),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Fetch open tasks
|
||||
let tasks_json = get_json(
|
||||
&client,
|
||||
"https://tasks.googleapis.com/tasks/v1/lists/@default/tasks",
|
||||
&token,
|
||||
&[("showCompleted", "false"), ("maxResults", "20")],
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
eprintln!(
|
||||
"Warning: Failed to fetch tasks: {}",
|
||||
sanitize_for_terminal(&e.to_string())
|
||||
);
|
||||
})
|
||||
.unwrap_or(json!({}));
|
||||
let tasks = tasks_json
|
||||
.get("items")
|
||||
.and_then(|i| i.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let open_tasks: Vec<Value> = tasks
|
||||
.iter()
|
||||
.map(|t| {
|
||||
json!({
|
||||
"title": t.get("title").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"due": t.get("due").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let output = json!({
|
||||
"meetings": meetings,
|
||||
"meetingCount": meetings.len(),
|
||||
"tasks": open_tasks,
|
||||
"taskCount": open_tasks.len(),
|
||||
"date": now_in_tz.format("%Y-%m-%d").to_string(),
|
||||
});
|
||||
|
||||
format_and_print(&output, matches);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
let cal_scope = "https://www.googleapis.com/auth/calendar.readonly";
|
||||
let token = auth::get_token(&[cal_scope])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
|
||||
|
||||
let client = crate::client::build_client()?;
|
||||
let calendar_id = matches
|
||||
.get_one::<String>("calendar")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("primary");
|
||||
|
||||
// Use account timezone for current time
|
||||
let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?;
|
||||
let now_rfc = chrono::Utc::now().with_timezone(&tz).to_rfc3339();
|
||||
|
||||
let events_url = format!(
|
||||
"https://www.googleapis.com/calendar/v3/calendars/{}/events",
|
||||
crate::validate::encode_path_segment(calendar_id),
|
||||
);
|
||||
let events_json = get_json(
|
||||
&client,
|
||||
&events_url,
|
||||
&token,
|
||||
&[
|
||||
("timeMin", now_rfc.as_str()),
|
||||
("singleEvents", "true"),
|
||||
("orderBy", "startTime"),
|
||||
("maxResults", "1"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let items = events_json
|
||||
.get("items")
|
||||
.and_then(|i| i.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
if items.is_empty() {
|
||||
let output = json!({ "message": "No upcoming meetings found." });
|
||||
format_and_print(&output, matches);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let event = &items[0];
|
||||
let attendees = event
|
||||
.get("attendees")
|
||||
.and_then(|a| a.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let attendee_list: Vec<Value> = attendees
|
||||
.iter()
|
||||
.map(|a| {
|
||||
json!({
|
||||
"email": a.get("email").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"responseStatus": a.get("responseStatus").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let output = json!({
|
||||
"summary": event.get("summary").and_then(|v| v.as_str()).unwrap_or("(No title)"),
|
||||
"start": event.get("start").and_then(|s| s.get("dateTime").or(s.get("date"))).and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"end": event.get("end").and_then(|s| s.get("dateTime").or(s.get("date"))).and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"description": event.get("description").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"location": event.get("location").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"hangoutLink": event.get("hangoutLink").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"htmlLink": event.get("htmlLink").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"attendees": attendee_list,
|
||||
"attendeeCount": attendee_list.len(),
|
||||
});
|
||||
|
||||
format_and_print(&output, matches);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_email_to_task(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
let gmail_scope = "https://www.googleapis.com/auth/gmail.readonly";
|
||||
let tasks_scope = "https://www.googleapis.com/auth/tasks";
|
||||
let token = auth::get_token(&[gmail_scope, tasks_scope])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
|
||||
|
||||
let client = crate::client::build_client()?;
|
||||
let message_id = matches.get_one::<String>("message-id").unwrap();
|
||||
let tasklist = matches
|
||||
.get_one::<String>("tasklist")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("@default");
|
||||
|
||||
// 1. Fetch the email
|
||||
let msg_url = format!(
|
||||
"https://gmail.googleapis.com/gmail/v1/users/me/messages/{}",
|
||||
crate::validate::encode_path_segment(message_id),
|
||||
);
|
||||
let msg_json = get_json(
|
||||
&client,
|
||||
&msg_url,
|
||||
&token,
|
||||
&[("format", "metadata"), ("metadataHeaders", "Subject")],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let subject = msg_json
|
||||
.get("payload")
|
||||
.and_then(|p| p.get("headers"))
|
||||
.and_then(|h| h.as_array())
|
||||
.and_then(|headers| {
|
||||
headers.iter().find(|h| {
|
||||
h.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.is_some_and(|n| n.eq_ignore_ascii_case("Subject"))
|
||||
})
|
||||
})
|
||||
.and_then(|h| h.get("value"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("(No subject)");
|
||||
|
||||
let snippet = msg_json
|
||||
.get("snippet")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// 2. Create the task
|
||||
let task_body = json!({
|
||||
"title": subject,
|
||||
"notes": format!("From email: {}\n\n{}", message_id, snippet),
|
||||
});
|
||||
|
||||
let tasklist = crate::validate::validate_resource_name(tasklist)?;
|
||||
let task_url = format!(
|
||||
"https://tasks.googleapis.com/tasks/v1/lists/{}/tasks",
|
||||
tasklist,
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.post(&task_url)
|
||||
.bearer_auth(&token)
|
||||
.json(&task_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to create task: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(GwsError::Api {
|
||||
code: status.as_u16(),
|
||||
message: body,
|
||||
reason: "task_create_failed".to_string(),
|
||||
enable_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
let task_result: Value = resp.json().await.unwrap_or(json!({}));
|
||||
let output = json!({
|
||||
"created": true,
|
||||
"taskId": task_result.get("id").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
"title": subject,
|
||||
"sourceMessageId": message_id,
|
||||
});
|
||||
|
||||
format_and_print(&output, matches);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
let cal_scope = "https://www.googleapis.com/auth/calendar.readonly";
|
||||
let gmail_scope = "https://www.googleapis.com/auth/gmail.readonly";
|
||||
let token = auth::get_token(&[cal_scope, gmail_scope])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
|
||||
|
||||
let client = crate::client::build_client()?;
|
||||
|
||||
// Resolve account timezone for week boundaries
|
||||
let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?;
|
||||
let now_in_tz = chrono::Utc::now().with_timezone(&tz);
|
||||
let week_end = now_in_tz + chrono::Duration::days(7);
|
||||
let time_min = now_in_tz.to_rfc3339();
|
||||
let time_max = week_end.to_rfc3339();
|
||||
|
||||
// Fetch this week's events
|
||||
let events_json = get_json(
|
||||
&client,
|
||||
"https://www.googleapis.com/calendar/v3/calendars/primary/events",
|
||||
&token,
|
||||
&[
|
||||
("timeMin", time_min.as_str()),
|
||||
("timeMax", time_max.as_str()),
|
||||
("singleEvents", "true"),
|
||||
("orderBy", "startTime"),
|
||||
("maxResults", "50"),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
eprintln!(
|
||||
"Warning: Failed to fetch calendar events: {}",
|
||||
sanitize_for_terminal(&e.to_string())
|
||||
);
|
||||
})
|
||||
.unwrap_or(json!({}));
|
||||
let events = events_json
|
||||
.get("items")
|
||||
.and_then(|i| i.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let meetings: Vec<Value> = events
|
||||
.iter()
|
||||
.map(|e| {
|
||||
json!({
|
||||
"summary": e.get("summary").and_then(|v| v.as_str()).unwrap_or("(No title)"),
|
||||
"start": e.get("start").and_then(|s| s.get("dateTime").or(s.get("date"))).and_then(|v| v.as_str()).unwrap_or(""),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Fetch unread email count
|
||||
let gmail_json = get_json(
|
||||
&client,
|
||||
"https://gmail.googleapis.com/gmail/v1/users/me/messages",
|
||||
&token,
|
||||
&[("q", "is:unread"), ("maxResults", "1")],
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
eprintln!(
|
||||
"Warning: Failed to fetch unread email count: {}",
|
||||
sanitize_for_terminal(&e.to_string())
|
||||
);
|
||||
})
|
||||
.unwrap_or(json!({}));
|
||||
let unread_estimate = gmail_json
|
||||
.get("resultSizeEstimate")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let output = json!({
|
||||
"meetings": meetings,
|
||||
"meetingCount": meetings.len(),
|
||||
"unreadEmails": unread_estimate,
|
||||
"periodStart": time_min,
|
||||
"periodEnd": time_max,
|
||||
});
|
||||
|
||||
format_and_print(&output, matches);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_file_announce(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
let drive_scope = "https://www.googleapis.com/auth/drive.readonly";
|
||||
let chat_scope = "https://www.googleapis.com/auth/chat.messages.create";
|
||||
let token = auth::get_token(&[drive_scope, chat_scope])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
|
||||
|
||||
let client = crate::client::build_client()?;
|
||||
let file_id = matches.get_one::<String>("file-id").unwrap();
|
||||
let space = matches.get_one::<String>("space").unwrap();
|
||||
let custom_msg = matches.get_one::<String>("message");
|
||||
|
||||
// 1. Fetch file metadata from Drive
|
||||
let file_url = format!(
|
||||
"https://www.googleapis.com/drive/v3/files/{}",
|
||||
crate::validate::encode_path_segment(file_id),
|
||||
);
|
||||
let file_json = get_json(
|
||||
&client,
|
||||
&file_url,
|
||||
&token,
|
||||
&[("fields", "id,name,webViewLink")],
|
||||
)
|
||||
.await?;
|
||||
let file_name = file_json
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("file");
|
||||
let default_link = format!("https://drive.google.com/file/d/{}/view", file_id);
|
||||
let file_link = file_json
|
||||
.get("webViewLink")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&default_link);
|
||||
|
||||
// 2. Send Chat message
|
||||
let msg_text = custom_msg
|
||||
.map(|m| format!("{m}\n{file_link}"))
|
||||
.unwrap_or_else(|| format!("📎 {file_name}\n{file_link}"));
|
||||
|
||||
let chat_body = json!({ "text": msg_text });
|
||||
let space = crate::validate::validate_resource_name(space)?;
|
||||
let chat_url = format!("https://chat.googleapis.com/v1/{}/messages", space);
|
||||
|
||||
let chat_resp = client
|
||||
.post(&chat_url)
|
||||
.bearer_auth(&token)
|
||||
.json(&chat_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| GwsError::Other(anyhow::anyhow!("Chat send failed: {e}")))?;
|
||||
|
||||
if !chat_resp.status().is_success() {
|
||||
let status = chat_resp.status();
|
||||
let body = chat_resp.text().await.unwrap_or_default();
|
||||
return Err(GwsError::Api {
|
||||
code: status.as_u16(),
|
||||
message: body,
|
||||
reason: "chat_send_failed".to_string(),
|
||||
enable_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
let output = json!({
|
||||
"announced": true,
|
||||
"fileName": file_name,
|
||||
"fileLink": file_link,
|
||||
"space": space,
|
||||
});
|
||||
|
||||
format_and_print(&output, matches);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// (epoch_to_rfc3339 removed — replaced by account timezone resolution)
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_inject_commands() {
|
||||
let helper = WorkflowHelper;
|
||||
let cmd = Command::new("test");
|
||||
let doc = crate::discovery::RestDescription::default();
|
||||
let cmd = helper.inject_commands(cmd, &doc);
|
||||
let names: Vec<_> = cmd
|
||||
.get_subcommands()
|
||||
.map(|s| s.get_name().to_string())
|
||||
.collect();
|
||||
assert!(names.contains(&"+standup-report".to_string()));
|
||||
assert!(names.contains(&"+meeting-prep".to_string()));
|
||||
assert!(names.contains(&"+email-to-task".to_string()));
|
||||
assert!(names.contains(&"+weekly-digest".to_string()));
|
||||
assert!(names.contains(&"+file-announce".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_helper_only() {
|
||||
assert!(WorkflowHelper.helper_only());
|
||||
}
|
||||
|
||||
// (test_epoch_to_rfc3339 removed — function replaced by timezone resolution)
|
||||
|
||||
#[test]
|
||||
fn test_build_standup_report_cmd() {
|
||||
let cmd = build_standup_report_cmd();
|
||||
assert_eq!(cmd.get_name(), "+standup-report");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_meeting_prep_cmd() {
|
||||
let cmd = build_meeting_prep_cmd();
|
||||
assert_eq!(cmd.get_name(), "+meeting-prep");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_email_to_task_cmd() {
|
||||
let cmd = build_email_to_task_cmd();
|
||||
assert_eq!(cmd.get_name(), "+email-to-task");
|
||||
|
||||
// message-id is required
|
||||
let args = cmd
|
||||
.clone()
|
||||
.try_get_matches_from(vec!["+email-to-task", "--message-id", "123"]);
|
||||
assert!(args.is_ok());
|
||||
|
||||
let args_err = cmd.try_get_matches_from(vec!["+email-to-task"]);
|
||||
assert!(args_err.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_weekly_digest_cmd() {
|
||||
let cmd = build_weekly_digest_cmd();
|
||||
assert_eq!(cmd.get_name(), "+weekly-digest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_file_announce_cmd() {
|
||||
let cmd = build_file_announce_cmd();
|
||||
assert_eq!(cmd.get_name(), "+file-announce");
|
||||
|
||||
let args = cmd.clone().try_get_matches_from(vec![
|
||||
"+file-announce",
|
||||
"--file-id",
|
||||
"123",
|
||||
"--space",
|
||||
"spaces/test",
|
||||
]);
|
||||
assert!(args.is_ok());
|
||||
|
||||
let args_err = cmd.try_get_matches_from(vec!["+file-announce"]);
|
||||
assert!(args_err.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Structured Logging
|
||||
//!
|
||||
//! Provides opt-in, PII-free logging for HTTP requests and CLI operations.
|
||||
//! All output goes to stderr or a log file — stdout remains clean for
|
||||
//! machine-consumable JSON output.
|
||||
//!
|
||||
//! ## Environment Variables
|
||||
//!
|
||||
//! - `GOOGLE_WORKSPACE_CLI_LOG`: Filter directive for stderr logging
|
||||
//! (e.g., `gws=debug`, `gws=trace`). If unset, no stderr logging.
|
||||
//!
|
||||
//! - `GOOGLE_WORKSPACE_CLI_LOG_FILE`: Directory path for JSON-line log
|
||||
//! files with daily rotation. If unset, no file logging.
|
||||
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
/// Environment variable controlling stderr log output.
|
||||
const ENV_LOG: &str = "GOOGLE_WORKSPACE_CLI_LOG";
|
||||
|
||||
/// Environment variable controlling file log output.
|
||||
const ENV_LOG_FILE: &str = "GOOGLE_WORKSPACE_CLI_LOG_FILE";
|
||||
|
||||
/// Initialize the tracing subscriber based on environment variables.
|
||||
///
|
||||
/// If neither `GOOGLE_WORKSPACE_CLI_LOG` nor `GOOGLE_WORKSPACE_CLI_LOG_FILE`
|
||||
/// is set, this is a no-op and logging adds zero overhead.
|
||||
///
|
||||
/// This function must be called at most once (typically in `main()`).
|
||||
/// Subsequent calls will silently fail (tracing only allows one global
|
||||
/// subscriber).
|
||||
pub fn init_logging() {
|
||||
let stderr_filter = std::env::var(ENV_LOG).ok();
|
||||
let log_file_dir = std::env::var(ENV_LOG_FILE).ok();
|
||||
|
||||
// If neither env var is set, skip initialization entirely for zero overhead.
|
||||
if stderr_filter.is_none() && log_file_dir.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let registry = tracing_subscriber::registry();
|
||||
|
||||
// Stderr layer: human-readable, filtered by GOOGLE_WORKSPACE_CLI_LOG
|
||||
let stderr_layer = stderr_filter.map(|filter| {
|
||||
let env_filter = tracing_subscriber::EnvFilter::new(filter);
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.with_filter(env_filter)
|
||||
});
|
||||
|
||||
// File layer: JSON-line output with daily rotation
|
||||
let (file_layer, _guard) = if let Some(ref dir) = log_file_dir {
|
||||
let file_appender = tracing_appender::rolling::daily(dir, "gws.log");
|
||||
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||
let layer = tracing_subscriber::fmt::layer()
|
||||
.json()
|
||||
.with_writer(non_blocking)
|
||||
.with_target(true)
|
||||
.with_filter(tracing_subscriber::EnvFilter::new("gws=debug"));
|
||||
(Some(layer), Some(guard))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// Compose layers and set as global subscriber.
|
||||
// The guard is leaked intentionally so the non-blocking writer stays
|
||||
// alive for the lifetime of the process.
|
||||
let subscriber = registry.with(stderr_layer).with(file_layer);
|
||||
if tracing::subscriber::set_global_default(subscriber).is_ok() {
|
||||
if let Some(guard) = _guard {
|
||||
// Leak the guard so the non-blocking writer lives for the process lifetime.
|
||||
// This is the recommended pattern from tracing-appender docs.
|
||||
std::mem::forget(guard);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_init_logging_default_no_panic() {
|
||||
// With no env vars set, init_logging should be a no-op and not panic.
|
||||
// We can't truly test the global subscriber in unit tests (it's global state),
|
||||
// but we can verify the early-return path doesn't panic.
|
||||
std::env::remove_var(ENV_LOG);
|
||||
std::env::remove_var(ENV_LOG_FILE);
|
||||
init_logging();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_var_names() {
|
||||
assert_eq!(ENV_LOG, "GOOGLE_WORKSPACE_CLI_LOG");
|
||||
assert_eq!(ENV_LOG_FILE, "GOOGLE_WORKSPACE_CLI_LOG_FILE");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Google Workspace CLI (gws)
|
||||
//!
|
||||
//! A dynamic, schema-driven CLI for Google Workspace APIs.
|
||||
//! This tool dynamically parses Google API Discovery Documents to construct CLI commands.
|
||||
//! It supports deep schema validation, OAuth / Service Account authentication,
|
||||
//! interactive prompts, and integration with Model Armor.
|
||||
|
||||
mod auth;
|
||||
pub(crate) mod auth_commands;
|
||||
mod client;
|
||||
mod commands;
|
||||
pub(crate) mod credential_store;
|
||||
mod discovery;
|
||||
mod error;
|
||||
mod executor;
|
||||
mod formatter;
|
||||
mod fs_util;
|
||||
mod generate_skills;
|
||||
mod helpers;
|
||||
mod logging;
|
||||
mod oauth_config;
|
||||
mod output;
|
||||
mod schema;
|
||||
mod services;
|
||||
mod setup;
|
||||
mod setup_tui;
|
||||
mod text;
|
||||
mod timezone;
|
||||
mod token_storage;
|
||||
pub(crate) mod validate;
|
||||
|
||||
use error::{print_error_json, GwsError};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Load .env file if present (silently ignored if missing)
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
// Initialize structured logging (no-op if env vars are unset)
|
||||
logging::init_logging();
|
||||
|
||||
if let Err(err) = run().await {
|
||||
print_error_json(&err);
|
||||
std::process::exit(err.exit_code());
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<(), GwsError> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
if args.len() < 2 {
|
||||
print_usage();
|
||||
return Err(GwsError::Validation(
|
||||
"No service specified. Usage: gws <service> <resource> [sub-resource] <method> [flags]"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Find the first non-flag arg (skip --api-version and its value)
|
||||
let mut first_arg: Option<String> = None;
|
||||
{
|
||||
let mut skip_next = false;
|
||||
for a in args.iter().skip(1) {
|
||||
if skip_next {
|
||||
skip_next = false;
|
||||
continue;
|
||||
}
|
||||
if a == "--api-version" {
|
||||
skip_next = true;
|
||||
continue;
|
||||
}
|
||||
if a.starts_with("--api-version=") {
|
||||
continue;
|
||||
}
|
||||
if !a.starts_with("--") || a.as_str() == "--help" || a.as_str() == "--version" {
|
||||
first_arg = Some(a.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let first_arg = first_arg.ok_or_else(|| {
|
||||
GwsError::Validation(
|
||||
"No service specified. Usage: gws <service> <resource> [sub-resource] <method> [flags]"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Handle --help and --version at top level
|
||||
if is_help_flag(&first_arg) {
|
||||
print_usage();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if is_version_flag(&first_arg) {
|
||||
println!("gws {}", env!("CARGO_PKG_VERSION"));
|
||||
println!("This is not an officially supported Google product.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Handle the `schema` command
|
||||
if first_arg == "schema" {
|
||||
if args.len() < 3 {
|
||||
return Err(GwsError::Validation(
|
||||
"Usage: gws schema <service.resource.method> (e.g., gws schema drive.files.list) [--resolve-refs]"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let resolve_refs = args.iter().any(|arg| arg == "--resolve-refs");
|
||||
// Remove the flag if it exists so it doesn't mess up path parsing, or just pass the path
|
||||
// The path is args[2], flags might follow.
|
||||
let path = &args[2];
|
||||
return schema::handle_schema_command(path, resolve_refs).await;
|
||||
}
|
||||
|
||||
// Handle the `generate-skills` command
|
||||
if first_arg == "generate-skills" {
|
||||
let gen_args: Vec<String> = args.iter().skip(2).cloned().collect();
|
||||
return generate_skills::handle_generate_skills(&gen_args).await;
|
||||
}
|
||||
|
||||
// Handle the `auth` command
|
||||
if first_arg == "auth" {
|
||||
let auth_args: Vec<String> = args.iter().skip(2).cloned().collect();
|
||||
return auth_commands::handle_auth_command(&auth_args).await;
|
||||
}
|
||||
|
||||
// Parse service name and optional version override
|
||||
let (api_name, version) = parse_service_and_version(&args, &first_arg)?;
|
||||
|
||||
// For synthetic services (no Discovery doc), use an empty RestDescription
|
||||
let doc = if api_name == "workflow" {
|
||||
discovery::RestDescription {
|
||||
name: "workflow".to_string(),
|
||||
description: Some("Cross-service productivity workflows".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
// Fetch the Discovery Document
|
||||
discovery::fetch_discovery_document(&api_name, &version)
|
||||
.await
|
||||
.map_err(|e| GwsError::Discovery(format!("{e:#}")))?
|
||||
};
|
||||
|
||||
// Build the dynamic command tree (all commands shown regardless of auth state)
|
||||
let cli = commands::build_cli(&doc);
|
||||
|
||||
// Re-parse args (skip argv[0] which is the binary, and argv[1] which is the service name)
|
||||
// Filter out --api-version and its value
|
||||
// Prepend "gws" as the program name since try_get_matches_from expects argv[0]
|
||||
let sub_args = filter_args_for_subcommand(&args, &first_arg);
|
||||
|
||||
let matches = cli.try_get_matches_from(&sub_args).map_err(|e| {
|
||||
// If it's a help or version display, print it and exit cleanly
|
||||
if e.kind() == clap::error::ErrorKind::DisplayHelp
|
||||
|| e.kind() == clap::error::ErrorKind::DisplayVersion
|
||||
{
|
||||
print!("{e}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
GwsError::Validation(e.to_string())
|
||||
})?;
|
||||
|
||||
// Resolve --format flag
|
||||
let output_format = match matches.get_one::<String>("format") {
|
||||
Some(s) => match formatter::OutputFormat::parse(s) {
|
||||
Ok(fmt) => fmt,
|
||||
Err(unknown) => {
|
||||
eprintln!(
|
||||
"warning: unknown output format '{unknown}'; falling back to json (valid options: json, table, yaml, csv)"
|
||||
);
|
||||
formatter::OutputFormat::Json
|
||||
}
|
||||
},
|
||||
None => formatter::OutputFormat::default(),
|
||||
};
|
||||
|
||||
// Resolve --sanitize template (flag or env var)
|
||||
let sanitize_template = matches
|
||||
.get_one::<String>("sanitize")
|
||||
.cloned()
|
||||
.or_else(|| std::env::var("GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE").ok());
|
||||
|
||||
let sanitize_mode = std::env::var("GOOGLE_WORKSPACE_CLI_SANITIZE_MODE")
|
||||
.map(|v| helpers::modelarmor::SanitizeMode::from_str(&v))
|
||||
.unwrap_or(helpers::modelarmor::SanitizeMode::Warn);
|
||||
|
||||
let sanitize_config = parse_sanitize_config(sanitize_template, &sanitize_mode)?;
|
||||
|
||||
// Check if a helper wants to handle this command
|
||||
if let Some(helper) = helpers::get_helper(&doc.name) {
|
||||
if helper.handle(&doc, &matches, &sanitize_config).await? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Walk the subcommand tree to find the target method
|
||||
let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?;
|
||||
|
||||
let params_json = matched_args.get_one::<String>("params").map(|s| s.as_str());
|
||||
let body_json = matched_args
|
||||
.try_get_one::<String>("json")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|s| s.as_str());
|
||||
let upload_path = matched_args
|
||||
.try_get_one::<String>("upload")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|s| s.as_str());
|
||||
let output_path = matched_args.get_one::<String>("output").map(|s| s.as_str());
|
||||
|
||||
// Validate file paths against traversal before any I/O.
|
||||
// Use the returned canonical paths so the validated path is the one
|
||||
// actually used for I/O (closes TOCTOU gap).
|
||||
let upload_path_buf = if let Some(p) = upload_path {
|
||||
Some(crate::validate::validate_safe_file_path(p, "--upload")?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let output_path_buf = if let Some(p) = output_path {
|
||||
Some(crate::validate::validate_safe_file_path(p, "--output")?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let upload_path = upload_path_buf.as_deref().and_then(|p| p.to_str());
|
||||
let output_path = output_path_buf.as_deref().and_then(|p| p.to_str());
|
||||
|
||||
let upload = {
|
||||
let upload_content_type = matched_args
|
||||
.try_get_one::<String>("upload-content-type")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|s| s.as_str());
|
||||
upload_path.map(|path| executor::UploadSource::File {
|
||||
path,
|
||||
content_type: upload_content_type,
|
||||
})
|
||||
};
|
||||
|
||||
let dry_run = matched_args.get_flag("dry-run");
|
||||
|
||||
// Build pagination config from flags
|
||||
let pagination = parse_pagination_config(matched_args);
|
||||
|
||||
// Select the best scope for the method. Discovery Documents list scopes as
|
||||
// alternatives (any one grants access). We pick the first (broadest) scope
|
||||
// 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}")));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Execute
|
||||
executor::execute_method(
|
||||
&doc,
|
||||
method,
|
||||
params_json,
|
||||
body_json,
|
||||
token.as_deref(),
|
||||
auth_method,
|
||||
output_path,
|
||||
upload,
|
||||
dry_run,
|
||||
&pagination,
|
||||
sanitize_config.template.as_deref(),
|
||||
&sanitize_config.mode,
|
||||
&output_format,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Select the best scope from a method's scope list.
|
||||
///
|
||||
/// Discovery Documents list method scopes as alternatives — any single scope
|
||||
/// grants access. The first scope is typically the broadest. Using all scopes
|
||||
/// causes issues when restrictive scopes (e.g., `gmail.metadata`) are included,
|
||||
/// as the API enforces that scope's restrictions even when broader scopes are
|
||||
/// also present.
|
||||
pub(crate) fn select_scope(scopes: &[String]) -> Option<&str> {
|
||||
scopes.first().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
fn parse_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig {
|
||||
executor::PaginationConfig {
|
||||
page_all: matches.get_flag("page-all"),
|
||||
page_limit: matches.get_one::<u32>("page-limit").copied().unwrap_or(10),
|
||||
page_delay_ms: matches.get_one::<u64>("page-delay").copied().unwrap_or(100),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_service_and_version(
|
||||
args: &[String],
|
||||
first_arg: &str,
|
||||
) -> Result<(String, String), GwsError> {
|
||||
let mut service_arg = first_arg;
|
||||
let mut version_override: Option<String> = None;
|
||||
|
||||
// Check for --api-version flag anywhere in args
|
||||
for i in 0..args.len() {
|
||||
if args[i] == "--api-version" && i + 1 < args.len() {
|
||||
version_override = Some(args[i + 1].clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Support "service:version" syntax on the service arg itself
|
||||
if let Some((svc, ver)) = service_arg.split_once(':') {
|
||||
service_arg = svc;
|
||||
if version_override.is_none() {
|
||||
version_override = Some(ver.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let (api_name, default_version) = services::resolve_service(service_arg)?;
|
||||
let version = version_override.unwrap_or(default_version);
|
||||
Ok((api_name, version))
|
||||
}
|
||||
|
||||
pub fn filter_args_for_subcommand(args: &[String], service_name: &str) -> Vec<String> {
|
||||
let mut sub_args: Vec<String> = vec!["gws".to_string()];
|
||||
let mut skip_next = false;
|
||||
let mut service_skipped = false;
|
||||
for arg in args.iter().skip(1) {
|
||||
if skip_next {
|
||||
skip_next = false;
|
||||
continue;
|
||||
}
|
||||
if arg == "--api-version" {
|
||||
skip_next = true;
|
||||
continue;
|
||||
}
|
||||
if arg.starts_with("--api-version=") {
|
||||
continue;
|
||||
}
|
||||
if !service_skipped && arg == service_name {
|
||||
service_skipped = true;
|
||||
continue;
|
||||
}
|
||||
sub_args.push(arg.clone());
|
||||
}
|
||||
sub_args
|
||||
}
|
||||
|
||||
fn parse_sanitize_config(
|
||||
template: Option<String>,
|
||||
mode: &helpers::modelarmor::SanitizeMode,
|
||||
) -> Result<helpers::modelarmor::SanitizeConfig, GwsError> {
|
||||
Ok(helpers::modelarmor::SanitizeConfig {
|
||||
template,
|
||||
mode: mode.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Recursively walks clap ArgMatches to find the leaf method and its matches.
|
||||
fn resolve_method_from_matches<'a>(
|
||||
doc: &'a discovery::RestDescription,
|
||||
matches: &'a clap::ArgMatches,
|
||||
) -> Result<(&'a discovery::RestMethod, &'a clap::ArgMatches), GwsError> {
|
||||
// Walk the subcommand chain
|
||||
let mut path: Vec<&str> = Vec::new();
|
||||
let mut current_matches = matches;
|
||||
|
||||
while let Some((sub_name, sub_matches)) = current_matches.subcommand() {
|
||||
path.push(sub_name);
|
||||
current_matches = sub_matches;
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
return Err(GwsError::Validation(
|
||||
"No resource or method specified".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// path looks like ["files", "list"] or ["files", "permissions", "list"]
|
||||
// Walk the Discovery Document resources to find the method
|
||||
let resource_name = path[0];
|
||||
let resource = doc
|
||||
.resources
|
||||
.get(resource_name)
|
||||
.ok_or_else(|| GwsError::Validation(format!("Resource '{resource_name}' not found")))?;
|
||||
|
||||
let mut current_resource = resource;
|
||||
|
||||
// Navigate sub-resources (everything except the last element, which is the method)
|
||||
for &name in &path[1..path.len() - 1] {
|
||||
// Check if this is a sub-resource
|
||||
if let Some(sub) = current_resource.resources.get(name) {
|
||||
current_resource = sub;
|
||||
} else {
|
||||
return Err(GwsError::Validation(format!(
|
||||
"Sub-resource '{name}' not found"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// The last element is the method name
|
||||
let method_name = path[path.len() - 1];
|
||||
|
||||
// Check if this is a method on the current resource
|
||||
if let Some(method) = current_resource.methods.get(method_name) {
|
||||
return Ok((method, current_matches));
|
||||
}
|
||||
|
||||
// Maybe it's a resource that has methods — need one more subcommand
|
||||
Err(GwsError::Validation(format!(
|
||||
"Method '{method_name}' not found on resource. Available methods: {:?}",
|
||||
current_resource.methods.keys().collect::<Vec<_>>()
|
||||
)))
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
println!("gws — Google Workspace CLI");
|
||||
println!();
|
||||
println!("USAGE:");
|
||||
println!(" gws <service> <resource> [sub-resource] <method> [flags]");
|
||||
println!(" gws schema <service.resource.method> [--resolve-refs]");
|
||||
println!();
|
||||
println!("EXAMPLES:");
|
||||
println!(" gws drive files list --params '{{\"pageSize\": 10}}'");
|
||||
println!(" gws drive files get --params '{{\"fileId\": \"abc123\"}}'");
|
||||
println!(" gws sheets spreadsheets get --params '{{\"spreadsheetId\": \"...\"}}'");
|
||||
println!(" gws gmail users messages list --params '{{\"userId\": \"me\"}}'");
|
||||
println!(" gws schema drive.files.list");
|
||||
println!();
|
||||
println!("FLAGS:");
|
||||
println!(" --params <JSON> URL/Query parameters as JSON");
|
||||
println!(" --json <JSON> Request body as JSON (POST/PATCH/PUT)");
|
||||
println!(" --upload <PATH> Local file to upload as media content (multipart)");
|
||||
println!(" --upload-content-type <MIME> MIME type of the uploaded file (auto-detected from extension if omitted)");
|
||||
println!(" --output <PATH> Output file path for binary responses");
|
||||
println!(" --format <FMT> Output format: json (default), table, yaml, csv");
|
||||
println!(" --api-version <VER> Override the API version (e.g., v2, v3)");
|
||||
println!(" --page-all Auto-paginate, one JSON line per page (NDJSON)");
|
||||
println!(" --page-limit <N> Max pages to fetch with --page-all (default: 10)");
|
||||
println!(" --page-delay <MS> Delay between pages in ms (default: 100)");
|
||||
println!();
|
||||
println!("SERVICES:");
|
||||
for entry in services::SERVICES {
|
||||
let name = entry.aliases[0];
|
||||
let aliases = if entry.aliases.len() > 1 {
|
||||
format!(" (also: {})", entry.aliases[1..].join(", "))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
println!(" {:<20} {}{}", name, entry.description, aliases);
|
||||
}
|
||||
println!();
|
||||
println!("ENVIRONMENT:");
|
||||
println!(" GOOGLE_WORKSPACE_CLI_TOKEN Pre-obtained OAuth2 access token (highest priority)");
|
||||
println!(" GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE Path to OAuth credentials JSON file");
|
||||
println!(" GOOGLE_WORKSPACE_CLI_CLIENT_ID OAuth client ID (for gws auth login)");
|
||||
println!(
|
||||
" GOOGLE_WORKSPACE_CLI_CLIENT_SECRET OAuth client secret (for gws auth login)"
|
||||
);
|
||||
println!(
|
||||
" GOOGLE_WORKSPACE_CLI_CONFIG_DIR Override config directory (default: ~/.config/gws)"
|
||||
);
|
||||
println!(
|
||||
" GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND Keyring backend: keyring (default) or file"
|
||||
);
|
||||
println!(" GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE Default Model Armor template");
|
||||
println!(
|
||||
" GOOGLE_WORKSPACE_CLI_SANITIZE_MODE Sanitization mode: warn (default) or block"
|
||||
);
|
||||
println!(
|
||||
" GOOGLE_WORKSPACE_PROJECT_ID Override the GCP project ID for quota and billing"
|
||||
);
|
||||
println!(" GOOGLE_WORKSPACE_CLI_LOG Log level for stderr (e.g., gws=debug)");
|
||||
println!(
|
||||
" GOOGLE_WORKSPACE_CLI_LOG_FILE Directory for JSON log files (daily rotation)"
|
||||
);
|
||||
println!();
|
||||
println!("EXIT CODES:");
|
||||
for (code, description) in crate::error::EXIT_CODE_DOCUMENTATION {
|
||||
println!(" {:<5}{}", code, description);
|
||||
}
|
||||
println!();
|
||||
println!("COMMUNITY:");
|
||||
println!(" Star the repo: https://github.com/googleworkspace/cli");
|
||||
println!(" Report bugs / request features: https://github.com/googleworkspace/cli/issues");
|
||||
println!(" Please search existing issues first; if one already exists, comment there.");
|
||||
println!();
|
||||
println!("DISCLAIMER:");
|
||||
println!(" This is not an officially supported Google product.");
|
||||
}
|
||||
|
||||
fn is_help_flag(arg: &str) -> bool {
|
||||
matches!(arg, "--help" | "-h")
|
||||
}
|
||||
|
||||
fn is_version_flag(arg: &str) -> bool {
|
||||
matches!(arg, "--version" | "-V" | "version")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_pagination_config_defaults() {
|
||||
let matches = clap::Command::new("test")
|
||||
.arg(
|
||||
clap::Arg::new("page-all")
|
||||
.long("page-all")
|
||||
.action(clap::ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
clap::Arg::new("page-limit")
|
||||
.long("page-limit")
|
||||
.value_parser(clap::value_parser!(u32)),
|
||||
)
|
||||
.arg(
|
||||
clap::Arg::new("page-delay")
|
||||
.long("page-delay")
|
||||
.value_parser(clap::value_parser!(u64)),
|
||||
)
|
||||
.get_matches_from(vec!["test"]);
|
||||
|
||||
let config = parse_pagination_config(&matches);
|
||||
assert_eq!(config.page_all, false);
|
||||
assert_eq!(config.page_limit, 10);
|
||||
assert_eq!(config.page_delay_ms, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pagination_config_custom() {
|
||||
let matches = clap::Command::new("test")
|
||||
.arg(
|
||||
clap::Arg::new("page-all")
|
||||
.long("page-all")
|
||||
.action(clap::ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
clap::Arg::new("page-limit")
|
||||
.long("page-limit")
|
||||
.value_parser(clap::value_parser!(u32)),
|
||||
)
|
||||
.arg(
|
||||
clap::Arg::new("page-delay")
|
||||
.long("page-delay")
|
||||
.value_parser(clap::value_parser!(u64)),
|
||||
)
|
||||
.get_matches_from(vec![
|
||||
"test",
|
||||
"--page-all",
|
||||
"--page-limit",
|
||||
"20",
|
||||
"--page-delay",
|
||||
"500",
|
||||
]);
|
||||
|
||||
let config = parse_pagination_config(&matches);
|
||||
assert_eq!(config.page_all, true);
|
||||
assert_eq!(config.page_limit, 20);
|
||||
assert_eq!(config.page_delay_ms, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sanitize_config_valid() {
|
||||
let config = parse_sanitize_config(
|
||||
Some("tpl".to_string()),
|
||||
&helpers::modelarmor::SanitizeMode::Warn,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(config.template.as_deref(), Some("tpl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sanitize_config_no_template() {
|
||||
let config =
|
||||
parse_sanitize_config(None, &helpers::modelarmor::SanitizeMode::Block).unwrap();
|
||||
assert!(config.template.is_none());
|
||||
assert_eq!(config.mode, helpers::modelarmor::SanitizeMode::Block);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_version_flag() {
|
||||
assert!(is_version_flag("--version"));
|
||||
assert!(is_version_flag("-V"));
|
||||
assert!(is_version_flag("version"));
|
||||
assert!(!is_version_flag("--ver"));
|
||||
assert!(!is_version_flag("v"));
|
||||
assert!(!is_version_flag("drive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_help_flag() {
|
||||
assert!(is_help_flag("--help"));
|
||||
assert!(is_help_flag("-h"));
|
||||
assert!(!is_help_flag("help"));
|
||||
assert!(!is_help_flag("--h"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_method_from_matches_basic() {
|
||||
let mut resources = std::collections::HashMap::new();
|
||||
let mut files_res = crate::discovery::RestResource::default();
|
||||
files_res.methods.insert(
|
||||
"list".to_string(),
|
||||
crate::discovery::RestMethod {
|
||||
id: Some("drive.files.list".to_string()),
|
||||
http_method: "GET".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
resources.insert("files".to_string(), files_res);
|
||||
|
||||
let doc = discovery::RestDescription {
|
||||
name: "drive".to_string(),
|
||||
resources,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Simulate CLI structure
|
||||
let cmd = clap::Command::new("gws")
|
||||
.subcommand(clap::Command::new("files").subcommand(clap::Command::new("list")));
|
||||
|
||||
let matches = cmd.get_matches_from(vec!["gws", "files", "list"]);
|
||||
let (method, _) = resolve_method_from_matches(&doc, &matches).unwrap();
|
||||
assert_eq!(method.id.as_deref(), Some("drive.files.list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_method_from_matches_nested() {
|
||||
let mut resources = std::collections::HashMap::new();
|
||||
let mut files_res = crate::discovery::RestResource::default();
|
||||
let mut permissions_res = crate::discovery::RestResource::default();
|
||||
permissions_res.methods.insert(
|
||||
"get".to_string(),
|
||||
crate::discovery::RestMethod {
|
||||
id: Some("drive.files.permissions.get".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
files_res
|
||||
.resources
|
||||
.insert("permissions".to_string(), permissions_res);
|
||||
resources.insert("files".to_string(), files_res);
|
||||
|
||||
let doc = discovery::RestDescription {
|
||||
name: "drive".to_string(),
|
||||
resources,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cmd =
|
||||
clap::Command::new("gws").subcommand(clap::Command::new("files").subcommand(
|
||||
clap::Command::new("permissions").subcommand(clap::Command::new("get")),
|
||||
));
|
||||
|
||||
let matches = cmd.get_matches_from(vec!["gws", "files", "permissions", "get"]);
|
||||
let (method, _) = resolve_method_from_matches(&doc, &matches).unwrap();
|
||||
assert_eq!(method.id.as_deref(), Some("drive.files.permissions.get"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_args_strips_api_version() {
|
||||
let args: Vec<String> = vec![
|
||||
"gws".into(),
|
||||
"drive".into(),
|
||||
"--api-version".into(),
|
||||
"v3".into(),
|
||||
"files".into(),
|
||||
"list".into(),
|
||||
];
|
||||
let filtered = filter_args_for_subcommand(&args, "drive");
|
||||
assert_eq!(filtered, vec!["gws", "files", "list"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_args_no_special_flags() {
|
||||
let args: Vec<String> = vec![
|
||||
"gws".into(),
|
||||
"drive".into(),
|
||||
"files".into(),
|
||||
"list".into(),
|
||||
"--format".into(),
|
||||
"table".into(),
|
||||
];
|
||||
let filtered = filter_args_for_subcommand(&args, "drive");
|
||||
assert_eq!(filtered, vec!["gws", "files", "list", "--format", "table"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_scope_picks_first() {
|
||||
let scopes = vec![
|
||||
"https://mail.google.com/".to_string(),
|
||||
"https://www.googleapis.com/auth/gmail.metadata".to_string(),
|
||||
"https://www.googleapis.com/auth/gmail.modify".to_string(),
|
||||
"https://www.googleapis.com/auth/gmail.readonly".to_string(),
|
||||
];
|
||||
assert_eq!(select_scope(&scopes), Some("https://mail.google.com/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_scope_single() {
|
||||
let scopes = vec!["https://www.googleapis.com/auth/drive".to_string()];
|
||||
assert_eq!(
|
||||
select_scope(&scopes),
|
||||
Some("https://www.googleapis.com/auth/drive")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_scope_empty() {
|
||||
let scopes: Vec<String> = vec![];
|
||||
assert_eq!(select_scope(&scopes), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Helpers for the OAuth client configuration file.
|
||||
//!
|
||||
//! Uses the standard Google Cloud Console "installed application" JSON format:
|
||||
//! ```json
|
||||
//! {
|
||||
//! "installed": {
|
||||
//! "client_id": "...apps.googleusercontent.com",
|
||||
//! "project_id": "my-project",
|
||||
//! "auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
//! "token_uri": "https://oauth2.googleapis.com/token",
|
||||
//! "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
//! "client_secret": "GOCSPX-...",
|
||||
//! "redirect_uris": ["http://localhost"]
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The "installed" application config from Google Cloud Console.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct InstalledConfig {
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub project_id: String,
|
||||
pub auth_uri: String,
|
||||
pub token_uri: String,
|
||||
#[serde(default)]
|
||||
pub auth_provider_x509_cert_url: String,
|
||||
#[serde(default)]
|
||||
pub redirect_uris: Vec<String>,
|
||||
}
|
||||
|
||||
/// Wrapper matching the Google Cloud Console download format.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ClientSecretFile {
|
||||
pub installed: InstalledConfig,
|
||||
}
|
||||
|
||||
/// Returns the path for the client secret config file.
|
||||
pub fn client_config_path() -> PathBuf {
|
||||
crate::auth_commands::config_dir().join("client_secret.json")
|
||||
}
|
||||
|
||||
/// Saves OAuth client configuration in the standard Google Cloud Console format.
|
||||
pub fn save_client_config(
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
project_id: &str,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
let config = ClientSecretFile {
|
||||
installed: InstalledConfig {
|
||||
client_id: client_id.to_string(),
|
||||
client_secret: client_secret.to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(),
|
||||
token_uri: "https://oauth2.googleapis.com/token".to_string(),
|
||||
auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs".to_string(),
|
||||
redirect_uris: vec!["http://localhost".to_string()],
|
||||
},
|
||||
};
|
||||
|
||||
let path = client_config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(&config)?;
|
||||
crate::fs_util::atomic_write(&path, json.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write client config: {e}"))?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Loads OAuth client configuration from the standard Google Cloud Console format.
|
||||
pub fn load_client_config() -> anyhow::Result<InstalledConfig> {
|
||||
let path = client_config_path();
|
||||
let data = std::fs::read_to_string(&path)
|
||||
.map_err(|e| anyhow::anyhow!("Cannot read {}: {e}", path.display()))?;
|
||||
let file: ClientSecretFile = serde_json::from_str(&data)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid client_secret.json format: {e}"))?;
|
||||
Ok(file.installed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_save_load_round_trip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("client_secret.json");
|
||||
|
||||
let config = ClientSecretFile {
|
||||
installed: InstalledConfig {
|
||||
client_id: "test-id.apps.googleusercontent.com".to_string(),
|
||||
client_secret: "GOCSPX-test".to_string(),
|
||||
project_id: "my-project".to_string(),
|
||||
auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(),
|
||||
token_uri: "https://oauth2.googleapis.com/token".to_string(),
|
||||
auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs"
|
||||
.to_string(),
|
||||
redirect_uris: vec!["http://localhost".to_string()],
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&config).unwrap();
|
||||
std::fs::write(&path, &json).unwrap();
|
||||
|
||||
let data = std::fs::read_to_string(&path).unwrap();
|
||||
let loaded: ClientSecretFile = serde_json::from_str(&data).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
loaded.installed.client_id,
|
||||
"test-id.apps.googleusercontent.com"
|
||||
);
|
||||
assert_eq!(loaded.installed.client_secret, "GOCSPX-test");
|
||||
assert_eq!(loaded.installed.project_id, "my-project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_google_console_format() {
|
||||
// Real format from Google Cloud Console download
|
||||
let json = r#"{
|
||||
"installed": {
|
||||
"client_id": "test-client-id.apps.googleusercontent.com",
|
||||
"project_id": "test-project-id",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_secret": "test-client-secret",
|
||||
"redirect_uris": ["http://localhost"]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let config: ClientSecretFile = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.installed.project_id, "test-project-id");
|
||||
assert_eq!(config.installed.client_secret, "test-client-secret");
|
||||
assert_eq!(config.installed.redirect_uris, vec!["http://localhost"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_missing_optional_fields() {
|
||||
// Minimal format — only required fields
|
||||
let json = r#"{
|
||||
"installed": {
|
||||
"client_id": "test-id",
|
||||
"project_id": "test-project",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"client_secret": "secret"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let config: ClientSecretFile = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(config.installed.client_id, "test-id");
|
||||
assert!(config.installed.redirect_uris.is_empty());
|
||||
assert!(config.installed.auth_provider_x509_cert_url.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_json_fails() {
|
||||
let json = r#"{ "wrong_key": {} }"#;
|
||||
let result = serde_json::from_str::<ClientSecretFile>(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_missing_client_id_fails() {
|
||||
let json = r#"{
|
||||
"installed": {
|
||||
"project_id": "test",
|
||||
"auth_uri": "https://example.com",
|
||||
"token_uri": "https://example.com",
|
||||
"client_secret": "secret"
|
||||
}
|
||||
}"#;
|
||||
let result = serde_json::from_str::<ClientSecretFile>(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// Helper to manage the env var safely and clean up automatically
|
||||
struct EnvGuard {
|
||||
key: String,
|
||||
original_value: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn new(key: &str, value: &str) -> Self {
|
||||
let original_value = std::env::var(key).ok();
|
||||
std::env::set_var(key, value);
|
||||
Self {
|
||||
key: key.to_string(),
|
||||
original_value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(val) = &self.original_value {
|
||||
std::env::set_var(&self.key, val);
|
||||
} else {
|
||||
std::env::remove_var(&self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_load_client_config() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let _env_guard = EnvGuard::new(
|
||||
"GOOGLE_WORKSPACE_CLI_CONFIG_DIR",
|
||||
dir.path().to_str().unwrap(),
|
||||
);
|
||||
|
||||
// Initially no config file exists
|
||||
let result = load_client_config();
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.to_string().contains("Cannot read"));
|
||||
|
||||
// Create a valid config file
|
||||
save_client_config("test-id", "test-secret", "test-project").unwrap();
|
||||
|
||||
// Now loading should succeed
|
||||
let config = load_client_config().unwrap();
|
||||
assert_eq!(config.client_id, "test-id");
|
||||
assert_eq!(config.client_secret, "test-secret");
|
||||
assert_eq!(config.project_id, "test-project");
|
||||
|
||||
// Create an invalid config file
|
||||
let path = client_config_path();
|
||||
std::fs::write(&path, "invalid json").unwrap();
|
||||
|
||||
let result = load_client_config();
|
||||
let err = result.unwrap_err();
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("Invalid client_secret.json format"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Shared output helpers for terminal sanitization, coloring, and stderr
|
||||
//! messaging.
|
||||
//!
|
||||
//! Every function that prints untrusted content to the terminal should use
|
||||
//! these helpers to prevent escape-sequence injection, Unicode spoofing,
|
||||
//! and to respect `NO_COLOR` / non-TTY environments.
|
||||
|
||||
// Import dangerous-char detection from the library crate.
|
||||
pub(crate) use google_workspace::validate::is_dangerous_unicode;
|
||||
|
||||
// ── Sanitization ──────────────────────────────────────────────────────
|
||||
|
||||
/// Strip dangerous characters from untrusted text before printing to the
|
||||
/// terminal. Removes ASCII control characters (except `\n` and `\t`,
|
||||
/// which are preserved for readability) and dangerous Unicode characters
|
||||
/// (bidi overrides, zero-width chars, line/paragraph separators).
|
||||
pub(crate) fn sanitize_for_terminal(text: &str) -> String {
|
||||
text.chars()
|
||||
.filter(|&c| {
|
||||
if c == '\n' || c == '\t' {
|
||||
return true;
|
||||
}
|
||||
if c.is_control() {
|
||||
return false;
|
||||
}
|
||||
!is_dangerous_unicode(c)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Color ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns true when stderr is connected to an interactive terminal and
|
||||
/// `NO_COLOR` is not set, meaning ANSI color codes will be visible.
|
||||
pub(crate) fn stderr_supports_color() -> bool {
|
||||
use std::io::IsTerminal;
|
||||
std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none()
|
||||
}
|
||||
|
||||
/// Wrap `text` in ANSI bold + the given color code, resetting afterwards.
|
||||
/// Returns the plain text unchanged when stderr is not a TTY or `NO_COLOR`
|
||||
/// is set.
|
||||
pub(crate) fn colorize(text: &str, ansi_color: &str) -> String {
|
||||
if stderr_supports_color() && ansi_color.chars().all(|c| c.is_ascii_digit()) {
|
||||
format!("\x1b[1;{ansi_color}m{text}\x1b[0m")
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stderr helpers ────────────────────────────────────────────────────
|
||||
|
||||
/// Print a status message to stderr. The message is sanitized before
|
||||
/// printing to prevent terminal injection.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn status(msg: &str) {
|
||||
eprintln!("{}", sanitize_for_terminal(msg));
|
||||
}
|
||||
|
||||
/// Print a warning to stderr with a colored prefix. The message is
|
||||
/// sanitized before printing.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn warn(msg: &str) {
|
||||
let prefix = colorize("warning:", "33"); // yellow
|
||||
eprintln!("{prefix} {}", sanitize_for_terminal(msg));
|
||||
}
|
||||
|
||||
/// Print an informational message to stderr. The message is sanitized
|
||||
/// before printing.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn info(msg: &str) {
|
||||
eprintln!("{}", sanitize_for_terminal(msg));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── sanitize_for_terminal ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_ansi_escape_sequences() {
|
||||
let input = "normal \x1b[31mred text\x1b[0m end";
|
||||
let sanitized = sanitize_for_terminal(input);
|
||||
assert_eq!(sanitized, "normal [31mred text[0m end");
|
||||
assert!(!sanitized.contains('\x1b'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_preserves_newlines_and_tabs() {
|
||||
let input = "line1\nline2\ttab";
|
||||
assert_eq!(sanitize_for_terminal(input), "line1\nline2\ttab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_bell_and_backspace() {
|
||||
let input = "hello\x07bell\x08backspace";
|
||||
assert_eq!(sanitize_for_terminal(input), "hellobellbackspace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_carriage_return() {
|
||||
let input = "real\rfake";
|
||||
assert_eq!(sanitize_for_terminal(input), "realfake");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_bidi_overrides() {
|
||||
let input = "hello\u{202E}dlrow";
|
||||
assert_eq!(sanitize_for_terminal(input), "hellodlrow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_zero_width_chars() {
|
||||
assert_eq!(sanitize_for_terminal("foo\u{200B}bar"), "foobar");
|
||||
assert_eq!(sanitize_for_terminal("foo\u{FEFF}bar"), "foobar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_line_separators() {
|
||||
assert_eq!(sanitize_for_terminal("line1\u{2028}line2"), "line1line2");
|
||||
assert_eq!(sanitize_for_terminal("para1\u{2029}para2"), "para1para2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_directional_isolates() {
|
||||
assert_eq!(sanitize_for_terminal("a\u{2066}b\u{2069}c"), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_preserves_normal_unicode() {
|
||||
assert_eq!(sanitize_for_terminal("日本語 café αβγ"), "日本語 café αβγ");
|
||||
}
|
||||
|
||||
// ── colorize ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn colorize_returns_text_in_no_color_mode() {
|
||||
let result = colorize("hello", "31");
|
||||
assert!(result.contains("hello"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! JSON Schema Validation & Reference Resolution
|
||||
//!
|
||||
//! Provides utilities to validate JSON payloads against the Google API Discovery Document
|
||||
//! schemas before dispatching requests. This ensures immediate client-side feedback
|
||||
//! for invalid API payloads.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::discovery::{
|
||||
fetch_discovery_document, JsonSchema, MethodParameter, RestDescription, RestMethod,
|
||||
RestResource,
|
||||
};
|
||||
use crate::error::GwsError;
|
||||
use crate::services::resolve_service;
|
||||
|
||||
/// Handles the `gws schema <dotted.path>` command.
|
||||
///
|
||||
/// Path format: `service.resource[.subresource].method`
|
||||
/// Example: `drive.files.list` or `drive.files.permissions.list`
|
||||
pub async fn handle_schema_command(path: &str, resolve_refs: bool) -> Result<(), GwsError> {
|
||||
let parts: Vec<&str> = path.split('.').collect();
|
||||
if parts.len() < 2 {
|
||||
return Err(GwsError::Validation(format!(
|
||||
"Schema path must be at least 'service.Message' or 'service.resource.method', got '{path}'"
|
||||
)));
|
||||
}
|
||||
|
||||
let service_name = parts[0];
|
||||
let (api_name, version) = resolve_service(service_name)?;
|
||||
|
||||
let doc = fetch_discovery_document(&api_name, &version)
|
||||
.await
|
||||
.map_err(|e| GwsError::Discovery(format!("{e:#}")))?;
|
||||
|
||||
// Case 1: Schema lookup (e.g., "drive.File")
|
||||
if parts.len() == 2 {
|
||||
let schema_name = parts[1];
|
||||
if let Some(schema) = doc.schemas.get(schema_name) {
|
||||
let mut output = schema_to_json(schema);
|
||||
if resolve_refs {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
// Add self to seen to prevent immediate recursion
|
||||
seen.insert(schema_name.to_string());
|
||||
resolve_schema_refs(&mut output, &doc, &mut seen);
|
||||
}
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&output).unwrap_or_default()
|
||||
);
|
||||
return Ok(());
|
||||
} else {
|
||||
// It might be a resource path that is incomplete, but let's see if it's a schema typo first
|
||||
// or perhaps the user meant "drive.files" (resource) which we don't support dumping yet.
|
||||
// Let's check if it matches a resource name to give a better error.
|
||||
if doc.resources.contains_key(schema_name) {
|
||||
return Err(GwsError::Validation(format!(
|
||||
"'{schema_name}' is a resource. To see its methods, try 'gws schema {service_name}.{schema_name}.list' (or similar). To see a type definition, try 'gws schema {service_name}.<Type>'."
|
||||
)));
|
||||
}
|
||||
|
||||
let available: Vec<&String> = doc.schemas.keys().collect();
|
||||
return Err(GwsError::Validation(format!(
|
||||
"Schema or resource '{schema_name}' not found. Available schemas: {:?}",
|
||||
available
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Method lookup (e.g., "drive.files.list")
|
||||
let resource_path = &parts[1..parts.len() - 1];
|
||||
let method_name = parts[parts.len() - 1];
|
||||
|
||||
let method = find_method(&doc, resource_path, method_name)?;
|
||||
|
||||
let mut output = build_schema_output(&doc, method);
|
||||
if resolve_refs {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
resolve_schema_refs(&mut output, &doc, &mut seen);
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&output).unwrap_or_default()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walks the resource tree to find a method.
|
||||
fn find_method<'a>(
|
||||
doc: &'a RestDescription,
|
||||
resource_path: &[&str],
|
||||
method_name: &str,
|
||||
) -> Result<&'a RestMethod, GwsError> {
|
||||
if resource_path.is_empty() {
|
||||
return Err(GwsError::Validation(
|
||||
"Resource path cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let first_resource_name = resource_path[0];
|
||||
let resource = doc.resources.get(first_resource_name).ok_or_else(|| {
|
||||
let available: Vec<&String> = doc.resources.keys().collect();
|
||||
GwsError::Validation(format!(
|
||||
"Resource '{}' not found. Available resources: {:?}",
|
||||
first_resource_name, available
|
||||
))
|
||||
})?;
|
||||
|
||||
// Walk deeper into sub-resources
|
||||
let mut current_resource: &RestResource = resource;
|
||||
for &sub_name in &resource_path[1..] {
|
||||
current_resource = current_resource.resources.get(sub_name).ok_or_else(|| {
|
||||
let available: Vec<&String> = current_resource.resources.keys().collect();
|
||||
GwsError::Validation(format!(
|
||||
"Sub-resource '{}' not found. Available: {:?}",
|
||||
sub_name, available
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
current_resource.methods.get(method_name).ok_or_else(|| {
|
||||
let available: Vec<&String> = current_resource.methods.keys().collect();
|
||||
GwsError::Validation(format!(
|
||||
"Method '{}' not found. Available methods: {:?}",
|
||||
method_name, available
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the schema output JSON for a method.
|
||||
fn build_schema_output(doc: &RestDescription, method: &RestMethod) -> Value {
|
||||
let mut params = json!({});
|
||||
for (name, param) in &method.parameters {
|
||||
params[name] = param_to_json(param);
|
||||
}
|
||||
|
||||
let mut output = json!({
|
||||
"httpMethod": method.http_method,
|
||||
"path": method.path,
|
||||
"description": method.description.as_deref().unwrap_or(""),
|
||||
"parameters": params,
|
||||
"scopes": method.scopes,
|
||||
});
|
||||
|
||||
if !method.parameter_order.is_empty() {
|
||||
output["parameterOrder"] = json!(method.parameter_order);
|
||||
}
|
||||
|
||||
// Resolve request body schema
|
||||
if let Some(ref req_ref) = method.request {
|
||||
if let Some(ref schema_name) = req_ref.schema_ref {
|
||||
output["requestBody"] = json!({
|
||||
"schemaRef": schema_name,
|
||||
});
|
||||
if let Some(schema) = doc.schemas.get(schema_name) {
|
||||
output["requestBody"]["schema"] = schema_to_json(schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Response schema ref
|
||||
if let Some(ref resp_ref) = method.response {
|
||||
if let Some(ref schema_name) = resp_ref.schema_ref {
|
||||
output["response"] = json!({
|
||||
"schemaRef": schema_name,
|
||||
});
|
||||
// Also inline the response schema structure if available
|
||||
if let Some(schema) = doc.schemas.get(schema_name) {
|
||||
output["response"]["schema"] = schema_to_json(schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn param_to_json(param: &MethodParameter) -> Value {
|
||||
let mut p = json!({
|
||||
"type": param.param_type.as_deref().unwrap_or("string"),
|
||||
"required": param.required,
|
||||
});
|
||||
|
||||
if let Some(ref loc) = param.location {
|
||||
p["location"] = json!(loc);
|
||||
}
|
||||
if let Some(ref desc) = param.description {
|
||||
p["description"] = json!(desc);
|
||||
}
|
||||
if let Some(ref fmt) = param.format {
|
||||
p["format"] = json!(fmt);
|
||||
}
|
||||
if let Some(ref def) = param.default {
|
||||
p["default"] = json!(def);
|
||||
}
|
||||
if let Some(ref vals) = param.enum_values {
|
||||
p["enum"] = json!(vals);
|
||||
}
|
||||
if param.repeated {
|
||||
p["repeated"] = json!(true);
|
||||
}
|
||||
if param.deprecated {
|
||||
p["deprecated"] = json!(true);
|
||||
}
|
||||
|
||||
p
|
||||
}
|
||||
|
||||
fn schema_to_json(schema: &JsonSchema) -> Value {
|
||||
let mut s = json!({});
|
||||
|
||||
if let Some(ref t) = schema.schema_type {
|
||||
s["type"] = json!(t);
|
||||
}
|
||||
if let Some(ref desc) = schema.description {
|
||||
s["description"] = json!(desc);
|
||||
}
|
||||
|
||||
if !schema.properties.is_empty() {
|
||||
let mut props = json!({});
|
||||
for (name, prop) in &schema.properties {
|
||||
let mut p = json!({});
|
||||
if let Some(ref t) = prop.prop_type {
|
||||
p["type"] = json!(t);
|
||||
}
|
||||
if let Some(ref r) = prop.schema_ref {
|
||||
p["$ref"] = json!(r);
|
||||
}
|
||||
if let Some(ref desc) = prop.description {
|
||||
p["description"] = json!(desc);
|
||||
}
|
||||
if prop.read_only {
|
||||
p["readOnly"] = json!(true);
|
||||
}
|
||||
if let Some(ref fmt) = prop.format {
|
||||
p["format"] = json!(fmt);
|
||||
}
|
||||
|
||||
// Handle items for array types
|
||||
if let Some(ref items) = prop.items {
|
||||
let mut items_json = json!({});
|
||||
if let Some(ref t) = items.prop_type {
|
||||
items_json["type"] = json!(t);
|
||||
}
|
||||
if let Some(ref r) = items.schema_ref {
|
||||
items_json["$ref"] = json!(r);
|
||||
}
|
||||
p["items"] = items_json;
|
||||
}
|
||||
|
||||
props[name] = p;
|
||||
}
|
||||
s["properties"] = props;
|
||||
}
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
/// Recursively resolves "$ref" fields in the JSON value.
|
||||
fn resolve_schema_refs(
|
||||
val: &mut Value,
|
||||
doc: &RestDescription,
|
||||
seen: &mut std::collections::HashSet<String>,
|
||||
) {
|
||||
match val {
|
||||
Value::Object(map) => {
|
||||
// Check if this object is a reference
|
||||
if let Some(ref_name) = map
|
||||
.get("$ref")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
// If we haven't seen this schema yet in this branch
|
||||
if !seen.contains(&ref_name) {
|
||||
if let Some(schema) = doc.schemas.get(&ref_name) {
|
||||
seen.insert(ref_name.clone());
|
||||
let mut resolved = schema_to_json(schema);
|
||||
// Recursively resolve the resolved schema
|
||||
resolve_schema_refs(&mut resolved, doc, seen);
|
||||
seen.remove(&ref_name);
|
||||
|
||||
// Merge resolved schema into current object, but preserve existing fields
|
||||
// (though usually $ref stands alone)
|
||||
if let Value::Object(resolved_map) = resolved {
|
||||
for (k, v) in resolved_map {
|
||||
map.entry(k).or_insert(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into all fields
|
||||
for (_, v) in map.iter_mut() {
|
||||
resolve_schema_refs(v, doc, seen);
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
for v in arr {
|
||||
resolve_schema_refs(v, doc, seen);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_param_to_json() {
|
||||
let param = MethodParameter {
|
||||
param_type: Some("integer".to_string()),
|
||||
description: Some("desc".to_string()),
|
||||
location: Some("query".to_string()),
|
||||
required: true,
|
||||
format: Some("int32".to_string()),
|
||||
default: Some("0".to_string()),
|
||||
enum_values: Some(vec!["0".to_string(), "1".to_string()]),
|
||||
enum_descriptions: None,
|
||||
repeated: false,
|
||||
minimum: None,
|
||||
maximum: None,
|
||||
deprecated: true,
|
||||
};
|
||||
|
||||
let json = param_to_json(¶m);
|
||||
assert_eq!(json["type"], "integer");
|
||||
assert_eq!(json["description"], "desc");
|
||||
assert_eq!(json["location"], "query");
|
||||
assert_eq!(json["required"], true);
|
||||
assert_eq!(json["format"], "int32");
|
||||
assert_eq!(json["default"], "0");
|
||||
assert!(json["enum"].is_array());
|
||||
assert_eq!(json["deprecated"], true);
|
||||
// repeated: false should NOT appear in output
|
||||
assert!(json.get("repeated").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_param_to_json_repeated() {
|
||||
let param = MethodParameter {
|
||||
param_type: Some("string".to_string()),
|
||||
location: Some("query".to_string()),
|
||||
repeated: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let json = param_to_json(¶m);
|
||||
assert_eq!(json["type"], "string");
|
||||
assert_eq!(json["repeated"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_to_json_basic() {
|
||||
let mut properties = std::collections::HashMap::new();
|
||||
properties.insert(
|
||||
"name".to_string(),
|
||||
crate::discovery::JsonSchemaProperty {
|
||||
prop_type: Some("string".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let schema = JsonSchema {
|
||||
schema_type: Some("object".to_string()),
|
||||
properties,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let json = schema_to_json(&schema);
|
||||
assert_eq!(json["type"], "object");
|
||||
assert!(json["properties"].is_object());
|
||||
assert_eq!(json["properties"]["name"]["type"], "string");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_schema_refs_basic() {
|
||||
let mut schemas = std::collections::HashMap::new();
|
||||
let target_schema = JsonSchema {
|
||||
schema_type: Some("string".to_string()),
|
||||
description: Some("Resolved type".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
schemas.insert("Target".to_string(), target_schema);
|
||||
|
||||
let doc = RestDescription {
|
||||
schemas,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut val = json!({
|
||||
"$ref": "Target"
|
||||
});
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
resolve_schema_refs(&mut val, &doc, &mut seen);
|
||||
|
||||
assert_eq!(val["type"], "string");
|
||||
assert_eq!(val["description"], "Resolved type");
|
||||
// $ref might remain or effectively be merged, checking properties is key
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_schema_refs_nested() {
|
||||
let mut schemas = std::collections::HashMap::new();
|
||||
let child = JsonSchema {
|
||||
schema_type: Some("integer".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
schemas.insert("Child".to_string(), child);
|
||||
|
||||
let parent = JsonSchema {
|
||||
schema_type: Some("object".to_string()),
|
||||
properties: {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
map.insert(
|
||||
"f".to_string(),
|
||||
crate::discovery::JsonSchemaProperty {
|
||||
schema_ref: Some("Child".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
map
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
schemas.insert("Parent".to_string(), parent);
|
||||
|
||||
let doc = RestDescription {
|
||||
schemas,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut val = json!({
|
||||
"$ref": "Parent"
|
||||
});
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
resolve_schema_refs(&mut val, &doc, &mut seen);
|
||||
|
||||
// Check Parent resolved
|
||||
assert_eq!(val["type"], "object");
|
||||
// Check Child resolved inside Parent
|
||||
// note: schema_to_json converts ref to $ref property, then resolve_schema_refs follows it
|
||||
// The implementation matches on "$ref" keys in objects.
|
||||
// schema_to_json for Parent produces { properties: { f: { $ref: "Child" } } }
|
||||
// The recursion should resolve f.$ref to Child content.
|
||||
|
||||
let f_node = &val["properties"]["f"];
|
||||
assert_eq!(f_node["type"], "integer");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Service registry — re-exports from `google_workspace` library crate.
|
||||
|
||||
pub use google_workspace::services::*;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,265 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
/// Max chars for CLI `--help` method descriptions (terminal-width friendly).
|
||||
pub const CLI_DESCRIPTION_LIMIT: usize = 200;
|
||||
|
||||
/// Max chars for YAML frontmatter / skill index descriptions (compact metadata).
|
||||
pub const FRONTMATTER_DESCRIPTION_LIMIT: usize = 120;
|
||||
|
||||
/// Max chars for skill body method descriptions (markdown, agents benefit from detail).
|
||||
pub const SKILL_BODY_DESCRIPTION_LIMIT: usize = 500;
|
||||
|
||||
/// Truncates a description string to `max_chars` using smart boundaries.
|
||||
///
|
||||
/// When `strip_links` is true, markdown links `[text](url)` are replaced with
|
||||
/// just `text` to reclaim character budget (useful for CLI help / frontmatter).
|
||||
/// When false, links are preserved (useful for skill body text where agents can
|
||||
/// follow URLs).
|
||||
///
|
||||
/// Truncation strategy:
|
||||
/// 1. If a complete sentence (ending in `. `) fits within the limit, truncate there.
|
||||
/// 2. Otherwise, break at the last word boundary (space) and append `…`.
|
||||
/// 3. If no space exists, hard-cut at `max_chars - 1` and append `…`.
|
||||
pub fn truncate_description(desc: &str, max_chars: usize, strip_links: bool) -> String {
|
||||
if max_chars == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let cleaned = if strip_links {
|
||||
strip_markdown_links(desc)
|
||||
} else {
|
||||
desc.to_string()
|
||||
};
|
||||
let trimmed = cleaned.trim();
|
||||
|
||||
// Count chars (UTF-8 safe)
|
||||
let char_count = trimmed.chars().count();
|
||||
if char_count <= max_chars {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
|
||||
// Collect the first `max_chars` characters as a string to search within.
|
||||
let prefix: String = trimmed.chars().take(max_chars).collect();
|
||||
|
||||
// Try to find the last complete sentence within the limit.
|
||||
// A sentence ends with ". " followed by more text, or "." at the end of
|
||||
// the prefix. We look for the last ". " to find a sentence boundary.
|
||||
if let Some(sentence_end) = find_last_sentence_boundary(&prefix) {
|
||||
let truncated: String = trimmed.chars().take(sentence_end).collect();
|
||||
return truncated;
|
||||
}
|
||||
|
||||
// Fall back to last word boundary (space) within the limit.
|
||||
if let Some(last_space) = rfind_char_boundary(&prefix, ' ') {
|
||||
let truncated: String = trimmed.chars().take(last_space).collect();
|
||||
return format!("{truncated}…");
|
||||
}
|
||||
|
||||
// Hard cut — no spaces at all
|
||||
let truncated: String = trimmed.chars().take(max_chars - 1).collect();
|
||||
format!("{truncated}…")
|
||||
}
|
||||
|
||||
/// Strips markdown-style links `[text](url)` and replaces them with just `text`.
|
||||
fn strip_markdown_links(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let len = chars.len();
|
||||
let mut i = 0;
|
||||
|
||||
while i < len {
|
||||
if chars[i] == '[' {
|
||||
// Look for the closing ] followed by (
|
||||
if let Some(close_bracket) = find_char_from(&chars, ']', i + 1) {
|
||||
if close_bracket + 1 < len && chars[close_bracket + 1] == '(' {
|
||||
if let Some(close_paren) = find_char_from(&chars, ')', close_bracket + 2) {
|
||||
// Found a complete [text](url) — emit just the text
|
||||
result.extend(&chars[i + 1..close_bracket]);
|
||||
i = close_paren + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Finds the character-index of `target` starting from position `from`.
|
||||
fn find_char_from(chars: &[char], target: char, from: usize) -> Option<usize> {
|
||||
chars[from..]
|
||||
.iter()
|
||||
.position(|&c| c == target)
|
||||
.map(|p| from + p)
|
||||
}
|
||||
|
||||
/// Finds the last sentence boundary within a char-indexed string.
|
||||
/// A sentence boundary is a position right after ". " where we can cleanly cut.
|
||||
/// Returns the char-count to include (up to and including the period).
|
||||
fn find_last_sentence_boundary(prefix: &str) -> Option<usize> {
|
||||
let chars: Vec<char> = prefix.chars().collect();
|
||||
let mut last_boundary = None;
|
||||
|
||||
for (i, _) in chars.iter().enumerate() {
|
||||
if chars[i] == '.' {
|
||||
let after_period = i + 1;
|
||||
// Sentence boundary: period followed by a space, or period at end of prefix
|
||||
if after_period == chars.len()
|
||||
|| (after_period < chars.len() && chars[after_period] == ' ')
|
||||
{
|
||||
last_boundary = Some(after_period);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
last_boundary
|
||||
}
|
||||
|
||||
/// Finds the last occurrence of `target` in a string, returning its char-index.
|
||||
fn rfind_char_boundary(s: &str, target: char) -> Option<usize> {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
chars.iter().rposition(|&c| c == target)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn short_desc_unchanged() {
|
||||
let desc = "Lists all files.";
|
||||
assert_eq!(truncate_description(desc, 200, true), "Lists all files.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_at_sentence_boundary() {
|
||||
let desc = "Creates a file in Drive. This method supports multipart upload. See the guide for details on how to use it.";
|
||||
// At limit 30, only the first sentence fits before the sentence boundary.
|
||||
let result = truncate_description(desc, 30, true);
|
||||
assert_eq!(result, "Creates a file in Drive.");
|
||||
|
||||
// At limit 70, both first and second sentences fit.
|
||||
let result = truncate_description(desc, 70, true);
|
||||
assert_eq!(
|
||||
result,
|
||||
"Creates a file in Drive. This method supports multipart upload."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_at_word_boundary() {
|
||||
let desc = "Create a guest user with access to a subset of Workspace capabilities";
|
||||
let result = truncate_description(desc, 50, true);
|
||||
// Should cut at the last space before char 50
|
||||
assert!(result.ends_with('…'));
|
||||
assert!(result.len() <= 55); // 50 chars + ellipsis
|
||||
assert!(!result.contains("capabil")); // Should not cut mid-word
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_cut_no_spaces() {
|
||||
let desc = "abcdefghijklmnopqrstuvwxyz";
|
||||
let result = truncate_description(desc, 10, true);
|
||||
assert_eq!(result, "abcdefghi…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_markdown_links() {
|
||||
let desc = "Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545). This feature is in Alpha.";
|
||||
let result = truncate_description(desc, 200, true);
|
||||
assert_eq!(
|
||||
result,
|
||||
"Create a guest user with access to a subset of Workspace capabilities. This feature is in Alpha."
|
||||
);
|
||||
assert!(!result.contains("https://"));
|
||||
assert!(!result.contains('['));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_links_when_strip_links_false() {
|
||||
let desc = "Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545). This feature is in Alpha.";
|
||||
let result = truncate_description(desc, 500, false);
|
||||
assert!(result.contains("https://support.google.com"));
|
||||
assert!(result.contains("[subset of Workspace capabilities]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_markdown_links_and_truncates() {
|
||||
let desc = "Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545). This feature is currently in Alpha. Please reach out to support if you are interested in enabling this feature.";
|
||||
let result = truncate_description(desc, 120, true);
|
||||
// After stripping the link, the sentence boundary should work.
|
||||
assert!(result.contains("subset of Workspace capabilities."));
|
||||
assert!(!result.contains("https://"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_safe() {
|
||||
let desc = "Résumé création für Ñoño — a long description that should be safely truncated at word boundaries without panicking on multi-byte chars";
|
||||
let result = truncate_description(desc, 30, true);
|
||||
assert!(result.ends_with('…') || result.chars().count() <= 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_whitespace() {
|
||||
assert_eq!(truncate_description("", 100, true), "");
|
||||
assert_eq!(truncate_description(" ", 100, true), "");
|
||||
assert_eq!(truncate_description("", 0, true), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_markdown_links() {
|
||||
assert_eq!(strip_markdown_links("[text](http://example.com)"), "text");
|
||||
assert_eq!(
|
||||
strip_markdown_links("Use [this link](http://a.com) and [that](http://b.com) too"),
|
||||
"Use this link and that too"
|
||||
);
|
||||
assert_eq!(strip_markdown_links("no links here"), "no links here");
|
||||
// Incomplete link syntax should be left alone
|
||||
assert_eq!(strip_markdown_links("[broken"), "[broken");
|
||||
assert_eq!(strip_markdown_links("[text]no-parens"), "[text]no-parens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_sentence_ending_at_limit() {
|
||||
let desc = "Deletes a user.";
|
||||
assert_eq!(truncate_description(desc, 15, true), "Deletes a user.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_cut_url_looking_periods() {
|
||||
// Periods in URLs or abbreviations like "v1." shouldn't be treated as sentence ends
|
||||
// unless followed by a space
|
||||
let desc = "See the docs at developers.google.com for more details on this API endpoint";
|
||||
let result = truncate_description(desc, 50, true);
|
||||
// Should truncate at word boundary, not at "developers."
|
||||
assert!(result.ends_with('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentence_boundary_at_exact_limit() {
|
||||
// Period falls exactly at the end of the prefix — should still detect it
|
||||
let desc = "This is a complete sentence. And more text follows here.";
|
||||
let result = truncate_description(desc, 28, true);
|
||||
assert_eq!(result, "This is a complete sentence.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_max_chars() {
|
||||
assert_eq!(truncate_description("anything", 0, true), "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Account timezone resolution for Google Workspace CLI.
|
||||
//!
|
||||
//! Resolves the authenticated user's timezone with the following priority:
|
||||
//! 1. Explicit `--timezone` CLI flag (hard error if invalid)
|
||||
//! 2. Cached value from config dir (24h TTL)
|
||||
//! 3. Google Calendar Settings API (`users/me/settings/timezone`)
|
||||
//! 4. Machine-local timezone (fallback with warning)
|
||||
|
||||
use crate::error::GwsError;
|
||||
use chrono_tz::Tz;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Cache filename stored in the gws config directory.
|
||||
const CACHE_FILENAME: &str = "account_timezone";
|
||||
|
||||
/// Cache TTL in seconds (24 hours).
|
||||
const CACHE_TTL_SECS: u64 = 86400;
|
||||
|
||||
/// Returns the path to the timezone cache file.
|
||||
fn cache_path() -> PathBuf {
|
||||
crate::auth_commands::config_dir().join(CACHE_FILENAME)
|
||||
}
|
||||
|
||||
/// Remove the cached timezone file. Called on auth login/logout to
|
||||
/// invalidate stale values when the account changes.
|
||||
pub fn invalidate_cache() {
|
||||
let path = cache_path();
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
tracing::warn!(path = %path.display(), error = %e, "failed to invalidate timezone cache");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the cached timezone if it exists and is fresh (< 24h old).
|
||||
fn read_cache() -> Option<Tz> {
|
||||
let path = cache_path();
|
||||
let metadata = std::fs::metadata(&path).ok()?;
|
||||
let modified = metadata.modified().ok()?;
|
||||
let age = std::time::SystemTime::now().duration_since(modified).ok()?;
|
||||
if age.as_secs() > CACHE_TTL_SECS {
|
||||
return None;
|
||||
}
|
||||
let contents = std::fs::read_to_string(&path).ok()?;
|
||||
let tz_name = contents.trim();
|
||||
tz_name.parse::<Tz>().ok()
|
||||
}
|
||||
|
||||
/// Write a timezone name to the cache file.
|
||||
fn write_cache(tz_name: &str) {
|
||||
let path = cache_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
tracing::warn!(path = %parent.display(), error = %e, "failed to create timezone cache directory");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Err(e) = std::fs::write(&path, tz_name) {
|
||||
tracing::warn!(path = %path.display(), error = %e, "failed to write timezone cache");
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the account timezone from the Google Calendar Settings API.
|
||||
async fn fetch_account_timezone(client: &reqwest::Client, token: &str) -> Result<Tz, GwsError> {
|
||||
let url = "https://www.googleapis.com/calendar/v3/users/me/settings/timezone";
|
||||
let resp = client
|
||||
.get(url)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch account timezone: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(GwsError::Api {
|
||||
code: status.as_u16(),
|
||||
message: body,
|
||||
reason: "timezone_fetch_failed".to_string(),
|
||||
enable_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse timezone response: {e}")))?;
|
||||
|
||||
let tz_name = json
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
GwsError::Other(anyhow::anyhow!(
|
||||
"Timezone setting missing or empty 'value' field"
|
||||
))
|
||||
})?;
|
||||
|
||||
let tz: Tz = tz_name.parse().map_err(|_| {
|
||||
GwsError::Other(anyhow::anyhow!(
|
||||
"Google returned unrecognized timezone: {tz_name}"
|
||||
))
|
||||
})?;
|
||||
|
||||
// Cache for future use
|
||||
write_cache(tz_name);
|
||||
tracing::info!(
|
||||
timezone = tz_name,
|
||||
source = "calendar_api",
|
||||
"resolved account timezone"
|
||||
);
|
||||
|
||||
Ok(tz)
|
||||
}
|
||||
|
||||
/// Parse an explicit timezone string, returning an error if invalid.
|
||||
pub fn parse_timezone(tz_str: &str) -> Result<Tz, GwsError> {
|
||||
tz_str.parse::<Tz>().map_err(|_| {
|
||||
GwsError::Validation(format!(
|
||||
"Invalid timezone '{tz_str}'. Use an IANA timezone name (e.g. America/Denver, Europe/London, UTC)."
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the user's timezone with this priority:
|
||||
/// 1. `tz_override` (from `--timezone` flag) — hard error if invalid
|
||||
/// 2. Cached value in config dir — use if < 24h old
|
||||
/// 3. Google Calendar Settings API — fetch and cache
|
||||
/// 4. Machine-local timezone (log warning)
|
||||
pub async fn resolve_account_timezone(
|
||||
client: &reqwest::Client,
|
||||
token: &str,
|
||||
tz_override: Option<&str>,
|
||||
) -> Result<Tz, GwsError> {
|
||||
// 1. Explicit override — fail if invalid
|
||||
if let Some(tz_str) = tz_override {
|
||||
let tz = parse_timezone(tz_str)?;
|
||||
tracing::info!(
|
||||
timezone = tz_str,
|
||||
source = "cli_flag",
|
||||
"using explicit timezone"
|
||||
);
|
||||
return Ok(tz);
|
||||
}
|
||||
|
||||
// 2. Check cache
|
||||
if let Some(tz) = read_cache() {
|
||||
tracing::debug!(timezone = %tz, source = "cache", "using cached timezone");
|
||||
return Ok(tz);
|
||||
}
|
||||
|
||||
// 3. Fetch from Calendar Settings API
|
||||
match fetch_account_timezone(client, token).await {
|
||||
Ok(tz) => return Ok(tz),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to fetch account timezone, falling back to local");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fall back to machine-local timezone
|
||||
let local_iana = iana_time_zone_fallback();
|
||||
tracing::warn!(
|
||||
timezone = local_iana.as_str(),
|
||||
source = "local_machine",
|
||||
"using machine-local timezone as fallback"
|
||||
);
|
||||
let tz: Tz = local_iana.parse().unwrap_or(chrono_tz::UTC);
|
||||
Ok(tz)
|
||||
}
|
||||
|
||||
/// Return the start of today (midnight) in the given timezone as a
|
||||
/// timezone-aware `DateTime`. Errors if midnight cannot be resolved
|
||||
/// (e.g. a DST transition that skips midnight — extremely rare).
|
||||
pub fn start_of_today(tz: Tz) -> Result<chrono::DateTime<Tz>, crate::error::GwsError> {
|
||||
use chrono::{NaiveTime, TimeZone, Utc};
|
||||
|
||||
let now_in_tz = Utc::now().with_timezone(&tz);
|
||||
let today_start = now_in_tz
|
||||
.date_naive()
|
||||
.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap());
|
||||
tz.from_local_datetime(&today_start)
|
||||
.earliest()
|
||||
.ok_or_else(|| {
|
||||
crate::error::GwsError::Other(anyhow::anyhow!(
|
||||
"Could not determine start of day in timezone '{}'",
|
||||
tz
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Best-effort machine-local IANA timezone detection using the
|
||||
/// `iana-time-zone` crate, which reads the OS timezone database.
|
||||
fn iana_time_zone_fallback() -> String {
|
||||
match iana_time_zone::get_timezone() {
|
||||
Ok(tz) => tz,
|
||||
Err(_) => "UTC".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_valid_iana_timezone() {
|
||||
let tz = parse_timezone("America/Denver").unwrap();
|
||||
assert_eq!(tz, chrono_tz::America::Denver);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_utc_timezone() {
|
||||
let tz = parse_timezone("UTC").unwrap();
|
||||
assert_eq!(tz, chrono_tz::UTC);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_timezone_fails() {
|
||||
let result = parse_timezone("Not/A/Zone");
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("Invalid timezone"));
|
||||
assert!(err.contains("Not/A/Zone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_string_fails() {
|
||||
let result = parse_timezone("");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache_file = dir.path().join(CACHE_FILENAME);
|
||||
|
||||
// Write directly to test location
|
||||
std::fs::write(&cache_file, "America/New_York").unwrap();
|
||||
let contents = std::fs::read_to_string(&cache_file).unwrap();
|
||||
let tz: Tz = contents.trim().parse().unwrap();
|
||||
assert_eq!(tz, chrono_tz::America::New_York);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iana_fallback_returns_valid_tz() {
|
||||
let tz_name = iana_time_zone_fallback();
|
||||
// Should be parseable
|
||||
let result: Result<Tz, _> = tz_name.parse();
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Fallback timezone '{tz_name}' should be parseable"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use yup_oauth2::storage::{TokenInfo, TokenStorage, TokenStorageError};
|
||||
|
||||
use crate::output::sanitize_for_terminal;
|
||||
|
||||
/// A custom token storage implementation for `yup-oauth2` that encrypts
|
||||
/// the cached tokens at rest using AES-256-GCM encryption.
|
||||
pub struct EncryptedTokenStorage {
|
||||
file_path: PathBuf,
|
||||
// Add memory cache since TokenStorage getters can be called frequently
|
||||
cache: Arc<Mutex<Option<HashMap<String, TokenInfo>>>>,
|
||||
}
|
||||
|
||||
impl EncryptedTokenStorage {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self {
|
||||
file_path: path,
|
||||
cache: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_from_disk(&self) -> HashMap<String, TokenInfo> {
|
||||
let data = match tokio::fs::read(&self.file_path).await {
|
||||
Ok(d) => d,
|
||||
Err(_) => return HashMap::new(), // File doesn't exist yet — normal on first run
|
||||
};
|
||||
|
||||
let decrypted = match crate::credential_store::decrypt(&data) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"warning: failed to decrypt token cache ({}): {e:#}",
|
||||
self.file_path.display()
|
||||
);
|
||||
eprintln!("hint: you may need to re-authenticate with `gws auth login`");
|
||||
return HashMap::new();
|
||||
}
|
||||
};
|
||||
|
||||
let json = match String::from_utf8(decrypted) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"warning: token cache contains invalid UTF-8: {}",
|
||||
sanitize_for_terminal(&e.to_string())
|
||||
);
|
||||
return HashMap::new();
|
||||
}
|
||||
};
|
||||
|
||||
match serde_json::from_str(&json) {
|
||||
Ok(map) => map,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"warning: failed to parse token cache JSON: {}",
|
||||
sanitize_for_terminal(&e.to_string())
|
||||
);
|
||||
HashMap::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_to_disk(&self, map: &HashMap<String, TokenInfo>) -> anyhow::Result<()> {
|
||||
let json = serde_json::to_string(map)?;
|
||||
let encrypted = crate::credential_store::encrypt(json.as_bytes())?;
|
||||
|
||||
if let Some(parent) = self.file_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to create token directory '{}': {}",
|
||||
sanitize_for_terminal(&parent.display().to_string()),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
tokio::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to set permissions on token directory '{}': {}",
|
||||
sanitize_for_terminal(&parent.display().to_string()),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
// Write atomically via a sibling .tmp file + rename.
|
||||
crate::fs_util::atomic_write_async(&self.file_path, encrypted.as_slice()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Helper to join scopes consistently for cache keys
|
||||
fn cache_key(scopes: &[&str]) -> String {
|
||||
let mut s: Vec<&str> = scopes.to_vec();
|
||||
s.sort_unstable();
|
||||
s.dedup();
|
||||
s.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TokenStorage for EncryptedTokenStorage {
|
||||
async fn set(&self, scopes: &[&str], token: TokenInfo) -> Result<(), TokenStorageError> {
|
||||
let mut map_lock = self.cache.lock().await;
|
||||
|
||||
// Initialize cache if this is the first write
|
||||
if map_lock.is_none() {
|
||||
*map_lock = Some(self.load_from_disk().await);
|
||||
}
|
||||
|
||||
if let Some(map) = map_lock.as_mut() {
|
||||
map.insert(Self::cache_key(scopes), token);
|
||||
self.save_to_disk(map)
|
||||
.await
|
||||
.map_err(|e| TokenStorageError::Other(std::borrow::Cow::Owned(e.to_string())))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get(&self, scopes: &[&str]) -> Option<TokenInfo> {
|
||||
let mut map_lock = self.cache.lock().await;
|
||||
|
||||
if map_lock.is_none() {
|
||||
*map_lock = Some(self.load_from_disk().await);
|
||||
}
|
||||
|
||||
if let Some(map) = map_lock.as_ref() {
|
||||
let key = Self::cache_key(scopes);
|
||||
if let Some(token) = map.get(&key) {
|
||||
return Some(token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_encrypted_token_storage_new() {
|
||||
let path = PathBuf::from("/fake/path/to/token.json");
|
||||
let storage = EncryptedTokenStorage::new(path.clone());
|
||||
|
||||
assert_eq!(storage.file_path, path);
|
||||
|
||||
let cache_lock = storage.cache.lock().await;
|
||||
assert!(cache_lock.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Input validation — re-exports from `google_workspace` library crate.
|
||||
|
||||
pub use google_workspace::validate::*;
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"filterConfig": {
|
||||
"piAndJailbreakFilterSettings": {
|
||||
"filterEnforcement": "ENABLED",
|
||||
"confidenceLevel": "LOW_AND_ABOVE"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
[package]
|
||||
name = "google-workspace"
|
||||
version = "0.22.5"
|
||||
edition = "2021"
|
||||
description = "Google Workspace API client — Discovery Document types, service registry, and HTTP utilities"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/googleworkspace/cli"
|
||||
homepage = "https://github.com/googleworkspace/cli"
|
||||
readme = "README.md"
|
||||
authors = ["Justin Poehnelt"]
|
||||
keywords = ["google-workspace", "google", "discovery", "api-client"]
|
||||
categories = ["api-bindings", "web-programming"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
percent-encoding = "2.3.2"
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls-native-roots"], default-features = false }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["time", "fs"] }
|
||||
tracing = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
serial_test = "3.4.0"
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,40 @@
|
||||
# google-workspace
|
||||
|
||||
Core Rust library for interacting with Google Workspace APIs via the [Discovery Service](https://developers.google.com/discovery).
|
||||
|
||||
This crate provides the foundational types and utilities used by the [`google-workspace-cli`](https://crates.io/crates/google-workspace-cli) (`gws`) command-line tool, and can be used independently for programmatic access.
|
||||
|
||||
> **Dynamic Discovery** — this library fetches Google's Discovery Documents at runtime rather than relying on generated client crates. When Google adds or updates an API endpoint, your code picks it up automatically.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Description |
|
||||
|---|---|
|
||||
| `discovery` | Discovery Document types (`RestDescription`, `RestMethod`, etc.) and async fetch with optional disk caching |
|
||||
| `services` | Service registry mapping aliases (e.g., `drive`) to API name/version pairs |
|
||||
| `error` | Structured `GwsError` enum with exit codes and JSON serialization |
|
||||
| `validate` | Input validation: path safety, resource name checks, URL encoding |
|
||||
| `client` | HTTP client builder with automatic retry logic |
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use google_workspace::discovery::fetch_discovery_document;
|
||||
use google_workspace::services::resolve_service;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let (api, version) = resolve_service("drive").unwrap();
|
||||
let doc = fetch_discovery_document(api, version, None).await?;
|
||||
|
||||
println!("{} {} — {} resources",
|
||||
doc.name, doc.version,
|
||||
doc.resources.len(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 — see [LICENSE](https://github.com/googleworkspace/cli/blob/main/LICENSE).
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! HTTP client with retry logic for Google API requests.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
/// Maximum seconds to sleep on a 429 Retry-After header. Prevents a hostile
|
||||
/// or misconfigured server from hanging the process indefinitely.
|
||||
const MAX_RETRY_DELAY_SECS: u64 = 60;
|
||||
const CONNECT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
fn build_client_inner() -> Result<reqwest::Client, String> {
|
||||
let mut headers = HeaderMap::new();
|
||||
let name = env!("CARGO_PKG_NAME");
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
// Format: gl-rust/name-version (the gl-rust/ prefix is fixed)
|
||||
let client_header = format!("gl-rust/{}-{}", name, version);
|
||||
if let Ok(header_value) = HeaderValue::from_str(&client_header) {
|
||||
headers.insert("x-goog-api-client", header_value);
|
||||
}
|
||||
|
||||
reqwest::Client::builder()
|
||||
.default_headers(headers)
|
||||
.connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))
|
||||
}
|
||||
|
||||
pub fn build_client() -> Result<reqwest::Client, crate::error::GwsError> {
|
||||
build_client_inner().map_err(|message| crate::error::GwsError::Other(anyhow::anyhow!(message)))
|
||||
}
|
||||
|
||||
/// Returns a shared reqwest client clone backed by a single global connection pool.
|
||||
///
|
||||
/// `reqwest::Client` is cheap to clone, so callers can take ownership of the
|
||||
/// returned value while still sharing pooled connections underneath.
|
||||
pub fn shared_client() -> Result<reqwest::Client, crate::error::GwsError> {
|
||||
static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
|
||||
|
||||
match CLIENT.get_or_init(build_client_inner) {
|
||||
Ok(client) => Ok(client.clone()),
|
||||
Err(message) => Err(crate::error::GwsError::Other(anyhow::anyhow!(
|
||||
message.clone()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an HTTP request with automatic retry on 429 (rate limit) responses
|
||||
/// and transient connection/timeout errors.
|
||||
/// Respects the `Retry-After` header; falls back to exponential backoff (1s, 2s, 4s).
|
||||
pub async fn send_with_retry(
|
||||
build_request: impl Fn() -> reqwest::RequestBuilder,
|
||||
) -> Result<reqwest::Response, reqwest::Error> {
|
||||
let mut last_err: Option<reqwest::Error> = None;
|
||||
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
match build_request().send().await {
|
||||
Ok(resp) => {
|
||||
if resp.status() != reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
let header_value = resp
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
let retry_after = compute_retry_delay(header_value, attempt);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(retry_after)).await;
|
||||
}
|
||||
Err(e) if e.is_connect() || e.is_timeout() => {
|
||||
// Transient network error — retry with exponential backoff
|
||||
let delay = compute_retry_delay(None, attempt);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||
last_err = Some(e);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// Final attempt — return whatever we get
|
||||
match build_request().send().await {
|
||||
Ok(resp) => Ok(resp),
|
||||
Err(e) => Err(last_err.unwrap_or(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the retry delay from a Retry-After header value and attempt number.
|
||||
/// Falls back to exponential backoff (1, 2, 4s) when the header is absent or
|
||||
/// unparseable. Always caps the result at MAX_RETRY_DELAY_SECS.
|
||||
fn compute_retry_delay(header_value: Option<&str>, attempt: u32) -> u64 {
|
||||
header_value
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(2u64.saturating_pow(attempt))
|
||||
.min(MAX_RETRY_DELAY_SECS)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_client_succeeds() {
|
||||
assert!(build_client().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_client_succeeds() {
|
||||
assert!(shared_client().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_client_can_be_reused() {
|
||||
let client_a = shared_client().unwrap();
|
||||
let client_b = shared_client().unwrap();
|
||||
let request_a = client_a.get("https://example.com").build().unwrap();
|
||||
let request_b = client_b.get("https://example.com").build().unwrap();
|
||||
assert_eq!(request_a.url(), request_b.url());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_delay_caps_large_header_value() {
|
||||
assert_eq!(compute_retry_delay(Some("999999"), 0), MAX_RETRY_DELAY_SECS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_delay_passes_through_small_header_value() {
|
||||
assert_eq!(compute_retry_delay(Some("5"), 0), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_delay_falls_back_to_exponential_on_missing_header() {
|
||||
assert_eq!(compute_retry_delay(None, 0), 1); // 2^0
|
||||
assert_eq!(compute_retry_delay(None, 1), 2); // 2^1
|
||||
assert_eq!(compute_retry_delay(None, 2), 4); // 2^2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_delay_falls_back_on_unparseable_header() {
|
||||
assert_eq!(compute_retry_delay(Some("not-a-number"), 1), 2);
|
||||
assert_eq!(compute_retry_delay(Some(""), 0), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_delay_caps_at_boundary() {
|
||||
assert_eq!(compute_retry_delay(Some("60"), 0), 60);
|
||||
assert_eq!(compute_retry_delay(Some("61"), 0), MAX_RETRY_DELAY_SECS);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user