diff --git a/Cargo.lock b/Cargo.lock index 40290f22e..e52b34b07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -500,6 +500,7 @@ dependencies = [ name = "butterflow-core" version = "1.0.0-rc.15" dependencies = [ + "anyhow", "async-trait", "butterflow-models", "butterflow-runners", @@ -527,6 +528,7 @@ dependencies = [ name = "butterflow-models" version = "1.0.0-rc.15" dependencies = [ + "anyhow", "chrono", "regex", "reqwest 0.12.15", diff --git a/crates/cli/README.md b/crates/cli/README.md index dd61cb147..72c65cc8f 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -235,6 +235,7 @@ codemod validate ./my-workflow-bundle/ - Matrix strategy validation - State schema validation - Variable reference syntax validation +- `js-ast-grep` step validation: Ensures referenced JavaScript files exist **Example Output:** diff --git a/crates/cli/src/commands/publish.rs b/crates/cli/src/commands/publish.rs index 771e3ceaa..9b081b938 100644 --- a/crates/cli/src/commands/publish.rs +++ b/crates/cli/src/commands/publish.rs @@ -1,4 +1,6 @@ use anyhow::{anyhow, Result}; +use butterflow_core::utils; +use butterflow_models::workflow; use clap::Args; use log::{debug, info, warn}; use reqwest; @@ -11,6 +13,7 @@ use tempfile::TempDir; use walkdir::WalkDir; use crate::auth::TokenStorage; +use crate::commands::workflow::validate::validate_codemod_manifest_structure; use codemod_telemetry::send_event::{BaseEvent, TelemetrySender}; #[derive(Args, Debug)] @@ -149,18 +152,18 @@ pub async fn handler(args: &Command, telemetry: &dyn TelemetrySender) -> Result< } } - // Validate package structure - validate_package_structure(&package_path, &manifest)?; + // Validate codemod manifest structure + validate_codemod_manifest_structure(&package_path, &manifest)?; - // Create package bundle - let bundle_path = create_package_bundle(&package_path, &manifest, args.dry_run)?; + // Create codemod tarball + let tarball_path = create_codemod_tarball(&package_path, &manifest, args.dry_run)?; if args.dry_run { println!("✓ Package validation successful"); println!( - "✓ Bundle created: {} ({} bytes)", - bundle_path.display(), - fs::metadata(&bundle_path)?.len() + "✓ tarball created: {} ({} bytes)", + tarball_path.display(), + fs::metadata(&tarball_path)?.len() ); println!("📦 Package ready for publishing"); return Ok(()); @@ -186,7 +189,7 @@ pub async fn handler(args: &Command, telemetry: &dyn TelemetrySender) -> Result< })?; // Upload package - let response = upload_package( + let response = upload_codemod( ®istry_url, &bundle_path, &manifest, @@ -215,7 +218,7 @@ pub async fn handler(args: &Command, telemetry: &dyn TelemetrySender) -> Result< .await; println!("✅ Package published successfully!"); - println!("📦 {}", format_package_name(&response.package)); + println!("📦 {}", format_codemod_name(&response.package)); println!("🏷️ Version: {}", response.package.version); println!("📅 Published: {}", response.package.published_at); println!("🔗 Download: {}", response.package.download_url); @@ -249,59 +252,7 @@ fn load_manifest(package_path: &Path) -> Result { Ok(manifest) } -fn validate_package_structure(package_path: &Path, manifest: &CodemodManifest) -> Result<()> { - // Check required files - let workflow_path = package_path.join(&manifest.workflow); - if !workflow_path.exists() { - return Err(anyhow!( - "Workflow file not found: {}", - workflow_path.display() - )); - } - - // Validate workflow file - let workflow_content = fs::read_to_string(&workflow_path)?; - let _workflow: serde_yaml::Value = serde_yaml::from_str(&workflow_content) - .map_err(|e| anyhow!("Invalid workflow YAML: {}", e))?; - - // Check optional files - if let Some(readme) = &manifest.readme { - let readme_path = package_path.join(readme); - if !readme_path.exists() { - warn!("README file not found: {}", readme_path.display()); - } - } - - // Validate package name format - if !is_valid_package_name(&manifest.name) { - return Err(anyhow!("Invalid package name: {}. Must contain only lowercase letters, numbers, hyphens, and underscores.", manifest.name)); - } - - // Validate version format (semver) - if !is_valid_semver(&manifest.version) { - return Err(anyhow!( - "Invalid version: {}. Must be valid semantic version (x.y.z).", - manifest.version - )); - } - - // Check package size - let package_size = calculate_package_size(package_path)?; - const MAX_PACKAGE_SIZE: u64 = 50 * 1024 * 1024; // 50MB - - if package_size > MAX_PACKAGE_SIZE { - return Err(anyhow!( - "Package too large: {} bytes. Maximum allowed: {} bytes.", - package_size, - MAX_PACKAGE_SIZE - )); - } - - info!("Package validation successful"); - Ok(()) -} - -fn create_package_bundle( +fn create_codemod_tarball( package_path: &Path, manifest: &CodemodManifest, dry_run: bool, @@ -348,7 +299,7 @@ fn create_package_bundle( )); } - info!("Created bundle: {bundle_name} ({bundle_size} bytes)"); + info!("Created codemod tarball: {bundle_name} ({bundle_size} bytes)"); // Move to a persistent location (both dry-run and regular publishing) let output_path = if dry_run { @@ -411,15 +362,15 @@ fn should_include_file(file_path: &Path, package_root: &Path) -> bool { true } -async fn upload_package( +async fn upload_codemod( registry_url: &str, - bundle_path: &Path, + tarball_path: &Path, manifest: &CodemodManifest, access_token: &str, ) -> Result { let client = reqwest::Client::new(); - let package_name = if let Some(registry) = &manifest.registry { + let codemod_name = if let Some(registry) = &manifest.registry { if let Some(scope) = ®istry.scope { format!("{}/{}", scope, manifest.name) } else { @@ -429,10 +380,10 @@ async fn upload_package( manifest.name.clone() }; - let url = format!("{registry_url}/api/v1/registry/packages/{package_name}"); + let url = format!("{registry_url}/api/v1/registry/packages/{codemod_name}"); - // Read bundle file - let bundle_data = fs::read(bundle_path)?; + // Read tarball file + let tarball_data = fs::read(tarball_path)?; let manifest_json = serde_json::to_string(manifest)?; // Create multipart form @@ -478,46 +429,7 @@ async fn upload_package( Ok(publish_response) } -fn calculate_package_size(package_path: &Path) -> Result { - let mut total_size = 0; - - for entry in WalkDir::new(package_path) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_file()) - .filter(|e| should_include_file(e.path(), package_path)) - { - total_size += entry.metadata()?.len(); - } - - Ok(total_size) -} - -fn is_valid_package_name(name: &str) -> bool { - !name.is_empty() - && name.len() <= 50 - && name - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') - && !name.starts_with('-') - && !name.ends_with('-') -} - -fn is_valid_semver(version: &str) -> bool { - // Basic semver validation (x.y.z format) - let parts: Vec<&str> = version.split('.').collect(); - if parts.len() != 3 { - return false; - } - - parts.iter().all(|part| { - part.chars().all(|c| c.is_ascii_digit()) - && !part.is_empty() - && (*part == "0" || !part.starts_with('0')) - }) -} - -fn format_package_name(package: &PublishedPackage) -> String { +fn format_codemod_name(package: &PublishedPackage) -> String { if let Some(scope) = &package.scope { format!("{}/{}", scope, package.name) } else { diff --git a/crates/cli/src/commands/workflow/validate.rs b/crates/cli/src/commands/workflow/validate.rs index a3424e883..8a42a9db5 100644 --- a/crates/cli/src/commands/workflow/validate.rs +++ b/crates/cli/src/commands/workflow/validate.rs @@ -14,7 +14,77 @@ pub struct Command { /// Validate a workflow file pub fn handler(args: &Command) -> Result<()> { - validate_workflow(&args.workflow) + validate_workflow(&args.workflow)?; + validate_codemod_manifest_structure( + &args.workflow, + &utils::parse_workflow_file(&args.workflow)?, + )?; +} + +fn calculate_package_size(package_path: &Path) -> Result { + let mut total_size = 0; + + for entry in WalkDir::new(package_path) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .filter(|e| should_include_file(e.path(), package_path)) + { + total_size += entry.metadata()?.len(); + } + + Ok(total_size) +} + +pub fn validate_codemod_manifest_structure( + package_path: &Path, + manifest: &CodemodManifest, +) -> Result<()> { + // Check required files + let (workflow_path, _) = utils::resolve_workflow_source(&manifest.workflow) + .context("Failed to resolve workflow source")?; + + // Validate workflow file + utils::parse_workflow_file(&workflow_path).context(format!( + "Failed to parse workflow file: {}", + workflow_path.display() + ))?; + + // Check optional files + if let Some(readme) = &manifest.readme { + let readme_path = package_path.join(readme); + if !readme_path.exists() { + warn!("README file not found: {}", readme_path.display()); + } + } + + // Validate codemod name format + if !is_valid_codemod_name(&manifest.name) { + return Err(anyhow!("Invalid codemod name: {}. Must contain only lowercase letters, numbers, hyphens, and underscores.", manifest.name)); + } + + // Validate version format (semver) + if !is_valid_semver(&manifest.version) { + return Err(anyhow!( + "Invalid version: {}. Must be valid semantic version (x.y.z).", + manifest.version + )); + } + + // Check package size + let package_size = calculate_package_size(package_path)?; + const MAX_PACKAGE_SIZE: u64 = 50 * 1024 * 1024; // 50MB + + if package_size > MAX_PACKAGE_SIZE { + return Err(anyhow!( + "Package too large: {} bytes. Maximum allowed: {} bytes.", + package_size, + MAX_PACKAGE_SIZE + )); + } + + info!("Package validation successful"); + Ok(()) } fn validate_workflow(workflow_path: &Path) -> Result<()> { @@ -27,6 +97,13 @@ fn validate_workflow(workflow_path: &Path) -> Result<()> { // Validate workflow utils::validate_workflow(&workflow).context("Workflow validation failed")?; + // Get the base directory for resolving relative paths + let base_dir = workflow_path.parent().unwrap_or(Path::new(".")); + + workflow + .validate_js_ast_grep_files(base_dir) + .context("js-ast-grep file validation failed")?; + info!("✓ Workflow definition is valid"); info!("Schema validation: Passed"); info!( @@ -68,3 +145,27 @@ fn validate_workflow(workflow_path: &Path) -> Result<()> { Ok(()) } + +fn is_valid_codemod_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 50 + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + && !name.starts_with('-') + && !name.ends_with('-') +} + +fn is_valid_semver(version: &str) -> bool { + // Basic semver validation (x.y.z format) + let parts: Vec<&str> = version.split('.').collect(); + if parts.len() != 3 { + return false; + } + + parts.iter().all(|part| { + part.chars().all(|c| c.is_ascii_digit()) + && !part.is_empty() + && (*part == "0" || !part.starts_with('0')) + }) +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 69f7bd67f..a75672af9 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -10,6 +10,7 @@ license.workspace = true rust-version.workspace = true [dependencies] +anyhow = { workspace = true } butterflow-models = { workspace = true } butterflow-runners = { workspace = true } butterflow-state = { workspace = true } diff --git a/crates/core/src/utils.rs b/crates/core/src/utils.rs index fdcd348a0..de5a68de8 100644 --- a/crates/core/src/utils.rs +++ b/crates/core/src/utils.rs @@ -1,14 +1,20 @@ +use anyhow::{Context, Result}; +use butterflow_models::step::StepAction; +use butterflow_models::{Error, Node, Result, Workflow}; +use serde_yaml; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; -use butterflow_models::step::StepAction; -use serde_yaml; - -use butterflow_models::{Error, Node, Result, Workflow}; - /// Parse a workflow definition from a file pub fn parse_workflow_file>(path: P) -> Result { + if !path.as_ref().exists() { + return Err(anyhow!( + "Workflow file not found: {}", + path.as_ref().display() + )); + } + let content = fs::read_to_string(path.as_ref())?; // Try to parse as YAML first diff --git a/crates/models/Cargo.toml b/crates/models/Cargo.toml index b586bc71f..118d531e1 100644 --- a/crates/models/Cargo.toml +++ b/crates/models/Cargo.toml @@ -10,6 +10,7 @@ license.workspace = true rust-version.workspace = true [dependencies] +anyhow = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_yaml = { workspace = true } diff --git a/crates/models/src/workflow.rs b/crates/models/src/workflow.rs index 782824079..e701999e6 100644 --- a/crates/models/src/workflow.rs +++ b/crates/models/src/workflow.rs @@ -1,12 +1,14 @@ +use anyhow::{anyhow, Result}; use chrono::{DateTime, Utc}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use uuid::Uuid; use crate::node::Node; use crate::state::StateSchema; +use crate::step::StepAction; use crate::template::Template; use ts_rs::TS; @@ -16,6 +18,16 @@ pub struct Workflow { /// Version of the workflow format pub version: String, + /// Human-readable name of the workflow + #[serde(default)] + #[ts(optional=nullable)] + pub name: Option, + + /// Detailed description of the workflow + #[serde(default)] + #[ts(optional=nullable)] + pub description: Option, + /// State schema definition #[serde(default)] #[ts(optional=nullable)] @@ -91,3 +103,37 @@ pub enum WorkflowStatus { /// Workflow has been canceled Canceled, } + +impl Workflow { + /// Validate workflow's nodes and steps that use ast-grep js api + /// by checking if the files exist in the filesystem + pub fn validate_js_ast_grep_files(&self, base_dir: &Path) -> Result<()> { + for node in &self.nodes { + for step in &node.steps { + if let StepAction::JSAstGrep(js_ast_grep) = &step.action { + let js_file_path = base_dir.join(&js_ast_grep.js_file); + + if !js_file_path.exists() { + return Err(anyhow!( + "JavaScript file '{}' not found for step '{}' in node '{}'", + js_ast_grep.js_file, + step.name, + node.name + )); + } + + if !js_file_path.is_file() { + return Err(anyhow!( + "Path '{}' exists but is not a file for step '{}' in node '{}'", + js_ast_grep.js_file, + step.name, + node.name + )); + } + } + } + } + + Ok(()) + } +} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy