use std::{fs, path::PathBuf};
use anyhow::{Context, bail};
use clap::{Args, Subcommand};
use lenso_app_plan::authoring::HostCatalog;
use crate::plugins::{load_resolved_app, project_root};
#[derive(Clone, Debug, Subcommand)]
pub(crate) enum AppCommand {
Init(AppInitArgs),
Check(ProjectArgs),
Show(ProjectArgs),
}
#[derive(Args, Clone, Debug)]
pub(crate) struct AppInitArgs {
#[arg(long)]
host: PathBuf,
#[arg(long)]
host_catalog: PathBuf,
#[arg(long)]
root: Option<PathBuf>,
#[arg(long)]
json: bool,
}
#[derive(Args, Clone, Debug)]
pub(crate) struct ProjectArgs {
#[arg(long)]
root: Option<PathBuf>,
#[arg(long)]
json: bool,
}
pub(crate) fn app(command: AppCommand) -> anyhow::Result<()> {
match command {
AppCommand::Init(args) => init(args),
AppCommand::Check(args) => check(args),
AppCommand::Show(args) => show(args),
}
}
fn init(args: AppInitArgs) -> anyhow::Result<()> {
let root = args.root.unwrap_or(std::env::current_dir()?);
let host = fs::canonicalize(&args.host)
.with_context(|| format!("locate Host executable {}", args.host.display()))?;
let host_metadata = fs::symlink_metadata(&host)?;
if !host_metadata.file_type().is_file() {
bail!("Host executable must be a regular file: {}", host.display());
}
let catalog_bytes = fs::read(&args.host_catalog)
.with_context(|| format!("read Host Catalog {}", args.host_catalog.display()))?;
let _: HostCatalog =
serde_json::from_slice(&catalog_bytes).context("Host Catalog is invalid")?;
let control = root.join(".lenso");
let plugins = root.join("plugins");
if control.exists() || plugins.exists() {
bail!(
"App workspace already contains `.lenso/` or `plugins/`: {}",
root.display()
);
}
fs::create_dir_all(&root)
.with_context(|| format!("create App workspace {}", root.display()))?;
let staging = tempfile::Builder::new()
.prefix(".lenso-init-")
.tempdir_in(&root)
.context("stage App workspace")?;
let staged_control = staging.path().join(".lenso");
fs::create_dir(&staged_control)?;
fs::copy(&host, staged_control.join("host"))?;
fs::write(staged_control.join("host-catalog.json"), &catalog_bytes)?;
fs::create_dir(staging.path().join("plugins"))?;
fs::rename(&staged_control, &control)?;
if let Err(error) = fs::rename(staging.path().join("plugins"), &plugins) {
let _ = fs::remove_dir_all(&control);
return Err(error).context("publish Plugin Root");
}
let resolved = load_resolved_app(&root).inspect_err(|_| {
let _ = fs::remove_dir_all(&plugins);
let _ = fs::remove_dir_all(&control);
})?;
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"schema_version": 1,
"kind": "lenso.app-init",
"status": "created",
"root": root,
"plugin_instances": resolved.instances().len(),
"capability_bindings": resolved.plan().capability_bindings().len(),
}))?
);
} else {
println!("Created App workspace at {}.", root.display());
}
Ok(())
}
fn check(args: ProjectArgs) -> anyhow::Result<()> {
let root = project_root(args.root)?;
let resolved = load_resolved_app(&root)?;
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"schema_version": 1,
"kind": "lenso.app-check",
"status": "passed",
"plugin_instances": resolved.instances().len(),
"capability_bindings": resolved.plan().capability_bindings().len(),
}))?
);
} else {
println!(
"App is valid: {} Plugin Instance(s), {} Capability binding(s).",
resolved.instances().len(),
resolved.plan().capability_bindings().len()
);
}
Ok(())
}
fn show(args: ProjectArgs) -> anyhow::Result<()> {
let root = project_root(args.root)?;
let resolved = load_resolved_app(&root)?;
if args.json {
let instances = resolved
.instances()
.iter()
.map(|instance| {
serde_json::json!({
"id": instance.id().to_string(),
"source": format!("{:?}", instance.source()),
"plan_key": instance.plan_key(),
})
})
.collect::<Vec<_>>();
let bindings = resolved
.plan()
.capability_bindings()
.iter()
.map(|binding| {
serde_json::json!({
"consumer_instance": binding.consumer_instance(),
"capability_id": binding.capability_id(),
"descriptor_version": binding.descriptor_version(),
"provider_instance": binding.provider_instance(),
})
})
.collect::<Vec<_>>();
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"schema_version": 1,
"kind": "lenso.app-show",
"instances": instances,
"bindings": bindings,
}))?
);
return Ok(());
}
println!("Plugin Instances:");
for instance in resolved.instances() {
println!(
" {} source={:?} plan-key={}",
instance.id(),
instance.source(),
instance.plan_key()
);
}
println!("Capability bindings:");
for binding in resolved.plan().capability_bindings() {
println!(
" {} --{}@{}--> {}",
binding.consumer_instance(),
binding.capability_id(),
binding.descriptor_version(),
binding.provider_instance()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use lenso_app_plan::authoring::{
HostDefaultPlugin, HostPluginRelease, HostSlot, PluginDescriptor,
};
#[test]
fn init_publishes_one_valid_app_workspace() {
let temporary = tempfile::tempdir().unwrap();
let host = temporary.path().join("host-source");
fs::write(&host, b"host").unwrap();
let catalog = HostCatalog::new(
[HostSlot::one("agent")],
[HostPluginRelease::new(PluginDescriptor::new(
"example.agent",
"1.0.0",
"agent",
))],
[HostDefaultPlugin::new("example.agent", "default")],
);
let catalog_path = temporary.path().join("catalog.json");
fs::write(&catalog_path, serde_json::to_vec(&catalog).unwrap()).unwrap();
let root = temporary.path().join("app");
init(AppInitArgs {
host,
host_catalog: catalog_path,
root: Some(root.clone()),
json: false,
})
.unwrap();
assert!(root.join(".lenso/host").is_file());
assert!(root.join("plugins").is_dir());
assert_eq!(load_resolved_app(&root).unwrap().instances().len(), 1);
}
}