mirror of
https://github.com/googleworkspace/cli.git
synced 2026-09-14 16:47:13 +08:00
feat(apps-script): add +pull, +open, +run, +logs helpers with .clasp.json support
- Add +pull: download project files to local directory with filename sanitization - Add +open: open script editor in browser - Add +run: execute functions with cloud-platform scope and contextual error hints (extracts GCP project number from OAuth client ID for setup guidance) - Add +logs: view execution logs via processes.listScriptProcesses - Add .clasp.json support: auto-resolve scriptId/rootDir for all helpers - Add clasp_config.rs module with path traversal validation - Add validate_script_filename() for safe file writes during +pull - Regenerate skills for new helper commands
This commit is contained in:
Generated
+37
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -19,21 +19,22 @@ Upload local files to an Apps Script project
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gws apps-script +push --script <ID>
|
||||
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
|
||||
|
||||
@@ -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 <NAME>
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -23,6 +23,10 @@ gws apps-script <resource> <method> [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
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<Option<ClaspConfig>, 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<String, GwsError> {
|
||||
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 <ID> 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<PathBuf, GwsError> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+538
-65
@@ -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<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(_) => (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::<String>("script"))?;
|
||||
let dir = clasp_config::resolve_dir(matches.get_one::<String>("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::<String>("script"))?;
|
||||
let output_dir = matches
|
||||
.get_one::<String>("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::<String>("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::<String>("script"))?;
|
||||
let function_name = matches.get_one::<String>("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::<String>("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<serde_json::Value>) -> 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(""), "");
|
||||
}
|
||||
}
|
||||
|
||||
+109
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user