use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use serde_json::{Map, Value, json};
#[derive(Debug, Clone)]
pub struct RemoteModuleInstallOptions {
pub base_url: Option<String>,
pub console_plan: bool,
pub dry_run: bool,
pub env_file: Option<PathBuf>,
pub install_plan_file: Option<PathBuf>,
pub module_services_file: Option<PathBuf>,
pub repo_root: Option<PathBuf>,
pub run_install_commands: bool,
pub runtime_console_root: Option<PathBuf>,
pub source: String,
}
#[derive(Debug, Clone)]
pub struct RemoteModuleUninstallOptions {
pub dry_run: bool,
pub env_file: Option<PathBuf>,
pub install_plan_file: Option<PathBuf>,
pub module_services_file: Option<PathBuf>,
pub repo_root: Option<PathBuf>,
pub source: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ModuleDoctorOptions {
pub env_file: Option<PathBuf>,
pub module_name: Option<String>,
pub module_services_file: Option<PathBuf>,
pub repo_root: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct ModuleCatalogAddOptions {
pub base_url: Option<String>,
pub catalog_file: Option<PathBuf>,
pub dry_run: bool,
pub repo_root: Option<PathBuf>,
pub summary: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ModuleCreateOptions {
pub area: Option<String>,
pub capability: Option<String>,
pub dry_run: bool,
pub icon: Option<String>,
pub label: Option<String>,
pub module_id: String,
pub output_dir: Option<PathBuf>,
pub package_name: Option<String>,
pub package_root: Option<String>,
pub package_scope: Option<String>,
pub package_slug: Option<String>,
pub remote: bool,
pub repo_root: Option<PathBuf>,
pub route: Option<String>,
pub runtime_console_root: Option<PathBuf>,
pub source: Option<String>,
pub surface_name: Option<String>,
pub with_console: bool,
}
#[derive(Debug, Clone)]
pub struct ConsolePackageCreateOptions {
pub area: Option<String>,
pub capability: Option<String>,
pub dry_run: bool,
pub icon: Option<String>,
pub label: Option<String>,
pub module_id: String,
pub package_name: Option<String>,
pub package_scope: Option<String>,
pub package_slug: Option<String>,
pub route: Option<String>,
pub runtime_console_root: Option<PathBuf>,
pub source: Option<String>,
pub surface_name: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ConsolePackageApplyPlanOptions {
pub dependency_version: Option<String>,
pub dry_run: bool,
pub install_plan_file: Option<PathBuf>,
pub log_next_steps: bool,
pub repo_root: Option<PathBuf>,
pub runtime_console_root: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct AppliedConsolePlan {
repo_root: PathBuf,
runtime_console_root: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ModuleSource {
Linked,
Remote,
}
#[derive(Debug, Clone)]
struct ConsolePackageContext {
area: String,
capability: String,
component_name: String,
icon: String,
label: String,
manifest_name: String,
module_id: String,
module_name: String,
package_dir: PathBuf,
package_name: String,
package_private: bool,
package_slug: String,
registry_source: String,
route: String,
runtime_console_api_version: String,
surface_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct InstallCommandSpec {
command: String,
cwd: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RemoteModuleServiceInstallSpec {
name: String,
command: String,
cwd: Option<String>,
ready_url: String,
ready_timeout_ms: u64,
auto_start: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RemoteModuleServiceState {
module_name: String,
services: Vec<RemoteModuleServiceInstallSpec>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RemoteModuleServiceDoctorStatus {
Ready,
Disabled,
ManualNotReady,
NotConfigured,
NotReady,
StaleState,
}
impl RemoteModuleServiceDoctorStatus {
fn label(self) -> &'static str {
match self {
Self::Ready => "ready",
Self::Disabled => "disabled",
Self::ManualNotReady => "manual_not_ready",
Self::NotConfigured => "not_configured",
Self::NotReady => "not_ready",
Self::StaleState => "stale_or_starting",
}
}
fn is_issue(self) -> bool {
matches!(
self,
Self::NotConfigured | Self::NotReady | Self::StaleState
)
}
}
#[derive(Debug)]
struct RepoPaths {
lenso_bootstrap_cargo_toml_path: PathBuf,
lenso_bootstrap_lib_path: PathBuf,
cargo_toml_path: PathBuf,
}
type PendingWrites = BTreeMap<PathBuf, String>;
const MODULE_INSTALL_LEDGER_PATH: &str = ".lenso/module-installs.json";
pub async fn create_module(options: ModuleCreateOptions) -> Result<()> {
if options.remote {
return create_remote_module(options).await;
}
let repo_root = resolve_repo_root(options.repo_root.as_deref())?;
let module_id = slugify(&options.module_id);
if module_id.is_empty() {
bail!("Module id is required");
}
let module_crate = snake_case(&module_id);
let module_dir = repo_root.join("modules").join(&module_id);
if module_dir.exists() {
bail!("Module directory already exists: modules/{module_id}");
}
let runtime_console_root = options
.runtime_console_root
.clone()
.unwrap_or_else(|| repo_root.join("apps/runtime-console"));
let runtime_console_root = absolutize(&runtime_console_root)?;
let console_surface = if options.with_console {
let context = build_console_package_context(
ConsolePackageBuildInput::from_module_options(&options),
&runtime_console_root,
)?;
if context.package_dir.exists() {
bail!(
"Console package directory already exists: {}",
display_relative(&runtime_console_root, &context.package_dir)
);
}
Some(context)
} else {
None
};
let mut pending_writes = PendingWrites::new();
let paths = repo_paths(&repo_root);
queue_module_files(
&mut pending_writes,
&module_dir,
&module_id,
console_surface.as_ref(),
)?;
update_workspace_cargo_toml(&mut pending_writes, &paths.cargo_toml_path, &module_id)?;
update_lenso_bootstrap_cargo_toml(
&mut pending_writes,
&paths.lenso_bootstrap_cargo_toml_path,
&module_id,
)?;
update_lenso_bootstrap_lib(
&mut pending_writes,
&paths.lenso_bootstrap_lib_path,
&module_crate,
&module_id,
)?;
if let Some(console_surface) = console_surface.as_ref() {
queue_console_package(
&mut pending_writes,
&runtime_console_root,
console_surface,
true,
)?;
}
if options.dry_run {
println!("Module dry run:");
for file_path in pending_writes.keys() {
println!("- {}", display_relative(&repo_root, file_path));
}
return Ok(());
}
write_pending_files(&pending_writes)?;
println!("Created module {module_id}.");
if let Some(console_surface) = console_surface {
println!("Created {}.", console_surface.package_name);
}
println!("Next steps:");
println!("- cargo test --locked -p {module_crate}");
println!("- just rust-check");
println!("- just arch-check");
Ok(())
}
pub async fn create_console_package(options: ConsolePackageCreateOptions) -> Result<()> {
let runtime_console_root = options
.runtime_console_root
.as_deref()
.map(Path::to_path_buf)
.unwrap_or(std::env::current_dir().context("resolve current directory")?);
let runtime_console_root = absolutize(&runtime_console_root)?;
let context = build_console_package_context(
ConsolePackageBuildInput::from_console_package_options(&options),
&runtime_console_root,
)?;
if context.package_dir.exists() {
bail!(
"Console package directory already exists: {}",
display_relative(&runtime_console_root, &context.package_dir)
);
}
let mut pending_writes = PendingWrites::new();
queue_console_package(&mut pending_writes, &runtime_console_root, &context, true)?;
if options.dry_run {
println!("Console package dry run:");
for file_path in pending_writes.keys() {
println!("- {}", display_relative(&runtime_console_root, file_path));
}
return Ok(());
}
write_pending_files(&pending_writes)?;
println!("Created {}.", context.package_name);
println!("Next steps:");
println!(
"- Copy {}/console-surface.rs into the Rust module manifest",
context.package_slug
);
println!(
"- Keep navigation.workspace.id=\"{}\" so the module owns its workspace",
context.module_id
);
println!("- Omit navigation only for host System surfaces");
println!("- pnpm install --lockfile-only");
println!("- pnpm check:console-packages");
println!("- pnpm check");
Ok(())
}
pub async fn install_module(
module_reference: &str,
options: RemoteModuleInstallOptions,
) -> Result<()> {
if let Some(descriptor) = read_install_descriptor(module_reference).await? {
return install_module_descriptor(&descriptor, module_reference, options).await;
}
match parse_module_source(&options.source)? {
ModuleSource::Remote => add_remote_module(module_reference, options).await,
ModuleSource::Linked => install_linked_module(module_reference, options),
}
}
async fn install_module_descriptor(
descriptor: &Value,
descriptor_reference: &str,
options: RemoteModuleInstallOptions,
) -> Result<()> {
match parse_module_source(string_field(descriptor, "source")?)? {
ModuleSource::Remote => {
let manifest_reference = descriptor
.get("remote")
.and_then(|remote| {
remote
.get("manifest_url")
.or_else(|| remote.get("manifestUrl"))
})
.and_then(Value::as_str)
.unwrap_or(descriptor_reference);
add_remote_module(manifest_reference, options).await
}
ModuleSource::Linked => {
install_linked_module_descriptor(descriptor, descriptor_reference, options)
}
}
}
pub async fn add_remote_module(
manifest_reference: &str,
options: RemoteModuleInstallOptions,
) -> Result<()> {
let repo_root = resolve_repo_root(options.repo_root.as_deref())?;
let env_file_path = resolve_path(
&repo_root,
options
.env_file
.as_deref()
.unwrap_or_else(|| Path::new(".env")),
);
let install_plan_path = resolve_path(
&repo_root,
options
.install_plan_file
.as_deref()
.unwrap_or_else(|| Path::new(".lenso/console-package-install-plan.json")),
);
let install_ledger_path = repo_root.join(MODULE_INSTALL_LEDGER_PATH);
let module_services_path = resolve_path(
&repo_root,
options
.module_services_file
.as_deref()
.unwrap_or_else(|| Path::new(".lenso/module-services.json")),
);
let manifest = validate_remote_module_manifest(read_json_reference(manifest_reference).await?)?;
let module_name = string_field(&manifest, "name")?.trim().to_owned();
let base_url = derive_remote_base_url(options.base_url.as_deref(), manifest_reference)?;
let install_env = remote_module_install_env(&manifest)?;
let install_commands = remote_module_install_commands(&manifest)?;
let install_services = remote_module_install_services(&manifest, &module_name, &base_url)?;
let env_file = apply_manifest_install_env(
update_remote_modules_env(&env_file_path, &module_name, &base_url)?,
&install_env,
);
let install_plan = update_console_package_install_plan(
&install_plan_path,
&manifest,
manifest_reference,
&base_url,
&install_env,
&install_commands,
&install_services,
false,
)?;
let module_services =
update_remote_module_services_file(&module_services_path, &module_name, &install_services)?;
let install_ledger = update_module_install_ledger(
&install_ledger_path,
remote_module_install_ledger_entry(
&module_name,
manifest_reference,
&base_url,
remote_module_install_writes(
&repo_root,
&env_file_path,
&install_plan_path,
module_services
.as_ref()
.map(|_| module_services_path.as_path()),
),
&install_env,
&install_commands,
&install_services,
console_package_count_from_install_plan(&install_plan, &module_name),
),
)?;
let console_package_count =
console_package_count_from_install_plan(&install_plan, &module_name);
if options.dry_run {
println!("Remote module install dry run:");
println!("- {}", display_relative(&repo_root, &env_file_path));
println!("- {}", display_relative(&repo_root, &install_plan_path));
println!("- {}", display_relative(&repo_root, &install_ledger_path));
if module_services.is_some() {
println!("- {}", display_relative(&repo_root, &module_services_path));
}
println!("- {module_name}={base_url}");
println!("- install env vars: {}", install_env.len());
println!("- install commands: {}", install_commands.len());
println!("- install services: {}", install_services.len());
println!("- console packages: {console_package_count}");
return Ok(());
}
write_file(&env_file_path, env_file.as_bytes())?;
write_json(&install_plan_path, &install_plan)?;
write_json(&install_ledger_path, &install_ledger)?;
if let Some(module_services) = &module_services {
write_json(&module_services_path, module_services)?;
}
println!("Added remote module {module_name}.");
println!("Updated:");
println!("- {}", display_relative(&repo_root, &env_file_path));
println!("- {}", display_relative(&repo_root, &install_plan_path));
println!("- {}", display_relative(&repo_root, &install_ledger_path));
if module_services.is_some() {
println!("- {}", display_relative(&repo_root, &module_services_path));
}
println!("REMOTE_MODULES: {module_name}={base_url}");
println!("Install env vars: {}", install_env.len());
println!("Install commands: {}", install_commands.len());
println!("Install services: {}", install_services.len());
println!("Console packages: {console_package_count}");
let applied_console_plan = if console_package_count > 0 && options.console_plan {
Some(
apply_console_package_install_plan(ConsolePackageApplyPlanOptions {
dependency_version: None,
dry_run: false,
install_plan_file: Some(install_plan_path.clone()),
log_next_steps: false,
repo_root: Some(repo_root.clone()),
runtime_console_root: options.runtime_console_root,
})
.await?,
)
} else {
None
};
let install_commands_ran = if !install_commands.is_empty() && options.run_install_commands {
run_install_commands(&repo_root, &install_commands)?;
let install_plan = update_console_package_install_plan(
&install_plan_path,
&manifest,
manifest_reference,
&base_url,
&install_env,
&install_commands,
&install_services,
true,
)?;
write_json(&install_plan_path, &install_plan)?;
true
} else {
false
};
println!("Next steps:");
if let Some(applied) = applied_console_plan {
let console_root = display_relative(&applied.repo_root, &applied.runtime_console_root);
println!("- pnpm --dir {console_root} install");
println!("- pnpm --dir {console_root} check:console-packages");
println!("- restart Runtime Console after installing packages");
} else if console_package_count > 0 {
println!("- lenso console-package apply-plan");
println!("- pnpm install");
println!("- restart Runtime Console after applying the plan");
}
if !install_commands.is_empty() && !install_commands_ran {
println!("- rerun with --run-install-commands to execute manifest install commands");
}
println!("- restart the API and worker");
Ok(())
}
fn install_linked_module(module_name: &str, options: RemoteModuleInstallOptions) -> Result<()> {
set_linked_module_enabled(
module_name,
true,
options.env_file,
options.repo_root,
options.dry_run,
)
}
fn install_linked_module_descriptor(
descriptor: &Value,
descriptor_reference: &str,
options: RemoteModuleInstallOptions,
) -> Result<()> {
let module_name = string_field(descriptor, "name")?.trim();
if module_name.is_empty() {
bail!("Linked module descriptor name is required");
}
let linked = descriptor
.get("linked")
.ok_or_else(|| anyhow!("Linked module descriptor linked section is required"))?;
let call = string_field(linked, "call")?.trim();
if call.is_empty() {
bail!("Linked module descriptor linked.call is required");
}
let repo_root = resolve_repo_root(options.repo_root.as_deref())?;
let env_file_path = resolve_path(
&repo_root,
options
.env_file
.as_deref()
.unwrap_or_else(|| Path::new(".env")),
);
let cargo_toml_path = repo_root.join("Cargo.toml");
let host_lib_path = repo_root.join("src/lib.rs");
let install_ledger_path = repo_root.join(MODULE_INSTALL_LEDGER_PATH);
let dependencies = descriptor
.get("dependencies")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
let mut env_file =
set_linked_module_enabled_env(&read_text_if_exists(&env_file_path)?, module_name, true);
for dependency in &dependencies {
env_file = set_linked_module_enabled_env(&env_file, dependency, true);
}
let mut cargo_toml = read_text_if_exists(&cargo_toml_path)?;
let mut cargo_toml_changed = false;
let mut host_lib = read_text(&host_lib_path)?;
for dependency in &dependencies {
if let Some(dependency_descriptor) = builtin_linked_module_descriptor(dependency) {
let dependency_linked = dependency_descriptor.get("linked").ok_or_else(|| {
anyhow!("Linked dependency descriptor linked section is required")
})?;
if let Some(updated) = update_host_cargo_toml_for_linked_descriptor(
&cargo_toml,
dependency_linked.get("cargo"),
)? {
cargo_toml = updated;
cargo_toml_changed = true;
}
host_lib = update_host_lib_for_linked_descriptor(
&host_lib,
dependency_linked.get("use").and_then(Value::as_str),
string_field(dependency_linked, "call")?,
)?;
}
}
if let Some(updated) =
update_host_cargo_toml_for_linked_descriptor(&cargo_toml, linked.get("cargo"))?
{
cargo_toml = updated;
cargo_toml_changed = true;
}
host_lib = update_host_lib_for_linked_descriptor(
&host_lib,
linked.get("use").and_then(Value::as_str),
call,
)?;
let install_ledger = update_module_install_ledger(
&install_ledger_path,
linked_module_install_ledger_entry(
module_name,
descriptor_reference,
call,
&dependencies,
linked_module_install_writes(
&repo_root,
&env_file_path,
if cargo_toml_changed {
Some(cargo_toml_path.as_path())
} else {
None
},
&host_lib_path,
),
cargo_toml_changed,
),
)?;
if options.dry_run {
println!("Linked module install dry run:");
println!("- {}", display_relative(&repo_root, &env_file_path));
if cargo_toml_changed {
println!("- {}", display_relative(&repo_root, &cargo_toml_path));
}
println!("- {}", display_relative(&repo_root, &host_lib_path));
println!("- {}", display_relative(&repo_root, &install_ledger_path));
println!("- {module_name}");
return Ok(());
}
write_file(&env_file_path, env_file.as_bytes())?;
if cargo_toml_changed {
write_file(&cargo_toml_path, cargo_toml.as_bytes())?;
}
write_file(&host_lib_path, host_lib.as_bytes())?;
write_json(&install_ledger_path, &install_ledger)?;
println!("Installed linked module {module_name}.");
println!("Updated:");
println!("- {}", display_relative(&repo_root, &env_file_path));
if cargo_toml_changed {
println!("- {}", display_relative(&repo_root, &cargo_toml_path));
}
println!("- {}", display_relative(&repo_root, &host_lib_path));
println!("- {}", display_relative(&repo_root, &install_ledger_path));
println!("Next steps:");
println!("- cargo run --bin migrate");
println!("- restart the API and worker");
Ok(())
}
pub async fn uninstall_module(
module_name: &str,
options: RemoteModuleUninstallOptions,
) -> Result<()> {
match uninstall_module_source(module_name, &options)? {
ModuleSource::Remote => uninstall_remote_module(module_name, options).await,
ModuleSource::Linked => uninstall_linked_module(module_name, options),
}
}
fn uninstall_module_source(
module_name: &str,
options: &RemoteModuleUninstallOptions,
) -> Result<ModuleSource> {
if let Some(source) = options.source.as_deref() {
return parse_module_source(source);
}
let module_name = module_name.trim();
if module_name.is_empty() {
bail!("Module name is required");
}
let repo_root = resolve_repo_root(options.repo_root.as_deref())?;
let env_file_path = resolve_path(
&repo_root,
options
.env_file
.as_deref()
.unwrap_or_else(|| Path::new(".env")),
);
let install_plan_path = resolve_path(
&repo_root,
options
.install_plan_file
.as_deref()
.unwrap_or_else(|| Path::new(".lenso/console-package-install-plan.json")),
);
let install_ledger_path = repo_root.join(MODULE_INSTALL_LEDGER_PATH);
let module_services_path = resolve_path(
&repo_root,
options
.module_services_file
.as_deref()
.unwrap_or_else(|| Path::new(".lenso/module-services.json")),
);
if let Some(source) = module_install_ledger_source(&install_ledger_path, module_name)? {
return Ok(source);
}
infer_uninstall_module_source(
module_name,
&read_text_if_exists(&env_file_path)?,
remote_module_install_state_exists(
module_name,
&env_file_path,
&install_plan_path,
&module_services_path,
)?,
)
}
pub async fn uninstall_remote_module(
module_name: &str,
options: RemoteModuleUninstallOptions,
) -> Result<()> {
let module_name = module_name.trim();
if module_name.is_empty() {
bail!("Module name is required");
}
let repo_root = resolve_repo_root(options.repo_root.as_deref())?;
let env_file_path = resolve_path(
&repo_root,
options
.env_file
.as_deref()
.unwrap_or_else(|| Path::new(".env")),
);
let install_plan_path = resolve_path(
&repo_root,
options
.install_plan_file
.as_deref()
.unwrap_or_else(|| Path::new(".lenso/console-package-install-plan.json")),
);
let install_ledger_path = repo_root.join(MODULE_INSTALL_LEDGER_PATH);
let module_services_path = resolve_path(
&repo_root,
options
.module_services_file
.as_deref()
.unwrap_or_else(|| Path::new(".lenso/module-services.json")),
);
let env_file = remove_remote_module_from_env(&env_file_path, module_name)?;
let install_plan = remove_console_package_install_plan_module(&install_plan_path, module_name)?;
let install_ledger = remove_module_install_ledger_module(&install_ledger_path, module_name)?;
let module_services =
remove_remote_module_services_file_module(&module_services_path, module_name)?;
if options.dry_run {
println!("Remote module uninstall dry run:");
if env_file.is_some() {
println!("- {}", display_relative(&repo_root, &env_file_path));
}
if install_plan.is_some() {
println!("- {}", display_relative(&repo_root, &install_plan_path));
}
if install_ledger.is_some() {
println!("- {}", display_relative(&repo_root, &install_ledger_path));
}
if module_services.is_some() {
println!("- {}", display_relative(&repo_root, &module_services_path));
}
if env_file.is_none()
&& install_plan.is_none()
&& install_ledger.is_none()
&& module_services.is_none()
{
println!("- no local install state found");
}
return Ok(());
}
let changed = env_file.is_some()
|| install_plan.is_some()
|| install_ledger.is_some()
|| module_services.is_some();
if let Some(env_file) = env_file {
write_file(&env_file_path, env_file.as_bytes())?;
}
if let Some(install_plan) = install_plan {
write_json(&install_plan_path, &install_plan)?;
}
if let Some(install_ledger) = install_ledger {
write_json(&install_ledger_path, &install_ledger)?;
}
if let Some(module_services) = module_services {
write_json(&module_services_path, &module_services)?;
}
if !changed {
println!("Remote module {module_name} is not installed locally.");
return Ok(());
}
println!("Uninstalled remote module {module_name}.");
println!("Next steps:");
println!("- restart the API and worker");
Ok(())
}
pub async fn doctor_module(options: ModuleDoctorOptions) -> Result<()> {
let repo_root = resolve_repo_root(options.repo_root.as_deref())?;
let env_file_path = resolve_path(
&repo_root,
options
.env_file
.as_deref()
.unwrap_or_else(|| Path::new(".env")),
);
let module_services_path = resolve_path(
&repo_root,
options
.module_services_file
.as_deref()
.unwrap_or_else(|| Path::new(".lenso/module-services.json")),
);
let requested_module = options
.module_name
.as_deref()
.map(str::trim)
.filter(|module_name| !module_name.is_empty());
let env_source = read_text_if_exists(&env_file_path)?;
let remote_modules = remote_module_entries_from_env_source(&env_source);
let service_states = read_remote_module_service_states(&module_services_path)?;
let services_state_dir = module_services_path
.parent()
.unwrap_or_else(|| Path::new("."));
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(800))
.build()
.context("build module doctor HTTP client")?;
let mut issue_count = 0usize;
let mut checked_count = 0usize;
println!("Module doctor:");
println!("- env: {}", display_relative(&repo_root, &env_file_path));
println!(
"- services: {}",
display_relative(&repo_root, &module_services_path)
);
println!("- remote modules: {}", remote_modules.len());
for state in service_states
.iter()
.filter(|state| requested_module.is_none_or(|module_name| state.module_name == module_name))
{
let configured = remote_modules
.iter()
.any(|(module_name, _)| module_name == &state.module_name);
let enabled = module_enabled_from_env_source(&env_source, &state.module_name);
for service in &state.services {
checked_count += 1;
let ready = remote_service_ready_url(&client, &service.ready_url).await;
let lock_file_path = remote_module_service_state_path(
services_state_dir,
&state.module_name,
service,
"lock",
);
let pid_file_path = remote_module_service_state_path(
services_state_dir,
&state.module_name,
service,
"pid",
);
let lock_exists = lock_file_path.exists();
let pid_exists = pid_file_path.exists();
let status = remote_module_service_doctor_status(
configured,
enabled,
service.auto_start,
ready,
lock_exists,
pid_exists,
);
if status.is_issue() {
issue_count += 1;
}
println!(
"- {}/{}: {}",
state.module_name,
service.name,
status.label()
);
println!(" readyUrl: {}", service.ready_url);
if !ready {
println!(" command: {}", service.command);
}
if lock_exists || pid_exists {
println!(
" state: lock={} pid={}",
display_relative(&repo_root, &lock_file_path),
display_relative(&repo_root, &pid_file_path)
);
}
if let Some(fix) = remote_module_service_doctor_fix(status) {
println!(" fix: {fix}");
}
}
}
if checked_count == 0 {
if let Some(module_name) = requested_module {
println!("- services checked: 0 for {module_name}");
} else {
println!("- services checked: 0");
}
return Ok(());
}
println!("- services checked: {checked_count}");
if issue_count > 0 {
bail!("Module doctor found {issue_count} issue(s)");
}
Ok(())
}
pub async fn add_module_catalog_entry(
manifest_reference: &str,
options: ModuleCatalogAddOptions,
) -> Result<()> {
let repo_root = resolve_repo_root(options.repo_root.as_deref())?;
let catalog_file_path = resolve_path(
&repo_root,
options
.catalog_file
.as_deref()
.unwrap_or_else(|| Path::new(".lenso/module-catalog.json")),
);
let manifest = validate_remote_module_manifest(read_json_reference(manifest_reference).await?)?;
let module_name = string_field(&manifest, "name")?.trim().to_owned();
let version = string_field(&manifest, "version")?.trim().to_owned();
let base_url = derive_remote_base_url(options.base_url.as_deref(), manifest_reference)?;
let mut catalog = read_json_if_exists(&catalog_file_path)?
.unwrap_or_else(|| json!({ "modules": [], "version": 1 }));
let modules = catalog
.get_mut("modules")
.and_then(Value::as_array_mut)
.ok_or_else(|| anyhow!("Module catalog modules must be an array"))?;
modules.retain(|entry| entry.get("name").and_then(Value::as_str) != Some(module_name.as_str()));
modules.push(module_catalog_entry_from_manifest(
&manifest,
manifest_reference,
&base_url,
options.summary.as_deref(),
)?);
if options.dry_run {
println!("Module catalog dry run:");
println!("- {}", display_relative(&repo_root, &catalog_file_path));
println!("- {module_name} {version}");
return Ok(());
}
write_json(&catalog_file_path, &catalog)?;
println!("Added {module_name} to module catalog.");
println!("Updated:");
println!("- {}", display_relative(&repo_root, &catalog_file_path));
println!("Install:");
println!("- lenso module install {manifest_reference}");
Ok(())
}
pub async fn apply_console_package_install_plan(
options: ConsolePackageApplyPlanOptions,
) -> Result<AppliedConsolePlan> {
let repo_root = resolve_repo_root(options.repo_root.as_deref())?;
let runtime_console_root = options
.runtime_console_root
.as_deref()
.map(Path::to_path_buf)
.unwrap_or(default_runtime_console_root_for_repo(&repo_root)?);
let runtime_console_root = absolutize(&runtime_console_root)?;
let install_plan_path = resolve_path(
&repo_root,
options
.install_plan_file
.as_deref()
.unwrap_or_else(|| Path::new(".lenso/console-package-install-plan.json")),
);
let dependency_version = options
.dependency_version
.unwrap_or_else(|| "latest".to_owned());
let install_plan = read_json(&install_plan_path)?;
let paths = runtime_console_paths(&runtime_console_root);
let mut package_json = read_json(&paths.package_json_path)?;
let mut manifest_exports_source = read_text(&paths.manifest_exports_path)?;
let mut module_exports_source = read_text(&paths.module_exports_path)?;
let plan_items = unique_console_package_plan_items(&install_plan);
for item in &plan_items {
update_package_json_dependency(&mut package_json, &item.package_name, &dependency_version)?;
let manifest_name = manifest_name_from_module_export(&item.export_name);
manifest_exports_source = insert_before_needle(
&manifest_exports_source,
&format!(
"import {{ {manifest_name} }} from \"{}\";\n",
item.package_name
),
"export const consolePackageManifests",
)?;
manifest_exports_source = insert_before_needle(
&manifest_exports_source,
&format!(" {manifest_name},\n"),
"] as const;",
)?;
module_exports_source = insert_before_needle(
&module_exports_source,
&format!(
"import {{ {manifest_name}, {} }} from \"{}\";\n",
item.export_name, item.package_name
),
"import {",
)?;
module_exports_source = insert_before_needle(
&module_exports_source,
&format!(
" [consolePackageKey({manifest_name})]: {},\n",
item.export_name
),
"} satisfies ConsolePackageModuleExportsByKey;",
)?;
}
if options.dry_run {
println!("Console package install plan dry run:");
println!(
"- {}",
display_relative(&repo_root, &paths.package_json_path)
);
println!(
"- {}",
display_relative(&repo_root, &paths.manifest_exports_path)
);
println!(
"- {}",
display_relative(&repo_root, &paths.module_exports_path)
);
return Ok(AppliedConsolePlan {
repo_root,
runtime_console_root,
});
}
write_json(&paths.package_json_path, &package_json)?;
write_file(
&paths.manifest_exports_path,
manifest_exports_source.as_bytes(),
)?;
write_file(&paths.module_exports_path, module_exports_source.as_bytes())?;
println!(
"Applied {} console package install plan item(s).",
plan_items.len()
);
if options.log_next_steps {
let console_root = display_relative(&repo_root, &runtime_console_root);
println!("Next steps:");
println!("- pnpm --dir {console_root} install");
println!("- pnpm --dir {console_root} check:console-packages");
println!("- pnpm check");
}
Ok(AppliedConsolePlan {
repo_root,
runtime_console_root,
})
}
async fn create_remote_module(options: ModuleCreateOptions) -> Result<()> {
let module_id = slugify(&options.module_id);
if module_id.is_empty() {
bail!("Module id is required");
}
let output_root = options
.output_dir
.as_deref()
.map(Path::to_path_buf)
.unwrap_or(std::env::current_dir().context("resolve current directory")?);
let output_root = absolutize(&output_root)?;
let package_root_name = slugify(
options
.package_root
.as_deref()
.unwrap_or(&format!("lenso-{module_id}")),
);
if package_root_name.is_empty() {
bail!("Remote package root is required");
}
let package_root = output_root.join(&package_root_name);
if package_root.exists() {
bail!(
"Remote module package already exists: {}",
package_root.display()
);
}
let mut package_context = build_console_package_context(
ConsolePackageBuildInput::for_remote_module(&options, &module_id),
&package_root,
)?;
package_context.package_dir = package_root.join("console");
let mut pending_writes = PendingWrites::new();
queue_remote_module_files(
&mut pending_writes,
&package_root,
&package_root_name,
&package_context,
)?;
if options.dry_run {
println!("Remote module dry run:");
for file_path in pending_writes.keys() {
println!("- {}", display_relative(&output_root, file_path));
}
return Ok(());
}
write_pending_files(&pending_writes)?;
println!("Created remote module package {package_root_name}.");
println!("Next steps:");
println!("- pnpm --dir {package_root_name}/backend dev");
println!("- lenso module install http://127.0.0.1:4100/lenso/module/v1/manifest");
println!(
"- lenso module catalog add http://127.0.0.1:4100/lenso/module/v1/manifest # optional discovery"
);
println!("- publish or install the console package");
println!("- pnpm install");
Ok(())
}
#[derive(Debug, Clone)]
struct ConsolePackageBuildInput {
area: Option<String>,
capability: Option<String>,
icon: Option<String>,
label: Option<String>,
module_id: String,
package_name: Option<String>,
package_private: bool,
package_scope: Option<String>,
package_slug: Option<String>,
registry_source: Option<String>,
route: Option<String>,
runtime_console_api_version: String,
surface_name: Option<String>,
}
impl ConsolePackageBuildInput {
fn from_module_options(options: &ModuleCreateOptions) -> Self {
Self {
area: options.area.clone(),
capability: options.capability.clone(),
icon: options.icon.clone(),
label: options.label.clone(),
module_id: options.module_id.clone(),
package_name: options.package_name.clone(),
package_private: true,
package_scope: options.package_scope.clone(),
package_slug: options.package_slug.clone(),
registry_source: options.source.clone(),
route: options.route.clone(),
runtime_console_api_version: "workspace:*".to_owned(),
surface_name: options.surface_name.clone(),
}
}
fn from_console_package_options(options: &ConsolePackageCreateOptions) -> Self {
Self {
area: options.area.clone(),
capability: options.capability.clone(),
icon: options.icon.clone(),
label: options.label.clone(),
module_id: options.module_id.clone(),
package_name: options.package_name.clone(),
package_private: true,
package_scope: options.package_scope.clone(),
package_slug: options.package_slug.clone(),
registry_source: options.source.clone(),
route: options.route.clone(),
runtime_console_api_version: "workspace:*".to_owned(),
surface_name: options.surface_name.clone(),
}
}
fn for_remote_module(options: &ModuleCreateOptions, module_id: &str) -> Self {
Self {
area: options.area.clone(),
capability: options.capability.clone(),
icon: options.icon.clone(),
label: options.label.clone(),
module_id: module_id.to_owned(),
package_name: options.package_name.clone().or_else(|| {
Some(format!(
"{}/lenso-{module_id}-console",
options.package_scope.as_deref().unwrap_or("@vendor")
))
}),
package_private: false,
package_scope: options.package_scope.clone(),
package_slug: Some(format!("{module_id}-console")),
registry_source: options
.source
.clone()
.or_else(|| Some("installed".to_owned())),
route: options.route.clone(),
runtime_console_api_version: "^0.1.0".to_owned(),
surface_name: options.surface_name.clone(),
}
}
}
fn build_console_package_context(
input: ConsolePackageBuildInput,
runtime_console_root: &Path,
) -> Result<ConsolePackageContext> {
let module_id = slugify(&input.module_id);
if module_id.is_empty() {
bail!("Module id is required");
}
let package_slug = slugify(
input
.package_slug
.as_deref()
.unwrap_or(&format!("{module_id}-console")),
);
if package_slug.is_empty() {
bail!("Console package slug is required");
}
let package_name = input.package_name.unwrap_or_else(|| {
format!(
"{}/{}",
input.package_scope.as_deref().unwrap_or("@lenso"),
package_slug
)
});
let area = input.area.unwrap_or_else(|| "data".to_owned());
rust_console_area(&area)?;
let label = input.label.unwrap_or_else(|| title_case(&module_id));
let route = input
.route
.unwrap_or_else(|| format!("/{area}/{module_id}"));
let registry_source = input
.registry_source
.unwrap_or_else(|| "installed".to_owned());
let icon = input.icon.unwrap_or_else(|| default_icon(&area).to_owned());
let capability = input
.capability
.unwrap_or_else(|| format!("{module_id}.read"));
let surface_name = input.surface_name.unwrap_or_else(|| module_id.clone());
let export_stem = export_stem_from_package_slug(&package_slug);
let manifest_name = format!("{export_stem}Manifest");
let module_name = format!("{export_stem}Module");
let component_name = format!("{}ConsolePage", pascal_case(&module_id));
let package_dir = runtime_console_root.join("packages").join(&package_slug);
Ok(ConsolePackageContext {
area,
capability,
component_name,
icon,
label,
manifest_name,
module_id,
module_name,
package_dir,
package_name,
package_private: input.package_private,
package_slug,
registry_source,
route,
runtime_console_api_version: input.runtime_console_api_version,
surface_name,
})
}
fn queue_module_files(
pending_writes: &mut PendingWrites,
module_dir: &Path,
module_id: &str,
console_surface: Option<&ConsolePackageContext>,
) -> Result<()> {
queue_write(
pending_writes,
module_dir.join("Cargo.toml"),
module_cargo_toml(module_id),
);
queue_write(
pending_writes,
module_dir.join("src/lib.rs"),
"pub mod module;\n".to_owned(),
);
queue_write(
pending_writes,
module_dir.join("src/module.rs"),
module_manifest(module_id, console_surface)?,
);
Ok(())
}
fn module_cargo_toml(module_id: &str) -> String {
format!(
r#"[package]
name = "{module_id}"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish.workspace = true
rust-version.workspace = true
[dependencies]
platform-core.workspace = true
platform-module.workspace = true
[lints]
workspace = true
"#
)
}
fn module_manifest(
module_id: &str,
console_surface: Option<&ConsolePackageContext>,
) -> Result<String> {
let imports = if console_surface.is_some() {
"use platform_module::{ConsoleArea, ConsolePackage, ConsoleSurface, LinkedBinding, Module, ModuleManifest};"
} else {
"use platform_module::{LinkedBinding, Module, ModuleManifest};"
};
let manifest_builder = if let Some(console_surface) = console_surface {
format!(
r#"ModuleManifest::builder({})
.capabilities(vec![{}.to_owned()])
.console(vec![ConsoleSurface {{
name: {}.to_owned(),
label: {}.to_owned(),
area: ConsoleArea::{},
route: {}.to_owned(),
package: ConsolePackage {{
name: {}.to_owned(),
export: {}.to_owned(),
}},
icon: Some({}.to_owned()),
required_capabilities: vec![{}.to_owned()],
navigation: Some(platform_module::ConsoleNavigation {{
workspace: platform_module::ConsoleWorkspaceRef {{
id: {}.to_owned(),
label: {}.to_owned(),
icon: Some({}.to_owned()),
}},
group: None,
order: Some(10),
}}),
}}])
.build()"#,
rust_string_literal(module_id),
rust_string_literal(&console_surface.capability),
rust_string_literal(&console_surface.surface_name),
rust_string_literal(&console_surface.label),
rust_console_area(&console_surface.area)?,
rust_string_literal(&console_surface.route),
rust_string_literal(&console_surface.package_name),
rust_string_literal(&console_surface.module_name),
rust_string_literal(&console_surface.icon),
rust_string_literal(&console_surface.capability),
rust_string_literal(module_id),
rust_string_literal(&console_surface.label),
rust_string_literal(&console_surface.icon),
)
} else {
format!(
"ModuleManifest::builder({}).build()",
rust_string_literal(module_id)
)
};
Ok(format!(
r#"use platform_core::AppContext;
{imports}
/// Context-free manifest: serializable metadata only.
pub fn manifest() -> ModuleManifest {{
{manifest_builder}
}}
/// The loaded module: manifest + linked behavior.
pub fn module(_ctx: &AppContext) -> Module {{
Module::linked(manifest(), LinkedBinding::builder().build())
}}
#[cfg(test)]
mod tests {{
use super::*;
#[test]
fn manifest_uses_module_name() {{
assert_eq!(manifest().name, {});
}}
}}
"#,
rust_string_literal(module_id)
))
}
fn update_workspace_cargo_toml(
pending_writes: &mut PendingWrites,
cargo_toml_path: &Path,
module_id: &str,
) -> Result<()> {
let mut file_source = read_text(cargo_toml_path)?;
file_source = insert_before_first_needle(
&file_source,
&format!(" \"modules/{module_id}\",\n"),
&[" \"tools/", "]\n\n[workspace.package]"],
)?;
file_source = insert_before_first_needle(
&file_source,
&format!("{module_id} = {{ path = \"modules/{module_id}\" }}\n"),
&[
"generate-contracts =",
"arch-check =",
"remote-module-example =",
],
)?;
queue_write(pending_writes, cargo_toml_path.to_path_buf(), file_source);
Ok(())
}
fn update_lenso_bootstrap_cargo_toml(
pending_writes: &mut PendingWrites,
cargo_toml_path: &Path,
module_id: &str,
) -> Result<()> {
let file_source = read_text(cargo_toml_path)?;
queue_write(
pending_writes,
cargo_toml_path.to_path_buf(),
insert_before_first_needle(
&file_source,
&format!("{module_id}.workspace = true\n"),
&[
"serde_json.workspace",
"tracing.workspace",
"\n[dev-dependencies]",
],
)?,
);
Ok(())
}
fn update_lenso_bootstrap_lib(
pending_writes: &mut PendingWrites,
lenso_bootstrap_lib_path: &Path,
module_crate: &str,
module_id: &str,
) -> Result<()> {
let file_source = read_text(lenso_bootstrap_lib_path)?;
let entry = format!(
r#" LinkedModuleEntry {{
module_name: "{module_id}",
manifest: {module_crate}::module::manifest,
load: {module_crate}::module::module,
http_binding: None,
}},
"#
);
queue_write(
pending_writes,
lenso_bootstrap_lib_path.to_path_buf(),
insert_into_demo_linked_module_entries(&file_source, &entry)?,
);
Ok(())
}
fn queue_console_package(
pending_writes: &mut PendingWrites,
runtime_console_root: &Path,
context: &ConsolePackageContext,
update_host: bool,
) -> Result<()> {
queue_console_package_files(pending_writes, context)?;
if update_host {
let paths = runtime_console_paths(runtime_console_root);
update_runtime_console_package_json(pending_writes, &paths, context)?;
update_tsconfig(pending_writes, &paths, &context.package_slug)?;
update_oxlint_config(pending_writes, &paths, &context.package_slug)?;
update_manifest_exports(pending_writes, &paths, context)?;
update_module_exports(pending_writes, &paths, context)?;
}
Ok(())
}
fn queue_console_package_files(
pending_writes: &mut PendingWrites,
context: &ConsolePackageContext,
) -> Result<()> {
queue_write(
pending_writes,
context.package_dir.join("package.json"),
console_package_package_json(context)?,
);
queue_write(
pending_writes,
context.package_dir.join("console-surface.json"),
console_surface_json(context)?,
);
queue_write(
pending_writes,
context.package_dir.join("console-surface.rs"),
console_surface_rust(context)?,
);
queue_write(
pending_writes,
context.package_dir.join("src/manifest.ts"),
console_package_manifest_ts(context)?,
);
queue_write(
pending_writes,
context.package_dir.join("src/page.tsx"),
console_package_page_tsx(context),
);
queue_write(
pending_writes,
context.package_dir.join("src/index.tsx"),
console_package_index_tsx(context),
);
queue_write(
pending_writes,
context.package_dir.join("src/index.test.tsx"),
console_package_test_tsx(context),
);
Ok(())
}
fn console_package_package_json(context: &ConsolePackageContext) -> Result<String> {
json_string_pretty(&json!({
"exports": {
".": "./src/index.tsx",
},
"name": context.package_name,
"peerDependencies": {
"@lenso/runtime-console-api": context.runtime_console_api_version,
"react": "^19.1.0",
"react-dom": "^19.1.0",
},
"private": context.package_private,
"scripts": {
"check": "pnpm test && pnpm typecheck",
"test": "echo \"console package smoke passed\"",
"typecheck": "echo \"console package typecheck placeholder\"",
},
"type": "module",
"version": "0.1.0",
}))
}
fn console_surface_json(context: &ConsolePackageContext) -> Result<String> {
json_string_pretty(&json!({
"exportName": context.module_name,
"id": context.module_id,
"packageName": context.package_name,
"source": context.registry_source,
"surfaces": [
{
"area": context.area,
"icon": context.icon,
"label": context.label,
"navigation": {
"order": 10,
"workspace": {
"icon": context.icon,
"id": context.module_id,
"label": context.label,
},
},
"requiredCapabilities": [context.capability],
"route": context.route,
"surfaceName": context.surface_name,
},
],
"version": "workspace",
}))
}
fn console_surface_rust(context: &ConsolePackageContext) -> Result<String> {
Ok(format!(
r#"use platform_module::{{ConsoleArea, ConsolePackage, ConsoleSurface}};
ConsoleSurface {{
name: {}.to_owned(),
label: {}.to_owned(),
area: ConsoleArea::{},
route: {}.to_owned(),
package: ConsolePackage {{
name: {}.to_owned(),
export: {}.to_owned(),
}},
icon: Some({}.to_owned()),
required_capabilities: vec![{}.to_owned()],
navigation: Some(platform_module::ConsoleNavigation {{
workspace: platform_module::ConsoleWorkspaceRef {{
id: {}.to_owned(),
label: {}.to_owned(),
icon: Some({}.to_owned()),
}},
group: None,
order: Some(10),
}}),
}}
"#,
rust_string_literal(&context.surface_name),
rust_string_literal(&context.label),
rust_console_area(&context.area)?,
rust_string_literal(&context.route),
rust_string_literal(&context.package_name),
rust_string_literal(&context.module_name),
rust_string_literal(&context.icon),
rust_string_literal(&context.capability),
rust_string_literal(&context.module_id),
rust_string_literal(&context.label),
rust_string_literal(&context.icon),
))
}
fn console_package_manifest_ts(context: &ConsolePackageContext) -> Result<String> {
Ok(format!(
r#"import {{ defineConsolePackageManifest }} from "@lenso/runtime-console-api";
import consoleSurface from "../console-surface.json";
const consoleSurfaceContract = consoleSurface as unknown as {{
readonly exportName: {};
readonly id: {};
readonly packageName: {};
readonly source: {};
readonly surfaces: readonly [
{{
readonly area: {};
readonly icon: {};
readonly label: {};
readonly navigation: {{
readonly order: 10;
readonly workspace: {{
readonly icon: {};
readonly id: {};
readonly label: {};
}};
}};
readonly requiredCapabilities: readonly [{}];
readonly route: {};
readonly surfaceName: {};
}},
];
readonly version: "workspace";
}};
export const {} = defineConsolePackageManifest(
consoleSurfaceContract
);
"#,
ts_string_literal(&context.module_name)?,
ts_string_literal(&context.module_id)?,
ts_string_literal(&context.package_name)?,
ts_string_literal(&context.registry_source)?,
ts_string_literal(&context.area)?,
ts_string_literal(&context.icon)?,
ts_string_literal(&context.label)?,
ts_string_literal(&context.icon)?,
ts_string_literal(&context.module_id)?,
ts_string_literal(&context.label)?,
ts_string_literal(&context.capability)?,
ts_string_literal(&context.route)?,
ts_string_literal(&context.surface_name)?,
context.manifest_name,
))
}
fn console_package_page_tsx(context: &ConsolePackageContext) -> String {
format!(
r#"export function {}() {{
return (
<main className="flex min-h-screen flex-col gap-3 px-6 py-5">
<header>
<p className="font-medium text-muted-foreground text-xs uppercase tracking-normal">
{}
</p>
<h1 className="font-semibold text-2xl text-foreground">{}</h1>
</header>
</main>
);
}}
"#,
context.component_name, context.label, context.label
)
}
fn console_package_index_tsx(context: &ConsolePackageContext) -> String {
format!(
r#"import {{ defineConsoleModule }} from "@lenso/runtime-console-api";
import {{ {} }} from "./manifest";
import {{ {} }} from "./page";
const [consoleSurface] = {}.surfaces;
export const {} = defineConsoleModule({{
id: {}.id,
surfaces: [
{{
area: consoleSurface.area,
component: {},
icon: consoleSurface.icon,
label: consoleSurface.label,
navigation: consoleSurface.navigation,
path: consoleSurface.route,
}},
],
}});
export {{ {} }} from "./manifest";
export {{ {} }} from "./page";
"#,
context.manifest_name,
context.component_name,
context.manifest_name,
context.module_name,
context.manifest_name,
context.component_name,
context.manifest_name,
context.component_name,
)
}
fn console_package_test_tsx(context: &ConsolePackageContext) -> String {
format!(
r#"import {{ describe, expect, test }} from "vitest";
import {{ {}, {}, {} }} from ".";
const [consoleSurface] = {}.surfaces;
describe({}, () => {{
test("exports a console module manifest and route", () => {{
expect({}).toMatchObject({{
exportName: {},
id: {},
packageName: {},
surfaces: [{{ route: {} }}],
}});
expect({}).toMatchObject({{
id: {}.id,
surfaces: [
{{
area: consoleSurface.area,
icon: consoleSurface.icon,
label: consoleSurface.label,
path: consoleSurface.route,
}},
],
}});
expect({}.surfaces[0]?.component).toBe({});
}});
}});
"#,
context.component_name,
context.manifest_name,
context.module_name,
context.manifest_name,
ts_string_literal_lossy(&context.package_name),
context.manifest_name,
ts_string_literal_lossy(&context.module_name),
ts_string_literal_lossy(&context.module_id),
ts_string_literal_lossy(&context.package_name),
ts_string_literal_lossy(&context.route),
context.module_name,
context.manifest_name,
context.module_name,
context.component_name,
)
}
fn update_runtime_console_package_json(
pending_writes: &mut PendingWrites,
paths: &RuntimeConsolePaths,
context: &ConsolePackageContext,
) -> Result<()> {
let mut package_json = read_json(&paths.package_json_path)?;
update_package_json_dependency(&mut package_json, &context.package_name, "workspace:*")?;
let scripts = package_json
.as_object_mut()
.ok_or_else(|| anyhow!("Runtime Console package.json must be a JSON object"))?
.entry("scripts")
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()
.ok_or_else(|| anyhow!("Runtime Console package.json scripts must be an object"))?;
let current_test = scripts
.get("test")
.and_then(Value::as_str)
.unwrap_or_default();
scripts.insert(
"test".to_owned(),
Value::String(append_token(
current_test,
&format!("packages/{}/src", context.package_slug),
"packages/console-package-api/src",
)),
);
queue_write(
pending_writes,
paths.package_json_path.clone(),
json_string_pretty(&package_json)?,
);
Ok(())
}
fn update_tsconfig(
pending_writes: &mut PendingWrites,
paths: &RuntimeConsolePaths,
package_slug: &str,
) -> Result<()> {
let mut tsconfig = read_json(&paths.tsconfig_path)?;
let include = tsconfig
.as_object_mut()
.ok_or_else(|| anyhow!("Runtime Console tsconfig.json must be a JSON object"))?
.entry("include")
.or_insert_with(|| Value::Array(Vec::new()))
.as_array_mut()
.ok_or_else(|| anyhow!("Runtime Console tsconfig include must be an array"))?;
append_json_string(include, &format!("packages/{package_slug}/src"));
queue_write(
pending_writes,
paths.tsconfig_path.clone(),
json_string_pretty(&tsconfig)?,
);
Ok(())
}
fn update_oxlint_config(
pending_writes: &mut PendingWrites,
paths: &RuntimeConsolePaths,
package_slug: &str,
) -> Result<()> {
let file_source = read_text(&paths.oxlint_config_path)?;
queue_write(
pending_writes,
paths.oxlint_config_path.clone(),
insert_before_needle(
&file_source,
&format!(" \"packages/{package_slug}/src/**/*.{{ts,tsx}}\",\n"),
" \"vite.config.ts\",",
)?,
);
Ok(())
}
fn update_manifest_exports(
pending_writes: &mut PendingWrites,
paths: &RuntimeConsolePaths,
context: &ConsolePackageContext,
) -> Result<()> {
let mut file_source = read_text(&paths.manifest_exports_path)?;
file_source = insert_before_needle(
&file_source,
&format!(
"import {{ {} }} from \"{}\";\n",
context.manifest_name, context.package_name
),
"export const consolePackageManifests",
)?;
file_source = insert_before_needle(
&file_source,
&format!(" {},\n", context.manifest_name),
"] as const;",
)?;
queue_write(
pending_writes,
paths.manifest_exports_path.clone(),
file_source,
);
Ok(())
}
fn update_module_exports(
pending_writes: &mut PendingWrites,
paths: &RuntimeConsolePaths,
context: &ConsolePackageContext,
) -> Result<()> {
let mut file_source = read_text(&paths.module_exports_path)?;
file_source = insert_before_needle(
&file_source,
&format!(
"import {{ {}, {} }} from \"{}\";\n",
context.manifest_name, context.module_name, context.package_name
),
"import {",
)?;
file_source = insert_before_needle(
&file_source,
&format!(
" [consolePackageKey({})]: {},\n",
context.manifest_name, context.module_name
),
"} satisfies ConsolePackageModuleExportsByKey;",
)?;
queue_write(
pending_writes,
paths.module_exports_path.clone(),
file_source,
);
Ok(())
}
fn queue_remote_module_files(
pending_writes: &mut PendingWrites,
package_root: &Path,
package_root_name: &str,
context: &ConsolePackageContext,
) -> Result<()> {
queue_write(
pending_writes,
package_root.join("lenso.module.json"),
json_string_pretty(&remote_manifest_json(context))?,
);
queue_write(
pending_writes,
package_root.join("catalog-entry.json"),
json_string_pretty(&remote_catalog_entry_json(context))?,
);
queue_write(
pending_writes,
package_root.join("package.json"),
remote_root_package_json(&context.module_id)?,
);
queue_write(
pending_writes,
package_root.join("README.md"),
remote_package_readme(&context.module_id, package_root_name),
);
queue_write(
pending_writes,
package_root.join("backend/README.md"),
remote_backend_readme(&context.module_id),
);
queue_write(
pending_writes,
package_root.join("backend/package.json"),
remote_backend_package_json(&context.module_id)?,
);
queue_write(
pending_writes,
package_root.join("backend/src/server.mjs"),
remote_backend_server(context),
);
queue_write(
pending_writes,
package_root.join("backend/src/smoke.mjs"),
remote_backend_smoke(&context.module_id),
);
queue_write(
pending_writes,
package_root.join("backend/openapi.yaml"),
format!(
"openapi: 3.1.0\ninfo:\n title: {} Remote Module\n version: 0.1.0\npaths: {{}}\n",
context.label
),
);
queue_write(
pending_writes,
package_root.join("contracts/README.md"),
remote_contracts_readme(),
);
queue_write(
pending_writes,
package_root.join("contracts/events/.gitkeep"),
String::new(),
);
queue_write(
pending_writes,
package_root.join("contracts/runtime-functions/.gitkeep"),
String::new(),
);
queue_console_package_files(pending_writes, context)?;
Ok(())
}
fn remote_manifest_json(context: &ConsolePackageContext) -> Value {
json!({
"admin": {
"entities": [
{
"fields": [
{
"field_type": { "kind": "string" },
"label": "Email",
"name": "email",
"nullable": false,
},
{
"field_type": { "kind": "string" },
"label": "Name",
"name": "name",
"nullable": false,
},
{
"field_type": { "kind": "timestamp" },
"label": "Created At",
"name": "created_at",
"nullable": false,
},
],
"label": "Contacts",
"name": "contacts",
"read_capability": context.capability,
},
],
"kind": "schema",
},
"capabilities": [context.capability],
"console": [
{
"area": context.area,
"icon": context.icon,
"label": context.label,
"name": context.surface_name,
"navigation": {
"order": 10,
"workspace": {
"icon": context.icon,
"id": context.module_id,
"label": context.label,
},
},
"package": {
"export": context.module_name,
"name": context.package_name,
},
"required_capabilities": [context.capability],
"route": context.route,
},
],
"http_routes": [
{
"capability": context.capability,
"display_name": "Fetch Contact",
"method": "GET",
"path": "/contacts/{id}",
"story_title": "Fetch Contact",
},
],
"lifecycle": {
"activation_jobs": [
{
"function_name": format!("{}.contacts.enrich.v1", context.module_id),
"input": { "reason": "worker_startup" },
"name": "sync contacts on startup",
"required": true,
"run_policy": "every_startup",
},
],
"startup_checks": [
{
"function_name": format!("{}.contacts.enrich.v1", context.module_id),
"kind": "function_registered",
"name": "contacts enrich function is registered",
"required": true,
},
],
},
"name": context.module_id,
"runtime": {
"functions": [
{
"input_schema": format!("{}.contacts.enrich.v1", context.module_id),
"name": format!("{}.contacts.enrich.v1", context.module_id),
"queue": context.module_id,
"retry_policy": {
"initial_delay_ms": 1000,
"max_attempts": 3,
},
"version": 1,
},
],
},
"source": "remote",
"version": "0.1.0",
})
}
fn remote_catalog_entry_json(context: &ConsolePackageContext) -> Value {
json!({
"baseUrl": "https://example.com/lenso/module/v1",
"consolePackages": [
{
"exportName": context.module_name,
"packageName": context.package_name,
"route": context.route,
},
],
"manifestReference": "https://example.com/lenso/module/v1/manifest",
"name": context.module_id,
"source": "remote",
"summary": format!("{} workspace and operations", context.label),
"version": "0.1.0",
})
}
fn remote_root_package_json(module_id: &str) -> Result<String> {
json_string_pretty(&json!({
"name": format!("lenso-{module_id}"),
"private": true,
"scripts": {
"check": "pnpm --dir backend check && pnpm --dir console check",
"dev": "pnpm --dir backend dev",
"smoke": "pnpm --dir backend smoke",
},
"type": "module",
"version": "0.1.0",
}))
}
fn remote_package_readme(module_id: &str, package_root_name: &str) -> String {
format!(
r#"# {}
Remote Lenso module package scaffold.
## Shape
- `lenso.module.json`: install-time module manifest.
- `catalog-entry.json`: optional local catalog entry for discovery.
- `backend/`: remote module backend implementation.
- `console/`: optional Runtime Console package.
- `contracts/`: module-owned event and runtime-function contracts.
## Local
```sh
pnpm dev
pnpm smoke
pnpm check
```
## Install
Expose the remote module protocol from a stable base URL such as:
```text
GET https://example.com/lenso/module/v1/manifest
```
Use `catalog-entry.json` as the local discovery record, or add the manifest
URL directly:
```sh
lenso module install https://example.com/lenso/module/v1/manifest
```
If you want it to appear in Available Modules before installing it, add a local
catalog entry:
```sh
lenso module catalog add https://example.com/lenso/module/v1/manifest
```
If the manifest is inspected from a local file, provide the runtime base URL:
```sh
lenso module install ./lenso.module.json --base-url https://example.com/lenso/module/v1
```
Optional install-time host work can be declared in `lenso.module.json`:
```json
{{
"install": {{
"env": {{
"{}_API_BASE_URL": "https://example.com"
}},
"commands": [
{{ "command": "pnpm --dir ../lenso-runtime-console install" }}
],
"services": [
{{
"name": "{}-api",
"command": "pnpm --dir ../{}-backend dev",
"readyUrl": "https://example.com/lenso/module/v1/manifest",
"autoStart": true
}}
]
}}
}}
```
Env values are written to the host `.env`. Commands are recorded in the install
plan and only run when the operator passes `--run-install-commands`. Services
are stored in `.lenso/module-services.json` and started before the host loads
remote modules on API/worker startup.
This scaffold lives in `{package_root_name}` and should stay separate from a
host application's linked `modules/` workspace.
"#,
title_case(module_id),
module_id.replace('-', "_").to_ascii_uppercase(),
module_id,
module_id
)
}
fn remote_backend_readme(module_id: &str) -> String {
format!(
r#"# Remote module backend
The generated Node server exposes the {module_id} manifest at:
```text
GET /lenso/module/v1/manifest
```
Run it locally:
```sh
cd backend
pnpm install
pnpm dev
```
Replace `src/server.mjs` with the language or framework you prefer as the
module grows.
The backend should expose the remote module protocol expected by
`platform-module-remote`, including a stable manifest endpoint and any declared
schema-admin, action, HTTP proxy, or runtime-function endpoints.
The host owns auth, capability enforcement, proxy policy, runtime queues,
retries, Runtime Stories, and Technical Operations records.
"#
)
}
fn remote_backend_package_json(module_id: &str) -> Result<String> {
json_string_pretty(&json!({
"dependencies": {
"@lenso/remote-module-kit": "^0.1.0",
},
"name": format!("{module_id}-remote-backend"),
"private": true,
"scripts": {
"check": "node src/smoke.mjs",
"dev": "node src/server.mjs",
"smoke": "node src/smoke.mjs",
"start": "node src/server.mjs",
},
"type": "module",
"version": "0.1.0",
}))
}
fn remote_backend_server(context: &ConsolePackageContext) -> String {
format!(
r#"import {{
defineRemoteModule,
defineSchemaEntity,
everyStartup,
getRoute,
lifecycle,
runtimeFunction,
schemaAdmin,
serveRemoteModule,
textField,
timestampField,
}} from "@lenso/remote-module-kit";
const contacts = [
{{
id: "contact_1",
created_at: "2026-01-01T00:00:00Z",
email: "ada@example.com",
name: "Ada Lovelace",
}},
{{
id: "contact_2",
created_at: "2026-01-02T00:00:00Z",
email: "grace@example.com",
name: "Grace Hopper",
}},
];
const contactsEntity = defineSchemaEntity({{
fields: [textField("email"), textField("name"), timestampField("created_at")],
label: "Contacts",
name: "contacts",
readCapability: {},
}});
const module = defineRemoteModule({{
admin: schemaAdmin([contactsEntity]),
capabilities: [{}],
console: [
{{
area: {},
icon: {},
label: {},
name: {},
navigation: {{
order: 10,
workspace: {{
icon: {},
id: {},
label: {},
}},
}},
package: {{
export: {},
name: {},
}},
required_capabilities: [{}],
route: {},
}},
],
httpRoutes: [
getRoute("/contacts/{{id}}", {{
capability: {},
displayName: "Fetch Contact",
storyTitle: "Fetch Contact",
}}),
],
lifecycle: lifecycle({{
activationJobs: [
everyStartup(
"sync contacts on startup",
{},
{{
input: {{ reason: "worker_startup" }},
}}
),
],
startupChecks: [
{{
function_name: {},
kind: "function_registered",
name: "contacts enrich function is registered",
required: true,
}},
],
}}),
name: {},
runtimeFunctions: [
runtimeFunction({}, {{
inputSchema: {},
queue: {},
retryPolicy: {{
initial_delay_ms: 1000,
max_attempts: 3,
}},
version: 1,
}}),
],
version: "0.1.0",
}});
await serveRemoteModule(module, {{
data: {{
contacts: {{
detail: async (id) => contacts.find((contact) => contact.id === id),
list: async ({{ limit }}) => ({{
next_cursor: null,
records: contacts.slice(0, limit),
}}),
}},
}},
http: {{
"GET /contacts/{{id}}": ({{ params }}) =>
contacts.find((contact) => contact.id === params.id) ?? null,
}},
runtime: {{
{}: ({{ input }}) => {{
const contactId = input?.contact_id;
const contact = contacts.find((item) => item.id === contactId);
return {{
contact,
enriched: Boolean(contact),
source: {},
}};
}},
}},
port: Number(process.env.PORT ?? 4100),
onReady: ({{ manifestUrl }}) => {{
console.log({} + manifestUrl);
}},
}});
"#,
ts_string_literal_lossy(&context.capability),
ts_string_literal_lossy(&context.capability),
ts_string_literal_lossy(&context.area),
ts_string_literal_lossy(&context.icon),
ts_string_literal_lossy(&context.label),
ts_string_literal_lossy(&context.surface_name),
ts_string_literal_lossy(&context.icon),
ts_string_literal_lossy(&context.module_id),
ts_string_literal_lossy(&context.label),
ts_string_literal_lossy(&context.module_name),
ts_string_literal_lossy(&context.package_name),
ts_string_literal_lossy(&context.capability),
ts_string_literal_lossy(&context.route),
ts_string_literal_lossy(&context.capability),
ts_string_literal_lossy(&format!("{}.contacts.enrich.v1", context.module_id)),
ts_string_literal_lossy(&format!("{}.contacts.enrich.v1", context.module_id)),
ts_string_literal_lossy(&context.module_id),
ts_string_literal_lossy(&format!("{}.contacts.enrich.v1", context.module_id)),
ts_string_literal_lossy(&format!("{}.contacts.enrich.v1", context.module_id)),
ts_string_literal_lossy(&context.module_id),
ts_string_literal_lossy(&format!("{}.contacts.enrich.v1", context.module_id)),
ts_string_literal_lossy(&context.module_id),
ts_string_literal_lossy(&format!("{} manifest: ", context.module_id)),
)
}
fn remote_backend_smoke(module_id: &str) -> String {
format!(
r#"import {{ spawn }} from "node:child_process";
const childProcess = spawn(process.execPath, ["src/server.mjs"], {{
env: {{ ...process.env, PORT: "0" }},
stdio: ["ignore", "pipe", "inherit"],
}});
const timeout = setTimeout(() => childProcess.kill(), 3000);
try {{
let manifestUrl = "";
for await (const chunk of childProcess.stdout) {{
manifestUrl = String(chunk).match(new RegExp("http://\\S+", "u"))?.[0] ?? "";
if (manifestUrl) {{
break;
}}
}}
if (!manifestUrl) {{
throw new Error("manifest URL was not printed");
}}
const manifest = await fetch(manifestUrl).then((response) => response.json());
if (manifest.name !== {} || manifest.source !== "remote") {{
throw new Error("manifest response did not match {module_id}");
}}
const moduleBaseUrl = manifestUrl.slice(0, -"/manifest".length);
const contact = await fetch(moduleBaseUrl + "/contacts/contact_1").then(
(response) => response.json()
);
if (contact.email !== "ada@example.com") {{
throw new Error("HTTP route response did not match {module_id}");
}}
const runtimeResult = await fetch(
moduleBaseUrl + "/runtime/functions/{module_id}.contacts.enrich.v1/invoke",
{{
body: JSON.stringify({{
actor: {{ id: "worker", kind: "service", scopes: [] }},
attempt: 1,
correlation_id: "corr_1",
function_name: "{module_id}.contacts.enrich.v1",
function_run_id: "fnrun_1",
input: {{ contact_id: "contact_1" }},
request_id: "req_1",
trace: {{ span_id: "span_1", trace_id: "trace_1" }},
}}),
headers: {{ "content-type": "application/json" }},
method: "POST",
}}
).then((response) => response.json());
if (!runtimeResult.output?.enriched) {{
throw new Error("runtime function response did not match {module_id}");
}}
console.log("{module_id} backend smoke passed");
}} finally {{
clearTimeout(timeout);
childProcess.kill();
}}
"#,
ts_string_literal_lossy(module_id)
)
}
fn remote_contracts_readme() -> String {
"# Module-owned contracts\n\nKeep event and runtime-function JSON Schema contracts here.\n\nThe host may validate these before installing or enabling a remote module.\n".to_owned()
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ConsolePackagePlanItem {
export_name: String,
package_name: String,
}
#[derive(Debug)]
struct RuntimeConsolePaths {
manifest_exports_path: PathBuf,
module_exports_path: PathBuf,
oxlint_config_path: PathBuf,
package_json_path: PathBuf,
tsconfig_path: PathBuf,
}
fn repo_paths(repo_root: &Path) -> RepoPaths {
RepoPaths {
lenso_bootstrap_cargo_toml_path: repo_root.join("crates/lenso-bootstrap/Cargo.toml"),
lenso_bootstrap_lib_path: repo_root.join("crates/lenso-bootstrap/src/lib.rs"),
cargo_toml_path: repo_root.join("Cargo.toml"),
}
}
fn queue_write(pending_writes: &mut PendingWrites, file_path: PathBuf, contents: String) {
pending_writes.insert(file_path, contents);
}
fn write_pending_files(pending_writes: &PendingWrites) -> Result<()> {
for (file_path, contents) in pending_writes {
write_file(file_path, contents.as_bytes())?;
}
Ok(())
}
fn json_string_pretty(value: &Value) -> Result<String> {
let mut contents = serde_json::to_string_pretty(value)?;
contents.push('\n');
Ok(contents)
}
fn append_json_string(items: &mut Vec<Value>, item: &str) {
if items.iter().any(|value| value.as_str() == Some(item)) {
return;
}
items.push(Value::String(item.to_owned()));
}
fn append_token(value: &str, token: &str, before_token: &str) -> String {
let mut tokens = value.split(' ').collect::<Vec<_>>();
if tokens.contains(&token) {
return value.to_owned();
}
let insert_index = tokens
.iter()
.position(|candidate| *candidate == before_token)
.unwrap_or(tokens.len());
tokens.insert(insert_index, token);
tokens.join(" ")
}
fn insert_before_first_needle(file_source: &str, entry: &str, needles: &[&str]) -> Result<String> {
if file_source.contains(entry.trim()) {
return Ok(file_source.to_owned());
}
for needle in needles {
if file_source.contains(needle) {
return insert_before_needle(file_source, entry, needle);
}
}
Ok(format!("{}\n{entry}", file_source.trim_end()))
}
fn insert_into_demo_linked_module_entries(file_source: &str, entry: &str) -> Result<String> {
if file_source.contains(entry.trim()) {
return Ok(file_source.to_owned());
}
let entries_start = file_source
.find("const DEMO_LINKED_MODULE_ENTRIES")
.ok_or_else(|| anyhow!("Could not find DEMO_LINKED_MODULE_ENTRIES in lenso-bootstrap"))?;
let entries_end = file_source[entries_start..]
.find("];")
.map(|index| entries_start + index)
.ok_or_else(|| anyhow!("Could not find DEMO_LINKED_MODULE_ENTRIES closing bracket"))?;
Ok(format!(
"{}{}{}",
&file_source[..entries_end],
entry,
&file_source[entries_end..]
))
}
fn slugify(value: &str) -> String {
let mut output = String::new();
let mut last_was_dash = false;
for character in value.trim().chars().flat_map(char::to_lowercase) {
if character.is_ascii_alphanumeric() {
output.push(character);
last_was_dash = false;
} else if !last_was_dash && !output.is_empty() {
output.push('-');
last_was_dash = true;
}
}
output.trim_matches('-').to_owned()
}
fn snake_case(value: &str) -> String {
value.replace('-', "_")
}
fn camel_case(value: &str) -> String {
let mut output = String::new();
let mut uppercase_next = false;
for character in value.chars() {
if character == '-' {
uppercase_next = true;
} else if uppercase_next {
output.extend(character.to_uppercase());
uppercase_next = false;
} else {
output.push(character);
}
}
output
}
fn pascal_case(value: &str) -> String {
let camel = camel_case(value);
let mut chars = camel.chars();
let Some(first) = chars.next() else {
return String::new();
};
format!("{}{}", first.to_uppercase(), chars.collect::<String>())
}
fn export_stem_from_package_slug(package_slug: &str) -> String {
let normalized = package_slug
.strip_suffix("-console")
.unwrap_or(package_slug);
format!("{}Console", camel_case(normalized))
}
fn rust_console_area(area_name: &str) -> Result<&'static str> {
match area_name {
"configuration" => Ok("Configuration"),
"data" => Ok("Data"),
"operations" => Ok("Operations"),
"runtime" => Ok("Runtime"),
other => bail!("Unsupported console surface area: {other}"),
}
}
fn title_case(value: &str) -> String {
value
.split('-')
.map(|part| {
let mut chars = part.chars();
let Some(first) = chars.next() else {
return String::new();
};
format!("{}{}", first.to_uppercase(), chars.collect::<String>())
})
.collect::<Vec<_>>()
.join(" ")
}
fn default_icon(area_name: &str) -> &'static str {
if area_name == "runtime" {
"workflow"
} else {
"database"
}
}
fn rust_string_literal(value: &str) -> String {
format!("{value:?}")
}
fn ts_string_literal(value: &str) -> Result<String> {
serde_json::to_string(value).context("serialize TypeScript string literal")
}
fn ts_string_literal_lossy(value: &str) -> String {
ts_string_literal(value).unwrap_or_else(|_| "\"\"".to_owned())
}
fn validate_remote_module_manifest(manifest: Value) -> Result<Value> {
if !manifest.is_object() {
bail!("Remote module manifest must be a JSON object");
}
let name = string_field(&manifest, "name")?;
if name.trim().is_empty() {
bail!("Remote module manifest name is required");
}
let version = string_field(&manifest, "version")?;
if version.trim().is_empty() {
bail!("Remote module manifest version is required");
}
if manifest.get("source").and_then(Value::as_str) != Some("remote") {
bail!("Remote module manifest source must be remote");
}
if !manifest.get("capabilities").is_some_and(Value::is_array) {
bail!("Remote module manifest capabilities must be an array");
}
if !manifest.get("console").is_some_and(Value::is_array) {
bail!("Remote module manifest console must be an array");
}
Ok(manifest)
}
async fn read_json_reference(reference: &str) -> Result<Value> {
if reference.starts_with("http://") || reference.starts_with("https://") {
let response = reqwest::get(reference)
.await
.with_context(|| format!("fetch module manifest {reference}"))?;
if !response.status().is_success() {
bail!(
"Failed to fetch module manifest: {} {}",
response.status().as_u16(),
response.status().canonical_reason().unwrap_or("")
);
}
return response
.json::<Value>()
.await
.context("parse remote module manifest JSON");
}
let path = if let Some(file_path) = reference.strip_prefix("file://") {
PathBuf::from(file_path)
} else {
PathBuf::from(reference)
};
read_json(&path)
}
fn derive_remote_base_url(base_url: Option<&str>, manifest_reference: &str) -> Result<String> {
if let Some(base_url) = base_url {
return Ok(trim_trailing_slashes(base_url));
}
if manifest_reference.starts_with("http://") || manifest_reference.starts_with("https://") {
let mut url = reqwest::Url::parse(manifest_reference)
.with_context(|| format!("parse manifest URL {manifest_reference}"))?;
if url.path().ends_with("/manifest") {
let next_path = url.path().trim_end_matches("/manifest").to_owned();
url.set_path(&next_path);
url.set_query(None);
url.set_fragment(None);
return Ok(trim_trailing_slashes(url.as_str()));
}
}
bail!("Remote module base URL is required unless the manifest URL ends with /manifest");
}
fn update_remote_modules_env(
env_file_path: &Path,
module_name: &str,
base_url: &str,
) -> Result<String> {
let source = read_text_if_exists(env_file_path)?;
let current_value = source
.lines()
.find_map(|line| line.strip_prefix("REMOTE_MODULES="))
.unwrap_or_default();
let mut entries = parse_remote_module_entries(current_value);
entries.retain(|(name, _)| name != module_name);
entries.push((module_name.to_owned(), base_url.to_owned()));
Ok(upsert_env_value(
&source,
"REMOTE_MODULES",
&format_remote_module_entries(&entries),
))
}
fn remove_remote_module_from_env(
env_file_path: &Path,
module_name: &str,
) -> Result<Option<String>> {
if !env_file_path.exists() {
return Ok(None);
}
Ok(remove_remote_module_from_env_source(
&read_text(env_file_path)?,
module_name,
))
}
fn remove_remote_module_from_env_source(source: &str, module_name: &str) -> Option<String> {
let current_value = source
.lines()
.find_map(|line| line.strip_prefix("REMOTE_MODULES="))?;
let mut entries = parse_remote_module_entries(current_value);
let original_len = entries.len();
entries.retain(|(name, _)| name != module_name);
if entries.len() == original_len {
return None;
}
let next_value = format_remote_module_entries(&entries);
Some(if next_value.is_empty() {
remove_env_value(source, "REMOTE_MODULES")
} else {
upsert_env_value(source, "REMOTE_MODULES", &next_value)
})
}
fn remote_module_install_state_exists(
module_name: &str,
env_file_path: &Path,
install_plan_path: &Path,
module_services_path: &Path,
) -> Result<bool> {
let env_source = read_text_if_exists(env_file_path)?;
if remote_module_entries_from_env_source(&env_source)
.iter()
.any(|(name, _)| name == module_name)
{
return Ok(true);
}
if read_json_if_exists(install_plan_path)?
.as_ref()
.is_some_and(|plan| install_plan_has_module(plan, module_name))
{
return Ok(true);
}
Ok(read_remote_module_service_states(module_services_path)?
.iter()
.any(|state| state.module_name == module_name))
}
fn install_plan_has_module(plan: &Value, module_name: &str) -> bool {
plan.get("modules")
.and_then(Value::as_array)
.is_some_and(|modules| {
modules
.iter()
.any(|module| module.get("moduleName").and_then(Value::as_str) == Some(module_name))
})
}
fn update_module_install_ledger(ledger_path: &Path, entry: Value) -> Result<Value> {
let module_name = entry
.get("moduleName")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("Module install ledger entry moduleName is required"))?;
let mut ledger =
read_json_if_exists(ledger_path)?.unwrap_or_else(|| json!({ "modules": [], "version": 1 }));
let modules = ledger
.get_mut("modules")
.and_then(Value::as_array_mut)
.ok_or_else(|| anyhow!("Module install ledger modules must be an array"))?;
modules.retain(|module| module.get("moduleName").and_then(Value::as_str) != Some(module_name));
modules.push(entry);
Ok(json!({ "modules": modules.clone(), "version": 1 }))
}
fn remove_module_install_ledger_module(
ledger_path: &Path,
module_name: &str,
) -> Result<Option<Value>> {
read_json_if_exists(ledger_path)?.map_or(Ok(None), |ledger| {
remove_module_install_ledger_module_value(ledger, module_name)
})
}
fn remove_module_install_ledger_modules(
ledger_path: &Path,
module_names: &[String],
) -> Result<Option<Value>> {
read_json_if_exists(ledger_path)?.map_or(Ok(None), |mut ledger| {
let modules = ledger
.get_mut("modules")
.and_then(Value::as_array_mut)
.ok_or_else(|| anyhow!("Module install ledger modules must be an array"))?;
let original_len = modules.len();
modules.retain(|module| {
let Some(module_name) = module.get("moduleName").and_then(Value::as_str) else {
return true;
};
!module_names.iter().any(|name| name == module_name)
});
if modules.len() == original_len {
return Ok(None);
}
Ok(Some(json!({ "modules": modules.clone(), "version": 1 })))
})
}
fn remove_module_install_ledger_module_value(
mut ledger: Value,
module_name: &str,
) -> Result<Option<Value>> {
let modules = ledger
.get_mut("modules")
.and_then(Value::as_array_mut)
.ok_or_else(|| anyhow!("Module install ledger modules must be an array"))?;
let original_len = modules.len();
modules.retain(|module| module.get("moduleName").and_then(Value::as_str) != Some(module_name));
if modules.len() == original_len {
return Ok(None);
}
Ok(Some(json!({ "modules": modules.clone(), "version": 1 })))
}
fn set_linked_module_enabled_ledger(
ledger_path: &Path,
module_name: &str,
enabled: bool,
env_path: &str,
) -> Result<Value> {
let Some(mut ledger) = read_json_if_exists(ledger_path)? else {
return update_module_install_ledger(
ledger_path,
simple_linked_module_install_ledger_entry(module_name, enabled, env_path),
);
};
let modules = ledger
.get_mut("modules")
.and_then(Value::as_array_mut)
.ok_or_else(|| anyhow!("Module install ledger modules must be an array"))?;
if let Some(module) = modules
.iter_mut()
.find(|module| module.get("moduleName").and_then(Value::as_str) == Some(module_name))
{
module
.as_object_mut()
.ok_or_else(|| anyhow!("Module install ledger entries must be objects"))?
.insert("enabled".to_owned(), json!(enabled));
return Ok(json!({ "modules": modules.clone(), "version": 1 }));
}
modules.push(simple_linked_module_install_ledger_entry(
module_name,
enabled,
env_path,
));
Ok(json!({ "modules": modules.clone(), "version": 1 }))
}
fn module_install_ledger_source(
ledger_path: &Path,
module_name: &str,
) -> Result<Option<ModuleSource>> {
let Some(ledger) = read_json_if_exists(ledger_path)? else {
return Ok(None);
};
let source = ledger
.get("modules")
.and_then(Value::as_array)
.and_then(|modules| {
modules.iter().find(|module| {
module.get("moduleName").and_then(Value::as_str) == Some(module_name)
})
})
.and_then(|module| module.get("source"))
.and_then(Value::as_str);
source.map(parse_module_source).transpose()
}
fn linked_module_uninstall_call(ledger_path: &Path, module_name: &str) -> Result<Option<String>> {
if let Some(call) = read_json_if_exists(ledger_path)?
.as_ref()
.and_then(|ledger| {
ledger
.get("modules")
.and_then(Value::as_array)
.and_then(|modules| {
modules.iter().find(|module| {
module.get("moduleName").and_then(Value::as_str) == Some(module_name)
})
})
.and_then(|module| module.get("linked"))
.and_then(|linked| linked.get("call"))
.and_then(Value::as_str)
})
{
return Ok(Some(call.to_owned()));
}
linked_module_uninstall_call_from_builtin(module_name)
}
fn linked_module_uninstall_call_from_builtin(module_name: &str) -> Result<Option<String>> {
Ok(builtin_linked_module_descriptor(module_name)
.map(|descriptor| string_field(&descriptor["linked"], "call").map(ToOwned::to_owned))
.transpose()?)
}
fn remove_linked_module_from_host_lib_source(source: &str, call: &str) -> Option<String> {
let entry = format!(".linked_module({call})");
let lines = source
.lines()
.filter(|line| !line.trim().starts_with(&entry))
.collect::<Vec<_>>();
(lines.len() != source.lines().count()).then(|| format!("{}\n", lines.join("\n")))
}
fn remove_linked_modules_from_host_lib_source(source: &str, calls: &[String]) -> Option<String> {
let mut current = source.to_owned();
let mut changed = false;
for call in calls {
if let Some(next) = remove_linked_module_from_host_lib_source(¤t, call) {
current = next;
changed = true;
}
}
changed.then_some(current)
}
fn linked_modules_to_uninstall(
module_name: &str,
ledger: Option<&Value>,
env_source: &str,
host_lib_source: &str,
) -> Result<Vec<String>> {
let mut modules = Vec::new();
collect_linked_dependents_to_uninstall(
module_name,
ledger,
env_source,
host_lib_source,
&mut modules,
)?;
if !modules.iter().any(|candidate| candidate == module_name) {
modules.push(module_name.to_owned());
}
Ok(modules)
}
fn collect_linked_dependents_to_uninstall(
module_name: &str,
ledger: Option<&Value>,
env_source: &str,
host_lib_source: &str,
modules: &mut Vec<String>,
) -> Result<()> {
for dependent in builtin_linked_module_dependents(module_name)? {
if !linked_module_is_installed(&dependent, ledger, env_source, host_lib_source)? {
continue;
}
collect_linked_dependents_to_uninstall(
&dependent,
ledger,
env_source,
host_lib_source,
modules,
)?;
if !modules.iter().any(|module| module == &dependent) {
modules.push(dependent);
}
}
Ok(())
}
fn builtin_linked_module_dependents(module_name: &str) -> Result<Vec<String>> {
builtin_linked_module_names()
.iter()
.filter_map(|candidate| {
let descriptor = builtin_linked_module_descriptor(candidate)?;
let dependencies = descriptor.get("dependencies").and_then(Value::as_array)?;
dependencies
.iter()
.filter_map(Value::as_str)
.any(|dependency| dependency == module_name)
.then(|| Ok((*candidate).to_owned()))
})
.collect()
}
fn linked_module_is_installed(
module_name: &str,
ledger: Option<&Value>,
env_source: &str,
host_lib_source: &str,
) -> Result<bool> {
if ledger.is_some_and(|ledger| {
ledger
.get("modules")
.and_then(Value::as_array)
.is_some_and(|modules| {
modules.iter().any(|module| {
module.get("moduleName").and_then(Value::as_str) == Some(module_name)
&& module.get("source").and_then(Value::as_str) == Some("linked")
})
})
}) {
return Ok(true);
}
if linked_module_enabled_env_exists(env_source, module_name) {
return Ok(true);
}
Ok(linked_module_uninstall_call_from_builtin(module_name)?
.as_deref()
.is_some_and(|call| host_lib_source.contains(&format!(".linked_module({call})"))))
}
fn remote_module_install_ledger_entry(
module_name: &str,
manifest_reference: &str,
base_url: &str,
writes: Vec<Value>,
install_env: &[(String, String)],
install_commands: &[InstallCommandSpec],
install_services: &[RemoteModuleServiceInstallSpec],
console_package_count: usize,
) -> Value {
json!({
"baseUrl": base_url,
"enabled": true,
"install": {
"commands": install_command_receipts(install_commands),
"consolePackages": console_package_count,
"env": install_env_receipts(install_env),
"services": install_service_receipts(install_services),
},
"manifestReference": manifest_reference,
"moduleName": module_name,
"source": "remote",
"writes": writes,
})
}
fn linked_module_install_ledger_entry(
module_name: &str,
descriptor_reference: &str,
call: &str,
dependencies: &[String],
writes: Vec<Value>,
cargo_toml_changed: bool,
) -> Value {
let manifest_reference = if builtin_linked_module_descriptor(descriptor_reference).is_some() {
format!("builtin:{descriptor_reference}")
} else {
descriptor_reference.to_owned()
};
json!({
"dependencies": dependencies,
"enabled": true,
"linked": {
"call": call,
"cargoTomlChanged": cargo_toml_changed,
},
"manifestReference": manifest_reference,
"moduleName": module_name,
"source": "linked",
"writes": writes,
})
}
fn simple_linked_module_install_ledger_entry(
module_name: &str,
enabled: bool,
env_path: &str,
) -> Value {
json!({
"enabled": enabled,
"manifestReference": format!("linked:{module_name}"),
"moduleName": module_name,
"source": "linked",
"writes": [
{ "kind": "env", "key": linked_module_enabled_env_key(module_name), "path": env_path }
],
})
}
fn remote_module_install_writes(
repo_root: &Path,
env_file_path: &Path,
install_plan_path: &Path,
module_services_path: Option<&Path>,
) -> Vec<Value> {
let mut writes = vec![
json!({
"kind": "env",
"key": "REMOTE_MODULES",
"path": display_relative(repo_root, env_file_path),
}),
json!({
"kind": "consolePackageInstallPlan",
"path": display_relative(repo_root, install_plan_path),
}),
];
if let Some(module_services_path) = module_services_path {
writes.push(json!({
"kind": "moduleServices",
"path": display_relative(repo_root, module_services_path),
}));
}
writes
}
fn linked_module_install_writes(
repo_root: &Path,
env_file_path: &Path,
cargo_toml_path: Option<&Path>,
host_lib_path: &Path,
) -> Vec<Value> {
let mut writes = vec![json!({
"kind": "env",
"path": display_relative(repo_root, env_file_path),
})];
if let Some(cargo_toml_path) = cargo_toml_path {
writes.push(json!({
"kind": "cargoToml",
"path": display_relative(repo_root, cargo_toml_path),
}));
}
writes.push(json!({
"kind": "hostComposition",
"path": display_relative(repo_root, host_lib_path),
}));
writes
}
fn install_env_receipts(install_env: &[(String, String)]) -> Vec<Value> {
install_env
.iter()
.map(|(key, _)| json!({ "key": key }))
.collect()
}
fn install_command_receipts(install_commands: &[InstallCommandSpec]) -> Vec<Value> {
install_commands
.iter()
.map(|command| {
json!({
"command": &command.command,
"cwd": command.cwd.as_deref().unwrap_or("."),
})
})
.collect()
}
fn install_service_receipts(install_services: &[RemoteModuleServiceInstallSpec]) -> Vec<Value> {
install_services
.iter()
.map(|service| {
json!({
"autoStart": service.auto_start,
"command": &service.command,
"cwd": service.cwd.as_deref().unwrap_or("."),
"name": &service.name,
"readyTimeoutMs": service.ready_timeout_ms,
"readyUrl": &service.ready_url,
})
})
.collect()
}
fn set_linked_module_enabled(
module_name: &str,
enabled: bool,
env_file: Option<PathBuf>,
repo_root: Option<PathBuf>,
dry_run: bool,
) -> Result<()> {
let module_name = slugify(module_name);
if module_name.is_empty() {
bail!("Module name is required");
}
let repo_root = resolve_repo_root(repo_root.as_deref())?;
let env_file_path = resolve_path(
&repo_root,
env_file.as_deref().unwrap_or_else(|| Path::new(".env")),
);
let install_ledger_path = repo_root.join(MODULE_INSTALL_LEDGER_PATH);
let key = linked_module_enabled_env_key(&module_name);
let value = if enabled { "true" } else { "false" };
let env_file =
set_linked_module_enabled_env(&read_text_if_exists(&env_file_path)?, &module_name, enabled);
let install_ledger = set_linked_module_enabled_ledger(
&install_ledger_path,
&module_name,
enabled,
&display_relative(&repo_root, &env_file_path),
)?;
if dry_run {
let action = if enabled { "install" } else { "uninstall" };
println!("Linked module {action} dry run:");
println!("- {}", display_relative(&repo_root, &env_file_path));
println!("- {}", display_relative(&repo_root, &install_ledger_path));
println!("- {key}={value}");
return Ok(());
}
write_file(&env_file_path, env_file.as_bytes())?;
write_json(&install_ledger_path, &install_ledger)?;
if enabled {
println!("Enabled linked module {module_name}.");
} else {
println!("Disabled linked module {module_name}.");
}
println!("Next steps:");
println!("- restart the API and worker");
Ok(())
}
fn uninstall_linked_module(module_name: &str, options: RemoteModuleUninstallOptions) -> Result<()> {
let module_name = slugify(module_name);
if module_name.is_empty() {
bail!("Module name is required");
}
let repo_root = resolve_repo_root(options.repo_root.as_deref())?;
let env_file_path = resolve_path(
&repo_root,
options
.env_file
.as_deref()
.unwrap_or_else(|| Path::new(".env")),
);
let host_lib_path = repo_root.join("src/lib.rs");
let install_ledger_path = repo_root.join(MODULE_INSTALL_LEDGER_PATH);
let ledger = read_json_if_exists(&install_ledger_path)?;
let env_source = read_text_if_exists(&env_file_path)?;
let host_lib_source = read_text_if_exists(&host_lib_path)?;
let modules =
linked_modules_to_uninstall(&module_name, ledger.as_ref(), &env_source, &host_lib_source)?;
let mut calls = Vec::new();
for module_name in &modules {
if let Some(call) = linked_module_uninstall_call(&install_ledger_path, module_name)? {
calls.push(call);
}
}
let env_file = modules
.iter()
.fold(env_source.clone(), |source, module_name| {
remove_env_value(&source, &linked_module_enabled_env_key(module_name))
});
let env_file = (env_file != env_source).then_some(env_file);
let host_lib = remove_linked_modules_from_host_lib_source(&host_lib_source, &calls);
let install_ledger = remove_module_install_ledger_modules(&install_ledger_path, &modules)?;
if options.dry_run {
println!("Linked module uninstall dry run:");
if env_file.is_some() {
println!("- {}", display_relative(&repo_root, &env_file_path));
}
if host_lib.is_some() {
println!("- {}", display_relative(&repo_root, &host_lib_path));
}
if install_ledger.is_some() {
println!("- {}", display_relative(&repo_root, &install_ledger_path));
}
for call in calls {
println!("- remove {call}");
}
return Ok(());
}
if let Some(env_file) = env_file {
write_file(&env_file_path, env_file.as_bytes())?;
}
if let Some(host_lib) = host_lib {
write_file(&host_lib_path, host_lib.as_bytes())?;
}
if let Some(install_ledger) = install_ledger {
write_json(&install_ledger_path, &install_ledger)?;
}
println!("Uninstalled linked module(s): {}.", modules.join(", "));
println!("Next steps:");
println!("- restart the API and worker");
Ok(())
}
fn set_linked_module_enabled_env(source: &str, module_name: &str, enabled: bool) -> String {
upsert_env_value(
source,
&linked_module_enabled_env_key(module_name),
if enabled { "true" } else { "false" },
)
}
fn linked_module_enabled_env_key(module_name: &str) -> String {
format!(
"LENSO_MODULE_{}_ENABLED",
module_name.replace('-', "_").to_ascii_uppercase()
)
}
fn module_enabled_from_env_source(source: &str, module_name: &str) -> bool {
let key = linked_module_enabled_env_key(module_name);
source
.lines()
.find_map(|line| line.strip_prefix(&format!("{key}=")))
.and_then(parse_env_bool)
.unwrap_or(true)
}
fn infer_uninstall_module_source(
module_name: &str,
env_source: &str,
remote_installed: bool,
) -> Result<ModuleSource> {
if remote_installed {
return Ok(ModuleSource::Remote);
}
if builtin_linked_module_descriptor(module_name).is_some()
|| linked_module_enabled_env_exists(env_source, module_name)
{
return Ok(ModuleSource::Linked);
}
Ok(ModuleSource::Remote)
}
fn linked_module_enabled_env_exists(source: &str, module_name: &str) -> bool {
let key = linked_module_enabled_env_key(module_name);
source
.lines()
.any(|line| line.trim_start().starts_with(&format!("{key}=")))
}
fn parse_env_bool(value: &str) -> Option<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
fn run_install_commands(repo_root: &Path, commands: &[InstallCommandSpec]) -> Result<()> {
for command in commands {
let cwd = command
.cwd
.as_deref()
.map(|cwd| resolve_path(repo_root, Path::new(cwd)))
.unwrap_or_else(|| repo_root.to_path_buf());
println!("Running install command: {}", command.command);
let status = shell_command(&command.command)
.current_dir(&cwd)
.status()
.with_context(|| format!("run install command `{}`", command.command))?;
if !status.success() {
bail!("Install command failed: {}", command.command);
}
}
Ok(())
}
fn shell_command(command: &str) -> Command {
if cfg!(windows) {
let mut process = Command::new("cmd");
process.arg("/C").arg(command);
process
} else {
let mut process = Command::new("sh");
process.arg("-c").arg(command);
process
}
}
fn update_console_package_install_plan(
install_plan_path: &Path,
manifest: &Value,
manifest_reference: &str,
base_url: &str,
install_env: &[(String, String)],
install_commands: &[InstallCommandSpec],
install_services: &[RemoteModuleServiceInstallSpec],
install_commands_executed: bool,
) -> Result<Value> {
let module_name = string_field(manifest, "name")?.trim();
let mut plan = read_json_if_exists(install_plan_path)?
.unwrap_or_else(|| json!({ "modules": [], "version": 1 }));
let modules = plan
.get_mut("modules")
.and_then(Value::as_array_mut)
.ok_or_else(|| anyhow!("Console package install plan modules must be an array"))?;
modules.retain(|entry| entry.get("moduleName").and_then(Value::as_str) != Some(module_name));
modules.push(json!({
"baseUrl": base_url,
"consolePackages": remote_module_console_package_plans(manifest)?,
"installCommands": install_command_plans(install_commands, install_commands_executed),
"installEnv": install_env_plans(install_env),
"installServices": install_service_plans(install_services),
"manifestReference": manifest_reference,
"moduleName": module_name,
"restartRequired": true,
}));
Ok(json!({ "modules": modules, "version": 1 }))
}
fn update_remote_module_services_file(
services_file_path: &Path,
module_name: &str,
install_services: &[RemoteModuleServiceInstallSpec],
) -> Result<Option<Value>> {
let existed = services_file_path.exists();
let mut state = read_json_if_exists(services_file_path)?
.unwrap_or_else(|| json!({ "modules": [], "version": 1 }));
let modules = state
.get_mut("modules")
.and_then(Value::as_array_mut)
.ok_or_else(|| anyhow!("Remote module services file modules must be an array"))?;
let original_len = modules.len();
modules.retain(|entry| entry.get("moduleName").and_then(Value::as_str) != Some(module_name));
if !install_services.is_empty() {
modules.push(json!({
"moduleName": module_name,
"services": remote_module_service_plans(install_services),
}));
}
if !existed && modules.is_empty() {
return Ok(None);
}
if existed || original_len != modules.len() || !install_services.is_empty() {
return Ok(Some(json!({ "modules": modules.clone(), "version": 1 })));
}
Ok(None)
}
fn remove_remote_module_services_file_module(
services_file_path: &Path,
module_name: &str,
) -> Result<Option<Value>> {
read_json_if_exists(services_file_path)?.map_or(Ok(None), |mut state| {
let modules = state
.get_mut("modules")
.and_then(Value::as_array_mut)
.ok_or_else(|| anyhow!("Remote module services file modules must be an array"))?;
let original_len = modules.len();
modules
.retain(|entry| entry.get("moduleName").and_then(Value::as_str) != Some(module_name));
if modules.len() == original_len {
return Ok(None);
}
Ok(Some(json!({ "modules": modules.clone(), "version": 1 })))
})
}
fn apply_manifest_install_env(source: String, install_env: &[(String, String)]) -> String {
install_env.iter().fold(source, |source, (key, value)| {
upsert_env_value(&source, key, value)
})
}
fn remote_module_install_env(manifest: &Value) -> Result<Vec<(String, String)>> {
let Some(env) = manifest
.get("install")
.and_then(|install| install.get("env"))
else {
return Ok(Vec::new());
};
let object = env
.as_object()
.ok_or_else(|| anyhow!("Remote module manifest install.env must be an object"))?;
let mut values = Vec::new();
for (key, value) in object {
let key = key.trim();
if key.is_empty() {
bail!("Remote module manifest install.env keys must be non-empty");
}
if key == "REMOTE_MODULES" {
bail!("Remote module manifest install.env must not override REMOTE_MODULES");
}
let value = value
.as_str()
.ok_or_else(|| anyhow!("Remote module manifest install.env.{key} must be a string"))?;
values.push((key.to_owned(), value.to_owned()));
}
Ok(values)
}
fn remote_module_install_commands(manifest: &Value) -> Result<Vec<InstallCommandSpec>> {
let Some(commands) = manifest
.get("install")
.and_then(|install| install.get("commands"))
else {
return Ok(Vec::new());
};
let commands = commands
.as_array()
.ok_or_else(|| anyhow!("Remote module manifest install.commands must be an array"))?;
commands
.iter()
.map(|entry| match entry {
Value::String(command) => install_command_spec(command, None),
Value::Object(object) => {
let command = object
.get("command")
.and_then(Value::as_str)
.ok_or_else(|| {
anyhow!("Remote module manifest install.commands[].command is required")
})?;
let cwd = object
.get("cwd")
.map(|value| {
value.as_str().map(ToOwned::to_owned).ok_or_else(|| {
anyhow!(
"Remote module manifest install.commands[].cwd must be a string"
)
})
})
.transpose()?;
install_command_spec(command, cwd)
}
_ => {
bail!("Remote module manifest install.commands entries must be strings or objects")
}
})
.collect()
}
fn remote_module_install_services(
manifest: &Value,
module_name: &str,
base_url: &str,
) -> Result<Vec<RemoteModuleServiceInstallSpec>> {
let Some(services) = manifest
.get("install")
.and_then(|install| install.get("services"))
else {
return Ok(Vec::new());
};
let services = services
.as_array()
.ok_or_else(|| anyhow!("Remote module manifest install.services must be an array"))?;
services
.iter()
.map(|entry| {
let object = entry.as_object().ok_or_else(|| {
anyhow!("Remote module manifest install.services entries must be objects")
})?;
let command = object
.get("command")
.and_then(Value::as_str)
.ok_or_else(|| {
anyhow!("Remote module manifest install.services[].command is required")
})?
.trim();
if command.is_empty() {
bail!("Remote module manifest install service command must be non-empty");
}
let name = object
.get("name")
.and_then(Value::as_str)
.unwrap_or(module_name)
.trim();
let ready_url = object
.get("readyUrl")
.or_else(|| object.get("ready_url"))
.and_then(Value::as_str)
.map(trim_trailing_slashes)
.unwrap_or_else(|| format!("{}/manifest", trim_trailing_slashes(base_url)));
Ok(RemoteModuleServiceInstallSpec {
name: if name.is_empty() {
module_name.to_owned()
} else {
name.to_owned()
},
command: command.to_owned(),
cwd: object
.get("cwd")
.map(|value| {
value.as_str().map(ToOwned::to_owned).ok_or_else(|| {
anyhow!(
"Remote module manifest install.services[].cwd must be a string"
)
})
})
.transpose()?
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty()),
ready_url,
ready_timeout_ms: object
.get("readyTimeoutMs")
.or_else(|| object.get("ready_timeout_ms"))
.and_then(Value::as_u64)
.unwrap_or(10_000),
auto_start: object
.get("autoStart")
.or_else(|| object.get("auto_start"))
.and_then(Value::as_bool)
.unwrap_or(true),
})
})
.collect()
}
fn install_command_spec(command: &str, cwd: Option<String>) -> Result<InstallCommandSpec> {
let command = command.trim();
if command.is_empty() {
bail!("Remote module manifest install command must be non-empty");
}
Ok(InstallCommandSpec {
command: command.to_owned(),
cwd: cwd
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty()),
})
}
fn install_env_plans(install_env: &[(String, String)]) -> Vec<Value> {
install_env
.iter()
.map(|(key, _)| json!({ "key": key, "status": "written" }))
.collect()
}
fn install_service_plans(install_services: &[RemoteModuleServiceInstallSpec]) -> Vec<Value> {
install_services
.iter()
.map(|service| {
json!({
"autoStart": service.auto_start,
"command": &service.command,
"cwd": service.cwd.as_deref().unwrap_or("."),
"name": &service.name,
"readyTimeoutMs": service.ready_timeout_ms,
"readyUrl": &service.ready_url,
"status": if service.auto_start { "registered" } else { "manual" },
})
})
.collect()
}
fn remote_module_service_plans(install_services: &[RemoteModuleServiceInstallSpec]) -> Vec<Value> {
install_services
.iter()
.map(|service| {
json!({
"autoStart": service.auto_start,
"command": &service.command,
"cwd": service.cwd.as_deref().unwrap_or("."),
"name": &service.name,
"readyTimeoutMs": service.ready_timeout_ms,
"readyUrl": &service.ready_url,
})
})
.collect()
}
fn read_remote_module_service_states(
services_file_path: &Path,
) -> Result<Vec<RemoteModuleServiceState>> {
let Some(value) = read_json_if_exists(services_file_path)? else {
return Ok(Vec::new());
};
parse_remote_module_service_states(&value)
}
fn parse_remote_module_service_states(value: &Value) -> Result<Vec<RemoteModuleServiceState>> {
let modules = value
.get("modules")
.and_then(Value::as_array)
.ok_or_else(|| anyhow!("Remote module services file modules must be an array"))?;
let mut states = Vec::new();
for module in modules {
let module_name = module
.get("moduleName")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("Remote module services file moduleName must be a string"))?
.trim();
if module_name.is_empty() {
bail!("Remote module services file moduleName must be non-empty");
}
let services = module
.get("services")
.and_then(Value::as_array)
.ok_or_else(|| anyhow!("{module_name} services must be an array"))?;
let mut service_specs = Vec::new();
for service in services {
let command = service
.get("command")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("{module_name} service command must be a string"))?
.trim();
if command.is_empty() {
bail!("{module_name} service command must be non-empty");
}
let ready_url = service
.get("readyUrl")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("{module_name} service readyUrl must be a string"))?
.trim();
if ready_url.is_empty() {
bail!("{module_name} service readyUrl must be non-empty");
}
service_specs.push(RemoteModuleServiceInstallSpec {
name: service
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
.unwrap_or(module_name)
.to_owned(),
command: command.to_owned(),
cwd: service
.get("cwd")
.and_then(Value::as_str)
.map(str::trim)
.filter(|cwd| !cwd.is_empty())
.map(ToOwned::to_owned),
ready_url: ready_url.to_owned(),
ready_timeout_ms: service
.get("readyTimeoutMs")
.and_then(Value::as_u64)
.unwrap_or(10_000),
auto_start: service
.get("autoStart")
.and_then(Value::as_bool)
.unwrap_or(true),
});
}
states.push(RemoteModuleServiceState {
module_name: module_name.to_owned(),
services: service_specs,
});
}
Ok(states)
}
async fn remote_service_ready_url(client: &reqwest::Client, ready_url: &str) -> bool {
client
.get(ready_url)
.send()
.await
.is_ok_and(|response| response.status().is_success())
}
fn remote_module_service_doctor_status(
configured: bool,
enabled: bool,
auto_start: bool,
ready: bool,
lock_exists: bool,
pid_exists: bool,
) -> RemoteModuleServiceDoctorStatus {
if !configured {
return RemoteModuleServiceDoctorStatus::NotConfigured;
}
if !enabled {
return RemoteModuleServiceDoctorStatus::Disabled;
}
if ready {
return RemoteModuleServiceDoctorStatus::Ready;
}
if !auto_start {
return RemoteModuleServiceDoctorStatus::ManualNotReady;
}
if lock_exists || pid_exists {
return RemoteModuleServiceDoctorStatus::StaleState;
}
RemoteModuleServiceDoctorStatus::NotReady
}
fn remote_module_service_doctor_fix(
status: RemoteModuleServiceDoctorStatus,
) -> Option<&'static str> {
match status {
RemoteModuleServiceDoctorStatus::Ready => None,
RemoteModuleServiceDoctorStatus::Disabled => {
Some("enable the module if this service should run")
}
RemoteModuleServiceDoctorStatus::ManualNotReady => {
Some("start this service manually or set autoStart=true in the manifest")
}
RemoteModuleServiceDoctorStatus::NotConfigured => {
Some("install the module or remove its service entry")
}
RemoteModuleServiceDoctorStatus::NotReady => {
Some("start the service command or restart the API/worker")
}
RemoteModuleServiceDoctorStatus::StaleState => {
Some("restart the API/worker; remove stale lock/pid files if it remains stuck")
}
}
}
fn remote_module_service_state_path(
services_state_dir: &Path,
module_name: &str,
service: &RemoteModuleServiceInstallSpec,
extension: &str,
) -> PathBuf {
services_state_dir.join(format!(
"remote-{}-{}.{}",
remote_module_service_state_segment(module_name),
remote_module_service_state_segment(&service.name),
extension
))
}
fn remote_module_service_state_segment(value: &str) -> String {
let mut segment = String::new();
let mut previous_dash = false;
for character in value.chars() {
if character.is_ascii_alphanumeric() {
segment.push(character.to_ascii_lowercase());
previous_dash = false;
} else if !segment.is_empty() && !previous_dash {
segment.push('-');
previous_dash = true;
}
}
while segment.ends_with('-') {
segment.pop();
}
if segment.is_empty() {
"service".to_owned()
} else {
segment
}
}
fn install_command_plans(
install_commands: &[InstallCommandSpec],
install_commands_executed: bool,
) -> Vec<Value> {
let status = if install_commands_executed {
"executed"
} else {
"requires_manual_run"
};
install_commands
.iter()
.map(|command| {
json!({
"command": &command.command,
"cwd": command.cwd.as_deref().unwrap_or("."),
"status": status,
})
})
.collect()
}
fn remove_console_package_install_plan_module(
install_plan_path: &Path,
module_name: &str,
) -> Result<Option<Value>> {
read_json_if_exists(install_plan_path)?.map_or(Ok(None), |plan| {
remove_console_package_install_plan_module_value(plan, module_name)
})
}
fn remove_console_package_install_plan_module_value(
mut plan: Value,
module_name: &str,
) -> Result<Option<Value>> {
let version = plan.get("version").cloned().unwrap_or_else(|| json!(1));
let modules = plan
.get_mut("modules")
.and_then(Value::as_array_mut)
.ok_or_else(|| anyhow!("Console package install plan modules must be an array"))?;
let original_len = modules.len();
modules.retain(|entry| entry.get("moduleName").and_then(Value::as_str) != Some(module_name));
if modules.len() == original_len {
return Ok(None);
}
Ok(Some(
json!({ "modules": modules.clone(), "version": version }),
))
}
fn remote_module_console_package_plans(manifest: &Value) -> Result<Vec<Value>> {
let module_name = string_field(manifest, "name")?.trim();
let mut items = Vec::new();
for surface in manifest
.get("console")
.and_then(Value::as_array)
.ok_or_else(|| anyhow!("Remote module manifest console must be an array"))?
{
let package = surface.get("package").and_then(Value::as_object);
let Some(package_name) = package.and_then(|p| p.get("name")).and_then(Value::as_str) else {
continue;
};
let Some(export_name) = package
.and_then(|p| p.get("export"))
.and_then(Value::as_str)
else {
continue;
};
let route = surface.get("route").and_then(Value::as_str).unwrap_or("-");
let surface_name = surface.get("name").and_then(Value::as_str).unwrap_or("-");
let surface_label = surface
.get("label")
.and_then(Value::as_str)
.unwrap_or(surface_name);
let key = console_package_key(package_name, export_name);
items.push(json!({
"command": format!("pnpm add {package_name}"),
"exportName": export_name,
"key": key,
"packageName": package_name,
"reason": format!("{module_name} / {surface_label} / {route}"),
"requestedByModule": module_name,
"route": route,
"status": "requires_manual_install",
"surfaceLabel": surface_label,
"surfaceName": surface_name,
}));
}
Ok(items)
}
fn console_package_count_from_install_plan(install_plan: &Value, module_name: &str) -> usize {
install_plan
.get("modules")
.and_then(Value::as_array)
.and_then(|modules| {
modules.iter().find(|module| {
module.get("moduleName").and_then(Value::as_str) == Some(module_name)
})
})
.and_then(|module| module.get("consolePackages"))
.and_then(Value::as_array)
.map_or(0, Vec::len)
}
fn module_catalog_entry_from_manifest(
manifest: &Value,
manifest_reference: &str,
base_url: &str,
summary: Option<&str>,
) -> Result<Value> {
let empty = Vec::new();
let console_surfaces = manifest
.get("console")
.and_then(Value::as_array)
.unwrap_or(&empty);
let console_packages = console_surfaces
.iter()
.filter_map(|surface| {
let package = surface.get("package").and_then(Value::as_object)?;
Some(json!({
"exportName": package.get("export")?.as_str()?,
"packageName": package.get("name")?.as_str()?,
"route": surface.get("route").and_then(Value::as_str).unwrap_or("-"),
}))
})
.collect::<Vec<_>>();
Ok(json!({
"baseUrl": base_url,
"consolePackages": console_packages,
"manifestReference": manifest_reference,
"name": string_field(manifest, "name")?.trim(),
"source": "remote",
"summary": summary.or_else(|| manifest.get("summary").and_then(Value::as_str)).unwrap_or("-"),
"version": string_field(manifest, "version")?.trim(),
}))
}
fn unique_console_package_plan_items(install_plan: &Value) -> Vec<ConsolePackagePlanItem> {
let mut items_by_key = BTreeMap::new();
for module_plan in install_plan
.get("modules")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
for console_package in module_plan
.get("consolePackages")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let Some(package_name) = console_package.get("packageName").and_then(Value::as_str)
else {
continue;
};
let Some(export_name) = console_package.get("exportName").and_then(Value::as_str)
else {
continue;
};
items_by_key.insert(
console_package_key(package_name, export_name),
ConsolePackagePlanItem {
export_name: export_name.to_owned(),
package_name: package_name.to_owned(),
},
);
}
}
items_by_key.into_values().collect()
}
fn update_package_json_dependency(
package_json: &mut Value,
package_name: &str,
dependency_version: &str,
) -> Result<()> {
let object = package_json
.as_object_mut()
.ok_or_else(|| anyhow!("Runtime Console package.json must be a JSON object"))?;
let dependencies = object
.entry("dependencies")
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()
.ok_or_else(|| anyhow!("Runtime Console package.json dependencies must be an object"))?;
dependencies
.entry(package_name.to_owned())
.or_insert_with(|| Value::String(dependency_version.to_owned()));
Ok(())
}
fn manifest_name_from_module_export(module_name: &str) -> String {
module_name.strip_suffix("Module").map_or_else(
|| format!("{module_name}Manifest"),
|stem| format!("{stem}Manifest"),
)
}
fn console_package_key(package_name: &str, export_name: &str) -> String {
format!("{package_name}#{export_name}")
}
async fn read_install_descriptor(reference: &str) -> Result<Option<Value>> {
if let Some(descriptor) = builtin_linked_module_descriptor(reference) {
return Ok(Some(descriptor));
}
if !looks_like_json_reference(reference) {
return Ok(None);
}
let descriptor = read_json_reference(reference).await?;
Ok(descriptor.get("source").is_some().then_some(descriptor))
}
fn builtin_linked_module_descriptor(reference: &str) -> Option<Value> {
match reference.trim() {
"auth" => Some(json!({
"name": "auth",
"source": "linked",
"linked": {
"call": "builtins::auth()"
}
})),
"auth-password" => Some(json!({
"name": "auth-password",
"source": "linked",
"dependencies": ["auth"],
"linked": {
"call": "builtins::auth_password()"
}
})),
_ => None,
}
}
fn builtin_linked_module_names() -> &'static [&'static str] {
&["auth", "auth-password"]
}
fn looks_like_json_reference(reference: &str) -> bool {
reference.starts_with("http://")
|| reference.starts_with("https://")
|| reference.starts_with("file://")
|| reference.ends_with(".json")
|| Path::new(reference).exists()
}
fn update_host_cargo_toml_for_linked_descriptor(
source: &str,
cargo: Option<&Value>,
) -> Result<Option<String>> {
let Some(cargo) = cargo.filter(|value| !value.is_null()) else {
return Ok(None);
};
let package = string_field(cargo, "package")?.trim();
if package.is_empty() {
bail!("Linked module descriptor linked.cargo.package is required");
}
if source
.lines()
.any(|line| line.trim_start().starts_with(&format!("{package} ")))
{
return Ok(None);
}
let dependency = linked_cargo_dependency(package, cargo)?;
Ok(Some(insert_after_needle(
source,
&format!("{dependency}\n"),
"[dependencies]\n",
)?))
}
fn linked_cargo_dependency(package: &str, cargo: &Value) -> Result<String> {
if let Some(path) = cargo.get("path").and_then(Value::as_str) {
return Ok(format!(
"{package} = {{ path = {} }}",
rust_string_literal(path)
));
}
if let Some(git) = cargo.get("git").and_then(Value::as_str) {
let mut fields = vec![format!("git = {}", rust_string_literal(git))];
for key in ["rev", "tag", "branch"] {
if let Some(value) = cargo.get(key).and_then(Value::as_str) {
fields.push(format!("{key} = {}", rust_string_literal(value)));
}
}
return Ok(format!("{package} = {{ {} }}", fields.join(", ")));
}
let version = cargo.get("version").and_then(Value::as_str).unwrap_or("*");
Ok(format!("{package} = {}", rust_string_literal(version)))
}
fn update_host_lib_for_linked_descriptor(
source: &str,
use_path: Option<&str>,
call: &str,
) -> Result<String> {
let source = maybe_insert_use(source, use_path)?;
let entry = format!(" .linked_module({call})\n");
if source.contains(entry.trim()) {
return Ok(source);
}
if source.contains(" .linked_module(modules::app::linked_module())\n") {
return insert_before_needle(
&source,
&entry,
" .linked_module(modules::app::linked_module())\n",
);
}
insert_before_needle(&source, &entry, " .build()")
}
fn maybe_insert_use(source: &str, use_path: Option<&str>) -> Result<String> {
let Some(use_path) = use_path.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(source.to_owned());
};
let entry = format!("use {use_path};\n");
if source.contains(entry.trim()) {
return Ok(source.to_owned());
}
insert_after_needle(source, &entry, "use lenso::host::prelude::*;\n")
}
fn parse_module_source(source: &str) -> Result<ModuleSource> {
match source.trim().to_ascii_lowercase().as_str() {
"linked" => Ok(ModuleSource::Linked),
"remote" => Ok(ModuleSource::Remote),
other => bail!("Unsupported module source `{other}`; expected `remote` or `linked`"),
}
}
fn parse_remote_module_entries(value: &str) -> Vec<(String, String)> {
value
.split(',')
.filter_map(|entry| {
let entry = entry.trim();
if entry.is_empty() {
return None;
}
let (name, base_url) = entry.split_once('=')?;
let name = name.trim();
let base_url = base_url.trim();
if name.is_empty() || base_url.is_empty() {
None
} else {
Some((name.to_owned(), base_url.to_owned()))
}
})
.collect()
}
fn remote_module_entries_from_env_source(source: &str) -> Vec<(String, String)> {
let current_value = source
.lines()
.find_map(|line| line.strip_prefix("REMOTE_MODULES="))
.unwrap_or_default();
parse_remote_module_entries(current_value)
}
fn format_remote_module_entries(entries: &[(String, String)]) -> String {
entries
.iter()
.map(|(name, base_url)| format!("{name}={base_url}"))
.collect::<Vec<_>>()
.join(",")
}
fn upsert_env_value(source: &str, key: &str, value: &str) -> String {
let key_prefix = format!("{key}=");
let mut lines = if source.is_empty() {
Vec::new()
} else {
source
.split('\n')
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
};
if let Some(index) = lines.iter().position(|line| line.starts_with(&key_prefix)) {
lines[index] = format!("{key}={value}");
format!("{}\n", lines.join("\n").trim_end_matches('\n'))
} else {
let trimmed = source.trim_end();
if trimmed.is_empty() {
format!("{key}={value}\n")
} else {
format!("{trimmed}\n{key}={value}\n")
}
}
}
fn remove_env_value(source: &str, key: &str) -> String {
let key_prefix = format!("{key}=");
let lines = source
.lines()
.filter(|line| !line.starts_with(&key_prefix))
.collect::<Vec<_>>();
if lines.is_empty() {
String::new()
} else {
format!("{}\n", lines.join("\n"))
}
}
fn insert_before_needle(file_source: &str, entry: &str, needle: &str) -> Result<String> {
if file_source.contains(entry.trim()) {
return Ok(file_source.to_owned());
}
let index = file_source
.find(needle)
.ok_or_else(|| anyhow!("Could not find insertion point: {needle}"))?;
Ok(format!(
"{}{}{}",
&file_source[..index],
entry,
&file_source[index..]
))
}
fn insert_after_needle(file_source: &str, entry: &str, needle: &str) -> Result<String> {
if file_source.contains(entry.trim()) {
return Ok(file_source.to_owned());
}
let index = file_source
.find(needle)
.ok_or_else(|| anyhow!("Could not find insertion point: {needle}"))?
+ needle.len();
Ok(format!(
"{}{}{}",
&file_source[..index],
entry,
&file_source[index..]
))
}
fn runtime_console_paths(runtime_console_root: &Path) -> RuntimeConsolePaths {
RuntimeConsolePaths {
manifest_exports_path: runtime_console_root.join("src/console-package-manifest-exports.ts"),
module_exports_path: runtime_console_root.join("src/console-package-module-exports.ts"),
oxlint_config_path: runtime_console_root.join("oxlint.config.ts"),
package_json_path: runtime_console_root.join("package.json"),
tsconfig_path: runtime_console_root.join("tsconfig.json"),
}
}
fn default_runtime_console_root_for_repo(repo_root: &Path) -> Result<PathBuf> {
if repo_root
.join("src/console-package-module-exports.ts")
.exists()
{
return Ok(repo_root.to_path_buf());
}
let nested = repo_root.join("apps/runtime-console");
if nested
.join("src/console-package-module-exports.ts")
.exists()
{
return Ok(nested);
}
let cwd = std::env::current_dir().context("resolve current directory")?;
if cwd.join("src/console-package-module-exports.ts").exists() {
return Ok(cwd);
}
Ok(nested)
}
fn resolve_repo_root(repo_root: Option<&Path>) -> Result<PathBuf> {
if let Some(repo_root) = repo_root {
return absolutize(repo_root);
}
find_repo_root(&std::env::current_dir().context("resolve current directory")?)
}
fn find_repo_root(start_path: &Path) -> Result<PathBuf> {
let mut current = absolutize(start_path)?;
loop {
if current.join("Cargo.toml").exists() && current.join("crates/lenso-bootstrap").exists() {
return Ok(current);
}
let Some(parent) = current.parent() else {
return absolutize(start_path);
};
if parent == current {
return absolutize(start_path);
}
current = parent.to_path_buf();
}
}
fn absolutize(path: &Path) -> Result<PathBuf> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
Ok(std::env::current_dir()
.context("resolve current directory")?
.join(path))
}
}
fn resolve_path(repo_root: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
repo_root.join(path)
}
}
fn display_relative(base: &Path, path: &Path) -> String {
path.strip_prefix(base)
.unwrap_or(path)
.to_string_lossy()
.to_string()
}
fn trim_trailing_slashes(value: &str) -> String {
value.trim_end_matches('/').to_owned()
}
fn string_field<'a>(value: &'a Value, key: &str) -> Result<&'a str> {
value
.get(key)
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("Remote module manifest {key} is required"))
}
fn read_json(path: &Path) -> Result<Value> {
let source = read_text(path)?;
serde_json::from_str(&source).with_context(|| format!("parse JSON {}", path.display()))
}
fn read_json_if_exists(path: &Path) -> Result<Option<Value>> {
if path.exists() {
Ok(Some(read_json(path)?))
} else {
Ok(None)
}
}
fn read_text(path: &Path) -> Result<String> {
fs::read_to_string(path).with_context(|| format!("read {}", path.display()))
}
fn read_text_if_exists(path: &Path) -> Result<String> {
if path.exists() {
read_text(path)
} else {
Ok(String::new())
}
}
fn write_file(path: &Path, contents: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create directory {}", parent.display()))?;
}
fs::write(path, contents).with_context(|| format!("write {}", path.display()))
}
fn write_json(path: &Path, value: &Value) -> Result<()> {
let mut contents = serde_json::to_string_pretty(value)?;
contents.push('\n');
write_file(path, contents.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_remote_modules_are_upserted() {
let source = "APP_ENV=local\nREMOTE_MODULES=crm=http://old\nRUST_LOG=info\n";
let updated = upsert_env_value(
source,
"REMOTE_MODULES",
&format_remote_module_entries(&[
("crm".to_owned(), "http://old".to_owned()),
("billing".to_owned(), "http://new".to_owned()),
]),
);
assert!(updated.contains("APP_ENV=local"));
assert!(updated.contains("RUST_LOG=info"));
assert!(updated.contains("REMOTE_MODULES=crm=http://old,billing=http://new"));
}
#[test]
fn env_remote_modules_are_removed() {
let source = "APP_ENV=local\nREMOTE_MODULES=crm=http://old,billing=http://new\n";
let updated = remove_remote_module_from_env_source(source, "crm").unwrap();
assert!(updated.contains("APP_ENV=local"));
assert!(updated.contains("REMOTE_MODULES=billing=http://new"));
assert!(!updated.contains("crm=http://old"));
}
#[test]
fn env_remote_modules_line_is_removed_when_empty() {
let source = "APP_ENV=local\nREMOTE_MODULES=crm=http://old\n";
let updated = remove_remote_module_from_env_source(source, "crm").unwrap();
assert_eq!(updated, "APP_ENV=local\n");
}
#[test]
fn linked_module_enabled_env_is_upserted() {
let source = "APP_ENV=local\n";
let updated = set_linked_module_enabled_env(source, "auth-password", false);
assert_eq!(
updated,
"APP_ENV=local\nLENSO_MODULE_AUTH_PASSWORD_ENABLED=false\n"
);
}
#[test]
fn module_source_parses_supported_values() {
assert_eq!(parse_module_source("remote").unwrap(), ModuleSource::Remote);
assert_eq!(parse_module_source("linked").unwrap(), ModuleSource::Linked);
assert!(parse_module_source("wasm").is_err());
}
#[test]
fn uninstall_source_infers_linked_for_builtin_when_remote_is_absent() {
assert_eq!(
infer_uninstall_module_source("auth", "", false).unwrap(),
ModuleSource::Linked
);
}
#[test]
fn uninstall_source_prefers_remote_install_state() {
assert_eq!(
infer_uninstall_module_source("auth", "", true).unwrap(),
ModuleSource::Remote
);
}
#[test]
fn uninstall_source_infers_linked_from_env_toggle() {
assert_eq!(
infer_uninstall_module_source("billing", "LENSO_MODULE_BILLING_ENABLED=true\n", false)
.unwrap(),
ModuleSource::Linked
);
}
#[test]
fn install_ledger_entry_replaces_existing_module() {
let path = Path::new("/tmp/missing-module-installs.json");
let entry = simple_linked_module_install_ledger_entry("auth", true, ".env");
let ledger = update_module_install_ledger(path, entry).unwrap();
let updated = update_module_install_ledger(
path,
simple_linked_module_install_ledger_entry("auth", false, ".env"),
)
.unwrap();
assert_eq!(ledger["modules"].as_array().unwrap().len(), 1);
assert_eq!(updated["modules"].as_array().unwrap().len(), 1);
assert_eq!(updated["modules"][0]["enabled"], false);
}
#[test]
fn install_ledger_module_is_removed() {
let ledger = json!({
"modules": [
{ "moduleName": "crm", "source": "remote" },
{ "moduleName": "auth", "source": "linked" }
],
"version": 1
});
let updated = remove_module_install_ledger_module_value(ledger, "crm")
.unwrap()
.unwrap();
assert_eq!(updated["modules"].as_array().unwrap().len(), 1);
assert_eq!(updated["modules"][0]["moduleName"], "auth");
}
#[test]
fn linked_uninstall_call_reads_install_receipt() {
let path =
std::env::temp_dir().join(format!("lenso-module-installs-{}.json", std::process::id()));
let ledger = json!({
"modules": [
{
"enabled": true,
"linked": { "call": "builtins::auth()" },
"moduleName": "auth",
"source": "linked"
}
],
"version": 1
});
write_json(&path, &ledger).unwrap();
let call = linked_module_uninstall_call(&path, "auth").unwrap();
fs::remove_file(&path).ok();
assert_eq!(call.as_deref(), Some("builtins::auth()"));
}
#[test]
fn linked_module_is_removed_from_host_composition() {
let source = "pub fn host_composition() -> HostComposition {\n HostBuilder::new()\n .linked_module(builtins::auth())\n .linked_module(modules::app::linked_module())\n .build()\n}\n";
let updated = remove_linked_module_from_host_lib_source(source, "builtins::auth()")
.expect("host lib should change");
assert!(!updated.contains("builtins::auth()"));
assert!(updated.contains(".linked_module(modules::app::linked_module())"));
}
#[test]
fn linked_uninstall_includes_installed_dependents_first() {
let host_lib = "HostBuilder::new()\n .linked_module(builtins::auth())\n .linked_module(builtins::auth_password())\n .build()\n";
let modules = linked_modules_to_uninstall("auth", None, "", host_lib).unwrap();
assert_eq!(modules, vec!["auth-password", "auth"]);
}
#[test]
fn linked_modules_are_removed_from_host_composition() {
let source = "pub fn host_composition() -> HostComposition {\n HostBuilder::new()\n .linked_module(builtins::auth())\n .linked_module(builtins::auth_password())\n .linked_module(modules::app::linked_module())\n .build()\n}\n";
let updated = remove_linked_modules_from_host_lib_source(
source,
&[
"builtins::auth_password()".to_owned(),
"builtins::auth()".to_owned(),
],
)
.expect("host lib should change");
assert!(!updated.contains("builtins::auth()"));
assert!(!updated.contains("builtins::auth_password()"));
assert!(updated.contains(".linked_module(modules::app::linked_module())"));
}
#[test]
fn builtin_auth_descriptor_declares_linked_source() {
let descriptor = builtin_linked_module_descriptor("auth").expect("auth descriptor");
assert_eq!(descriptor["name"], "auth");
assert_eq!(descriptor["source"], "linked");
assert_eq!(descriptor["linked"]["call"], "builtins::auth()");
}
#[test]
fn linked_descriptor_updates_host_cargo_toml() {
let source = "[package]\nname = \"app\"\n\n[dependencies]\nanyhow = \"1\"\n";
let cargo = json!({
"package": "lenso-billing",
"version": "0.1"
});
let updated = update_host_cargo_toml_for_linked_descriptor(source, Some(&cargo))
.expect("cargo update")
.expect("cargo should change");
assert!(updated.contains("[dependencies]\nlenso-billing = \"0.1\"\nanyhow = \"1\""));
}
#[test]
fn linked_descriptor_updates_host_composition() {
let source = "mod modules;\n\nuse lenso::host::prelude::*;\n\npub fn host_composition() -> HostComposition {\n HostBuilder::new()\n .linked_module(modules::app::linked_module())\n .build()\n}\n";
let updated = update_host_lib_for_linked_descriptor(
source,
Some("lenso_billing::linked_module"),
"linked_module()",
)
.expect("host lib update");
assert!(updated.contains("use lenso_billing::linked_module;\n"));
assert!(updated.contains(
" .linked_module(linked_module())\n .linked_module(modules::app::linked_module())"
));
}
#[test]
fn manifest_install_env_updates_source() {
let updated = apply_manifest_install_env(
"APP_ENV=local\n".to_owned(),
&[("CRM_API_URL".to_owned(), "http://crm".to_owned())],
);
assert_eq!(updated, "APP_ENV=local\nCRM_API_URL=http://crm\n");
}
#[test]
fn manifest_install_directives_are_parsed_and_planned() {
let manifest = json!({
"install": {
"env": {
"CRM_API_URL": "http://crm"
},
"commands": [
"just migrate",
{ "command": "pnpm install", "cwd": "../lenso-runtime-console" }
]
}
});
let env = remote_module_install_env(&manifest).unwrap();
let commands = remote_module_install_commands(&manifest).unwrap();
let command_plan = install_command_plans(&commands, false);
assert_eq!(
env,
vec![("CRM_API_URL".to_owned(), "http://crm".to_owned())]
);
assert_eq!(commands[0].command, "just migrate");
assert_eq!(commands[1].cwd.as_deref(), Some("../lenso-runtime-console"));
assert_eq!(
command_plan[0].get("status").and_then(Value::as_str),
Some("requires_manual_run")
);
}
#[test]
fn manifest_install_env_cannot_override_remote_modules() {
let manifest = json!({
"install": {
"env": {
"REMOTE_MODULES": "crm=http://other"
}
}
});
assert!(remote_module_install_env(&manifest).is_err());
}
#[test]
fn manifest_install_services_are_planned() {
let manifest = json!({
"install": {
"services": [
{
"name": "crm-api",
"command": "pnpm --dir ../crm/backend dev",
"cwd": ".",
"readyTimeoutMs": 12000
}
]
}
});
let services = remote_module_install_services(
&manifest,
"crm",
"http://127.0.0.1:4100/lenso/module/v1",
)
.unwrap();
let service_file = update_remote_module_services_file(
Path::new("/tmp/missing-module-services.json"),
"crm",
&services,
)
.unwrap()
.unwrap();
let service_plan = install_service_plans(&services);
assert_eq!(
services[0].ready_url,
"http://127.0.0.1:4100/lenso/module/v1/manifest"
);
assert_eq!(
service_plan[0].get("status").and_then(Value::as_str),
Some("registered")
);
assert_eq!(
service_file
.get("modules")
.and_then(Value::as_array)
.and_then(|modules| modules.first())
.and_then(|module| module.get("moduleName"))
.and_then(Value::as_str),
Some("crm")
);
}
#[test]
fn remote_module_service_states_are_parsed() {
let state = json!({
"modules": [
{
"moduleName": "crm",
"services": [
{
"autoStart": false,
"command": "pnpm --dir ../crm/backend dev",
"cwd": "../crm",
"name": "crm-api",
"readyTimeoutMs": 12000,
"readyUrl": "http://127.0.0.1:4100/lenso/module/v1/manifest"
}
]
}
],
"version": 1
});
let states = parse_remote_module_service_states(&state).unwrap();
assert_eq!(states.len(), 1);
assert_eq!(states[0].module_name, "crm");
assert_eq!(states[0].services[0].name, "crm-api");
assert_eq!(states[0].services[0].cwd.as_deref(), Some("../crm"));
assert_eq!(states[0].services[0].ready_timeout_ms, 12000);
assert!(!states[0].services[0].auto_start);
}
#[test]
fn module_enabled_env_defaults_to_true_and_parses_false() {
assert!(module_enabled_from_env_source("", "crm"));
assert!(!module_enabled_from_env_source(
"LENSO_MODULE_CRM_ENABLED=false\n",
"crm"
));
assert!(module_enabled_from_env_source(
"LENSO_MODULE_CRM_ENABLED=yes\n",
"crm"
));
}
#[test]
fn doctor_status_flags_stale_started_state() {
assert_eq!(
remote_module_service_doctor_status(true, true, true, false, true, true),
RemoteModuleServiceDoctorStatus::StaleState
);
assert_eq!(
remote_module_service_doctor_status(true, true, false, false, false, false),
RemoteModuleServiceDoctorStatus::ManualNotReady
);
assert_eq!(
remote_module_service_doctor_status(false, true, true, true, false, false),
RemoteModuleServiceDoctorStatus::NotConfigured
);
}
#[test]
fn remote_module_service_state_path_sanitizes_names() {
let service = RemoteModuleServiceInstallSpec {
name: "API Worker".to_owned(),
command: "node server.mjs".to_owned(),
cwd: None,
ready_url: "http://127.0.0.1:4100/lenso/module/v1/manifest".to_owned(),
ready_timeout_ms: 10_000,
auto_start: true,
};
let path =
remote_module_service_state_path(Path::new(".lenso"), "CRM Module", &service, "lock");
assert_eq!(
path,
PathBuf::from(".lenso/remote-crm-module-api-worker.lock")
);
}
#[test]
fn install_plan_module_is_removed() {
let plan = json!({
"modules": [
{ "moduleName": "crm", "consolePackages": [] },
{ "moduleName": "billing", "consolePackages": [] }
],
"version": 1
});
let updated = remove_console_package_install_plan_module_value(plan, "crm")
.unwrap()
.unwrap();
let modules = updated.get("modules").and_then(Value::as_array).unwrap();
assert_eq!(modules.len(), 1);
assert_eq!(
modules[0].get("moduleName").and_then(Value::as_str),
Some("billing")
);
}
#[test]
fn manifest_url_derives_base_url() {
let base = derive_remote_base_url(
None,
"https://example.com/lenso/module/v1/manifest?debug=1#hash",
)
.unwrap();
assert_eq!(base, "https://example.com/lenso/module/v1");
}
#[test]
fn plan_items_are_unique() {
let plan = json!({
"modules": [
{
"consolePackages": [
{ "packageName": "@vendor/a", "exportName": "aModule" },
{ "packageName": "@vendor/a", "exportName": "aModule" }
]
}
]
});
assert_eq!(
unique_console_package_plan_items(&plan),
vec![ConsolePackagePlanItem {
export_name: "aModule".to_owned(),
package_name: "@vendor/a".to_owned(),
}]
);
}
}