use std::fmt::Write as _; use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use anyhow::{Context, Result, anyhow, bail}; use bds_core::db::{Database, DbConnection}; use bds_core::engine::{self, cli_sync, domain_events}; use bds_core::model::{DomainEntity, NotificationAction, Project, ScriptKind}; use bds_core::scripting::{CoreHost, ExecutionControl, ExecutionKind, execute_many_with_host}; use clap::{Args, Parser, Subcommand, ValueEnum}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; #[derive(Debug, Parser)] #[command( name = "bds-cli", version, about = "RuDS workspace automation using the desktop application's shared engines" )] pub struct Cli { /// Print a stable JSON result envelope. #[arg(long, global = true)] pub json: bool, /// Gate network activity and route automatic AI work to the local endpoint. #[arg(long, global = true)] pub airplane: bool, #[command(subcommand)] pub command: Command, } #[derive(Debug, Subcommand)] pub enum Command { /// Rebuild the cache database from the active project's files. Rebuild { #[arg(long)] incremental: bool, }, /// Run one derived-data repair task. Repair { part: RepairPart }, /// Render the generated site. Render { #[arg(long, conflicts_with = "force")] incremental: bool, #[arg(long, conflicts_with = "incremental")] force: bool, }, /// Upload generated HTML, thumbnails, and media using publishing settings. Upload, /// Push the active project repository to origin. Push, /// Fast-forward pull the active project and reconcile its cache database. Pull, /// Create a post from flags or JSON stdin. Post(PostArgs), /// Import and optionally AI-enrich one image. Media { file: PathBuf, #[arg(long)] language: Option, }, /// Create a post and import/link all supplied images. Gallery(GalleryArgs), /// Read or update global application settings. Config { #[command(subcommand)] command: ConfigCommand, }, /// List, add, or switch projects in the shared registry. Project { #[command(subcommand)] command: ProjectCommand, }, /// Start the authenticated headless SSH server. Server(ServerArgs), /// Start interactive TUI mode (normally intercepted by the packaged launcher). Tui, /// Run an enabled utility Lua script from the active project. Lua { script: String, #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, /// Install this packaged CLI in ~/.local/bin. Install, } #[derive(Debug, Args, Default)] pub struct ServerArgs { /// SSH listen address. Defaults to loopback; external access must be explicit. #[arg(long)] pub bind: Option, /// SSH listen port. #[arg(long)] pub port: Option, /// Application database path. #[arg(long)] pub database: Option, /// Private application data directory containing SSH key material. #[arg(long)] pub data_dir: Option, } #[derive(Debug, Clone, Copy, ValueEnum)] pub enum RepairPart { PostLinks, MediaLinks, Thumbnails, Embeddings, Search, } #[derive(Debug, Args, Default)] pub struct PostArgs { /// Read the post object as JSON from standard input. #[arg(long)] pub stdin: bool, /// Skip automatic translation after creation. #[arg(long)] pub no_translate: bool, /// Post title (required unless --stdin is used). #[arg(long)] pub title: Option, /// Markdown post body. #[arg(long)] pub content: Option, /// Short post excerpt. #[arg(long)] pub excerpt: Option, /// Author name. #[arg(long)] pub author: Option, /// BCP 47 source language; detected when omitted. #[arg(long)] pub language: Option, /// Post template slug. #[arg(long)] pub template: Option, /// Comma-separated tags. #[arg(long, value_delimiter = ',')] pub tags: Vec, /// Comma-separated categories. #[arg(long, value_delimiter = ',')] pub categories: Vec, } #[derive(Debug, Args, Default)] pub struct GalleryArgs { #[command(flatten)] pub post: PostArgs, /// Image files to import and link to the new post. #[arg(value_name = "IMAGE")] pub images: Vec, } #[derive(Debug, Subcommand)] pub enum ConfigCommand { Get { key: String }, Set { key: String, value: String }, List, } #[derive(Debug, Subcommand)] pub enum ProjectCommand { List, Add { path: PathBuf, #[arg(long)] name: Option, }, Switch { project: String, }, } #[derive(Debug, Clone)] pub struct RunContext { pub database_path: PathBuf, pub stdin: String, pub home_dir: PathBuf, pub executable_path: PathBuf, } impl RunContext { pub fn system() -> Self { Self { database_path: bds_core::util::application_database_path(), stdin: String::new(), home_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")), executable_path: std::env::current_exe().unwrap_or_else(|_| PathBuf::from("bds-cli")), } } } #[derive(Debug, Serialize)] pub struct CommandOutput { pub command: &'static str, pub message: String, pub data: Value, pub progress: Vec, pub notices: Vec, #[serde(skip)] json: bool, } impl std::fmt::Display for CommandOutput { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if self.json { let envelope = json!({ "ok": true, "command": self.command, "message": self.message, "data": self.data, "progress": self.progress, "notices": self.notices, }); return write!( formatter, "{}", serde_json::to_string(&envelope).map_err(|_| std::fmt::Error)? ); } for line in &self.progress { writeln!(formatter, "{line}")?; } for notice in &self.notices { writeln!(formatter, "Notice: {notice}")?; } formatter.write_str(&self.message) } } #[derive(Debug, Clone, Deserialize)] struct PostInput { title: String, #[serde(default)] content: String, excerpt: Option, author: Option, language: Option, template: Option, #[serde(default, deserialize_with = "string_list")] tags: Vec, #[serde(default, deserialize_with = "string_list")] categories: Vec, #[serde(default)] images: Vec, } fn string_list<'de, D>(deserializer: D) -> std::result::Result, D::Error> where D: serde::Deserializer<'de>, { let value = Value::deserialize(deserializer)?; Ok(match value { Value::Array(values) => values .into_iter() .filter_map(|value| value.as_str().map(str::to_string)) .collect(), Value::String(value) => split_list(&value), _ => Vec::new(), }) } pub fn run(cli: Cli, context: RunContext) -> Result { let command_name = command_name(&cli.command); let mut output = execute(cli.command, &context, cli.airplane)?; output.command = command_name; output.json = cli.json; Ok(output) } fn execute(command: Command, context: &RunContext, airplane: bool) -> Result { if matches!(command, Command::Install) { return install_launcher(context); } if matches!(command, Command::Tui | Command::Server(_)) { bail!("server and TUI modes are started directly by the native bds-cli entry point"); } let db = open_database(&context.database_path)?; match command { Command::Rebuild { incremental } => rebuild(&db, incremental), Command::Repair { part } => repair(&db, part), Command::Render { incremental, force } => render(&db, incremental, force), Command::Upload => upload(&db, airplane), Command::Push => git_push(&db, airplane), Command::Pull => git_pull(&db, airplane), Command::Post(args) => create_post(&db, args, &context.stdin, airplane), Command::Media { file, language } => import_media(&db, &file, language, airplane), Command::Gallery(args) => create_gallery(&db, args, &context.stdin, airplane), Command::Config { command } => config(&db, command), Command::Project { command } => project(&db, command), Command::Lua { script, args } => run_lua(&db, &script, &args, airplane), Command::Server(_) | Command::Tui | Command::Install => unreachable!(), } } fn open_database(path: &Path) -> Result { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).with_context(|| { format!( "could not create application data directory {}", parent.display() ) })?; } let db = Database::open(path).with_context(|| format!("could not open {}", path.display()))?; db.migrate() .map_err(|error| anyhow!("could not migrate the shared database: {error}"))?; engine::search::prepare_search_index(db.conn()) .context("could not prepare the shared search index")?; Ok(db) } fn active_project(db: &Database) -> Result<(Project, PathBuf)> { let project = engine::project::get_active_project(db.conn())? .ok_or_else(|| anyhow!("no active project selected; use: project switch "))?; let data_dir = project .data_path .as_deref() .map(PathBuf::from) .ok_or_else(|| anyhow!("active project {} has no portable data path", project.id))?; if !data_dir.is_dir() { bail!( "active project folder does not exist: {}", data_dir.display() ); } Ok((project, data_dir)) } fn rebuild(db: &Database, incremental: bool) -> Result { let (project, data_dir) = active_project(db)?; if incremental { let (applied, imported, failed) = cli_sync::run_cli_mutation(db.conn(), || { let report = engine::metadata_diff::compute_metadata_diff(db.conn(), &data_dir, &project.id)?; if !report.errors.is_empty() { return Err(engine::EngineError::Validation(report.errors.join("; "))); } for item in &report.diffs { engine::metadata_diff::repair_metadata_diff_item( db.conn(), &data_dir, &project.id, engine::metadata_diff::RepairDirection::FileToDatabase, item, )?; } let filesystem_orphans = report .orphans .iter() .filter(|orphan| orphan.reason == "file_without_db_entry") .collect::>(); let mut imported = 0; let mut failed = 0; for orphan in filesystem_orphans { match engine::metadata_diff::import_orphan_file( db.conn(), &data_dir, &project.id, orphan, ) { Ok(()) => imported += 1, Err(_) => failed += 1, } } if !report.diffs.is_empty() || imported > 0 { emit_bulk(&project.id); } Ok((report.diffs.len(), imported, failed)) })?; return Ok(output( "Applied incremental filesystem changes", json!({"differences_applied": applied, "orphans_imported": imported, "orphans_failed": failed}), )); } let progress = Arc::new(Mutex::new(Vec::new())); let progress_sink = Arc::clone(&progress); let (report, thumbnails) = cli_sync::run_cli_mutation(db.conn(), || { let report = engine::rebuild::rebuild_from_filesystem_with_progress( db.conn(), &data_dir, &project.id, Some(Arc::new(move |value, message| { let line = format!("[{:>3}%] {message}", (value * 100.0).round() as i32); let mut progress = progress_sink .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); if progress.last() != Some(&line) { progress.push(line); } })), )?; let thumbnails = engine::media::regenerate_missing_thumbnails(db.conn(), &data_dir, &project.id)?; emit_bulk(&project.id); Ok((report, thumbnails)) })?; let mut result = output( "Rebuild complete", json!({ "posts_created": report.posts_created, "posts_updated": report.posts_updated, "translations_created": report.translations_created, "translations_updated": report.translations_updated, "media_created": report.media_created, "media_updated": report.media_updated, "templates_created": report.templates_created, "templates_updated": report.templates_updated, "scripts_created": report.scripts_created, "scripts_updated": report.scripts_updated, "thumbnails_generated": thumbnails.thumbnails_generated, }), ); result.progress = progress .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .clone(); Ok(result) } fn repair(db: &Database, part: RepairPart) -> Result { let (project, data_dir) = active_project(db)?; match part { RepairPart::PostLinks => { let links = cli_sync::run_cli_mutation(db.conn(), || { let links = engine::post::rebuild_all_links(db.conn(), &data_dir, &project.id)?; domain_events::entity_changed( &project.id, DomainEntity::Post, "*", NotificationAction::Updated, ); Ok(links) })?; Ok(output("Post links rebuilt", json!({"links": links}))) } RepairPart::MediaLinks => { let report = cli_sync::run_cli_mutation(db.conn(), || { let report = engine::media::rebuild_media_links(db.conn(), &data_dir, &project.id)?; domain_events::entity_changed( &project.id, DomainEntity::Media, "*", NotificationAction::Updated, ); Ok(report) })?; Ok(output( "Media links rebuilt", json!({"links": report.links}), )) } RepairPart::Thumbnails => { let report = cli_sync::run_cli_mutation(db.conn(), || { let report = engine::media::regenerate_missing_thumbnails( db.conn(), &data_dir, &project.id, )?; domain_events::entity_changed( &project.id, DomainEntity::Media, "*", NotificationAction::Updated, ); Ok(report) })?; Ok(output( "Missing thumbnails regenerated", json!({"media_repaired": report.media_repaired, "thumbnails_generated": report.thumbnails_generated}), )) } RepairPart::Search => { let report = cli_sync::run_cli_mutation(db.conn(), || { let report = engine::search::reindex_project(db.conn(), &project.id, None)?; domain_events::entity_changed( &project.id, DomainEntity::Post, "*", NotificationAction::Updated, ); domain_events::entity_changed( &project.id, DomainEntity::Media, "*", NotificationAction::Updated, ); Ok(report) })?; Ok(output( "Search text reindexed", json!({"posts_indexed": report.posts_indexed, "media_indexed": report.media_indexed}), )) } RepairPart::Embeddings => { let metadata = engine::meta::read_project_json(&data_dir)?; if metadata.semantic_similarity_enabled { let service = engine::embedding::EmbeddingService::production(db.conn(), &data_dir); let indexed = service.reindex_all(&project.id)?; service.flush_project(&project.id)?; return Ok(output( "Embedding index rebuilt", json!({"rebuilt": indexed.len(), "disabled": false}), )); } Ok(output( "Embedding repair skipped because semantic similarity is disabled", json!({"rebuilt": 0, "disabled": true}), )) } } } fn render(db: &Database, incremental: bool, force: bool) -> Result { let (project, data_dir) = active_project(db)?; let metadata = engine::meta::read_project_json(&data_dir)?; let posts = published_sources(db.conn(), &data_dir, &project.id)?; let output_dir = data_dir.join("html"); std::fs::create_dir_all(&output_dir)?; if incremental { let validation = engine::validate_site::validate_site(db.conn(), &data_dir, &project.id)?; let sections = engine::generation::sections_from_validation_report(&validation); let report = engine::generation::apply_validation_sections( db.conn(), &output_dir, &project.id, &metadata, &posts, §ions, )?; return Ok(output( "Validation differences applied", json!({"written": report.written_paths.len(), "skipped": report.skipped_paths.len(), "deleted": report.deleted_paths.len()}), )); } if force { engine::generation::clear_generation_cache(db.conn(), &project.id)?; } let report = engine::generation::generate_starter_site( db.conn(), &output_dir, &project.id, &metadata, &posts, metadata.main_language.as_deref().unwrap_or("en"), )?; Ok(output( "Site rendered", json!({"written": report.written_paths.len(), "skipped": report.skipped_paths.len(), "deleted": report.deleted_paths.len(), "force": force}), )) } fn published_sources( conn: &DbConnection, data_dir: &Path, project_id: &str, ) -> Result> { let mut sources = Vec::new(); for post in bds_core::db::queries::post::list_posts_by_project(conn, project_id)? { if let Some(source) = engine::generation::load_published_post_source(data_dir, post)? { sources.push(source); } } Ok(sources) } fn upload(db: &Database, airplane: bool) -> Result { if airplane { bail!("upload is unavailable in airplane mode"); } let (_project, data_dir) = active_project(db)?; let preferences = engine::meta::read_publishing_json(&data_dir)?; let private_cache_dir = db .conn() .database_path()? .parent() .map(PathBuf::from) .ok_or_else(|| anyhow!("private application directory unavailable"))?; let job = engine::publishing::upload_site( &data_dir, &private_cache_dir, &preferences, |_current, _total, _kind| {}, )?; Ok(output( "Upload complete", json!({"targets": job.completed_targets.len()}), )) } fn git_push(db: &Database, airplane: bool) -> Result { if airplane { bail!("git push is unavailable in airplane mode"); } let (_project, data_dir) = active_project(db)?; let result = engine::git::GitEngine::new(data_dir).push(|| false, |_| {})?; Ok(output("Pushed", json!({"output": result.output}))) } fn git_pull(db: &Database, airplane: bool) -> Result { if airplane { bail!("git pull is unavailable in airplane mode"); } let (_project, data_dir) = active_project(db)?; let result = engine::git::GitEngine::new(data_dir).pull(|| false, |_| {})?; let mut rebuilt = rebuild(db, true)?; rebuilt.message = "Pulled and reconciled the cache database".into(); rebuilt.data["git_output"] = Value::String(result.output); Ok(rebuilt) } fn create_post( db: &Database, args: PostArgs, stdin: &str, airplane: bool, ) -> Result { let (project, data_dir) = active_project(db)?; let no_translate = args.no_translate; let mut input = post_input(args, stdin)?; let mut notices = Vec::new(); ensure_language(db.conn(), &mut input, airplane, &mut notices); let metadata = engine::meta::read_project_json(&data_dir)?; let (post, translation) = cli_sync::run_cli_mutation(db.conn(), || { let mut post = engine::post::create_post( db.conn(), &data_dir, &project.id, &input.title, Some(&input.content), input.tags.clone(), input.categories.clone(), input.author.as_deref(), input.language.as_deref(), input.template.as_deref(), )?; if input.excerpt.is_some() { post = engine::post::update_post( db.conn(), &data_dir, &post.id, None, None, Some(input.excerpt.as_deref()), None, None, None, None, None, None, None, )?; } let translation = if no_translate { None } else { Some(engine::auto_translation::translate_missing_for_post( db.conn(), &data_dir, &post.id, metadata.main_language.as_deref().unwrap_or("en"), &metadata.blog_languages, airplane, || false, )) }; Ok((post, translation)) })?; translation_notice(translation, &mut notices); Ok(CommandOutput { command: "post", message: format!( "Created post {} ({}, {})", post.id, post.slug, post.language.as_deref().unwrap_or("unknown language") ), data: json!({"id": post.id, "slug": post.slug, "language": post.language}), progress: Vec::new(), notices, json: false, }) } fn import_media( db: &Database, file: &Path, language: Option, airplane: bool, ) -> Result { require_file(file)?; let (project, data_dir) = active_project(db)?; let metadata = engine::meta::read_project_json(&data_dir)?; let language = language .or(metadata.main_language.clone()) .unwrap_or_else(|| "en".into()); let targets = engine::gallery_import::translation_targets( metadata.main_language.as_deref(), &metadata.blog_languages, &language, ); let ai_available = engine::gallery_import::active_ai_endpoint_configured(db.conn(), airplane); let imported = cli_sync::run_cli_mutation(db.conn(), || { let original_name = file .file_name() .and_then(|name| name.to_str()) .unwrap_or("image"); let imported = engine::media::import_media( db.conn(), &data_dir, &project.id, file, original_name, None, None, None, None, Some(&language), Vec::new(), )?; if ai_available { let _ = engine::gallery_import::enrich_imported_image( db.conn(), &data_dir, &imported, airplane, &targets, ); } Ok(imported) })?; let notices = (!ai_available) .then(|| { "AI enrichment was not run because the permitted endpoint is not configured".into() }) .into_iter() .collect(); Ok(CommandOutput { command: "media", message: format!( "Imported media {} ({})", imported.id, imported.original_name ), data: json!({"id": imported.id, "file_path": imported.file_path, "language": imported.language}), progress: Vec::new(), notices, json: false, }) } fn create_gallery( db: &Database, args: GalleryArgs, stdin: &str, airplane: bool, ) -> Result { let no_translate = args.post.no_translate; let stdin_mode = args.post.stdin; let supplied_images = args.images; let mut input = post_input(args.post, stdin)?; if !stdin_mode { input.images = supplied_images; } if input.images.is_empty() { bail!("pass at least one image (or use --stdin with a non-empty images array)"); } for image in &input.images { require_file(image)?; } let (project, data_dir) = active_project(db)?; let metadata = engine::meta::read_project_json(&data_dir)?; let mut notices = Vec::new(); ensure_language(db.conn(), &mut input, airplane, &mut notices); let post = cli_sync::run_cli_mutation(db.conn(), || { let mut post = engine::post::create_post( db.conn(), &data_dir, &project.id, &input.title, Some(&input.content), input.tags.clone(), input.categories.clone(), input.author.as_deref(), input.language.as_deref(), input.template.as_deref(), )?; if input.excerpt.is_some() { post = engine::post::update_post( db.conn(), &data_dir, &post.id, None, None, Some(input.excerpt.as_deref()), None, None, None, None, None, None, None, )?; } let report = engine::gallery_import::import_gallery_images( &db.conn().database_path().map_err(engine::EngineError::Db)?, &data_dir, &project.id, &post.id, input.images.clone(), input.language.as_deref().unwrap_or("en"), airplane, ); for outcome in &report.outcomes { if let Ok(imported) = &outcome.result { domain_events::entity_changed( &project.id, DomainEntity::Media, &imported.media_id, NotificationAction::Created, ); } } domain_events::entity_changed( &project.id, DomainEntity::Post, &post.id, NotificationAction::Updated, ); if !no_translate { let translation = engine::auto_translation::translate_missing_for_post( db.conn(), &data_dir, &post.id, metadata.main_language.as_deref().unwrap_or("en"), &metadata.blog_languages, airplane, || false, ); translation_notice(Some(translation), &mut notices); } let failed = report .outcomes .iter() .filter(|item| item.result.is_err()) .count(); if failed > 0 { notices.push(format!( "{failed} image import(s) failed; successful imports remain linked" )); } Ok(post) })?; Ok(CommandOutput { command: "gallery", message: format!( "Created gallery post {} ({}) with {} image(s)", post.id, post.slug, input.images.len() ), data: json!({"id": post.id, "slug": post.slug, "images": input.images.len()}), progress: Vec::new(), notices, json: false, }) } fn config(db: &Database, command: ConfigCommand) -> Result { match command { ConfigCommand::Get { key } => { let value = engine::settings::get_effective(db.conn(), &key)? .ok_or_else(|| anyhow!("{key} is not set"))?; let value = printable_config_value(&key, &value); Ok(output(&value, json!({"key": key, "value": value}))) } ConfigCommand::Set { key, value } => { cli_sync::run_cli_mutation(db.conn(), || { if key == engine::settings::ONLINE_API_KEY { engine::ai::save_online_api_key(db.conn(), &value) } else if key == engine::settings::AIRPLANE_API_KEY { engine::ai::save_endpoint_api_key( db.conn(), engine::ai::AiEndpointKind::Airplane, &value, ) } else { engine::settings::set(db.conn(), &key, &value) } })?; let printable = printable_config_value(&key, &value); Ok(output( &format!("{key} = {printable}"), json!({"key": key, "value": printable}), )) } ConfigCommand::List => { let settings = engine::settings::list_effective(db.conn())?; let mut message = String::new(); let mut values = serde_json::Map::new(); for (key, value) in settings { let value = printable_config_value(&key, &value); let _ = writeln!(message, "{key}={value}"); values.insert(key, Value::String(value)); } Ok(output(message.trim_end(), Value::Object(values))) } } } fn printable_config_value(key: &str, value: &str) -> String { let key = key.to_ascii_lowercase(); if [ "api_key", "access_key", "private_key", "password", "secret", "token", "credential", ] .iter() .any(|name| key.ends_with(name) || key.split(['.', '-']).any(|part| part == *name)) { if value.trim().is_empty() { "".to_string() } else { "".to_string() } } else { value.to_string() } } fn project(db: &Database, command: ProjectCommand) -> Result { match command { ProjectCommand::List => { let projects = engine::project::list_projects(db.conn())?; let message = projects .iter() .map(|project| { format!( "{} {} {} {}", if project.is_active { "*" } else { " " }, project.id, project.slug, project.name ) }) .collect::>() .join("\n"); Ok(output(&message, serde_json::to_value(projects)?)) } ProjectCommand::Add { path, name } => { if !path.is_dir() { bail!("{} is not a directory", path.display()); } let path = path.canonicalize()?; let project = cli_sync::run_cli_mutation(db.conn(), || { if path.join("meta/project.json").is_file() { engine::project::open_project(db.conn(), &path) } else { let fallback = path .file_name() .and_then(|value| value.to_str()) .unwrap_or("Blog"); engine::project::create_project( db.conn(), name.as_deref().unwrap_or(fallback), path.to_str(), ) } })?; Ok(output( &format!( "Added project {} ({}); switch with: project switch {}", project.id, project.slug, project.slug ), serde_json::to_value(project)?, )) } ProjectCommand::Switch { project: reference } => { let selected = engine::project::list_projects(db.conn())? .into_iter() .find(|project| { reference == project.id || reference == project.slug || reference == project.name }) .ok_or_else(|| anyhow!("no project matches {reference:?}"))?; cli_sync::run_cli_mutation(db.conn(), || { engine::project::set_active_project(db.conn(), &selected.id) })?; Ok(output( &format!("Active project: {}", selected.name), serde_json::to_value(selected)?, )) } } } fn run_lua(db: &Database, slug: &str, args: &[String], airplane: bool) -> Result { let (project, data_dir) = active_project(db)?; let script = bds_core::db::queries::script::get_script_by_slug(db.conn(), &project.id, slug) .with_context(|| format!("no script with slug {slug:?} in the active project"))?; if script.kind != ScriptKind::Utility { bail!( "script {slug:?} is a {} script; only utility scripts can be run", script.kind.as_str() ); } if !script.enabled { bail!("script {slug:?} is disabled"); } let source = if let Some(content) = script.content { content } else { let raw = std::fs::read_to_string(data_dir.join(&script.file_path))?; bds_core::util::frontmatter::read_script_file(&raw) .map_err(|error| anyhow!(error))? .1 }; let values = args.iter().cloned().map(Value::String).collect::>(); let host = Arc::new( CoreHost::new(db.conn().database_path()?, project.id.clone(), data_dir) .with_offline_mode(airplane), ); let execution = cli_sync::run_cli_mutation(db.conn(), || { execute_many_with_host( &source, &script.entrypoint, &values, ExecutionKind::Utility, &ExecutionControl::default(), host, ) .map_err(|error| engine::EngineError::Validation(format!("script failed: {error}"))) })?; let mut message = execution.output.join("\n"); if !message.is_empty() { message.push('\n'); } write!(message, "Script finished: {}", execution.value)?; Ok(output( &message, json!({"value": execution.value, "output": execution.output, "progress": execution.progress.iter().map(|item| json!({"current": item.current, "total": item.total, "message": item.message})).collect::>() }), )) } fn install_launcher(context: &RunContext) -> Result { let target = engine::cli_launcher::install_launcher(&context.executable_path, &context.home_dir)?; Ok(output( &format!("Installed launcher at {}", target.display()), json!({"path": target}), )) } fn post_input(args: PostArgs, stdin: &str) -> Result { if args.stdin { if stdin.trim().is_empty() { bail!("no JSON data on stdin"); } let input: PostInput = serde_json::from_str(stdin).context("invalid JSON on stdin")?; if input.title.trim().is_empty() { bail!("JSON post data needs a non-empty title"); } return Ok(input); } let title = args .title .filter(|value| !value.trim().is_empty()) .ok_or_else(|| anyhow!("--title is required (or pass --stdin with JSON post data)"))?; Ok(PostInput { title, content: args.content.unwrap_or_default(), excerpt: args.excerpt, author: args.author, language: args.language, template: args.template, tags: cleaned(args.tags), categories: cleaned(args.categories), images: Vec::new(), }) } fn ensure_language( conn: &DbConnection, input: &mut PostInput, airplane: bool, notices: &mut Vec, ) { if input .language .as_deref() .is_some_and(|value| !value.trim().is_empty()) { return; } let request = engine::ai::OneShotRequest { operation: engine::ai::OneShotOperation::DetectLanguage, content: json!({"title": input.title, "content": input.content}), }; if let Ok((engine::ai::OneShotResponse::LanguageDetection(result), _)) = engine::ai::run_one_shot(conn, airplane, &request) { input.language = Some(result.language_code); } else { input.language = Some( engine::search::detect_language(&format!("{}\n{}", input.title, input.content)).into(), ); notices.push("AI language detection was unavailable; used the offline heuristic".into()); } } fn translation_notice( result: Option>, notices: &mut Vec, ) { match result { None => {} Some(Ok(report)) if report.nothing_to_do => notices.push( "automatic translation was not needed (single language or already translated)".into(), ), Some(Ok(report)) if report.failed_count > 0 => notices.push(format!( "automatic translation completed with {} failure(s): {}", report.failed_count, report.errors.join("; ") )), Some(Ok(report)) => notices.push(format!( "automatic translation created {} post and {} media translation(s)", report.translated_posts, report.translated_media )), Some(Err(error)) => notices.push(format!( "automatic translation was not run (offline, unconfigured AI, or endpoint error): {error}" )), } } fn emit_bulk(project_id: &str) { for entity in [ DomainEntity::Post, DomainEntity::Media, DomainEntity::Script, DomainEntity::Template, ] { domain_events::entity_changed(project_id, entity, "*", NotificationAction::Updated); } } fn require_file(path: &Path) -> Result<()> { if path.is_file() { Ok(()) } else { bail!("no such file: {}", path.display()) } } fn cleaned(values: Vec) -> Vec { values .into_iter() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .collect() } fn split_list(value: &str) -> Vec { cleaned(value.split(',').map(str::to_string).collect()) } fn output(message: &str, data: Value) -> CommandOutput { CommandOutput { command: "", message: message.to_string(), data, progress: Vec::new(), notices: Vec::new(), json: false, } } fn command_name(command: &Command) -> &'static str { match command { Command::Rebuild { .. } => "rebuild", Command::Repair { .. } => "repair", Command::Render { .. } => "render", Command::Upload => "upload", Command::Push => "push", Command::Pull => "pull", Command::Post(_) => "post", Command::Media { .. } => "media", Command::Gallery(_) => "gallery", Command::Config { .. } => "config", Command::Project { .. } => "project", Command::Server(_) => "server", Command::Tui => "tui", Command::Lua { .. } => "lua", Command::Install => "install", } } #[cfg(test)] mod tests { use super::*; use bds_core::model::ScriptKind; use clap::CommandFactory as _; use std::io::{Read as _, Write as _}; use std::net::TcpListener; use std::thread; use tempfile::TempDir; struct Fixture { _root: TempDir, database_path: PathBuf, project_dir: PathBuf, home_dir: PathBuf, } impl Fixture { fn new(active: bool) -> Self { let root = tempfile::tempdir().unwrap(); let database_path = root.path().join("app/bds.db"); let project_dir = root.path().join("project"); let home_dir = root.path().join("home"); std::fs::create_dir_all(&project_dir).unwrap(); std::fs::create_dir_all(&home_dir).unwrap(); let db = open_database(&database_path).unwrap(); if active { let project = engine::project::create_project(db.conn(), "CLI Test", project_dir.to_str()) .unwrap(); engine::project::set_active_project(db.conn(), &project.id).unwrap(); } Self { _root: root, database_path, project_dir, home_dir, } } fn context(&self, stdin: &str) -> RunContext { RunContext { database_path: self.database_path.clone(), stdin: stdin.to_string(), home_dir: self.home_dir.clone(), executable_path: self._root.path().join("missing-bds-cli"), } } fn run(&self, args: &[&str], stdin: &str) -> Result { let cli = Cli::try_parse_from(std::iter::once("bds-cli").chain(args.iter().copied()))?; run(cli, self.context(stdin)) } fn image(&self, name: &str) -> PathBuf { let path = self._root.path().join(name); std::fs::write( &path, include_bytes!( "../../../fixtures/golden-generated-sites/rfc1437-sample/images/close.png" ), ) .unwrap(); path } } #[test] fn help_exposes_every_command_family_and_invalid_argv_fails() { let mut help = Vec::new(); Cli::command().write_long_help(&mut help).unwrap(); let help = String::from_utf8(help).unwrap(); for command in [ "rebuild", "repair", "render", "upload", "push", "pull", "post", "media", "gallery", "config", "project", "server", "tui", "lua", "install", ] { assert!(help.contains(command), "missing {command} from help"); } let server = Cli::try_parse_from([ "bds-cli", "server", "--bind", "127.0.0.2", "--port", "2233", "--database", "/tmp/bds.db", "--data-dir", "/tmp/bds", ]) .unwrap(); assert!(matches!(server.command, Command::Server(_))); assert!(Cli::try_parse_from(["bds-cli", "unknown"]).is_err()); assert!(Cli::try_parse_from(["bds-cli", "render", "--incremental", "--force"]).is_err()); assert!(Cli::try_parse_from(["bds-cli", "repair", "unknown"]).is_err()); } #[test] fn config_and_project_families_dispatch_success_and_failure() { let fixture = Fixture::new(false); assert!(fixture.run(&["config", "get", "missing"], "").is_err()); let defaults = fixture.run(&["config", "list"], "").unwrap(); assert_eq!(defaults.data["editor.default_mode"], "markdown"); assert_eq!(defaults.data["editor.diff_view_style"], "inline"); assert_eq!(defaults.data["ai.endpoint.online.api_key"], ""); assert!( !defaults .message .contains("app.search-index-rebuild-required") ); assert_eq!( fixture .run(&["config", "get", "editor.default_mode"], "") .unwrap() .message, "markdown" ); fixture .run(&["config", "set", "editor.mode", "markdown"], "") .unwrap(); let read = fixture .run(&["--json", "config", "get", "editor.mode"], "") .unwrap(); assert_eq!(read.data["value"], "markdown"); assert!(read.to_string().contains("\"ok\":true")); let secret = fixture .run(&["config", "set", "service.api_key", "secret-token"], "") .unwrap(); assert_eq!(secret.message, "service.api_key = "); assert!(!secret.to_string().contains("secret-token")); let secret = fixture .run(&["--json", "config", "get", "service.api_key"], "") .unwrap(); assert_eq!(secret.data["value"], ""); assert!(!secret.to_string().contains("secret-token")); let secrets = fixture.run(&["--json", "config", "list"], "").unwrap(); assert_eq!(secrets.data["service.api_key"], ""); assert!(!secrets.to_string().contains("secret-token")); let db = open_database(&fixture.database_path).unwrap(); engine::settings::set(db.conn(), "ai.endpoint.online.api_key_configured", "true").unwrap(); let configured = fixture.run(&["config", "list"], "").unwrap(); assert_eq!(configured.data["ai.endpoint.online.api_key"], ""); assert!( !configured .message .contains("ai.endpoint.online.api_key_configured") ); assert!(fixture.run(&["project", "switch", "missing"], "").is_err()); let project = fixture._root.path().join("second"); std::fs::create_dir_all(&project).unwrap(); fixture .run( &[ "project", "add", project.to_str().unwrap(), "--name", "Second Blog", ], "", ) .unwrap(); fixture .run(&["project", "switch", "second-blog"], "") .unwrap(); let listed = fixture.run(&["project", "list"], "").unwrap(); assert!(listed.message.contains("Second Blog")); } #[test] fn post_family_supports_flags_json_fallback_language_and_one_notification() { let fixture = Fixture::new(true); assert!(fixture.run(&["post"], "").is_err()); let created = fixture .run( &[ "post", "--title", "Über Rust", "--content", "Das ist ein Beitrag und die Sprache ist Deutsch.", "--excerpt", "Kurz", "--tags", "rust,cli", "--no-translate", ], "", ) .unwrap(); assert_eq!(created.data["language"], "de"); assert!(created.notices[0].contains("offline heuristic")); let db = open_database(&fixture.database_path).unwrap(); let notifications = bds_core::db::queries::db_notification::list_unseen_cli_notifications(db.conn()) .unwrap(); assert_eq!(notifications.len(), 1); assert_eq!(notifications[0].entity_type, DomainEntity::Post); let json_post = fixture .run( &["post", "--stdin", "--no-translate"], r#"{"title":"JSON post","content":"body","language":"en","categories":["article"]}"#, ) .unwrap(); assert_eq!(json_post.data["slug"], "json-post"); assert!(fixture.run(&["post", "--stdin"], "not json").is_err()); } #[test] fn post_language_detection_uses_configured_airplane_endpoint() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let endpoint = format!("http://{}", listener.local_addr().unwrap()); thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let mut request = [0_u8; 8192]; let _ = stream.read(&mut request).unwrap(); let body = r#"{"choices":[{"message":{"content":"{\"language_code\":\"fr\"}"}}],"usage":{"prompt_tokens":1,"completion_tokens":1}}"#; let response = format!( "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", body.len(), body ); stream.write_all(response.as_bytes()).unwrap(); }); let fixture = Fixture::new(true); let db = open_database(&fixture.database_path).unwrap(); engine::ai::save_endpoint( db.conn(), &engine::ai::AiEndpointConfig { kind: engine::ai::AiEndpointKind::Airplane, url: endpoint, model: "local-model".into(), api_key: None, }, ) .unwrap(); let created = fixture .run( &[ "--airplane", "post", "--title", "Bonjour", "--content", "Texte sans accents", "--no-translate", ], "", ) .unwrap(); assert_eq!(created.data["language"], "fr"); assert!(created.notices.is_empty()); } #[test] fn media_and_gallery_families_dispatch_shared_import_pipeline() { let fixture = Fixture::new(true); assert!(fixture.run(&["media", "missing.png"], "").is_err()); let image = fixture.image("one.png"); let imported = fixture .run( &[ "--airplane", "media", image.to_str().unwrap(), "--language", "en", ], "", ) .unwrap(); assert!(imported.message.contains("Imported media")); assert!( fixture .project_dir .join("media") .read_dir() .unwrap() .next() .is_some() ); let imported_id = imported.data["id"].as_str().unwrap().to_string(); let db = open_database(&fixture.database_path).unwrap(); let stored = bds_core::db::queries::media::get_media_by_id(db.conn(), &imported_id).unwrap(); assert!(fixture.project_dir.join(&stored.sidecar_path).is_file()); fixture.run(&["rebuild"], "").unwrap(); assert!( bds_core::db::queries::media::get_media_by_id(db.conn(), &imported_id).is_ok(), "filesystem rebuild must restore CLI-imported media" ); let second = fixture.image("two.png"); let gallery = fixture .run( &[ "--airplane", "gallery", "--title", "Gallery", "--language", "en", "--no-translate", second.to_str().unwrap(), ], "", ) .unwrap(); assert_eq!(gallery.data["images"], 1); assert!( fixture .run(&["gallery", "--title", "Empty", "--no-translate"], "",) .is_err() ); } #[test] fn rebuild_repair_and_render_families_dispatch_success_and_failure() { let fixture = Fixture::new(true); fixture.run(&["rebuild"], "").unwrap(); fixture.run(&["rebuild", "--incremental"], "").unwrap(); for part in ["post-links", "media-links", "thumbnails", "search"] { fixture.run(&["repair", part], "").unwrap(); } let skipped = fixture.run(&["repair", "embeddings"], "").unwrap(); assert_eq!(skipped.data["disabled"], true); let mut metadata = engine::meta::read_project_json(&fixture.project_dir).unwrap(); metadata.semantic_similarity_enabled = true; engine::meta::write_project_json(&fixture.project_dir, &metadata).unwrap(); let repaired = fixture.run(&["repair", "embeddings"], "").unwrap(); assert_eq!(repaired.data["rebuilt"], 0); fixture.run(&["rebuild"], "").unwrap(); metadata.semantic_similarity_enabled = false; engine::meta::write_project_json(&fixture.project_dir, &metadata).unwrap(); fixture.run(&["render"], "").unwrap(); fixture.run(&["render", "--force"], "").unwrap(); fixture.run(&["render", "--incremental"], "").unwrap(); assert!(fixture.project_dir.join("html/index.html").is_file()); let no_project = Fixture::new(false); assert!(no_project.run(&["rebuild"], "").is_err()); assert!(no_project.run(&["repair", "search"], "").is_err()); assert!(no_project.run(&["render"], "").is_err()); } #[test] fn lua_family_runs_only_enabled_utility_scripts() { let fixture = Fixture::new(true); let db = open_database(&fixture.database_path).unwrap(); let (project, _) = active_project(&db).unwrap(); engine::script::create_script( db.conn(), &project.id, "Echo", ScriptKind::Utility, "function main(value) print(value); return value end", Some("main"), ) .unwrap(); engine::script::create_script( db.conn(), &project.id, "Macro", ScriptKind::Macro, "function render() return 'x' end", Some("render"), ) .unwrap(); let execution = fixture.run(&["lua", "echo", "hello"], "").unwrap(); assert!(execution.message.contains("hello")); assert!(fixture.run(&["lua", "macro"], "").is_err()); assert!(fixture.run(&["lua", "missing"], "").is_err()); } #[test] fn external_and_launcher_families_have_actionable_failure_dispatch() { let fixture = Fixture::new(true); assert!(fixture.run(&["upload"], "").is_err()); assert!(fixture.run(&["push"], "").is_err()); assert!(fixture.run(&["pull"], "").is_err()); assert!( fixture .run(&["--airplane", "push"], "") .unwrap_err() .to_string() .contains("airplane") ); assert!(fixture.run(&["tui"], "").is_err()); assert!(fixture.run(&["install"], "").is_err()); } #[test] fn launcher_install_is_idempotent_and_refuses_overwrite() { let fixture = Fixture::new(false); let executable = fixture._root.path().join("packaged-bds-cli"); std::fs::write(&executable, b"binary").unwrap(); let context = RunContext { executable_path: executable.clone(), ..fixture.context("") }; run( Cli::try_parse_from(["bds-cli", "install"]).unwrap(), context.clone(), ) .unwrap(); run( Cli::try_parse_from(["bds-cli", "install"]).unwrap(), context, ) .unwrap(); let target = fixture.home_dir.join(".local/bin/bds-cli"); assert!(!target.is_symlink()); assert!( std::fs::read_to_string(target) .unwrap() .contains(executable.canonicalize().unwrap().to_str().unwrap()) ); } }