diff --git a/.changeset/publish-skills-workflow.md b/.changeset/publish-skills-workflow.md new file mode 100644 index 0000000..3ca8b77 --- /dev/null +++ b/.changeset/publish-skills-workflow.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Add workflow to publish OpenClaw skills to ClawHub diff --git a/.github/workflows/publish-skills.yml b/.github/workflows/publish-skills.yml new file mode 100644 index 0000000..5d18dd2 --- /dev/null +++ b/.github/workflows/publish-skills.yml @@ -0,0 +1,47 @@ +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" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install ClawHub CLI + run: npm i -g clawhub@0.7.0 + + - name: Publish skills + env: + CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }} + run: | + if [ -z "$CLAWHUB_TOKEN" ]; then + echo "::error::CLAWHUB_TOKEN secret is not set" + exit 1 + fi + if [ "${{ github.event_name }}" = "pull_request" ]; then + clawhub sync --root skills --all --dry-run + else + clawhub sync --root skills --all + fi diff --git a/Cargo.lock b/Cargo.lock index 1bb72c5..b76aa17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -860,6 +860,7 @@ dependencies = [ "futures-util", "hostname", "keyring", + "open", "percent-encoding", "rand 0.8.5", "ratatui", @@ -1243,6 +1244,25 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1510,6 +1530,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -1554,6 +1585,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" diff --git a/Cargo.toml b/Cargo.toml index ef6b8cb..f521d97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ chrono = "0.4.44" keyring = "3.6.3" async-trait = "0.1.89" serde_yaml = "0.9.34" +open = "5" percent-encoding = "2.3.2" diff --git a/docs/skills.md b/docs/skills.md index 8025e54..2147135 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -52,6 +52,10 @@ Shortcut commands for common operations. | [gws-docs-write](../skills/gws-docs-write/SKILL.md) | Google Docs: Append text to a document. | | [gws-chat-send](../skills/gws-chat-send/SKILL.md) | Google Chat: Send a message to a space. | | [gws-apps-script-push](../skills/gws-apps-script-push/SKILL.md) | Google Apps Script: Upload local files to an Apps Script project. | +| [gws-apps-script-pull](../skills/gws-apps-script-pull/SKILL.md) | Google Apps Script: Download project files to local directory. | +| [gws-apps-script-open](../skills/gws-apps-script-open/SKILL.md) | Google Apps Script: Open the script editor in your browser. | +| [gws-apps-script-run](../skills/gws-apps-script-run/SKILL.md) | Google Apps Script: Execute a function in the script. | +| [gws-apps-script-logs](../skills/gws-apps-script-logs/SKILL.md) | Google Apps Script: View execution logs for the script. | | [gws-events-subscribe](../skills/gws-events-subscribe/SKILL.md) | Google Workspace Events: Subscribe to Workspace events and stream them as NDJSON. | | [gws-events-renew](../skills/gws-events-renew/SKILL.md) | Google Workspace Events: Renew/reactivate Workspace Events subscriptions. | | [gws-modelarmor-sanitize-prompt](../skills/gws-modelarmor-sanitize-prompt/SKILL.md) | Google Model Armor: Sanitize a user prompt through a Model Armor template. | diff --git a/skills/gws-apps-script-logs/SKILL.md b/skills/gws-apps-script-logs/SKILL.md new file mode 100644 index 0000000..9507c11 --- /dev/null +++ b/skills/gws-apps-script-logs/SKILL.md @@ -0,0 +1,46 @@ +--- +name: gws-apps-script-logs +version: 1.0.0 +description: "Google Apps Script: View execution logs for the script." +metadata: + openclaw: + category: "productivity" + requires: + bins: ["gws"] + cliHelp: "gws apps-script +logs --help" +--- + +# apps-script +logs + +> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it. + +View execution logs for the script + +## Usage + +```bash +gws apps-script +logs +``` + +## Flags + +| Flag | Required | Default | Description | +|------|----------|---------|-------------| +| `--script` | — | — | Script Project ID (reads .clasp.json if omitted) | + +## Examples + +```bash +gws script +logs --script SCRIPT_ID +gws script +logs # uses .clasp.json +``` + +## Tips + +- Shows recent script executions and their status. +- Use --format table for a readable summary. + +## See Also + +- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth +- [gws-apps-script](../gws-apps-script/SKILL.md) — All manage and execute apps script projects commands diff --git a/skills/gws-apps-script-open/SKILL.md b/skills/gws-apps-script-open/SKILL.md new file mode 100644 index 0000000..81d428b --- /dev/null +++ b/skills/gws-apps-script-open/SKILL.md @@ -0,0 +1,41 @@ +--- +name: gws-apps-script-open +version: 1.0.0 +description: "Google Apps Script: Open the script editor in your browser." +metadata: + openclaw: + category: "productivity" + requires: + bins: ["gws"] + cliHelp: "gws apps-script +open --help" +--- + +# apps-script +open + +> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it. + +Open the script editor in your browser + +## Usage + +```bash +gws apps-script +open +``` + +## Flags + +| Flag | Required | Default | Description | +|------|----------|---------|-------------| +| `--script` | — | — | Script Project ID (reads .clasp.json if omitted) | + +## Examples + +```bash +gws script +open --script SCRIPT_ID +gws script +open # uses .clasp.json +``` + +## See Also + +- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth +- [gws-apps-script](../gws-apps-script/SKILL.md) — All manage and execute apps script projects commands diff --git a/skills/gws-apps-script-pull/SKILL.md b/skills/gws-apps-script-pull/SKILL.md new file mode 100644 index 0000000..ccde699 --- /dev/null +++ b/skills/gws-apps-script-pull/SKILL.md @@ -0,0 +1,47 @@ +--- +name: gws-apps-script-pull +version: 1.0.0 +description: "Google Apps Script: Download project files to local directory." +metadata: + openclaw: + category: "productivity" + requires: + bins: ["gws"] + cliHelp: "gws apps-script +pull --help" +--- + +# apps-script +pull + +> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it. + +Download project files to local directory + +## Usage + +```bash +gws apps-script +pull +``` + +## Flags + +| Flag | Required | Default | Description | +|------|----------|---------|-------------| +| `--script` | — | — | Script Project ID (reads .clasp.json if omitted) | +| `--dir` | — | — | Output directory (reads .clasp.json rootDir, or defaults to current dir) | + +## Examples + +```bash +gws script +pull --script SCRIPT_ID +gws script +pull --script SCRIPT_ID --dir ./src +gws script +pull # uses .clasp.json +FILES CREATED: +SERVER_JS → {name}.gs +HTML → {name}.html +JSON → appsscript.json +``` + +## See Also + +- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth +- [gws-apps-script](../gws-apps-script/SKILL.md) — All manage and execute apps script projects commands diff --git a/skills/gws-apps-script-push/SKILL.md b/skills/gws-apps-script-push/SKILL.md index 1866e0e..4e3d078 100644 --- a/skills/gws-apps-script-push/SKILL.md +++ b/skills/gws-apps-script-push/SKILL.md @@ -19,21 +19,22 @@ Upload local files to an Apps Script project ## Usage ```bash -gws apps-script +push --script +gws apps-script +push ``` ## Flags | Flag | Required | Default | Description | |------|----------|---------|-------------| -| `--script` | ✓ | — | Script Project ID | -| `--dir` | — | — | Directory containing script files (defaults to current dir) | +| `--script` | — | — | Script Project ID (reads .clasp.json if omitted) | +| `--dir` | — | — | Directory containing script files (reads .clasp.json rootDir, or defaults to current dir) | ## Examples ```bash gws script +push --script SCRIPT_ID gws script +push --script SCRIPT_ID --dir ./src +gws script +push # uses .clasp.json ``` ## Tips diff --git a/skills/gws-apps-script-run/SKILL.md b/skills/gws-apps-script-run/SKILL.md new file mode 100644 index 0000000..c56f31e --- /dev/null +++ b/skills/gws-apps-script-run/SKILL.md @@ -0,0 +1,50 @@ +--- +name: gws-apps-script-run +version: 1.0.0 +description: "Google Apps Script: Execute a function in the script." +metadata: + openclaw: + category: "productivity" + requires: + bins: ["gws"] + cliHelp: "gws apps-script +run --help" +--- + +# apps-script +run + +> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it. + +Execute a function in the script + +## Usage + +```bash +gws apps-script +run --function +``` + +## Flags + +| Flag | Required | Default | Description | +|------|----------|---------|-------------| +| `--script` | — | — | Script Project ID (reads .clasp.json if omitted) | +| `--function` | ✓ | — | Function name to execute | +| `--dev-mode` | — | — | Run the script in dev mode (HEAD deployment) | + +## Examples + +```bash +gws script +run --script SCRIPT_ID --function main +gws script +run --function main # uses .clasp.json +gws script +run --function main --dev-mode +SETUP REQUIREMENTS: +1. Auth with cloud-platform scope: gws auth login +2. Link the script to your OAuth client's GCP project: +Open the script editor (gws apps-script +open) → Project Settings → +Change GCP project → enter your project number. +3. Add to appsscript.json: "executionApi": {"access": "MYSELF"} +``` + +## See Also + +- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth +- [gws-apps-script](../gws-apps-script/SKILL.md) — All manage and execute apps script projects commands diff --git a/skills/gws-apps-script/SKILL.md b/skills/gws-apps-script/SKILL.md index 75f4744..3014208 100644 --- a/skills/gws-apps-script/SKILL.md +++ b/skills/gws-apps-script/SKILL.md @@ -23,6 +23,10 @@ gws apps-script [flags] | Command | Description | |---------|-------------| | [`+push`](../gws-apps-script-push/SKILL.md) | Upload local files to an Apps Script project | +| [`+pull`](../gws-apps-script-pull/SKILL.md) | Download project files to local directory | +| [`+open`](../gws-apps-script-open/SKILL.md) | Open the script editor in your browser | +| [`+run`](../gws-apps-script-run/SKILL.md) | Execute a function in the script | +| [`+logs`](../gws-apps-script-logs/SKILL.md) | View execution logs for the script | ## API Resources diff --git a/src/helpers/clasp_config.rs b/src/helpers/clasp_config.rs new file mode 100644 index 0000000..f4fbf70 --- /dev/null +++ b/src/helpers/clasp_config.rs @@ -0,0 +1,220 @@ +// 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. + +//! Support for reading `.clasp.json` configuration files. +//! +//! This allows `gws` to be a drop-in replacement for `clasp` by reusing the +//! same project configuration format. When `--script` is omitted, helpers +//! will attempt to read the script ID from `.clasp.json` in the current directory. + +use crate::error::GwsError; +use serde::Deserialize; +use std::path::PathBuf; + +/// Represents a `.clasp.json` configuration file. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaspConfig { + pub script_id: String, + pub root_dir: Option, +} + +/// Attempts to load `.clasp.json` from the current working directory. +/// +/// Returns `Ok(Some(config))` if found and valid, `Ok(None)` if no file exists, +/// or `Err` if the file exists but is malformed or contains unsafe values. +pub fn load_clasp_config() -> Result, GwsError> { + let path = PathBuf::from(".clasp.json"); + if !path.exists() { + return Ok(None); + } + + let contents = std::fs::read_to_string(&path) + .map_err(|e| GwsError::Validation(format!("Failed to read .clasp.json: {e}")))?; + + let config: ClaspConfig = serde_json::from_str(&contents) + .map_err(|e| GwsError::Validation(format!("Failed to parse .clasp.json: {e}")))?; + + // Validate scriptId against injection + if config.script_id.is_empty() { + return Err(GwsError::Validation( + ".clasp.json: scriptId must not be empty".to_string(), + )); + } + crate::validate::validate_resource_name(&config.script_id) + .map_err(|e| GwsError::Validation(format!(".clasp.json: invalid scriptId: {e}")))?; + + // Validate rootDir against path traversal if present + if let Some(ref root_dir) = config.root_dir { + if root_dir != "." { + crate::validate::validate_safe_dir_path(root_dir) + .map_err(|e| GwsError::Validation(format!(".clasp.json: invalid rootDir: {e}")))?; + } + } + + Ok(Some(config)) +} + +/// Resolves the script ID from an explicit `--script` flag or `.clasp.json`. +/// +/// Returns the script ID or an error with a helpful message. +pub fn resolve_script_id(explicit: Option<&String>) -> Result { + if let Some(id) = explicit { + return Ok(id.clone()); + } + + match load_clasp_config()? { + Some(config) => Ok(config.script_id), + None => Err(GwsError::Validation( + "No --script flag provided and no .clasp.json found in current directory. \ + Either pass --script or create a .clasp.json with {\"scriptId\": \"...\"}." + .to_string(), + )), + } +} + +/// Resolves the working directory from `--dir`, `.clasp.json` `rootDir`, or CWD. +pub fn resolve_dir(explicit_dir: Option<&String>) -> Result { + if let Some(dir) = explicit_dir { + return crate::validate::validate_safe_dir_path(dir); + } + + if let Ok(Some(config)) = load_clasp_config() { + if let Some(ref root_dir) = config.root_dir { + return crate::validate::validate_safe_dir_path(root_dir); + } + } + + std::env::current_dir() + .map_err(|e| GwsError::Validation(format!("Failed to determine current directory: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::fs; + use tempfile::tempdir; + + #[test] + #[serial] + fn test_clasp_config_parse() { + let dir = tempdir().unwrap(); + let canonical = dir.path().canonicalize().unwrap(); + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical).unwrap(); + + fs::write(".clasp.json", r#"{"scriptId": "abc123", "rootDir": "."}"#).unwrap(); + + let config = load_clasp_config().unwrap().unwrap(); + assert_eq!(config.script_id, "abc123"); + assert_eq!(config.root_dir.as_deref(), Some(".")); + + std::env::set_current_dir(&saved_cwd).unwrap(); + } + + #[test] + #[serial] + fn test_clasp_config_missing() { + let dir = tempdir().unwrap(); + let canonical = dir.path().canonicalize().unwrap(); + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical).unwrap(); + + let config = load_clasp_config().unwrap(); + assert!(config.is_none()); + + std::env::set_current_dir(&saved_cwd).unwrap(); + } + + #[test] + #[serial] + fn test_clasp_config_no_root_dir() { + let dir = tempdir().unwrap(); + let canonical = dir.path().canonicalize().unwrap(); + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical).unwrap(); + + fs::write(".clasp.json", r#"{"scriptId": "xyz789"}"#).unwrap(); + + let config = load_clasp_config().unwrap().unwrap(); + assert_eq!(config.script_id, "xyz789"); + assert!(config.root_dir.is_none()); + + std::env::set_current_dir(&saved_cwd).unwrap(); + } + + #[test] + #[serial] + fn test_clasp_config_malicious_root_dir() { + let dir = tempdir().unwrap(); + let canonical = dir.path().canonicalize().unwrap(); + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical).unwrap(); + + fs::write( + ".clasp.json", + r#"{"scriptId": "abc", "rootDir": "../../.ssh"}"#, + ) + .unwrap(); + + let result = load_clasp_config(); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("rootDir"), "got: {msg}"); + + std::env::set_current_dir(&saved_cwd).unwrap(); + } + + #[test] + #[serial] + fn test_clasp_config_malicious_script_id() { + let dir = tempdir().unwrap(); + let canonical = dir.path().canonicalize().unwrap(); + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical).unwrap(); + + fs::write(".clasp.json", r#"{"scriptId": "../../../etc/passwd"}"#).unwrap(); + + let result = load_clasp_config(); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("scriptId"), "got: {msg}"); + + std::env::set_current_dir(&saved_cwd).unwrap(); + } + + #[test] + #[serial] + fn test_resolve_script_id_explicit() { + let id = resolve_script_id(Some(&"explicit123".to_string())).unwrap(); + assert_eq!(id, "explicit123"); + } + + #[test] + #[serial] + fn test_resolve_script_id_no_config() { + let dir = tempdir().unwrap(); + let canonical = dir.path().canonicalize().unwrap(); + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical).unwrap(); + + let result = resolve_script_id(None); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains(".clasp.json"), "got: {msg}"); + + std::env::set_current_dir(&saved_cwd).unwrap(); + } +} diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index 378f1ea..53278c1 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -18,6 +18,7 @@ use std::future::Future; use std::pin::Pin; pub mod calendar; pub mod chat; +pub mod clasp_config; pub mod docs; pub mod drive; pub mod events; diff --git a/src/helpers/script.rs b/src/helpers/script.rs index 8685afb..7a94aec 100644 --- a/src/helpers/script.rs +++ b/src/helpers/script.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use super::clasp_config; use super::Helper; use crate::auth; use crate::error::GwsError; @@ -32,20 +33,20 @@ impl Helper for ScriptHelper { mut cmd: Command, _doc: &crate::discovery::RestDescription, ) -> Command { + // +push 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) + .help("Script Project ID (reads .clasp.json if omitted)") .value_name("ID"), ) .arg( Arg::new("dir") .long("dir") - .help("Directory containing script files (defaults to current dir)") + .help("Directory containing script files (reads .clasp.json rootDir, or defaults to current dir)") .value_name("DIR"), ) .after_help( @@ -53,6 +54,7 @@ impl Helper for ScriptHelper { EXAMPLES: gws script +push --script SCRIPT_ID gws script +push --script SCRIPT_ID --dir ./src + gws script +push # uses .clasp.json TIPS: Supports .gs, .js, .html, and appsscript.json files. @@ -60,6 +62,116 @@ TIPS: This replaces ALL files in the project.", ), ); + + // +pull + cmd = cmd.subcommand( + Command::new("+pull") + .about("[Helper] Download project files to local directory") + .arg( + Arg::new("script") + .long("script") + .help("Script Project ID (reads .clasp.json if omitted)") + .value_name("ID"), + ) + .arg( + Arg::new("dir") + .long("dir") + .help("Output directory (reads .clasp.json rootDir, or defaults to current dir)") + .value_name("DIR"), + ) + .after_help( + "\ +EXAMPLES: + gws script +pull --script SCRIPT_ID + gws script +pull --script SCRIPT_ID --dir ./src + gws script +pull # uses .clasp.json + +FILES CREATED: + SERVER_JS → {name}.gs + HTML → {name}.html + JSON → appsscript.json", + ), + ); + + // +open + cmd = cmd.subcommand( + Command::new("+open") + .about("[Helper] Open the script editor in your browser") + .arg( + Arg::new("script") + .long("script") + .help("Script Project ID (reads .clasp.json if omitted)") + .value_name("ID"), + ) + .after_help( + "\ +EXAMPLES: + gws script +open --script SCRIPT_ID + gws script +open # uses .clasp.json", + ), + ); + + // +run + cmd = cmd.subcommand( + Command::new("+run") + .about("[Helper] Execute a function in the script") + .arg( + Arg::new("script") + .long("script") + .help("Script Project ID (reads .clasp.json if omitted)") + .value_name("ID"), + ) + .arg( + Arg::new("function") + .long("function") + .help("Function name to execute") + .required(true) + .value_name("NAME"), + ) + .arg( + Arg::new("dev-mode") + .long("dev-mode") + .help("Run the script in dev mode (HEAD deployment)") + .action(clap::ArgAction::SetTrue), + ) + .after_help( + "\ +EXAMPLES: + gws script +run --script SCRIPT_ID --function main + gws script +run --function main # uses .clasp.json + gws script +run --function main --dev-mode + +SETUP REQUIREMENTS: + 1. Auth with cloud-platform scope: gws auth login + 2. Link the script to your OAuth client's GCP project: + Open the script editor (gws apps-script +open) → Project Settings → + Change GCP project → enter your project number. + 3. Add to appsscript.json: \"executionApi\": {\"access\": \"MYSELF\"}", + ), + ); + + // +logs + cmd = cmd.subcommand( + Command::new("+logs") + .about("[Helper] View execution logs for the script") + .arg( + Arg::new("script") + .long("script") + .help("Script Project ID (reads .clasp.json if omitted)") + .value_name("ID"), + ) + .after_help( + "\ +EXAMPLES: + gws script +logs --script SCRIPT_ID + gws script +logs # uses .clasp.json + +TIPS: + Shows recent script executions and their status. + Use --format table for a readable summary.", + ), + ); + cmd } @@ -70,74 +182,426 @@ TIPS: _sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, ) -> Pin> + Send + 'a>> { Box::pin(async move { - if let Some(matches) = matches.subcommand_matches("+push") { - let script_id = matches.get_one::("script").unwrap(); - let dir_path = matches - .get_one::("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(_) => (None, executor::AuthMethod::None), - }; - - 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); + if let Some(sub) = matches.subcommand_matches("+push") { + return handle_push(doc, sub).await.map(|_| true); + } + if let Some(sub) = matches.subcommand_matches("+pull") { + return handle_pull(doc, sub).await.map(|_| true); + } + if let Some(sub) = matches.subcommand_matches("+open") { + return handle_open(sub).await.map(|_| true); + } + if let Some(sub) = matches.subcommand_matches("+run") { + return handle_run(doc, sub).await.map(|_| true); + } + if let Some(sub) = matches.subcommand_matches("+logs") { + return handle_logs(doc, sub).await.map(|_| true); } Ok(false) }) } } +// --------------------------------------------------------------------------- +// +push +// --------------------------------------------------------------------------- + +async fn handle_push( + doc: &crate::discovery::RestDescription, + matches: &ArgMatches, +) -> Result<(), GwsError> { + let script_id = clasp_config::resolve_script_id(matches.get_one::("script"))?; + let dir = clasp_config::resolve_dir(matches.get_one::("dir"))?; + + let mut files = Vec::new(); + visit_dirs(&dir, &mut files)?; + + if files.is_empty() { + return Err(GwsError::Validation(format!( + "No eligible files found in '{}'", + dir.display() + ))); + } + + 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()) + })?; + + 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(_) => (None, executor::AuthMethod::None), + }; + + 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?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// +pull +// --------------------------------------------------------------------------- + +async fn handle_pull( + doc: &crate::discovery::RestDescription, + matches: &ArgMatches, +) -> Result<(), GwsError> { + let script_id = clasp_config::resolve_script_id(matches.get_one::("script"))?; + let output_dir = matches + .get_one::("dir") + .map(|d| crate::validate::validate_safe_output_dir(d)) + .transpose()? + .unwrap_or_else(|| { + clasp_config::resolve_dir(None).unwrap_or_else(|_| std::env::current_dir().unwrap()) + }); + + let projects_res = doc + .resources + .get("projects") + .ok_or_else(|| GwsError::Discovery("Resource 'projects' not found".to_string()))?; + let get_content_method = projects_res + .methods + .get("getContent") + .ok_or_else(|| GwsError::Discovery("Method 'projects.getContent' not found".to_string()))?; + + let scopes: Vec<&str> = get_content_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(_) => (None, executor::AuthMethod::None), + }; + + let params = json!({ "scriptId": script_id }); + let params_str = params.to_string(); + + let response = executor::execute_method( + doc, + get_content_method, + Some(¶ms_str), + None, + token.as_deref(), + auth_method, + None, + None, + false, + &executor::PaginationConfig::default(), + None, + &crate::helpers::modelarmor::SanitizeMode::Warn, + &crate::formatter::OutputFormat::default(), + true, // capture output + ) + .await?; + + let response_value = + response.ok_or_else(|| GwsError::Validation("No response from getContent".to_string()))?; + + let files = response_value + .get("files") + .and_then(|f| f.as_array()) + .ok_or_else(|| GwsError::Validation("Response missing 'files' array".to_string()))?; + + // Create output directory if needed + fs::create_dir_all(&output_dir).map_err(|e| { + GwsError::Validation(format!( + "Failed to create output directory '{}': {e}", + output_dir.display() + )) + })?; + + let canonical_output = output_dir.canonicalize().map_err(|e| { + GwsError::Validation(format!( + "Failed to canonicalize output directory '{}': {e}", + output_dir.display() + )) + })?; + + let mut written = 0; + for file in files { + let name = file.get("name").and_then(|n| n.as_str()).unwrap_or(""); + let file_type = file.get("type").and_then(|t| t.as_str()).unwrap_or(""); + let source = file.get("source").and_then(|s| s.as_str()).unwrap_or(""); + + // Validate the filename from the API + crate::validate::validate_script_filename(name)?; + + let extension = file_type_to_extension(file_type); + if extension.is_empty() { + continue; // Skip unknown types + } + + let filename = format!("{name}{extension}"); + let file_path = canonical_output.join(&filename); + + // Final containment check — ensure resolved path is inside output dir + let canonical_file = if file_path.exists() { + file_path + .canonicalize() + .map_err(|e| GwsError::Validation(format!("Failed to resolve path: {e}")))? + } else { + // For new files, the parent exists (we created it), so this is safe + file_path.clone() + }; + + if !canonical_file.starts_with(&canonical_output) { + return Err(GwsError::Validation(format!( + "File '{}' would be written outside output directory — refusing", + filename + ))); + } + + fs::write(&file_path, source).map_err(|e| { + GwsError::Validation(format!("Failed to write '{}': {e}", file_path.display())) + })?; + written += 1; + } + + eprintln!("Pulled {written} files to {}", canonical_output.display()); + Ok(()) +} + +/// Maps Apps Script file type to local file extension. +fn file_type_to_extension(file_type: &str) -> &str { + match file_type { + "SERVER_JS" => ".gs", + "HTML" => ".html", + "JSON" => ".json", + _ => "", + } +} + +// --------------------------------------------------------------------------- +// +open +// --------------------------------------------------------------------------- + +async fn handle_open(matches: &ArgMatches) -> Result<(), GwsError> { + let script_id = clasp_config::resolve_script_id(matches.get_one::("script"))?; + + let url = format!("https://script.google.com/d/{}/edit", script_id); + eprintln!("Opening {url}"); + + open::that(&url).map_err(|e| GwsError::Validation(format!("Failed to open browser: {e}")))?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// +run +// --------------------------------------------------------------------------- + +async fn handle_run( + doc: &crate::discovery::RestDescription, + matches: &ArgMatches, +) -> Result<(), GwsError> { + let script_id = clasp_config::resolve_script_id(matches.get_one::("script"))?; + let function_name = matches.get_one::("function").unwrap(); + let dev_mode = matches.get_flag("dev-mode"); + + let scripts_res = doc + .resources + .get("scripts") + .ok_or_else(|| GwsError::Discovery("Resource 'scripts' not found".to_string()))?; + let run_method = scripts_res + .methods + .get("run") + .ok_or_else(|| GwsError::Discovery("Method 'scripts.run' not found".to_string()))?; + + let mut body = json!({ + "function": function_name, + }); + if dev_mode { + body["devMode"] = json!(true); + } + let body_str = body.to_string(); + + // scripts.run is special: the discovery doc lists scopes of services the + // *script* uses (spreadsheets, drive, mail, etc), not a dedicated "run" scope. + // cloud-platform is a superset that covers all of them. + let (token, auth_method) = + match auth::get_token(&["https://www.googleapis.com/auth/cloud-platform"]).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; + + let params = json!({ "scriptId": script_id }); + let params_str = params.to_string(); + + let result = executor::execute_method( + doc, + run_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; + + if let Err(ref e) = result { + let msg = e.to_string(); + let gcp_project_hint = extract_gcp_project_number(); + + if msg.contains("authentication scopes") { + eprintln!( + "\n\x1b[33mHint:\x1b[0m scripts.run requires scopes matching the services your \ + script uses.\n\ + Re-run \x1b[1mgws auth login\x1b[0m and include the \x1b[1mcloud-platform\x1b[0m scope." + ); + } else if msg.contains("does not have permission") || msg.contains("403") { + eprintln!( + "\n\x1b[33mHint:\x1b[0m The script must be linked to your OAuth client's GCP project.{gcp}\n\ + 1. Open the script editor: \x1b[1mgws apps-script +open\x1b[0m\n\ + 2. Go to Project Settings (gear icon)\n\ + 3. Under 'Google Cloud Platform (GCP) Project', click 'Change project'\n\ + 4. Enter your GCP project number and click 'Set project'\n\n\ + Also ensure appsscript.json includes: \x1b[1m\"executionApi\": {{\"access\": \"MYSELF\"}}\x1b[0m", + gcp = gcp_project_hint, + ); + } else if msg.contains("not found") || msg.contains("404") { + eprintln!( + "\n\x1b[33mHint:\x1b[0m The script was not found. Possible causes:\n\ + • The script is not linked to a GCP project{gcp}\n\ + (see \x1b[1mgws apps-script +open\x1b[0m → Project Settings)\n\ + • The script has no API-executable deployment (create one with:\n\ + \x1b[1mgws apps-script projects versions create\x1b[0m then\n\ + \x1b[1mgws apps-script projects deployments create\x1b[0m)\n\ + • Use \x1b[1m--dev-mode\x1b[0m to run the latest saved code without deploying", + gcp = gcp_project_hint, + ); + } + } + result?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Extracts the GCP project number from the stored OAuth client ID. +/// +/// Client IDs have the format `{project_number}-{hash}.apps.googleusercontent.com`. +/// Returns a formatted string like `\n GCP project number: 388781138000` or empty string. +fn extract_gcp_project_number() -> String { + let enc_path = crate::credential_store::encrypted_credentials_path(); + if !enc_path.exists() { + return String::new(); + } + + let json_str = match crate::credential_store::load_encrypted_from_path(&enc_path) { + Ok(s) => s, + Err(_) => return String::new(), + }; + + let creds: serde_json::Value = match serde_json::from_str(&json_str) { + Ok(v) => v, + Err(_) => return String::new(), + }; + + if let Some(client_id) = creds.get("client_id").and_then(|v| v.as_str()) { + if let Some(project_num) = client_id.split('-').next() { + if project_num.chars().all(|c| c.is_ascii_digit()) && !project_num.is_empty() { + return format!( + "\n Your OAuth client's GCP project number: \x1b[1m{project_num}\x1b[0m" + ); + } + } + } + + String::new() +} + +// --------------------------------------------------------------------------- +// +logs +// --------------------------------------------------------------------------- + +async fn handle_logs( + doc: &crate::discovery::RestDescription, + matches: &ArgMatches, +) -> Result<(), GwsError> { + let script_id = clasp_config::resolve_script_id(matches.get_one::("script"))?; + + let processes_res = doc + .resources + .get("processes") + .ok_or_else(|| GwsError::Discovery("Resource 'processes' not found".to_string()))?; + let list_method = processes_res + .methods + .get("listScriptProcesses") + .ok_or_else(|| { + GwsError::Discovery("Method 'processes.listScriptProcesses' not found".to_string()) + })?; + + let scopes: Vec<&str> = list_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(_) => (None, executor::AuthMethod::None), + }; + + let params = json!({ "scriptId": script_id }); + let params_str = params.to_string(); + + executor::execute_method( + doc, + list_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?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Shared utilities +// --------------------------------------------------------------------------- + fn visit_dirs(dir: &Path, files: &mut Vec) -> Result<(), GwsError> { if dir.is_dir() { for entry in fs::read_dir(dir).context("Failed to read dir")? { @@ -286,4 +750,13 @@ mod tests { assert!(names.contains(&"root")); assert!(names.contains(&"utils")); } + + #[test] + fn test_file_type_to_extension() { + assert_eq!(file_type_to_extension("SERVER_JS"), ".gs"); + assert_eq!(file_type_to_extension("HTML"), ".html"); + assert_eq!(file_type_to_extension("JSON"), ".json"); + assert_eq!(file_type_to_extension("UNKNOWN"), ""); + assert_eq!(file_type_to_extension(""), ""); + } } diff --git a/src/validate.rs b/src/validate.rs index 581352b..d3b7844 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -207,6 +207,67 @@ pub fn validate_resource_name(s: &str) -> Result<&str, GwsError> { Ok(s) } +/// Validates an Apps Script file name from the API for safe use as a local filename. +/// +/// This is critical for `+pull` — the API returns `name` fields that become +/// filenames on disk. A malicious script project could set names like +/// `../../.ssh/authorized_keys` to escape the target directory. +/// +/// Rejects: +/// - Empty names +/// - Names containing `/` or `\` (directory separators) +/// - Names containing `..` (path traversal) +/// - Names starting with `.` (hidden files) +/// - Names with null bytes or control characters +/// - Names with characters outside `[a-zA-Z0-9_-]` +pub fn validate_script_filename(name: &str) -> Result<&str, GwsError> { + if name.is_empty() { + return Err(GwsError::Validation( + "Script file name must not be empty".to_string(), + )); + } + + // Reject control characters and null bytes + if name.bytes().any(|b| b < 0x20) { + return Err(GwsError::Validation(format!( + "Script file name contains invalid control characters: {name}" + ))); + } + + // Reject directory separators + if name.contains('/') || name.contains('\\') { + return Err(GwsError::Validation(format!( + "Script file name must not contain directory separators: {name}" + ))); + } + + // Reject path traversal + if name.contains("..") { + return Err(GwsError::Validation(format!( + "Script file name must not contain '..': {name}" + ))); + } + + // Reject hidden files + if name.starts_with('.') { + return Err(GwsError::Validation(format!( + "Script file name must not start with '.': {name}" + ))); + } + + // Allowlist: only alphanumeric, underscore, hyphen + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(GwsError::Validation(format!( + "Script file name contains invalid characters (allowed: a-z, A-Z, 0-9, _, -): {name}" + ))); + } + + Ok(name) +} + #[cfg(test)] mod tests { use super::*; @@ -471,4 +532,52 @@ mod tests { // Just % should be rejected too assert!(validate_resource_name("spaces/100%").is_err()); } + + // -- validate_script_filename --------------------------------------------- + + #[test] + fn test_validate_script_filename_clean() { + assert!(validate_script_filename("Code").is_ok()); + assert!(validate_script_filename("Utils").is_ok()); + assert!(validate_script_filename("appsscript").is_ok()); + assert!(validate_script_filename("my-lib").is_ok()); + assert!(validate_script_filename("helper_2").is_ok()); + } + + #[test] + fn test_validate_script_filename_traversal() { + assert!(validate_script_filename("../evil").is_err()); + assert!(validate_script_filename("..").is_err()); + assert!(validate_script_filename("foo..bar").is_err()); + } + + #[test] + fn test_validate_script_filename_hidden() { + assert!(validate_script_filename(".evil").is_err()); + assert!(validate_script_filename(".ssh").is_err()); + } + + #[test] + fn test_validate_script_filename_slashes() { + assert!(validate_script_filename("foo/bar").is_err()); + assert!(validate_script_filename("foo\\bar").is_err()); + } + + #[test] + fn test_validate_script_filename_control_chars() { + assert!(validate_script_filename("foo\0bar").is_err()); + assert!(validate_script_filename("foo\nbar").is_err()); + } + + #[test] + fn test_validate_script_filename_empty() { + assert!(validate_script_filename("").is_err()); + } + + #[test] + fn test_validate_script_filename_special_chars() { + assert!(validate_script_filename("foo bar").is_err()); // space + assert!(validate_script_filename("foo@bar").is_err()); // @ + assert!(validate_script_filename("foo.bar").is_err()); // dot (not start) + } }