working on workflows
This commit is contained in:
@@ -52,7 +52,7 @@ pub enum ActionCommands {
|
||||
action_ref: String,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short, long)]
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Execute an action
|
||||
|
||||
@@ -7,3 +7,4 @@ pub mod pack_index;
|
||||
pub mod rule;
|
||||
pub mod sensor;
|
||||
pub mod trigger;
|
||||
pub mod workflow;
|
||||
|
||||
@@ -11,6 +11,37 @@ use crate::output::{self, OutputFormat};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum PackCommands {
|
||||
/// Create an empty pack
|
||||
///
|
||||
/// Creates a new pack with no actions, triggers, rules, or sensors.
|
||||
/// Use --interactive (-i) to be prompted for each field, or provide
|
||||
/// fields via flags. Only --ref is required in non-interactive mode
|
||||
/// (--label defaults to a title-cased ref, version defaults to 0.1.0).
|
||||
Create {
|
||||
/// Unique reference identifier (e.g., "my_pack", "slack")
|
||||
#[arg(long, short = 'r')]
|
||||
r#ref: Option<String>,
|
||||
|
||||
/// Human-readable label (defaults to title-cased ref)
|
||||
#[arg(long, short)]
|
||||
label: Option<String>,
|
||||
|
||||
/// Pack description
|
||||
#[arg(long, short)]
|
||||
description: Option<String>,
|
||||
|
||||
/// Pack version (semver format recommended)
|
||||
#[arg(long = "pack-version", default_value = "0.1.0")]
|
||||
pack_version: String,
|
||||
|
||||
/// Tags for categorization (comma-separated)
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
tags: Vec<String>,
|
||||
|
||||
/// Interactive mode — prompt for each field
|
||||
#[arg(long, short)]
|
||||
interactive: bool,
|
||||
},
|
||||
/// List all installed packs
|
||||
List {
|
||||
/// Filter by pack name
|
||||
@@ -75,7 +106,7 @@ pub enum PackCommands {
|
||||
pack_ref: String,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short = 'y', long)]
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Register a pack from a local directory (path must be accessible by the API server)
|
||||
@@ -282,6 +313,17 @@ struct UploadPackResponse {
|
||||
tests_skipped: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CreatePackBody {
|
||||
r#ref: String,
|
||||
label: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn handle_pack_command(
|
||||
profile: &Option<String>,
|
||||
command: PackCommands,
|
||||
@@ -289,6 +331,27 @@ pub async fn handle_pack_command(
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
PackCommands::Create {
|
||||
r#ref,
|
||||
label,
|
||||
description,
|
||||
pack_version,
|
||||
tags,
|
||||
interactive,
|
||||
} => {
|
||||
handle_create(
|
||||
profile,
|
||||
r#ref,
|
||||
label,
|
||||
description,
|
||||
pack_version,
|
||||
tags,
|
||||
interactive,
|
||||
api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
PackCommands::List { name } => handle_list(profile, name, api_url, output_format).await,
|
||||
PackCommands::Show { pack_ref } => {
|
||||
handle_show(profile, pack_ref, api_url, output_format).await
|
||||
@@ -401,6 +464,169 @@ pub async fn handle_pack_command(
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a human-readable label from a pack ref.
|
||||
///
|
||||
/// Splits on `_`, `-`, or `.` and title-cases each word.
|
||||
fn label_from_ref(r: &str) -> String {
|
||||
r.split(|c| c == '_' || c == '-' || c == '.')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
Some(first) => {
|
||||
let upper: String = first.to_uppercase().collect();
|
||||
format!("{}{}", upper, chars.as_str())
|
||||
}
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
async fn handle_create(
|
||||
profile: &Option<String>,
|
||||
ref_flag: Option<String>,
|
||||
label_flag: Option<String>,
|
||||
description_flag: Option<String>,
|
||||
version_flag: String,
|
||||
tags_flag: Vec<String>,
|
||||
interactive: bool,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
// ── Collect field values ────────────────────────────────────────
|
||||
let (pack_ref, label, description, version, tags) = if interactive {
|
||||
// Interactive prompts
|
||||
let pack_ref: String = match ref_flag {
|
||||
Some(r) => r,
|
||||
None => dialoguer::Input::new()
|
||||
.with_prompt("Pack ref (unique identifier, e.g. \"my_pack\")")
|
||||
.interact_text()?,
|
||||
};
|
||||
|
||||
let default_label = label_flag
|
||||
.clone()
|
||||
.unwrap_or_else(|| label_from_ref(&pack_ref));
|
||||
let label: String = dialoguer::Input::new()
|
||||
.with_prompt("Label")
|
||||
.default(default_label)
|
||||
.interact_text()?;
|
||||
|
||||
let default_desc = description_flag.clone().unwrap_or_default();
|
||||
let description: String = dialoguer::Input::new()
|
||||
.with_prompt("Description (optional, Enter to skip)")
|
||||
.default(default_desc)
|
||||
.allow_empty(true)
|
||||
.interact_text()?;
|
||||
let description = if description.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(description)
|
||||
};
|
||||
|
||||
let version: String = dialoguer::Input::new()
|
||||
.with_prompt("Version")
|
||||
.default(version_flag)
|
||||
.interact_text()?;
|
||||
|
||||
let default_tags = if tags_flag.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
tags_flag.join(", ")
|
||||
};
|
||||
let tags_input: String = dialoguer::Input::new()
|
||||
.with_prompt("Tags (comma-separated, optional)")
|
||||
.default(default_tags)
|
||||
.allow_empty(true)
|
||||
.interact_text()?;
|
||||
let tags: Vec<String> = tags_input
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
// Show summary and confirm
|
||||
println!();
|
||||
output::print_section("New Pack Summary");
|
||||
output::print_key_value_table(vec![
|
||||
("Ref", pack_ref.clone()),
|
||||
("Label", label.clone()),
|
||||
(
|
||||
"Description",
|
||||
description
|
||||
.clone()
|
||||
.unwrap_or_else(|| "(none)".to_string()),
|
||||
),
|
||||
("Version", version.clone()),
|
||||
(
|
||||
"Tags",
|
||||
if tags.is_empty() {
|
||||
"(none)".to_string()
|
||||
} else {
|
||||
tags.join(", ")
|
||||
},
|
||||
),
|
||||
]);
|
||||
println!();
|
||||
|
||||
let confirm = dialoguer::Confirm::new()
|
||||
.with_prompt("Create this pack?")
|
||||
.default(true)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
output::print_info("Pack creation cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
(pack_ref, label, description, version, tags)
|
||||
} else {
|
||||
// Non-interactive: ref is required
|
||||
let pack_ref = ref_flag.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Pack ref is required. Provide --ref <value> or use --interactive mode."
|
||||
)
|
||||
})?;
|
||||
|
||||
let label = label_flag.unwrap_or_else(|| label_from_ref(&pack_ref));
|
||||
let description = description_flag;
|
||||
let version = version_flag;
|
||||
let tags = tags_flag;
|
||||
|
||||
(pack_ref, label, description, version, tags)
|
||||
};
|
||||
|
||||
// ── Send request ────────────────────────────────────────────────
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let body = CreatePackBody {
|
||||
r#ref: pack_ref,
|
||||
label,
|
||||
description,
|
||||
version,
|
||||
tags,
|
||||
};
|
||||
|
||||
let pack: Pack = client.post("/packs", &body).await?;
|
||||
|
||||
// ── Output ──────────────────────────────────────────────────────
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&pack, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!(
|
||||
"Pack '{}' created successfully (id: {})",
|
||||
pack.pack_ref, pack.id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_list(
|
||||
profile: &Option<String>,
|
||||
name: Option<String>,
|
||||
@@ -1630,3 +1856,48 @@ async fn handle_update(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_label_from_ref_underscores() {
|
||||
assert_eq!(label_from_ref("my_cool_pack"), "My Cool Pack");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_from_ref_hyphens() {
|
||||
assert_eq!(label_from_ref("my-cool-pack"), "My Cool Pack");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_from_ref_dots() {
|
||||
assert_eq!(label_from_ref("my.cool.pack"), "My Cool Pack");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_from_ref_mixed_separators() {
|
||||
assert_eq!(label_from_ref("my_cool-pack.v2"), "My Cool Pack V2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_from_ref_single_word() {
|
||||
assert_eq!(label_from_ref("slack"), "Slack");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_from_ref_already_capitalized() {
|
||||
assert_eq!(label_from_ref("AWS"), "AWS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_from_ref_empty() {
|
||||
assert_eq!(label_from_ref(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_from_ref_consecutive_separators() {
|
||||
assert_eq!(label_from_ref("my__pack"), "My Pack");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ pub enum TriggerCommands {
|
||||
trigger_ref: String,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short, long)]
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
|
||||
699
crates/cli/src/commands/workflow.rs
Normal file
699
crates/cli/src/commands/workflow.rs
Normal file
@@ -0,0 +1,699 @@
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Subcommand;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::client::ApiClient;
|
||||
use crate::config::CliConfig;
|
||||
use crate::output::{self, OutputFormat};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum WorkflowCommands {
|
||||
/// Upload a workflow action from local YAML files to an existing pack.
|
||||
///
|
||||
/// Reads the action YAML file, finds the referenced workflow YAML file
|
||||
/// via its `workflow_file` field, and uploads both to the API. The pack
|
||||
/// is determined from the action ref (e.g. `mypack.deploy` → pack `mypack`).
|
||||
Upload {
|
||||
/// Path to the action YAML file (e.g. actions/deploy.yaml).
|
||||
/// Must contain a `workflow_file` field pointing to the workflow YAML.
|
||||
action_file: String,
|
||||
|
||||
/// Force update if the workflow already exists
|
||||
#[arg(short, long)]
|
||||
force: bool,
|
||||
},
|
||||
/// List workflows
|
||||
List {
|
||||
/// Filter by pack reference
|
||||
#[arg(long)]
|
||||
pack: Option<String>,
|
||||
|
||||
/// Filter by tag (comma-separated)
|
||||
#[arg(long)]
|
||||
tags: Option<String>,
|
||||
|
||||
/// Search term (matches label/description)
|
||||
#[arg(long)]
|
||||
search: Option<String>,
|
||||
},
|
||||
/// Show details of a specific workflow
|
||||
Show {
|
||||
/// Workflow reference (e.g. core.install_packs)
|
||||
workflow_ref: String,
|
||||
},
|
||||
/// Delete a workflow
|
||||
Delete {
|
||||
/// Workflow reference (e.g. core.install_packs)
|
||||
workflow_ref: String,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Local YAML models (for parsing action YAML files) ──────────────────
|
||||
|
||||
/// Minimal representation of an action YAML file, capturing only the fields
|
||||
/// we need to build a `SaveWorkflowFileRequest`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ActionYaml {
|
||||
/// Full action ref, e.g. `python_example.timeline_demo`
|
||||
#[serde(rename = "ref")]
|
||||
action_ref: String,
|
||||
|
||||
/// Human-readable label
|
||||
#[serde(default)]
|
||||
label: String,
|
||||
|
||||
/// Description
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
|
||||
/// Relative path to the workflow YAML from the `actions/` directory
|
||||
workflow_file: Option<String>,
|
||||
|
||||
/// Parameter schema (flat format)
|
||||
#[serde(default)]
|
||||
parameters: Option<serde_json::Value>,
|
||||
|
||||
/// Output schema (flat format)
|
||||
#[serde(default)]
|
||||
output: Option<serde_json::Value>,
|
||||
|
||||
/// Tags
|
||||
#[serde(default)]
|
||||
tags: Option<Vec<String>>,
|
||||
|
||||
/// Whether the action is enabled
|
||||
#[serde(default)]
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
// ── API DTOs ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Mirrors the API's `SaveWorkflowFileRequest`.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SaveWorkflowFileRequest {
|
||||
name: String,
|
||||
label: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
version: String,
|
||||
pack_ref: String,
|
||||
definition: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
param_schema: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
out_schema: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tags: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct WorkflowResponse {
|
||||
id: i64,
|
||||
#[serde(rename = "ref")]
|
||||
workflow_ref: String,
|
||||
pack: i64,
|
||||
pack_ref: String,
|
||||
label: String,
|
||||
description: Option<String>,
|
||||
version: String,
|
||||
param_schema: Option<serde_json::Value>,
|
||||
out_schema: Option<serde_json::Value>,
|
||||
definition: serde_json::Value,
|
||||
tags: Vec<String>,
|
||||
enabled: bool,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct WorkflowSummary {
|
||||
id: i64,
|
||||
#[serde(rename = "ref")]
|
||||
workflow_ref: String,
|
||||
pack_ref: String,
|
||||
label: String,
|
||||
description: Option<String>,
|
||||
version: String,
|
||||
tags: Vec<String>,
|
||||
enabled: bool,
|
||||
created: String,
|
||||
updated: String,
|
||||
}
|
||||
|
||||
// ── Command dispatch ────────────────────────────────────────────────────
|
||||
|
||||
pub async fn handle_workflow_command(
|
||||
profile: &Option<String>,
|
||||
command: WorkflowCommands,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
WorkflowCommands::Upload { action_file, force } => {
|
||||
handle_upload(profile, action_file, force, api_url, output_format).await
|
||||
}
|
||||
WorkflowCommands::List { pack, tags, search } => {
|
||||
handle_list(profile, pack, tags, search, api_url, output_format).await
|
||||
}
|
||||
WorkflowCommands::Show { workflow_ref } => {
|
||||
handle_show(profile, workflow_ref, api_url, output_format).await
|
||||
}
|
||||
WorkflowCommands::Delete { workflow_ref, yes } => {
|
||||
handle_delete(profile, workflow_ref, yes, api_url, output_format).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload ──────────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_upload(
|
||||
profile: &Option<String>,
|
||||
action_file: String,
|
||||
force: bool,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let action_path = Path::new(&action_file);
|
||||
|
||||
// ── 1. Validate & read the action YAML ──────────────────────────────
|
||||
if !action_path.exists() {
|
||||
anyhow::bail!("Action YAML file not found: {}", action_file);
|
||||
}
|
||||
if !action_path.is_file() {
|
||||
anyhow::bail!("Path is not a file: {}", action_file);
|
||||
}
|
||||
|
||||
let action_yaml_content =
|
||||
std::fs::read_to_string(action_path).context("Failed to read action YAML file")?;
|
||||
|
||||
let action: ActionYaml = serde_yaml_ng::from_str(&action_yaml_content)
|
||||
.context("Failed to parse action YAML file")?;
|
||||
|
||||
// ── 2. Extract pack_ref and workflow name from the action ref ────────
|
||||
let (pack_ref, workflow_name) = split_action_ref(&action.action_ref)?;
|
||||
|
||||
// ── 3. Resolve the workflow_file path ───────────────────────────────
|
||||
let workflow_file_rel = action.workflow_file.as_deref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Action YAML does not contain a 'workflow_file' field. \
|
||||
This command requires a workflow action — regular actions should be \
|
||||
uploaded as part of a pack."
|
||||
)
|
||||
})?;
|
||||
|
||||
// workflow_file is relative to the actions/ directory. The action YAML is
|
||||
// typically at `<pack>/actions/<name>.yaml`, so the workflow file is
|
||||
// resolved relative to the action YAML's parent directory.
|
||||
let workflow_path = resolve_workflow_path(action_path, workflow_file_rel)?;
|
||||
|
||||
if !workflow_path.exists() {
|
||||
anyhow::bail!(
|
||||
"Workflow file not found: {}\n \
|
||||
(resolved from workflow_file: '{}' relative to '{}')",
|
||||
workflow_path.display(),
|
||||
workflow_file_rel,
|
||||
action_path
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
.display()
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. Read and parse the workflow YAML ─────────────────────────────
|
||||
let workflow_yaml_content =
|
||||
std::fs::read_to_string(&workflow_path).context("Failed to read workflow YAML file")?;
|
||||
|
||||
let workflow_definition: serde_json::Value =
|
||||
serde_yaml_ng::from_str(&workflow_yaml_content).context(format!(
|
||||
"Failed to parse workflow YAML file: {}",
|
||||
workflow_path.display()
|
||||
))?;
|
||||
|
||||
// Extract version from the workflow definition, defaulting to "1.0.0"
|
||||
let version = workflow_definition
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("1.0.0")
|
||||
.to_string();
|
||||
|
||||
// ── 5. Build the API request ────────────────────────────────────────
|
||||
//
|
||||
// Merge the action-level fields from the workflow definition back into the
|
||||
// definition payload (the API's SaveWorkflowFileRequest.definition carries
|
||||
// the full blob; write_workflow_yaml on the server side strips the action-
|
||||
// level fields before writing the graph-only file).
|
||||
let mut definition_map: serde_json::Map<String, serde_json::Value> =
|
||||
if let Some(obj) = workflow_definition.as_object() {
|
||||
obj.clone()
|
||||
} else {
|
||||
serde_json::Map::new()
|
||||
};
|
||||
|
||||
// Ensure action-level fields are present in the definition (the API and
|
||||
// web UI store the combined form in the database; the server splits them
|
||||
// into two files on disk).
|
||||
if let Some(params) = &action.parameters {
|
||||
definition_map
|
||||
.entry("parameters".to_string())
|
||||
.or_insert_with(|| params.clone());
|
||||
}
|
||||
if let Some(out) = &action.output {
|
||||
definition_map
|
||||
.entry("output".to_string())
|
||||
.or_insert_with(|| out.clone());
|
||||
}
|
||||
|
||||
let request = SaveWorkflowFileRequest {
|
||||
name: workflow_name.clone(),
|
||||
label: if action.label.is_empty() {
|
||||
workflow_name.clone()
|
||||
} else {
|
||||
action.label.clone()
|
||||
},
|
||||
description: action.description.clone(),
|
||||
version,
|
||||
pack_ref: pack_ref.clone(),
|
||||
definition: serde_json::Value::Object(definition_map),
|
||||
param_schema: action.parameters.clone(),
|
||||
out_schema: action.output.clone(),
|
||||
tags: action.tags.clone(),
|
||||
enabled: action.enabled,
|
||||
};
|
||||
|
||||
// ── 6. Print progress ───────────────────────────────────────────────
|
||||
if output_format == OutputFormat::Table {
|
||||
output::print_info(&format!(
|
||||
"Uploading workflow action '{}.{}' to pack '{}'",
|
||||
pack_ref, workflow_name, pack_ref,
|
||||
));
|
||||
output::print_info(&format!(" Action YAML: {}", action_path.display()));
|
||||
output::print_info(&format!(" Workflow YAML: {}", workflow_path.display()));
|
||||
}
|
||||
|
||||
// ── 7. Send to API ──────────────────────────────────────────────────
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let workflow_ref = format!("{}.{}", pack_ref, workflow_name);
|
||||
|
||||
// Try create first; if 409 Conflict and --force, fall back to update.
|
||||
let create_path = format!("/packs/{}/workflow-files", pack_ref);
|
||||
|
||||
let result: Result<WorkflowResponse> = client.post(&create_path, &request).await;
|
||||
|
||||
let response: WorkflowResponse = match result {
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
let err_str = err.to_string();
|
||||
if err_str.contains("409") || err_str.to_lowercase().contains("conflict") {
|
||||
if !force {
|
||||
anyhow::bail!(
|
||||
"Workflow '{}' already exists. Use --force to update it.",
|
||||
workflow_ref
|
||||
);
|
||||
}
|
||||
|
||||
if output_format == OutputFormat::Table {
|
||||
output::print_info("Workflow already exists, updating...");
|
||||
}
|
||||
|
||||
let update_path = format!("/workflows/{}/file", workflow_ref);
|
||||
client.put(&update_path, &request).await.context(
|
||||
"Failed to update existing workflow. \
|
||||
Check that the pack exists and the workflow ref is correct.",
|
||||
)?
|
||||
} else {
|
||||
return Err(err).context("Failed to upload workflow");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ── 8. Print result ─────────────────────────────────────────────────
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&response, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
println!();
|
||||
output::print_success(&format!(
|
||||
"Workflow '{}' uploaded successfully",
|
||||
response.workflow_ref
|
||||
));
|
||||
output::print_key_value_table(vec![
|
||||
("ID", response.id.to_string()),
|
||||
("Reference", response.workflow_ref.clone()),
|
||||
("Pack", response.pack_ref.clone()),
|
||||
("Label", response.label.clone()),
|
||||
("Version", response.version.clone()),
|
||||
(
|
||||
"Tags",
|
||||
if response.tags.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
response.tags.join(", ")
|
||||
},
|
||||
),
|
||||
("Enabled", output::format_bool(response.enabled)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── List ────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_list(
|
||||
profile: &Option<String>,
|
||||
pack: Option<String>,
|
||||
tags: Option<String>,
|
||||
search: Option<String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = if let Some(ref pack_ref) = pack {
|
||||
format!("/packs/{}/workflows", pack_ref)
|
||||
} else {
|
||||
let mut query_parts: Vec<String> = Vec::new();
|
||||
if let Some(ref t) = tags {
|
||||
query_parts.push(format!("tags={}", urlencoding::encode(t)));
|
||||
}
|
||||
if let Some(ref s) = search {
|
||||
query_parts.push(format!("search={}", urlencoding::encode(s)));
|
||||
}
|
||||
if query_parts.is_empty() {
|
||||
"/workflows".to_string()
|
||||
} else {
|
||||
format!("/workflows?{}", query_parts.join("&"))
|
||||
}
|
||||
};
|
||||
|
||||
let workflows: Vec<WorkflowSummary> = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&workflows, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
if workflows.is_empty() {
|
||||
output::print_info("No workflows found");
|
||||
} else {
|
||||
let mut table = output::create_table();
|
||||
output::add_header(
|
||||
&mut table,
|
||||
vec!["ID", "Reference", "Pack", "Label", "Version", "Enabled", "Tags"],
|
||||
);
|
||||
|
||||
for wf in &workflows {
|
||||
table.add_row(vec![
|
||||
wf.id.to_string(),
|
||||
wf.workflow_ref.clone(),
|
||||
wf.pack_ref.clone(),
|
||||
output::truncate(&wf.label, 30),
|
||||
wf.version.clone(),
|
||||
output::format_bool(wf.enabled),
|
||||
if wf.tags.is_empty() {
|
||||
"-".to_string()
|
||||
} else {
|
||||
output::truncate(&wf.tags.join(", "), 25)
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{}", table);
|
||||
output::print_info(&format!("{} workflow(s) found", workflows.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Show ────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_show(
|
||||
profile: &Option<String>,
|
||||
workflow_ref: String,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let path = format!("/workflows/{}", workflow_ref);
|
||||
let workflow: WorkflowResponse = client.get(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&workflow, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_section(&format!("Workflow: {}", workflow.workflow_ref));
|
||||
output::print_key_value_table(vec![
|
||||
("ID", workflow.id.to_string()),
|
||||
("Reference", workflow.workflow_ref.clone()),
|
||||
("Pack", workflow.pack_ref.clone()),
|
||||
("Pack ID", workflow.pack.to_string()),
|
||||
("Label", workflow.label.clone()),
|
||||
(
|
||||
"Description",
|
||||
workflow
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
),
|
||||
("Version", workflow.version.clone()),
|
||||
("Enabled", output::format_bool(workflow.enabled)),
|
||||
(
|
||||
"Tags",
|
||||
if workflow.tags.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
workflow.tags.join(", ")
|
||||
},
|
||||
),
|
||||
("Created", output::format_timestamp(&workflow.created)),
|
||||
("Updated", output::format_timestamp(&workflow.updated)),
|
||||
]);
|
||||
|
||||
// Show parameter schema if present
|
||||
if let Some(ref params) = workflow.param_schema {
|
||||
if !params.is_null() && params.as_object().is_some_and(|o| !o.is_empty()) {
|
||||
output::print_section("Parameters");
|
||||
let yaml = serde_yaml_ng::to_string(params)?;
|
||||
println!("{}", yaml);
|
||||
}
|
||||
}
|
||||
|
||||
// Show output schema if present
|
||||
if let Some(ref out) = workflow.out_schema {
|
||||
if !out.is_null() && out.as_object().is_some_and(|o| !o.is_empty()) {
|
||||
output::print_section("Output Schema");
|
||||
let yaml = serde_yaml_ng::to_string(out)?;
|
||||
println!("{}", yaml);
|
||||
}
|
||||
}
|
||||
|
||||
// Show task summary from definition
|
||||
if let Some(tasks) = workflow.definition.get("tasks") {
|
||||
if let Some(arr) = tasks.as_array() {
|
||||
output::print_section("Tasks");
|
||||
let mut table = output::create_table();
|
||||
output::add_header(&mut table, vec!["#", "Name", "Action", "Transitions"]);
|
||||
|
||||
for (i, task) in arr.iter().enumerate() {
|
||||
let name = task
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("?");
|
||||
let action = task
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("-");
|
||||
|
||||
let transition_count = task
|
||||
.get("next")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
// Count total target tasks across all transitions
|
||||
a.iter()
|
||||
.filter_map(|t| {
|
||||
t.get("do").and_then(|d| d.as_array()).map(|d| d.len())
|
||||
})
|
||||
.sum::<usize>()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
let transitions_str = if transition_count == 0 {
|
||||
"terminal".to_string()
|
||||
} else {
|
||||
format!("{} target(s)", transition_count)
|
||||
};
|
||||
|
||||
table.add_row(vec![
|
||||
(i + 1).to_string(),
|
||||
name.to_string(),
|
||||
output::truncate(action, 35),
|
||||
transitions_str,
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{}", table);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Delete ──────────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_delete(
|
||||
profile: &Option<String>,
|
||||
workflow_ref: String,
|
||||
yes: bool,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
if !yes && output_format == OutputFormat::Table {
|
||||
let confirm = dialoguer::Confirm::new()
|
||||
.with_prompt(format!(
|
||||
"Are you sure you want to delete workflow '{}'?",
|
||||
workflow_ref
|
||||
))
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
output::print_info("Delete cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let path = format!("/workflows/{}", workflow_ref);
|
||||
client.delete_no_response(&path).await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let msg = serde_json::json!({"message": format!("Workflow '{}' deleted", workflow_ref)});
|
||||
output::print_output(&msg, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!("Workflow '{}' deleted successfully", workflow_ref));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Split an action ref like `pack_name.action_name` into `(pack_ref, name)`.
|
||||
///
|
||||
/// Supports multi-segment pack refs: `org.pack.action` → `("org.pack", "action")`.
|
||||
/// The last dot-separated segment is the workflow/action name; everything before
|
||||
/// it is the pack ref.
|
||||
fn split_action_ref(action_ref: &str) -> Result<(String, String)> {
|
||||
let dot_pos = action_ref.rfind('.').ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Invalid action ref '{}': expected format 'pack_ref.name' (at least one dot)",
|
||||
action_ref
|
||||
)
|
||||
})?;
|
||||
|
||||
let pack_ref = &action_ref[..dot_pos];
|
||||
let name = &action_ref[dot_pos + 1..];
|
||||
|
||||
if pack_ref.is_empty() || name.is_empty() {
|
||||
anyhow::bail!(
|
||||
"Invalid action ref '{}': both pack_ref and name must be non-empty",
|
||||
action_ref
|
||||
);
|
||||
}
|
||||
|
||||
Ok((pack_ref.to_string(), name.to_string()))
|
||||
}
|
||||
|
||||
/// Resolve the workflow YAML path from the action YAML's location and the
|
||||
/// `workflow_file` value.
|
||||
///
|
||||
/// `workflow_file` is relative to the `actions/` directory. Since the action
|
||||
/// YAML is typically at `<pack>/actions/<name>.yaml`, the workflow path is
|
||||
/// resolved relative to the action YAML's parent directory.
|
||||
fn resolve_workflow_path(action_yaml_path: &Path, workflow_file: &str) -> Result<PathBuf> {
|
||||
let action_dir = action_yaml_path
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."));
|
||||
|
||||
let resolved = action_dir.join(workflow_file);
|
||||
|
||||
// Canonicalize if possible (for better error messages), but don't fail
|
||||
// if the file doesn't exist yet — we'll check existence later.
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_split_action_ref_simple() {
|
||||
let (pack, name) = split_action_ref("core.echo").unwrap();
|
||||
assert_eq!(pack, "core");
|
||||
assert_eq!(name, "echo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_action_ref_multi_segment_pack() {
|
||||
let (pack, name) = split_action_ref("org.infra.deploy").unwrap();
|
||||
assert_eq!(pack, "org.infra");
|
||||
assert_eq!(name, "deploy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_action_ref_no_dot() {
|
||||
assert!(split_action_ref("nodot").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_action_ref_empty_parts() {
|
||||
assert!(split_action_ref(".name").is_err());
|
||||
assert!(split_action_ref("pack.").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_workflow_path() {
|
||||
let action_path = Path::new("/packs/mypack/actions/deploy.yaml");
|
||||
let resolved =
|
||||
resolve_workflow_path(action_path, "workflows/deploy.workflow.yaml").unwrap();
|
||||
assert_eq!(
|
||||
resolved,
|
||||
PathBuf::from("/packs/mypack/actions/workflows/deploy.workflow.yaml")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_workflow_path_relative() {
|
||||
let action_path = Path::new("actions/deploy.yaml");
|
||||
let resolved =
|
||||
resolve_workflow_path(action_path, "workflows/deploy.workflow.yaml").unwrap();
|
||||
assert_eq!(
|
||||
resolved,
|
||||
PathBuf::from("actions/workflows/deploy.workflow.yaml")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user