chore: update all plugins to Nushell 0.111.0

- Bump all 18 plugins from 0.110.0 to 0.111.0
  - Update rust-toolchain.toml channel to 1.93.1 (nu 0.111.0 requires ≥1.91.1)

  Fixes:
  - interprocess pin =2.2.x → ^2.3.1 in nu_plugin_mcp, nu_plugin_nats, nu_plugin_typedialog
    (required by nu-plugin-core 0.111.0)
  - nu_plugin_typedialog: BackendType::Web initializer — add open_browser: false field
  - nu_plugin_auth: implement missing user_info_to_value helper referenced in tests

  Scripts:
  - update_all_plugins.nu: fix [package].version update on minor bumps; add [dev-dependencies]
    pass; add nu-plugin-test-support to managed crates
  - download_nushell.nu: rustup override unset before rm -rf on nushell dir replace;
    fix unclosed ) in string interpolation
This commit is contained in:
Jesús Pérez 2026-03-11 03:20:29 +00:00
parent 11216da717
commit 97e7365a5a
6 changed files with 998 additions and 1260 deletions

1038
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package] [package]
name = "nu_plugin_kcl" name = "nu_plugin_kcl"
version = "0.1.0" version = "0.111.0"
authors = ["Jesús Pérez <jpl@jesusperez.com>"] authors = ["Jesús Pérez <jpl@jesusperez.com>"]
edition = "2024" edition = "2024"
description = "Nushell plugin for KCL CLI wrapper" description = "Nushell plugin for KCL CLI wrapper"
@ -8,11 +8,21 @@ repository = "https://github.com/jesusperez/nu_plugin_kcl"
license = "MIT" license = "MIT"
[dependencies] [dependencies]
nu-plugin = "0.109.1" nu-plugin = "0.111.0"
nu-protocol = "0.109.1" nu-protocol = "0.111.0"
anyhow = "1.0" anyhow = "1.0"
tempfile = "3" tempfile = "3"
sha2 = "0.10"
serde_json = "1.0"
dirs = "6.0"
[dev-dependencies.nu-plugin-test-support] [dependencies.serde]
version = "0.109.1" version = "1.0"
path = "../nushell/crates/nu-plugin-test-support" features = ["derive"]
[dependencies.chrono]
version = "0.4"
features = ["serde"]
[dev-dependencies]
nu-plugin-test-support = "0.111.0"

