use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use include_dir::{Dir, DirEntry, include_dir};
const TEMPLATE_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/starter-host");
const CONSOLE_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/console");
#[derive(Debug, Clone)]
struct Rewrites {
package_name: String,
lib_name: String,
}
pub fn init(dir: &str, name: Option<&str>, force: bool) -> Result<()> {
let target = PathBuf::from(dir);
let default_name = target
.file_name()
.and_then(|n| n.to_str())
.filter(|n| !n.is_empty())
.unwrap_or("lenso-app");
let package_name = name.unwrap_or(default_name).to_owned();
validate_package_name(&package_name)?;
let lib_name = lib_name_from(&package_name);
let rewrites = Rewrites {
package_name: package_name.clone(),
lib_name,
};
prepare_target(&target, force)?;
extract(&TEMPLATE_DIR, &target, PathBuf::new(), &rewrites)?;
let console_status = install_embedded_console(&target)?;
print_next_steps(&target, &package_name, console_status);
Ok(())
}
pub fn update_console(repo_root: Option<&Path>) -> Result<()> {
let target = repo_root.unwrap_or_else(|| Path::new("."));
match install_embedded_console(target)? {
ConsoleInstallStatus::Installed => {
eprintln!(
"Updated bundled Runtime Console in {}",
target.join(".lenso").join("console").display()
);
Ok(())
}
ConsoleInstallStatus::NotPackaged => bail!(
"Runtime Console assets were not embedded in this lenso-cli build; install a release build that includes the console"
),
}
}
pub async fn serve(
repo_root: Option<&Path>,
skip_db: bool,
skip_migrate: bool,
separate_worker: bool,
) -> Result<()> {
let repo_root = repo_root.unwrap_or_else(|| Path::new("."));
ensure_host_root(repo_root)?;
if !skip_db {
run(repo_root, "docker", &["compose", "up", "-d", "postgres"])?;
}
if !skip_migrate {
run(repo_root, "cargo", &cargo_run_args("migrate"))?;
}
let embedded_worker = !separate_worker && has_bin(repo_root, "serve");
let api_label = if embedded_worker { "api+worker" } else { "api" };
let mut api = spawn_cargo_bin(repo_root, if embedded_worker { "serve" } else { "api" })?;
let mut worker = if embedded_worker {
None
} else {
Some(spawn_cargo_bin(repo_root, "worker")?)
};
eprintln!("Lenso host is serving. Press Ctrl-C to stop.");
loop {
tokio::select! {
signal = tokio::signal::ctrl_c() => {
signal.context("listen for Ctrl-C")?;
stop_child(api_label, &mut api);
if let Some(worker) = worker.as_mut() {
stop_child("worker", worker);
}
return Ok(());
}
() = tokio::time::sleep(Duration::from_millis(500)) => {
if let Some(status) = api.try_wait().with_context(|| format!("check {api_label} process"))? {
if let Some(worker) = worker.as_mut() {
stop_child("worker", worker);
}
bail!("{api_label} exited with {status}");
}
if let Some(worker) = worker.as_mut() {
if let Some(status) = worker.try_wait().context("check worker process")? {
stop_child(api_label, &mut api);
bail!("worker exited with {status}");
}
}
}
}
}
}
fn validate_package_name(name: &str) -> Result<()> {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() => {}
_ => bail!("package name must start with an ASCII letter: {name}"),
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
bail!("package name may only contain ASCII letters, digits, '_' and '-': {name}");
}
Ok(())
}
fn ensure_host_root(repo_root: &Path) -> Result<()> {
if !repo_root.join("Cargo.toml").exists() {
bail!(
"{} does not look like a Lenso host root",
repo_root.display()
);
}
Ok(())
}
fn has_bin(repo_root: &Path, bin: &str) -> bool {
repo_root
.join("src")
.join("bin")
.join(format!("{bin}.rs"))
.exists()
}
fn cargo_run_args(bin: &str) -> Vec<&str> {
vec!["run", "--bin", bin]
}
fn run(repo_root: &Path, program: &str, args: &[&str]) -> Result<()> {
eprintln!("$ {} {}", program, args.join(" "));
let status = Command::new(program)
.args(args)
.current_dir(repo_root)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.with_context(|| format!("run {program}"))?;
if !status.success() {
bail!("{program} exited with {status}");
}
Ok(())
}
fn spawn_cargo_bin(repo_root: &Path, bin: &str) -> Result<Child> {
let args = cargo_run_args(bin);
eprintln!("$ cargo {}", args.join(" "));
Command::new("cargo")
.args(args)
.current_dir(repo_root)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.with_context(|| format!("start {bin}"))
}
fn stop_child(label: &str, child: &mut Child) {
if matches!(child.try_wait(), Ok(Some(_))) {
return;
}
let _ = child.kill();
let _ = child.wait();
eprintln!("Stopped {label}.");
}
fn lib_name_from(package_name: &str) -> String {
package_name.replace('-', "_")
}
fn prepare_target(target: &Path, force: bool) -> Result<()> {
if target.exists() {
let is_empty = target
.read_dir()
.with_context(|| format!("read target directory {}", target.display()))?
.next()
.is_none();
if !is_empty && !force {
bail!(
"target directory is not empty: {} (pass --force to overwrite)",
target.display()
);
}
} else {
fs::create_dir_all(target)
.with_context(|| format!("create target directory {}", target.display()))?;
}
Ok(())
}
fn extract(dir: &Dir, target: &Path, rel: PathBuf, rewrites: &Rewrites) -> Result<()> {
for entry in dir.entries() {
let name = entry_name(entry)?;
let entry_rel = rel.join(name);
let out_path = target.join(&entry_rel);
match entry {
DirEntry::Dir(child) => {
fs::create_dir_all(&out_path)
.with_context(|| format!("create directory {}", out_path.display()))?;
extract(child, target, entry_rel, rewrites)?;
}
DirEntry::File(file) => {
let out_path = output_path(target, &entry_rel);
write_file(
file.contents(),
rewrite_for(&entry_rel),
&out_path,
rewrites,
)?;
}
}
}
Ok(())
}
fn rewrite_for(rel: &Path) -> RewriteKind {
match rel.to_str() {
Some("Cargo.toml.tmpl") => RewriteKind::Manifest,
Some(p) if p.starts_with("src/bin/") && p.ends_with(".rs") => RewriteKind::BinSource,
_ => RewriteKind::None,
}
}
#[derive(Debug, Clone, Copy)]
enum RewriteKind {
None,
Manifest,
BinSource,
}
fn output_path(target: &Path, rel: &Path) -> PathBuf {
let mut out = target.join(rel);
if out.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml.tmpl") {
out.set_file_name("Cargo.toml");
}
out
}
fn entry_name<'a>(entry: &DirEntry<'a>) -> Result<&'a str> {
entry
.path()
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| {
anyhow!(
"template entry without a valid file name: {}",
entry.path().display()
)
})
}
fn write_file(contents: &[u8], kind: RewriteKind, out: &Path, rewrites: &Rewrites) -> Result<()> {
if let Some(parent) = out.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create directory {}", parent.display()))?;
}
let bytes: Vec<u8> = match kind {
RewriteKind::Manifest => rewrite_cargo_toml(contents, rewrites)?.into_bytes(),
RewriteKind::BinSource => rewrite_bin_source(contents, rewrites).into_bytes(),
RewriteKind::None => contents.to_vec(),
};
fs::write(out, bytes).with_context(|| format!("write {}", out.display()))?;
Ok(())
}
fn install_embedded_console(target: &Path) -> Result<ConsoleInstallStatus> {
let Some(dist_dir) = CONSOLE_DIR.get_dir("dist") else {
return Ok(ConsoleInstallStatus::NotPackaged);
};
if CONSOLE_DIR.get_file("dist/index.html").is_none() {
return Ok(ConsoleInstallStatus::NotPackaged);
}
let console_root = target.join(".lenso").join("console");
copy_embedded_dir(dist_dir, &console_root.join("dist"))?;
if let Some(extensions_dir) = CONSOLE_DIR.get_dir("extensions") {
copy_embedded_dir(extensions_dir, &console_root.join("extensions"))?;
} else {
fs::create_dir_all(console_root.join("extensions"))
.with_context(|| format!("create {}", console_root.join("extensions").display()))?;
}
if let Some(host_extensions_dir) = CONSOLE_DIR.get_dir("dist/extensions/host") {
copy_embedded_dir(
host_extensions_dir,
&console_root.join("extensions").join("host"),
)?;
}
let registry = console_root.join("extensions").join("registry.json");
if !registry.exists() {
fs::write(®istry, b"{\"version\":1,\"bundles\":[]}\n")
.with_context(|| format!("write {}", registry.display()))?;
}
Ok(ConsoleInstallStatus::Installed)
}
fn copy_embedded_dir(dir: &Dir, target: &Path) -> Result<()> {
if target.exists() {
fs::remove_dir_all(target).with_context(|| format!("remove {}", target.display()))?;
}
fs::create_dir_all(target).with_context(|| format!("create {}", target.display()))?;
for entry in dir.entries() {
let name = entry_name(entry)?;
let out_path = target.join(name);
match entry {
DirEntry::Dir(child) => copy_embedded_dir(child, &out_path)?,
DirEntry::File(file) => {
fs::write(&out_path, file.contents())
.with_context(|| format!("write {}", out_path.display()))?;
}
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum ConsoleInstallStatus {
Installed,
NotPackaged,
}
fn rewrite_cargo_toml(contents: &[u8], rewrites: &Rewrites) -> Result<String> {
let text = std::str::from_utf8(contents).context("template Cargo.toml is not UTF-8")?;
let original = "name = \"lenso-starter-host\"";
let replacement = format!("name = \"{}\"", rewrites.package_name);
if !text.contains(original) {
bail!("template Cargo.toml no longer declares the starter package name");
}
Ok(text.replacen(original, &replacement, 1))
}
fn rewrite_bin_source(contents: &[u8], rewrites: &Rewrites) -> String {
let text = std::str::from_utf8(contents).unwrap_or_default();
text.replace("lenso_starter_host", &rewrites.lib_name)
}
fn print_next_steps(target: &Path, package_name: &str, console_status: ConsoleInstallStatus) {
eprintln!(
"Created Lenso host project `{package_name}` in {}",
target.display()
);
match console_status {
ConsoleInstallStatus::Installed => {
eprintln!("Installed the bundled Runtime Console into .lenso/console.");
}
ConsoleInstallStatus::NotPackaged => {
eprintln!("Runtime Console assets were not embedded in this lenso-cli build.");
}
}
eprintln!();
eprintln!("Next steps:");
eprintln!(" cd {}", target.display());
eprintln!(" cp .env.example .env");
eprintln!(" lenso serve");
if console_status == ConsoleInstallStatus::Installed {
eprintln!(" open http://127.0.0.1:3000/console");
}
eprintln!();
eprintln!("Install a remote module with `lenso module install <manifest-url>`.");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lib_name_replaces_dashes() {
assert_eq!(lib_name_from("lenso-starter-host"), "lenso_starter_host");
assert_eq!(lib_name_from("my-app"), "my_app");
assert_eq!(lib_name_from("app"), "app");
}
#[test]
fn validates_package_names() {
assert!(validate_package_name("my-app").is_ok());
assert!(validate_package_name("App2").is_ok());
assert!(validate_package_name("2app").is_err());
assert!(validate_package_name("my app").is_err());
assert!(validate_package_name("-app").is_err());
}
#[test]
fn rewrites_cargo_toml_package_name() {
let rewrites = Rewrites {
package_name: "billing-svc".to_owned(),
lib_name: "billing_svc".to_owned(),
};
let input = b"[package]\nname = \"lenso-starter-host\"\nversion = \"0.1.0\"\n";
let out = rewrite_cargo_toml(input, &rewrites).unwrap();
assert!(out.contains("name = \"billing-svc\""));
assert!(!out.contains("lenso-starter-host"));
}
#[test]
fn rewrites_bin_source_lib_reference() {
let rewrites = Rewrites {
package_name: "billing-svc".to_owned(),
lib_name: "billing_svc".to_owned(),
};
let input = b"lenso_starter_host::host_composition()";
let out = rewrite_bin_source(input, &rewrites);
assert_eq!(out, "billing_svc::host_composition()");
}
#[test]
fn cargo_run_args_target_host_bins() {
assert_eq!(cargo_run_args("api"), vec!["run", "--bin", "api"]);
assert_eq!(cargo_run_args("serve"), vec!["run", "--bin", "serve"]);
assert_eq!(cargo_run_args("worker"), vec!["run", "--bin", "worker"]);
}
#[test]
fn copies_embedded_console_tree() {
let target = std::env::temp_dir().join(format!(
"lenso-cli-console-copy-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = fs::remove_dir_all(&target);
copy_embedded_dir(&CONSOLE_DIR, &target).unwrap();
assert!(target.join(".keep").exists());
fs::remove_dir_all(target).unwrap();
}
#[test]
fn update_console_matches_packaged_asset_state() {
let target = std::env::temp_dir().join(format!(
"lenso-cli-console-update-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&target).unwrap();
let result = update_console(Some(&target));
if CONSOLE_DIR.get_file("dist/index.html").is_some() {
result.unwrap();
assert!(target.join(".lenso/console/dist/index.html").exists());
if CONSOLE_DIR.get_dir("dist/extensions/host").is_some() {
assert!(
target
.join(".lenso/console/extensions/host/runtime-console-api.js")
.exists()
);
}
} else {
assert!(result.is_err());
}
fs::remove_dir_all(target).unwrap();
}
}