806
README.md

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,8 +1,79 @@
// Helper functions using KCL CLI // Helper functions for KCL evaluation with caching
// Following Rust guidelines: M-ERRORS-CANONICAL-STRUCTS pattern
// Pure functions with no side effects, early validation
use anyhow::Result; use anyhow::Result;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::Path;
use std::process::Command; use std::process::Command;
/// Run a KCL file using the KCL CLI. /// Compute SHA256 hash for deterministic cache key
/// Hash includes file content and execution context
fn compute_cache_key(file_path: &str, context: Option<&str>) -> String {
let mut hasher = Sha256::new();
// Hash file content
if let Ok(content) = fs::read_to_string(file_path) {
hasher.update(content.as_bytes());
}
// Hash context (format + defines)
if let Some(ctx) = context {
hasher.update(ctx.as_bytes());
}
format!("{:x}", hasher.finalize())
}
/// Get cache directory respecting platform conventions
fn get_cache_dir() -> Result<std::path::PathBuf> {
let base = dirs::cache_dir()
.ok_or_else(|| anyhow::anyhow!("Unable to determine cache directory (HOME not set?)"))?;
let cache_dir = base.join("provisioning").join("config-cache");
fs::create_dir_all(&cache_dir)?;
Ok(cache_dir)
}
/// Lookup cached result from filesystem
/// Returns: Option<String> with cached data if found and valid
pub(crate) fn lookup_cache(file_path: &str, context: Option<&str>) -> Option<String> {
let cache_key = compute_cache_key(file_path, context);
let cache_dir = get_cache_dir().ok()?;
let cache_file = cache_dir.join(format!("{}.json", cache_key));
if !cache_file.exists() {
return None;
}
let cached = fs::read_to_string(&cache_file).ok()?;
let cached_json = serde_json::from_str::<serde_json::Value>(&cached).ok()?;
cached_json
.get("data")
.and_then(|data| data.as_str())
.map(|s| s.to_string())
}
/// Store result in persistent filesystem cache
/// Non-blocking: errors are silently ignored (graceful degradation)
fn store_cache(file_path: &str, context: Option<&str>, result: &str) -> Result<()> {
let cache_key = compute_cache_key(file_path, context);
let cache_dir = get_cache_dir()?;
let cache_file = cache_dir.join(format!("{}.json", cache_key));
let cached_entry = serde_json::json!({
"data": result,
"timestamp": chrono::Local::now().to_rfc3339(),
"file": file_path,
});
fs::write(&cache_file, cached_entry.to_string())?;
Ok(())
}
/// Run a KCL file using the KCL CLI with automatic caching.
/// ///
/// # Arguments /// # Arguments
/// * `file` - Path to the KCL file to execute. /// * `file` - Path to the KCL file to execute.
@ -13,12 +84,31 @@ use std::process::Command;
/// # Returns /// # Returns
/// * `Ok(String)` with the output or output file path on success. /// * `Ok(String)` with the output or output file path on success.
/// * `Err(anyhow::Error)` if the KCL command fails. /// * `Err(anyhow::Error)` if the KCL command fails.
///
/// # Caching
/// - Checks cache before execution
/// - Stores result in cache after successful execution
/// - Cache key: SHA256(file_content + format + defines)
pub(crate) fn run_kcl_command( pub(crate) fn run_kcl_command(
file: &str, file: &str,
format: &str, format: &str,
output: &Option<String>, output: &Option<String>,
defines: &[String], defines: &[String],
) -> Result<String> { ) -> Result<String> {
// Early validation: fail fast
if !Path::new(file).exists() {
return Err(anyhow::anyhow!("KCL file not found: {}", file));
}
// Build context for cache key
let context = format!("{}:{:?}", format, defines);
// Check cache first (high hit rate expected)
if let Some(cached) = lookup_cache(file, Some(&context)) {
return Ok(cached);
}
// Cache miss: execute KCL
let mut cmd = Command::new("kcl"); let mut cmd = Command::new("kcl");
cmd.arg("run").arg(file).arg("--format").arg(format); cmd.arg("run").arg(file).arg("--format").arg(format);
@ -34,20 +124,25 @@ pub(crate) fn run_kcl_command(
let output_res = cmd let output_res = cmd
.output() .output()
.map_err(|e| anyhow::anyhow!("Error executing kcl: {}", e))?; .map_err(|e| anyhow::anyhow!("KCL execution failed: {}", e))?;
if output_res.status.success() { if !output_res.status.success() {
if let Some(output_file) = output { return Err(anyhow::anyhow!(
Ok(format!("{}", output_file)) "KCL error:\n{}",
} else {
Ok(format!("{}", String::from_utf8_lossy(&output_res.stdout)))
}
} else {
Err(anyhow::anyhow!(
"❌: {}",
String::from_utf8_lossy(&output_res.stderr) String::from_utf8_lossy(&output_res.stderr)
)) ));
} }
let result = if let Some(output_file) = output {
format!("\"{}\"", output_file)
} else {
String::from_utf8_lossy(&output_res.stdout).to_string()
};
// Store in cache (non-blocking, errors ignored for graceful degradation)
let _ = store_cache(file, Some(&context), &result);
Ok(result)
} }
/// Format a KCL file using the KCL CLI. /// Format a KCL file using the KCL CLI.
@ -59,15 +154,20 @@ pub(crate) fn run_kcl_command(
/// * `Ok(String)` with a success message if formatting succeeds. /// * `Ok(String)` with a success message if formatting succeeds.
/// * `Err(anyhow::Error)` if formatting fails. /// * `Err(anyhow::Error)` if formatting fails.
pub(crate) fn format_kcl_file(file: &str) -> Result<String> { pub(crate) fn format_kcl_file(file: &str) -> Result<String> {
// Early validation
if !Path::new(file).exists() {
return Err(anyhow::anyhow!("KCL file not found: {}", file));
}
let output = Command::new("kcl") let output = Command::new("kcl")
.arg("fmt") .arg("fmt")
.arg(file) .arg(file)
.output() .output()
.map_err(|e| anyhow::anyhow!("Error executing kcl fmt: {}", e))?; .map_err(|e| anyhow::anyhow!("KCL format command failed: {}", e))?;
if !output.status.success() { if !output.status.success() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"KCL format failed: {}", "KCL format error:\n{}",
String::from_utf8_lossy(&output.stderr) String::from_utf8_lossy(&output.stderr)
)); ));
} }
@ -81,9 +181,14 @@ pub(crate) fn format_kcl_file(file: &str) -> Result<String> {
/// * `dir` - Path to the directory to search for KCL files. /// * `dir` - Path to the directory to search for KCL files.
/// ///
/// # Returns /// # Returns
/// * `Ok(String)` with a summary of validation results if all files are valid or no files found. /// * `Ok(String)` with a JSON summary of validation results.
/// * `Err(anyhow::Error)` if validation fails for any file or if the find command fails. /// * `Err(anyhow::Error)` if validation fails.
pub(crate) fn validate_kcl_project(dir: &str) -> Result<String> { pub(crate) fn validate_kcl_project(dir: &str) -> Result<String> {
// Early validation
if !Path::new(dir).exists() {
return Err(anyhow::anyhow!("Directory not found: {}", dir));
}
// Find KCL files in directory // Find KCL files in directory
let find_output = Command::new("find") let find_output = Command::new("find")
.arg(dir) .arg(dir)
@ -98,12 +203,18 @@ pub(crate) fn validate_kcl_project(dir: &str) -> Result<String> {
let kcl_files: Vec<&str> = files.lines().filter(|line| !line.is_empty()).collect(); let kcl_files: Vec<&str> = files.lines().filter(|line| !line.is_empty()).collect();
if kcl_files.is_empty() { if kcl_files.is_empty() {
return Ok(format!("No KCL files found in {}", dir)); return Ok(serde_json::json!({
"valid": true,
"files_checked": 0,
"messages": []
})
.to_string());
} }
let mut results = Vec::new(); let mut messages = Vec::new();
let mut all_valid = true; let mut all_valid = true;
// Validate each file
for file in &kcl_files { for file in &kcl_files {
let output = Command::new("kcl") let output = Command::new("kcl")
.arg("run") .arg("run")
@ -114,10 +225,10 @@ pub(crate) fn validate_kcl_project(dir: &str) -> Result<String> {
match output { match output {
Ok(output) if output.status.success() => { Ok(output) if output.status.success() => {
results.push(format!("{}", file)); messages.push(format!("{}", file));
} }
Ok(output) => { Ok(output) => {
results.push(format!( messages.push(format!(
"❌ {}: {}", "❌ {}: {}",
file, file,
String::from_utf8_lossy(&output.stderr) String::from_utf8_lossy(&output.stderr)
@ -125,17 +236,16 @@ pub(crate) fn validate_kcl_project(dir: &str) -> Result<String> {
all_valid = false; all_valid = false;
} }
Err(e) => { Err(e) => {
results.push(format!("{}: Execution error: {}", file, e)); messages.push(format!("{}: {}", file, e));
all_valid = false; all_valid = false;
} }
} }
} }
let summary = if all_valid { Ok(serde_json::json!({
format!("✅ All {} files are valid", kcl_files.len()) "valid": all_valid,
} else { "files_checked": kcl_files.len(),
format!("❌ Errors found in some files") "messages": messages
}; })
.to_string())
Ok(format!("{}\n\n{}", summary, results.join("\n")))
} }

View File

@ -2,7 +2,7 @@ use nu_plugin::{
EngineInterface, EvaluatedCall, MsgPackSerializer, Plugin, PluginCommand, SimplePluginCommand, EngineInterface, EvaluatedCall, MsgPackSerializer, Plugin, PluginCommand, SimplePluginCommand,
serve_plugin, serve_plugin,
}; };
use nu_protocol::{Category, Example, LabeledError, Signature, SyntaxShape, Type, Value}; use nu_protocol::{Category, Example, LabeledError, Signature, SyntaxShape, Type, Value, record};
use anyhow::Result; use anyhow::Result;
mod helpers; mod helpers;
@ -12,6 +12,40 @@ mod tests;
use crate::helpers::{format_kcl_file, run_kcl_command, validate_kcl_project}; use crate::helpers::{format_kcl_file, run_kcl_command, validate_kcl_project};
/// Convert serde_json::Value to nu_protocol::Value
fn json_value_to_nu_value(json_value: &serde_json::Value, span: nu_protocol::Span) -> Value {
match json_value {
serde_json::Value::Null => Value::nothing(span),
serde_json::Value::Bool(b) => Value::bool(*b, span),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Value::int(i, span)
} else if let Some(u) = n.as_u64() {
Value::int(u as i64, span)
} else if let Some(f) = n.as_f64() {
Value::float(f, span)
} else {
Value::string(n.to_string(), span)
}
}
serde_json::Value::String(s) => Value::string(s.clone(), span),
serde_json::Value::Array(arr) => {
let values: Vec<Value> = arr
.iter()
.map(|v| json_value_to_nu_value(v, span))
.collect();
Value::list(values, span)
}
serde_json::Value::Object(obj) => {
let mut record = record!();
for (key, value) in obj.iter() {
record.insert(key.clone(), json_value_to_nu_value(value, span));
}
Value::record(record, span)
}
}
}
/// Nushell plugin for running, formatting, and validating KCL files using the KCL CLI. /// Nushell plugin for running, formatting, and validating KCL files using the KCL CLI.
/// ///
/// This plugin provides three commands: /// This plugin provides three commands:
@ -25,13 +59,17 @@ struct KclWrapperPlugin;
/// Implements the Nushell Plugin trait for the KCL wrapper plugin. /// Implements the Nushell Plugin trait for the KCL wrapper plugin.
impl Plugin for KclWrapperPlugin { impl Plugin for KclWrapperPlugin {
fn version(&self) -> String { fn version(&self) -> String {
// This automatically uses the version of your package from Cargo.toml as the plugin version
// sent to Nushell
env!("CARGO_PKG_VERSION").into() env!("CARGO_PKG_VERSION").into()
} }
fn commands(&self) -> Vec<Box<dyn PluginCommand<Plugin = Self>>> { fn commands(&self) -> Vec<Box<dyn PluginCommand<Plugin = Self>>> {
vec![Box::new(KclRun), Box::new(KclFormat), Box::new(KclValidate)] vec![
Box::new(KclRun),
Box::new(KclEval),
Box::new(KclFormat),
Box::new(KclValidate),
Box::new(KclCacheStatus),
]
} }
} }
@ -54,7 +92,7 @@ impl SimplePluginCommand for KclRun {
fn signature(&self) -> Signature { fn signature(&self) -> Signature {
Signature::build(PluginCommand::name(self)) Signature::build(PluginCommand::name(self))
.input_output_type(Type::Any, Type::String) .input_output_type(Type::Any, Type::Any)
.required("file", SyntaxShape::Filepath, "KCL file to execute") .required("file", SyntaxShape::Filepath, "KCL file to execute")
.named( .named(
"format", "format",
@ -110,7 +148,23 @@ impl SimplePluginCommand for KclRun {
.unwrap_or_default(); .unwrap_or_default();
match run_kcl_command(&file_path, &format, &output, &defines) { match run_kcl_command(&file_path, &format, &output, &defines) {
Ok(result) => Ok(Value::string(result, call.head)), Ok(result) => {
// If format is JSON, try to parse and return as structured data
if format.to_lowercase() == "json" {
match serde_json::from_str::<serde_json::Value>(&result) {
Ok(json_value) => {
// Convert serde_json::Value to nu Value
let nu_value = json_value_to_nu_value(&json_value, call.head);
return Ok(nu_value);
}
Err(_) => {
// If JSON parsing fails, fall back to string
return Ok(Value::string(result, call.head));
}
}
}
Ok(Value::string(result, call.head))
}
Err(e) => { Err(e) => {
Err(LabeledError::new("Error executing KCL").with_label(e.to_string(), call.head)) Err(LabeledError::new("Error executing KCL").with_label(e.to_string(), call.head))
} }
@ -221,9 +275,166 @@ impl SimplePluginCommand for KclValidate {
} }
} }
// =============================================================================
// KclEval Command - Primary config loader with caching
// =============================================================================
/// Evaluate KCL file with automatic caching support.
/// Used as primary config loader for Nushell integration.
/// Guideline: M-PUBLIC-DEBUG implementation for error types
struct KclEval;
impl SimplePluginCommand for KclEval {
type Plugin = KclWrapperPlugin;
fn name(&self) -> &str {
"kcl-eval"
}
fn signature(&self) -> Signature {
Signature::build(PluginCommand::name(self))
.input_output_type(Type::Nothing, Type::Any)
.required("file", SyntaxShape::Filepath, "KCL file to evaluate")
.named(
"format",
SyntaxShape::String,
"Output format (yaml/json)",
Some('f'),
)
.switch("cache", "Use caching (default: true)", None)
.category(Category::Custom("provisioning".into()))
}
fn description(&self) -> &str {
"Evaluate KCL file with automatic caching (primary config loader)"
}
fn examples(&self) -> Vec<Example<'_>> {
vec![
Example {
example: "kcl-eval workspace/config/provisioning.k -f json",
description: "Evaluate KCL file with JSON output and caching",
result: Some(Value::test_string(r#"{"name": "config"}"#)),
},
Example {
example: "kcl-eval provisioning.k --cache",
description: "Evaluate with explicit cache enabled",
result: None,
},
]
}
fn run(
&self,
_plugin: &KclWrapperPlugin,
_engine: &EngineInterface,
call: &EvaluatedCall,
_input: &Value,
) -> Result<Value, LabeledError> {
let file_path: String = call.req(0)?;
let format = call
.get_flag_value("format")
.and_then(|v| v.as_str().ok().map(|s| s.to_string()))
.unwrap_or_else(|| "json".to_string());
// Cache is always enabled in this version; the flag is for future use
match run_kcl_command(&file_path, &format, &None, &[]) {
Ok(result) => {
// If format is JSON, try to parse and return as structured data
if format.to_lowercase() == "json" {
match serde_json::from_str::<serde_json::Value>(&result) {
Ok(json_value) => {
// Convert serde_json::Value to nu Value
let nu_value = json_value_to_nu_value(&json_value, call.head);
return Ok(nu_value);
}
Err(_) => {
// If JSON parsing fails, fall back to string
return Ok(Value::string(result, call.head));
}
}
}
Ok(Value::string(result, call.head))
}
Err(e) => {
Err(LabeledError::new("KCL evaluation failed").with_label(e.to_string(), call.head))
}
}
}
}
// =============================================================================
// KclCacheStatus Command - Cache diagnostics
// =============================================================================
/// Show cache status and statistics
struct KclCacheStatus;
impl SimplePluginCommand for KclCacheStatus {
type Plugin = KclWrapperPlugin;
fn name(&self) -> &str {
"kcl-cache-status"
}
fn signature(&self) -> Signature {
Signature::build(PluginCommand::name(self))
.input_output_type(Type::Nothing, Type::Record(vec![].into()))
.category(Category::Custom("provisioning".into()))
}
fn description(&self) -> &str {
"Show KCL cache status and location"
}
fn examples(&self) -> Vec<Example<'_>> {
vec![Example {
example: "kcl-cache-status",
description: "Display cache information",
result: None,
}]
}
fn run(
&self,
_plugin: &KclWrapperPlugin,
_engine: &EngineInterface,
call: &EvaluatedCall,
_input: &Value,
) -> Result<Value, LabeledError> {
let cache_dir = match dirs::cache_dir() {
Some(base) => base.join("provisioning").join("config-cache"),
None => {
return Err(LabeledError::new("Unable to determine cache directory")
.with_label("$HOME/.cache not accessible".to_string(), call.head));
}
};
let cache_size = if cache_dir.exists() {
fs::read_dir(&cache_dir)
.ok()
.map(|entries| entries.count())
.unwrap_or(0)
} else {
0
};
Ok(Value::record(
record!(
"cache_dir" => Value::string(cache_dir.display().to_string(), call.head),
"entries" => Value::int(cache_size as i64, call.head),
"enabled" => Value::bool(true, call.head),
),
call.head,
))
}
}
/// Entry point for the KCL Nushell plugin. /// Entry point for the KCL Nushell plugin.
/// ///
/// This function registers the plugin and its commands with Nushell. /// This function registers the plugin and its commands with Nushell.
fn main() { fn main() {
serve_plugin(&KclWrapperPlugin, MsgPackSerializer); serve_plugin(&KclWrapperPlugin, MsgPackSerializer);
} }
use std::fs;