From fdfae200a02d9e795037619be1036b341e2b85d4 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 19 Jul 2026 14:25:15 +0200 Subject: [PATCH] Implement the shared automation CLI. --- Cargo.lock | 7 +- Cargo.toml | 1 + README.md | 3 +- RUST_PLAN_CORE.md | 2 +- RUST_PLAN_EXTENSION.md | 8 +- crates/bds-cli/Cargo.toml | 8 +- crates/bds-cli/src/lib.rs | 1520 ++++++++++++++++++ crates/bds-cli/src/main.rs | 53 +- crates/bds-cli/tests/process.rs | 188 +++ crates/bds-core/Cargo.toml | 1 + crates/bds-core/src/db/connection.rs | 4 +- crates/bds-core/src/engine/cli_launcher.rs | 73 + crates/bds-core/src/engine/cli_sync.rs | 64 +- crates/bds-core/src/engine/gallery_import.rs | 6 +- crates/bds-core/src/engine/generation.rs | 14 + crates/bds-core/src/engine/media.rs | 199 +++ crates/bds-core/src/engine/metadata_diff.rs | 56 + crates/bds-core/src/engine/mod.rs | 1 + crates/bds-core/src/engine/post.rs | 2 +- crates/bds-core/src/engine/search.rs | 44 + crates/bds-core/src/engine/validate_media.rs | 34 +- crates/bds-core/src/util/app_paths.rs | 34 + crates/bds-core/src/util/mod.rs | 2 + crates/bds-core/tests/domain_events.rs | 45 + crates/bds-ui/Cargo.toml | 6 +- crates/bds-ui/src/app.rs | 26 +- crates/bds-ui/src/views/settings_view.rs | 8 +- locales/ui/de.ftl | 2 + locales/ui/en.ftl | 2 + locales/ui/es.ftl | 2 + locales/ui/fr.ftl | 2 + locales/ui/it.ftl | 2 + specs/cli.allium | 50 +- 33 files changed, 2411 insertions(+), 58 deletions(-) create mode 100644 crates/bds-cli/src/lib.rs create mode 100644 crates/bds-cli/tests/process.rs create mode 100644 crates/bds-core/src/engine/cli_launcher.rs create mode 100644 crates/bds-core/src/util/app_paths.rs diff --git a/Cargo.lock b/Cargo.lock index 03281fb..dc8c71a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -789,7 +789,11 @@ version = "0.1.0" dependencies = [ "anyhow", "bds-core", - "tokio", + "clap", + "dirs 5.0.1", + "serde", + "serde_json", + "tempfile", ] [[package]] @@ -802,6 +806,7 @@ dependencies = [ "deunicode", "diesel", "diesel_migrations", + "dirs 5.0.1", "fluent-bundle", "fluent-syntax", "htmd", diff --git a/Cargo.toml b/Cargo.toml index c411b88..e06b91a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ sha2 = "0.10" deunicode = "1" thiserror = "2" anyhow = "1" +clap = { version = "4", features = ["derive"] } tokio = { version = "1", features = ["full"] } axum = "0.8" walkdir = "2" diff --git a/README.md b/README.md index 56b349d..1681722 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ The project is under active development. Core blogging workflows are broadly ava - Template and Lua script management with explicit syntax-check feedback, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms. - SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search. - Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings. +- Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, shared settings/projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`. - Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups. - Local preview in the app or system browser. - Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using online or local OpenAI-compatible endpoints with airplane-mode gating. @@ -27,7 +28,7 @@ RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from - `crates/bds-core` — data, engines, rendering, AI, publishing, and Lua - `crates/bds-editor` — reusable syntax-highlighting editor - `crates/bds-ui` — desktop application and platform integration -- `crates/bds-cli` — planned automation CLI +- `crates/bds-cli` — headless automation CLI over the shared engines - `specs` — authoritative Allium behavior specifications - `fixtures` — compatibility projects and generated-site fixtures - `locales` — UI and native-menu translations diff --git a/RUST_PLAN_CORE.md b/RUST_PLAN_CORE.md index c496147..0d44787 100644 --- a/RUST_PLAN_CORE.md +++ b/RUST_PLAN_CORE.md @@ -28,7 +28,7 @@ Status in this document describes the current source code as of 2026-07-19. It d | `bds-core` | Models, SQLite, filesystem formats, engines, rendering, generation, AI, publishing, and Lua | | `bds-editor` | Reusable Ropey/Syntect/Cosmic Text editor widget | | `bds-ui` | Iced application, native menus/dialogs, platform lifecycle, and embedded preview | -| `bds-cli` | Extension-only headless automation surface; currently a stub | +| `bds-cli` | Extension-only headless automation surface over the shared engines | ## Current Core Status diff --git a/RUST_PLAN_EXTENSION.md b/RUST_PLAN_EXTENSION.md index f628c30..2b7f58e 100644 --- a/RUST_PLAN_EXTENSION.md +++ b/RUST_PLAN_EXTENSION.md @@ -81,17 +81,17 @@ Open: - OPML/menu editor UI. - Replace the Menu Editor placeholder. -### CLI and MCP — Open; domain events — Done +### CLI and domain events — Done; MCP — Open Done: - Domain event bus from `events.allium` for desktop, CLI, TUI, server, and future remote clients, including deterministic subscriptions, project scope, and persisted CLI notification consumption/pruning. +- Native `bds-cli` with Clap help/error handling, optional JSON output, shared application paths/database/projects/settings, full and incremental rebuild, derived-data repair, full/targeted/forced generation, publishing, fast-forward Git sync, post/media/gallery creation, offline/local AI routing and translation, project/config operations, sandboxed utility Lua execution, and guarded launcher installation. +- CLI process and dispatch tests use temporary databases/projects; CLI mutations persist deduplicated desktop notifications and imported filesystem metadata survives rebuild. +- Settings → Data exposes the same localized packaged-launcher installer as `bds-cli install`. Open: -- Implement `bds-cli`; its current binary is only a stub. -- Commands from `cli.allium` and `cli_sync.allium` using the same project, database, engines, and settings as the desktop app. -- Reuse the core gallery batch-import engine already used by the desktop post editor for the CLI `gallery` command. - MCP tools/resources and proposal-based writes from `mcp.allium`. - Replace the MCP settings placeholder. diff --git a/crates/bds-cli/Cargo.toml b/crates/bds-cli/Cargo.toml index 0aed22a..6d8d196 100644 --- a/crates/bds-cli/Cargo.toml +++ b/crates/bds-cli/Cargo.toml @@ -7,4 +7,10 @@ license.workspace = true [dependencies] bds-core = { workspace = true } anyhow = { workspace = true } -tokio = { workspace = true } +clap = { workspace = true } +dirs = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/bds-cli/src/lib.rs b/crates/bds-cli/src/lib.rs new file mode 100644 index 0000000..f30844a --- /dev/null +++ b/crates/bds-cli/src/lib.rs @@ -0,0 +1,1520 @@ +use std::fmt::Write as _; +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 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, 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) { + bail!( + "the packaged bds-cli launcher starts TUI mode directly; a direct CLI invocation cannot own the terminal" + ); + } + + 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::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)?; + let metadata = engine::meta::read_project_json(&data_dir)?; + if metadata.semantic_similarity_enabled { + bail!( + "semantic similarity is enabled, but rebuild cannot continue without the real on-device embedding engine" + ); + } + 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 { + bail!( + "semantic similarity is enabled, but no real on-device embedding engine is registered" + ); + } + 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(db.conn(), &key)? + .ok_or_else(|| anyhow!("{key} is not set"))?; + Ok(output(&value, json!({"key": key, "value": value}))) + } + ConfigCommand::Set { key, value } => { + cli_sync::run_cli_mutation(db.conn(), || { + engine::settings::set(db.conn(), &key, &value) + })?; + Ok(output( + &format!("{key} = {value}"), + json!({"key": key, "value": value}), + )) + } + ConfigCommand::List => { + let settings = bds_core::db::queries::setting::list_all_settings(db.conn())?; + let mut message = String::new(); + let mut values = serde_json::Map::new(); + for setting in settings { + let _ = writeln!(message, "{}={}", setting.key, setting.value); + values.insert(setting.key, Value::String(setting.value)); + } + Ok(output(message.trim_end(), Value::Object(values))) + } + } +} + +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::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", "tui", "lua", "install", + ] { + assert!(help.contains(command), "missing {command} from help"); + } + 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()); + 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")); + 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(); + assert!(fixture.run(&["repair", "embeddings"], "").is_err()); + assert!(fixture.run(&["rebuild"], "").is_err()); + 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_eq!( + target.canonicalize().unwrap(), + executable.canonicalize().unwrap() + ); + } +} diff --git a/crates/bds-cli/src/main.rs b/crates/bds-cli/src/main.rs index 1928ff1..fd54621 100644 --- a/crates/bds-cli/src/main.rs +++ b/crates/bds-cli/src/main.rs @@ -1,3 +1,52 @@ -fn main() { - println!("bds-cli: headless automation surface (not yet implemented)"); +use std::io::Read as _; +use std::process::ExitCode; + +use clap::Parser; + +fn main() -> ExitCode { + let cli = match bds_cli::Cli::try_parse() { + Ok(cli) => cli, + Err(error) => { + let success = matches!( + error.kind(), + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ); + let _ = error.print(); + return if success { + ExitCode::SUCCESS + } else { + ExitCode::from(1) + }; + } + }; + + let json = cli.json; + let needs_stdin = match &cli.command { + bds_cli::Command::Post(args) => args.stdin, + bds_cli::Command::Gallery(args) => args.post.stdin, + _ => false, + }; + let mut context = bds_cli::RunContext::system(); + if needs_stdin && let Err(error) = std::io::stdin().read_to_string(&mut context.stdin) { + eprintln!("Error: could not read stdin: {error}"); + return ExitCode::from(1); + } + + match bds_cli::run(cli, context) { + Ok(output) => { + println!("{output}"); + ExitCode::SUCCESS + } + Err(error) => { + if json { + eprintln!( + "{}", + serde_json::json!({"ok": false, "error": format!("{error:#}")}) + ); + } else { + eprintln!("Error: {error:#}"); + } + ExitCode::from(1) + } + } } diff --git a/crates/bds-cli/tests/process.rs b/crates/bds-cli/tests/process.rs new file mode 100644 index 0000000..6a0195e --- /dev/null +++ b/crates/bds-cli/tests/process.rs @@ -0,0 +1,188 @@ +use std::process::Command; + +fn cli(home: &std::path::Path, args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_bds-cli")) + .env("HOME", home) + .args(args) + .output() + .unwrap() +} + +#[test] +fn process_exit_codes_help_and_shared_state_roundtrip() { + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + let project = root.path().join("project"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&project).unwrap(); + + let help = cli(&home, &["--help"]); + assert!(help.status.success()); + assert!(String::from_utf8_lossy(&help.stdout).contains("rebuild")); + + let invalid = cli(&home, &["not-a-command"]); + assert_eq!(invalid.status.code(), Some(1)); + assert!(!invalid.stderr.is_empty()); + + let added = cli( + &home, + &[ + "project", + "add", + project.to_str().unwrap(), + "--name", + "Process Blog", + ], + ); + assert!( + added.status.success(), + "{}", + String::from_utf8_lossy(&added.stderr) + ); + assert!( + cli(&home, &["project", "switch", "process-blog"]) + .status + .success() + ); + + let created = Command::new(env!("CARGO_BIN_EXE_bds-cli")) + .env("HOME", &home) + .args(["--json", "post", "--stdin", "--no-translate"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + use std::io::Write as _; + child + .stdin + .take() + .unwrap() + .write_all(br#"{"title":"Process post","content":"Body","language":"en"}"#)?; + child.wait_with_output() + }) + .unwrap(); + assert!( + created.status.success(), + "{}", + String::from_utf8_lossy(&created.stderr) + ); + let value: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["slug"], "process-post"); + + let get_missing = cli(&home, &["config", "get", "missing"]); + assert_eq!(get_missing.status.code(), Some(1)); +} + +#[cfg(unix)] +#[test] +fn process_upload_push_and_pull_dispatch_successfully() { + use std::os::unix::fs::PermissionsExt as _; + + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + let project = root.path().join("project"); + let remote = root.path().join("remote.git"); + let peer = root.path().join("peer"); + let fake_bin = root.path().join("bin"); + for directory in [&home, &project, &fake_bin] { + std::fs::create_dir_all(directory).unwrap(); + } + assert!( + cli( + &home, + &[ + "project", + "add", + project.to_str().unwrap(), + "--name", + "External Blog", + ], + ) + .status + .success() + ); + assert!( + cli(&home, &["project", "switch", "external-blog"]) + .status + .success() + ); + + std::fs::write( + project.join("meta/publishing.json"), + r#"{"sshHost":"example.test","sshUser":"deploy","sshRemotePath":"/srv/blog","sshMode":"rsync"}"#, + ) + .unwrap(); + let rsync = fake_bin.join("rsync"); + std::fs::write(&rsync, "#!/bin/sh\nexit 0\n").unwrap(); + let mut permissions = std::fs::metadata(&rsync).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&rsync, permissions).unwrap(); + let upload = Command::new(env!("CARGO_BIN_EXE_bds-cli")) + .env("HOME", &home) + .env("PATH", &fake_bin) + .env("SSH_AUTH_SOCK", root.path().join("agent.sock")) + .arg("upload") + .output() + .unwrap(); + assert!( + upload.status.success(), + "{}", + String::from_utf8_lossy(&upload.stderr) + ); + + git(&project, &["init", "-b", "main"]); + git(&project, &["config", "user.email", "cli@example.test"]); + git(&project, &["config", "user.name", "CLI Test"]); + git(&project, &["add", "."]); + git(&project, &["commit", "-m", "Initial"]); + git(root.path(), &["init", "--bare", remote.to_str().unwrap()]); + git( + &project, + &["remote", "add", "origin", remote.to_str().unwrap()], + ); + git(&project, &["push", "-u", "origin", "main"]); + git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]); + + let pushed = cli(&home, &["push"]); + assert!( + pushed.status.success(), + "{}", + String::from_utf8_lossy(&pushed.stderr) + ); + + git( + root.path(), + &["clone", remote.to_str().unwrap(), peer.to_str().unwrap()], + ); + git(&peer, &["config", "user.email", "peer@example.test"]); + git(&peer, &["config", "user.name", "Peer Test"]); + std::fs::write(peer.join("remote-change.txt"), "change").unwrap(); + git(&peer, &["add", "remote-change.txt"]); + git(&peer, &["commit", "-m", "Remote change"]); + git(&peer, &["push"]); + + let pulled = cli(&home, &["pull"]); + assert!( + pulled.status.success(), + "{}", + String::from_utf8_lossy(&pulled.stderr) + ); + assert!(project.join("remote-change.txt").is_file()); +} + +#[cfg(unix)] +fn git(cwd: &std::path::Path, args: &[&str]) { + let output = Command::new("git") + .current_dir(cwd) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {} failed:\n{}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/crates/bds-core/Cargo.toml b/crates/bds-core/Cargo.toml index 88835fc..18fb924 100644 --- a/crates/bds-core/Cargo.toml +++ b/crates/bds-core/Cargo.toml @@ -37,6 +37,7 @@ libsqlite3-sys = { workspace = true } mlua = { workspace = true } url = { workspace = true } base64 = { workspace = true } +dirs = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/crates/bds-core/src/db/connection.rs b/crates/bds-core/src/db/connection.rs index 09e1ee8..90a583a 100644 --- a/crates/bds-core/src/db/connection.rs +++ b/crates/bds-core/src/db/connection.rs @@ -53,7 +53,9 @@ impl DbConnection { .batch_execute("ROLLBACK TO bds_operation; RELEASE bds_operation") } - pub(crate) fn database_path(&self) -> diesel::QueryResult { + /// Filesystem database path for sibling surfaces that must open their own + /// short-lived connection (gallery workers, preview servers, Lua hosts). + pub fn database_path(&self) -> diesel::QueryResult { self.with(|conn| { diesel::sql_query("SELECT file FROM pragma_database_list WHERE name = 'main'") .get_result::(conn) diff --git a/crates/bds-core/src/engine/cli_launcher.rs b/crates/bds-core/src/engine/cli_launcher.rs new file mode 100644 index 0000000..d36447c --- /dev/null +++ b/crates/bds-core/src/engine/cli_launcher.rs @@ -0,0 +1,73 @@ +use std::path::{Path, PathBuf}; + +use crate::engine::{EngineError, EngineResult}; + +/// Install a recoverable launcher pointing at a packaged `bds-cli` binary. +/// Existing unrelated files are never overwritten. +pub fn install_launcher(executable: &Path, home_dir: &Path) -> EngineResult { + if !executable.is_file() { + return Err(EngineError::Validation(format!( + "installing the CLI requires the packaged bds-cli executable (not found at {})", + executable.display() + ))); + } + let bin_dir = home_dir.join(".local/bin"); + std::fs::create_dir_all(&bin_dir)?; + let target = bin_dir.join(if cfg!(windows) { + "bds-cli.exe" + } else { + "bds-cli" + }); + if target.exists() { + let existing = target.canonicalize().ok(); + let source = executable.canonicalize()?; + if existing.as_ref() != Some(&source) { + return Err(EngineError::Conflict(format!( + "refusing to overwrite existing launcher at {}", + target.display() + ))); + } + return Ok(target); + } + + #[cfg(unix)] + std::os::unix::fs::symlink(executable.canonicalize()?, &target)?; + #[cfg(windows)] + std::fs::copy(executable, &target)?; + Ok(target) +} + +/// Resolve the CLI shipped beside the desktop executable and install it. +pub fn install_packaged_launcher(home_dir: &Path) -> EngineResult { + let app = std::env::current_exe()?; + let cli = app.with_file_name(if cfg!(windows) { + "bds-cli.exe" + } else { + "bds-cli" + }); + install_launcher(&cli, home_dir) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn install_is_idempotent_and_never_overwrites_an_unrelated_file() { + let root = tempfile::tempdir().unwrap(); + let executable = root.path().join("packaged-bds-cli"); + std::fs::write(&executable, b"binary").unwrap(); + let home = root.path().join("home"); + let target = install_launcher(&executable, &home).unwrap(); + assert_eq!( + target.canonicalize().unwrap(), + executable.canonicalize().unwrap() + ); + assert_eq!(install_launcher(&executable, &home).unwrap(), target); + + std::fs::remove_file(&target).unwrap(); + std::fs::write(&target, b"mine").unwrap(); + assert!(install_launcher(&executable, &home).is_err()); + assert_eq!(std::fs::read(&target).unwrap(), b"mine"); + } +} diff --git a/crates/bds-core/src/engine/cli_sync.rs b/crates/bds-core/src/engine/cli_sync.rs index 763ea48..8975aff 100644 --- a/crates/bds-core/src/engine/cli_sync.rs +++ b/crates/bds-core/src/engine/cli_sync.rs @@ -21,12 +21,74 @@ pub fn run_cli_mutation( ) -> EngineResult { let (result, events) = domain_events::capture_current_thread(operation) .map_err(|message| EngineError::Validation(message.to_string()))?; - for event in &events { + let mut unique = Vec::::new(); + for event in events { + if let Some(seen) = unique + .iter_mut() + .find(|seen| same_notification(seen, &event)) + { + merge_notification_action(seen, &event); + } else { + unique.push(event); + } + } + for event in &unique { record_cli_event(conn, event)?; } result } +fn merge_notification_action(existing: &mut DomainEvent, incoming: &DomainEvent) { + let ( + DomainEvent::EntityChanged { + action: existing_action, + .. + }, + DomainEvent::EntityChanged { + action: incoming_action, + .. + }, + ) = (existing, incoming) + else { + return; + }; + if *incoming_action == NotificationAction::Deleted + || *existing_action != NotificationAction::Created + { + *existing_action = incoming_action.clone(); + } +} + +fn same_notification(left: &DomainEvent, right: &DomainEvent) -> bool { + match (left, right) { + ( + DomainEvent::EntityChanged { + project_id: left_project, + entity: left_entity, + entity_id: left_id, + .. + }, + DomainEvent::EntityChanged { + project_id: right_project, + entity: right_entity, + entity_id: right_id, + .. + }, + ) => left_project == right_project && left_entity == right_entity && left_id == right_id, + ( + DomainEvent::SettingsChanged { + project_id: left_project, + key: left_key, + }, + DomainEvent::SettingsChanged { + project_id: right_project, + key: right_key, + }, + ) => left_project == right_project && left_key == right_key, + _ => false, + } +} + pub fn record_cli_event(conn: &Connection, event: &DomainEvent) -> EngineResult<()> { record_cli_event_at(conn, event, now_unix_ms()) } diff --git a/crates/bds-core/src/engine/gallery_import.rs b/crates/bds-core/src/engine/gallery_import.rs index ed2581f..8b5d952 100644 --- a/crates/bds-core/src/engine/gallery_import.rs +++ b/crates/bds-core/src/engine/gallery_import.rs @@ -183,7 +183,7 @@ fn import_gallery_image( .map_err(|error| error.to_string())?; let title = if ai_available { - enrich_image( + enrich_imported_image( db.conn(), data_dir, &imported, @@ -201,7 +201,9 @@ fn import_gallery_image( }) } -fn enrich_image( +/// Apply the shared gallery AI enrichment and translation pipeline to one +/// already-imported image. Returns the generated title when AI was available. +pub fn enrich_imported_image( conn: &crate::db::DbConnection, data_dir: &Path, imported: &crate::model::Media, diff --git a/crates/bds-core/src/engine/generation.rs b/crates/bds-core/src/engine/generation.rs index c1dd48d..0ae665f 100644 --- a/crates/bds-core/src/engine/generation.rs +++ b/crates/bds-core/src/engine/generation.rs @@ -103,6 +103,20 @@ pub fn generate_starter_site( ) } +/// Forget stored generated-file hashes so the next render writes every +/// artifact while repopulating the cache with its current content hash. +pub fn clear_generation_cache(conn: &Connection, project_id: &str) -> EngineResult { + use crate::db::schema::generated_file_hashes; + use diesel::prelude::*; + + Ok(conn.with(|connection| { + diesel::delete( + generated_file_hashes::table.filter(generated_file_hashes::project_id.eq(project_id)), + ) + .execute(connection) + })?) +} + pub fn generate_starter_site_with_progress( conn: &Connection, output_dir: &Path, diff --git a/crates/bds-core/src/engine/media.rs b/crates/bds-core/src/engine/media.rs index 0f660e2..5d0db77 100644 --- a/crates/bds-core/src/engine/media.rs +++ b/crates/bds-core/src/engine/media.rs @@ -34,6 +34,116 @@ pub struct MediaRebuildReport { pub errors: Vec, } +#[derive(Debug, Default, PartialEq, Eq)] +pub struct MediaLinkRebuildReport { + pub links: usize, +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct ThumbnailRepairReport { + pub media_repaired: usize, + pub thumbnails_generated: usize, +} + +/// Rebuild the exact post/media relationship set stored in canonical media +/// sidecars. Stale database links are removed as well as missing links added. +pub fn rebuild_media_links( + conn: &Connection, + data_dir: &Path, + project_id: &str, +) -> EngineResult { + conn.begin_savepoint()?; + match rebuild_media_links_inner(conn, data_dir, project_id) { + Ok(report) => { + conn.release_savepoint()?; + Ok(report) + } + Err(error) => { + let _ = conn.rollback_savepoint(); + Err(error) + } + } +} + +fn rebuild_media_links_inner( + conn: &Connection, + data_dir: &Path, + project_id: &str, +) -> EngineResult { + let mut report = MediaLinkRebuildReport::default(); + for item in qm::list_media_by_project(conn, project_id)? { + let sidecar = read_sidecar(&fs::read_to_string(data_dir.join(&item.sidecar_path))?) + .map_err(EngineError::Parse)?; + for link in qpm::list_post_media_by_media(conn, &item.id)? { + qpm::unlink_media(conn, &link.post_id, &item.id)?; + } + for (sort_order, post_id) in sidecar.linked_post_ids.into_iter().enumerate() { + let post = qp::get_post_by_id(conn, &post_id)?; + if post.project_id != project_id { + return Err(EngineError::Validation(format!( + "media {} sidecar links to a post outside the active project", + item.id + ))); + } + qpm::link_media( + conn, + &PostMedia { + id: Uuid::new_v4().to_string(), + project_id: project_id.to_string(), + post_id, + media_id: item.id.clone(), + sort_order: sort_order as i32, + created_at: now_unix_ms(), + }, + )?; + report.links += 1; + } + } + Ok(report) +} + +/// Regenerate all standard thumbnail variants for items missing at least one +/// variant. Existing complete sets are left untouched. +pub fn regenerate_missing_thumbnails( + conn: &Connection, + data_dir: &Path, + project_id: &str, +) -> EngineResult { + let mut report = ThumbnailRepairReport::default(); + for item in qm::list_media_by_project(conn, project_id)? { + if !item.mime_type.starts_with("image/") { + continue; + } + let prefix = &item.id[..2.min(item.id.len())]; + let missing = THUMBNAIL_SIZES + .iter() + .filter(|size| { + let extension = match size.format { + ThumbnailFormat::Webp => "webp", + ThumbnailFormat::Jpeg => "jpg", + }; + !data_dir + .join("thumbnails") + .join(prefix) + .join(format!("{}-{}.{}", item.id, size.name, extension)) + .is_file() + }) + .count(); + if missing == 0 { + continue; + } + generate_all_thumbnails( + &data_dir.join(&item.file_path), + &data_dir.join("thumbnails"), + &item.id, + ) + .map_err(EngineError::Parse)?; + report.media_repaired += 1; + report.thumbnails_generated += missing; + } + Ok(report) +} + /// Supported image MIME types for import (per media_processing.allium). const SUPPORTED_IMAGE_TYPES: &[&str] = &[ "image/jpeg", @@ -959,6 +1069,95 @@ mod tests { assert_eq!(sc.tags, vec!["updated-tag"]); } + #[test] + fn rebuild_media_links_replaces_stale_links_and_rolls_back_invalid_sidecars() { + let (db, dir) = setup(); + let source = create_test_png(dir.path()); + let media = import_media( + db.conn(), + dir.path(), + "p1", + &source, + "photo.png", + None, + None, + None, + None, + None, + vec![], + ) + .unwrap(); + let first = crate::engine::post::create_post( + db.conn(), + dir.path(), + "p1", + "First", + None, + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); + let stale = crate::engine::post::create_post( + db.conn(), + dir.path(), + "p1", + "Stale", + None, + vec![], + vec![], + None, + None, + None, + ) + .unwrap(); + crate::engine::post_media::link_media_to_post( + db.conn(), + dir.path(), + "p1", + &first.id, + &media.id, + 0, + ) + .unwrap(); + crate::engine::post_media::link_media_to_post( + db.conn(), + dir.path(), + "p1", + &stale.id, + &media.id, + 1, + ) + .unwrap(); + atomic_write_str( + &dir.path().join(&media.sidecar_path), + &MediaSidecar::from_media(&media, std::slice::from_ref(&first.id)).to_string(), + ) + .unwrap(); + + let report = rebuild_media_links(db.conn(), dir.path(), "p1").unwrap(); + assert_eq!(report.links, 1); + let links = qpm::list_post_media_by_media(db.conn(), &media.id).unwrap(); + assert_eq!(links.len(), 1); + assert_eq!(links[0].post_id, first.id); + + atomic_write_str( + &dir.path().join(&media.sidecar_path), + &MediaSidecar::from_media(&media, &["missing-post".into()]).to_string(), + ) + .unwrap(); + assert!(rebuild_media_links(db.conn(), dir.path(), "p1").is_err()); + let links = qpm::list_post_media_by_media(db.conn(), &media.id).unwrap(); + assert_eq!( + links.len(), + 1, + "the failed repair must roll back link deletion" + ); + assert_eq!(links[0].post_id, first.id); + } + #[test] fn replace_media_file_preserves_identity_and_regenerates_artifacts() { let (db, dir) = setup(); diff --git a/crates/bds-core/src/engine/metadata_diff.rs b/crates/bds-core/src/engine/metadata_diff.rs index 1b4a464..bb32dde 100644 --- a/crates/bds-core/src/engine/metadata_diff.rs +++ b/crates/bds-core/src/engine/metadata_diff.rs @@ -228,6 +228,62 @@ pub fn repair_metadata_diff_item( Ok(()) } +/// Import one content file reported as a filesystem orphan by +/// [`compute_metadata_diff`]. The normal per-entity rebuild paths remain the +/// sole parsers and writers for these formats. +pub fn import_orphan_file( + conn: &Connection, + data_dir: &Path, + project_id: &str, + orphan: &OrphanFile, +) -> EngineResult<()> { + if orphan.reason != "file_without_db_entry" { + return Err(EngineError::Validation(format!( + "cannot import an orphan that is absent from the filesystem: {}", + orphan.file_path + ))); + } + let path = data_dir.join(&orphan.file_path); + let canonical_data_dir = data_dir.canonicalize()?; + let canonical_path = path.canonicalize()?; + if !canonical_path.starts_with(&canonical_data_dir) { + return Err(EngineError::Validation( + "orphan path is outside the active project".into(), + )); + } + + if orphan.file_path.starts_with("posts/") && orphan.file_path.ends_with(".md") { + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(""); + if crate::engine::post::is_translation_filename(stem) { + crate::engine::post::rebuild_translation(conn, data_dir, project_id, &path)?; + } else { + crate::engine::post::rebuild_canonical_post(conn, data_dir, project_id, &path)?; + } + } else if orphan.file_path.starts_with("media/") && orphan.file_path.ends_with(".meta") { + let raw = fs::read_to_string(&path)?; + if read_translation_sidecar(&raw).is_ok() { + crate::engine::media::rebuild_translation_sidecar(conn, data_dir, project_id, &path)?; + } else { + crate::engine::media::rebuild_canonical_media(conn, data_dir, project_id, &path)?; + } + } else if orphan.file_path.starts_with("scripts/") && orphan.file_path.ends_with(".lua") { + crate::engine::script_rebuild::rebuild_single_script(conn, data_dir, project_id, &path)?; + } else if orphan.file_path.starts_with("templates/") && orphan.file_path.ends_with(".liquid") { + crate::engine::template_rebuild::rebuild_single_template( + conn, data_dir, project_id, &path, + )?; + } else { + return Err(EngineError::Validation(format!( + "unsupported orphan file: {}", + orphan.file_path + ))); + } + Ok(()) +} + fn diff_project( data_dir: &Path, project: &crate::model::Project, diff --git a/crates/bds-core/src/engine/mod.rs b/crates/bds-core/src/engine/mod.rs index c0f1399..a8afe88 100644 --- a/crates/bds-core/src/engine/mod.rs +++ b/crates/bds-core/src/engine/mod.rs @@ -2,6 +2,7 @@ pub mod ai; pub mod auto_translation; pub mod blogmark; pub mod calendar; +pub mod cli_launcher; pub mod cli_sync; pub mod domain_events; pub mod error; diff --git a/crates/bds-core/src/engine/post.rs b/crates/bds-core/src/engine/post.rs index d3152b6..b2991ef 100644 --- a/crates/bds-core/src/engine/post.rs +++ b/crates/bds-core/src/engine/post.rs @@ -916,7 +916,7 @@ fn fts_index_post(conn: &Connection, post: &Post) -> EngineResult<()> { /// Check if a file stem looks like a translation filename: `{slug}.{lang}` /// where lang is a 2-letter code. We look for a dot followed by exactly 2 lowercase letters. -fn is_translation_filename(stem: &str) -> bool { +pub(crate) fn is_translation_filename(stem: &str) -> bool { if let Some(dot_pos) = stem.rfind('.') { let suffix = &stem[dot_pos + 1..]; suffix.len() == 2 && suffix.chars().all(|c| c.is_ascii_lowercase()) diff --git a/crates/bds-core/src/engine/search.rs b/crates/bds-core/src/engine/search.rs index 5129453..2225e15 100644 --- a/crates/bds-core/src/engine/search.rs +++ b/crates/bds-core/src/engine/search.rs @@ -14,6 +14,50 @@ use crate::util::now_unix_ms; const REBUILD_REQUIRED_SETTING: &str = "app.search-index-rebuild-required"; +/// Deterministic offline language fallback used when no permitted AI endpoint +/// is configured. This intentionally mirrors the legacy application's small +/// heuristic; it is a notice-worthy fallback, not a language model. +pub fn detect_language(text: &str) -> &'static str { + let normalized = text.to_lowercase(); + if normalized.trim().is_empty() { + "en" + } else if normalized.contains(['ä', 'ö', 'ü', 'ß']) { + "de" + } else if normalized.contains([ + 'à', 'â', 'ç', 'é', 'è', 'ê', 'ë', 'î', 'ï', 'ô', 'ù', 'û', 'ÿ', 'œ', + ]) { + "fr" + } else if normalized.contains(['ñ', '¡', '¿']) { + "es" + } else { + detect_language_from_hints(&normalized) + } +} + +fn detect_language_from_hints(text: &str) -> &'static str { + let padded = format!(" {text} "); + let scores = [ + ( + "de", + [" der ", " die ", " das ", " und ", " ist ", " nicht "], + ), + ("fr", [" le ", " la ", " les ", " et ", " est ", " pas "]), + ("es", [" el ", " la ", " los ", " y ", " es ", " no "]), + ]; + scores + .into_iter() + .map(|(language, hints)| { + let score = hints + .into_iter() + .filter(|hint| padded.contains(hint)) + .count(); + (language, score) + }) + .max_by_key(|(_, score)| *score) + .filter(|(_, score)| *score >= 2) + .map_or("en", |(language, _)| language) +} + /// Result of a full reindex operation. pub struct ReindexReport { pub posts_indexed: usize, diff --git a/crates/bds-core/src/engine/validate_media.rs b/crates/bds-core/src/engine/validate_media.rs index a1a378d..c59a196 100644 --- a/crates/bds-core/src/engine/validate_media.rs +++ b/crates/bds-core/src/engine/validate_media.rs @@ -10,7 +10,12 @@ use crate::model::Media; use crate::util::{media_sidecar_path, thumbnail_path}; /// Thumbnail sizes per media_processing.allium. -const THUMBNAIL_SIZES: &[&str] = &["small", "medium", "large", "ai"]; +const THUMBNAIL_VARIANTS: &[(&str, &str)] = &[ + ("small", "webp"), + ("medium", "webp"), + ("large", "webp"), + ("ai", "jpg"), +]; /// Types of media validation issues. #[derive(Debug, Clone, PartialEq, Eq)] @@ -125,8 +130,7 @@ fn check_media_item( // 3. Missing thumbnails — only for image types if is_image_mime(&media.mime_type) { - let ext = thumbnail_extension(&media.mime_type); - for size in THUMBNAIL_SIZES { + for (size, ext) in THUMBNAIL_VARIANTS { let thumb_rel = thumbnail_path(&media.id, size, ext); let thumb_path = data_dir.join(&thumb_rel); if !thumb_path.exists() { @@ -160,15 +164,6 @@ fn is_image_mime(mime: &str) -> bool { mime.starts_with("image/") } -fn thumbnail_extension(mime: &str) -> &str { - match mime { - "image/png" => "png", - "image/gif" => "gif", - "image/webp" => "webp", - _ => "jpg", - } -} - #[cfg(test)] mod tests { use super::*; @@ -182,10 +177,15 @@ mod tests { } #[test] - fn thumbnail_ext_defaults_to_jpg() { - assert_eq!(thumbnail_extension("image/jpeg"), "jpg"); - assert_eq!(thumbnail_extension("image/png"), "png"); - assert_eq!(thumbnail_extension("image/webp"), "webp"); - assert_eq!(thumbnail_extension("image/tiff"), "jpg"); // fallback + fn thumbnail_variants_match_the_generator_formats() { + assert_eq!( + THUMBNAIL_VARIANTS, + &[ + ("small", "webp"), + ("medium", "webp"), + ("large", "webp"), + ("ai", "jpg") + ] + ); } } diff --git a/crates/bds-core/src/util/app_paths.rs b/crates/bds-core/src/util/app_paths.rs new file mode 100644 index 0000000..93bede1 --- /dev/null +++ b/crates/bds-core/src/util/app_paths.rs @@ -0,0 +1,34 @@ +use std::path::PathBuf; + +/// Machine-local application data directory shared by every RuDS surface. +pub fn application_data_dir() -> PathBuf { + dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("bds") +} + +/// SQLite cache/registry used by desktop, CLI, TUI, and remote surfaces. +pub fn application_database_path() -> PathBuf { + application_data_dir().join("bds.db") +} + +/// Default portable project folder used on first launch. +pub fn default_project_data_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("bds") + .join("my-blog") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn database_is_inside_application_data_directory() { + assert_eq!( + application_database_path(), + application_data_dir().join("bds.db") + ); + } +} diff --git a/crates/bds-core/src/util/mod.rs b/crates/bds-core/src/util/mod.rs index 2288a46..4101413 100644 --- a/crates/bds-core/src/util/mod.rs +++ b/crates/bds-core/src/util/mod.rs @@ -1,3 +1,4 @@ +pub mod app_paths; pub mod atomic_write; mod checksum; pub mod frontmatter; @@ -7,6 +8,7 @@ mod slug; pub mod thumbnail; pub mod timestamp; +pub use app_paths::{application_data_dir, application_database_path, default_project_data_dir}; pub use atomic_write::{atomic_write, atomic_write_str}; pub use checksum::{content_hash, file_hash}; pub use paths::*; diff --git a/crates/bds-core/tests/domain_events.rs b/crates/bds-core/tests/domain_events.rs index 2af3a8d..2da4100 100644 --- a/crates/bds-core/tests/domain_events.rs +++ b/crates/bds-core/tests/domain_events.rs @@ -470,3 +470,48 @@ fn cli_mutation_persists_the_shared_event_for_the_desktop_process() { ); assert!(notifications[0].from_cli); } + +#[test] +fn cli_mutation_deduplicates_composite_events_and_keeps_the_final_state() { + let db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + + let result: bds_core::engine::EngineResult<()> = cli_sync::run_cli_mutation(db.conn(), || { + bds_core::engine::domain_events::entity_changed( + "project", + DomainEntity::Post, + "created-then-updated", + NotificationAction::Created, + ); + bds_core::engine::domain_events::entity_changed( + "project", + DomainEntity::Post, + "created-then-updated", + NotificationAction::Updated, + ); + bds_core::engine::domain_events::entity_changed( + "project", + DomainEntity::Media, + "updated-then-deleted", + NotificationAction::Updated, + ); + bds_core::engine::domain_events::entity_changed( + "project", + DomainEntity::Media, + "updated-then-deleted", + NotificationAction::Deleted, + ); + Err(bds_core::engine::EngineError::Validation( + "later composite step failed".into(), + )) + }); + assert!(result.is_err()); + + let notifications = + bds_core::db::queries::db_notification::list_notifications(db.conn()).unwrap(); + assert_eq!(notifications.len(), 2); + assert_eq!(notifications[0].entity_id, "created-then-updated"); + assert_eq!(notifications[0].action, NotificationAction::Created); + assert_eq!(notifications[1].entity_id, "updated-then-deleted"); + assert_eq!(notifications[1].action, NotificationAction::Deleted); +} diff --git a/crates/bds-ui/Cargo.toml b/crates/bds-ui/Cargo.toml index fea7d31..40cf7f7 100644 --- a/crates/bds-ui/Cargo.toml +++ b/crates/bds-ui/Cargo.toml @@ -37,7 +37,11 @@ winresource = "0.1" product-name = "Blogging Desktop Server" identifier = "de.rfc1437.ruds" description = "A desktop application for writing and publishing static blogs." -before-packaging-command = "cargo build --release -p bds-ui" +before-packaging-command = "cargo build --release -p bds-ui -p bds-cli" +binaries = [ + { path = "bds-ui", main = true }, + { path = "bds-cli" }, +] deep-link-protocols = [{ schemes = ["ruds"] }] icons = [ "assets/app-icons/bds.icns", diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index e45276a..42c8484 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -903,10 +903,7 @@ impl BdsApp { let os_locale = detect_os_locale(); // Open or create the database - let db_path = dirs::data_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("bds") - .join("bds.db"); + let db_path = bds_core::util::application_database_path(); // Ensure parent directory exists if let Some(parent) = db_path.parent() { @@ -946,10 +943,7 @@ impl BdsApp { // If no projects exist, ensure the default project per spec let init_task = if projects.is_empty() { if let Some(ref db) = db { - let default_data = dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("bds") - .join("my-blog"); + let default_data = bds_core::util::default_project_data_dir(); match engine::project::ensure_default_project(db.conn(), Some(&default_data)) { Ok(project) => Task::done(Message::ProjectsLoaded(vec![project])), Err(_) => Task::none(), @@ -5972,6 +5966,22 @@ impl BdsApp { let _ = open::that(dir); } } + SettingsMsg::InstallCli => { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + match engine::cli_launcher::install_packaged_launcher(&home) { + Ok(path) => self.notify( + ToastLevel::Success, + &tw( + self.ui_locale, + "settings.cliInstalled", + &[("path", &path.to_string_lossy())], + ), + ), + Err(error) => { + self.notify_operation_failed("settings.installCli", error.to_string()) + } + } + } SettingsMsg::FocusSection(section) => { state.focus_section(section); } diff --git a/crates/bds-ui/src/views/settings_view.rs b/crates/bds-ui/src/views/settings_view.rs index 814759a..fd7cba1 100644 --- a/crates/bds-ui/src/views/settings_view.rs +++ b/crates/bds-ui/src/views/settings_view.rs @@ -344,6 +344,7 @@ pub enum SettingsMsg { RebuildSearchIndex, RegenerateThumbnails, OpenDataFolder, + InstallCli, /// Navigate to a specific section from sidebar; expand it, collapse all others. FocusSection(SettingsSection), } @@ -996,7 +997,12 @@ fn section_data<'a>(locale: UiLocale) -> Element<'a, Message> { .style(inputs::secondary_button) .padding([6, 16]); - column![rebuild_btns, open] + let install_cli = button(text(t(locale, "settings.installCli")).size(13)) + .on_press(Message::Settings(SettingsMsg::InstallCli)) + .style(inputs::secondary_button) + .padding([6, 16]); + + column![rebuild_btns, row![open, install_cli].spacing(8)] .spacing(12) .padding([0, 16]) .into() diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index 88e473b..3c18478 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -391,6 +391,8 @@ settings-rebuildTemplates = Vorlagen neu aufbauen settings-rebuildLinks = Links neu aufbauen settings-regenerateThumbnails = Vorschaubilder regenerieren settings-openDataFolder = Datenordner öffnen +settings-installCli = CLI installieren +settings-cliInstalled = CLI unter { $path } installiert settings-contentPlaceholder = Inhaltseinstellungen erscheinen hier settings-technologyPlaceholder = Technologieeinstellungen erscheinen hier settings-mcpPlaceholder = MCP-Server-Einstellungen erscheinen hier diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index 7a452cd..8afbf58 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -391,6 +391,8 @@ settings-rebuildTemplates = Rebuild Templates settings-rebuildLinks = Rebuild Links settings-regenerateThumbnails = Regenerate Thumbnails settings-openDataFolder = Open Data Folder +settings-installCli = Install CLI +settings-cliInstalled = CLI installed at { $path } settings-contentPlaceholder = Content settings will appear here settings-technologyPlaceholder = Technology settings will appear here settings-mcpPlaceholder = MCP server settings will appear here diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index 0137411..eb77b19 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -391,6 +391,8 @@ settings-rebuildTemplates = Reconstruir plantillas settings-rebuildLinks = Reconstruir enlaces settings-regenerateThumbnails = Regenerar miniaturas settings-openDataFolder = Abrir carpeta de datos +settings-installCli = Instalar CLI +settings-cliInstalled = CLI instalada en { $path } settings-contentPlaceholder = La configuración de contenido aparecerá aquí settings-technologyPlaceholder = La configuración tecnológica aparecerá aquí settings-mcpPlaceholder = La configuración del servidor MCP aparecerá aquí diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index 89369c5..02bab9f 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -391,6 +391,8 @@ settings-rebuildTemplates = Reconstruire les modèles settings-rebuildLinks = Reconstruire les liens settings-regenerateThumbnails = Régénérer les miniatures settings-openDataFolder = Ouvrir le dossier de données +settings-installCli = Installer la CLI +settings-cliInstalled = CLI installée dans { $path } settings-contentPlaceholder = Les paramètres de contenu apparaîtront ici settings-technologyPlaceholder = Les paramètres technologiques apparaîtront ici settings-mcpPlaceholder = Les paramètres du serveur MCP apparaîtront ici diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index 79d759f..502470a 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -391,6 +391,8 @@ settings-rebuildTemplates = Ricostruisci modelli settings-rebuildLinks = Ricostruisci link settings-regenerateThumbnails = Rigenera miniature settings-openDataFolder = Apri cartella dati +settings-installCli = Installa CLI +settings-cliInstalled = CLI installata in { $path } settings-contentPlaceholder = Le impostazioni del contenuto appariranno qui settings-technologyPlaceholder = Le impostazioni tecnologiche appariranno qui settings-mcpPlaceholder = Le impostazioni del server MCP appariranno qui diff --git a/specs/cli.allium b/specs/cli.allium index 07672e8..7819048 100644 --- a/specs/cli.allium +++ b/specs/cli.allium @@ -1,16 +1,18 @@ -- allium: 1 --- Workspace CLI tool (issue #25) +-- Workspace CLI tool (RuDS issue #19; distilled from bDS2 issue #25) -- Scope: extension (Bucket G — MCP + Automation) -- Distilled from: lib/bds/cli.ex, lib/bds/cli/commands.ex, lib/bds/cli/install.ex, -- rel/overlays/cli/bin/bds-cli entity CliInvocation { command: rebuild | repair | render | upload | push | pull | post | media - | gallery | config | project | tui | lua + | gallery | config | project | tui | lua | install incremental: Boolean force: Boolean + airplane: Boolean + json_output: Boolean exit_code: Integer - -- Parsed by Optimus: subcommands, options, flags, and auto-generated + -- Parsed by the native Clap binary: subcommands, options, flags, and auto-generated -- help/version output. Unknown commands and invalid options exit 1 -- with formatted errors on stderr. } @@ -24,8 +26,8 @@ surface CliSurface { } invariant SharedDatabase { - -- The CLI boots the application in BDS_MODE=cli: the same repo, - -- settings, and cache database as the GUI/TUI app — but with no + -- The native CLI resolves the same OS application data path, settings, + -- project registry, and cache database as the GUI/TUI app — but with no -- HTTP listener, no SSH daemon, no window, and no sync watcher. -- Console logging is redirected to the rotating log file so stdout -- carries only command output. @@ -47,6 +49,24 @@ rule ExitCode { ensures: CliInvocation.exit_code.updated() } +rule MachineOutput { + when: CliCommandExecuted(command) + requires: CliInvocation.json_output + -- Successful results use one stable JSON envelope on stdout; execution + -- errors use an error envelope on stderr and still exit 1. + ensures: CliInvocation.exit_code.updated() +} + +rule AirplaneGate { + when: CliCommandExecuted(command) + requires: CliInvocation.airplane + -- --airplane blocks upload and Git network commands and routes all + -- automatic AI work exclusively to the configured local endpoint. If no + -- local endpoint exists, deterministic offline fallbacks or an explicit + -- notice are used; the online endpoint is never contacted. + ensures: OfflineAiGated() +} + rule RebuildFull { when: CliCommandExecuted(command: rebuild) requires: not CliInvocation.incremental @@ -158,18 +178,16 @@ rule RunLuaTask { } rule InstallLauncher { - when: CliInstallRequested() - -- Install buttons in the GUI settings (Data section) and the TUI - -- settings (data form action field) both call - -- BDS.UI.SettingsForm.run_action("install_cli"): a shim exec'ing - -- the release's cli/bin/bds-cli launcher is written to - -- ~/.local/bin/bds-cli. Outside a packaged release the action - -- reports that installing requires the packaged application. + when: CliInstallRequested() or CliCommandExecuted(command: install) + -- Settings/Data, the future TUI settings action, and `bds-cli install` + -- share one guarded installer. A launcher pointing at the packaged native + -- CLI is written to ~/.local/bin/bds-cli. Existing unrelated files are + -- never overwritten; outside a packaged release the action reports that + -- the packaged CLI executable is required. ensures: LauncherInstalled() } -invariant LauncherArgv { - -- bin/bds eval cannot forward arguments, so the launcher passes the - -- argv in BDS_CLI_ARGV joined with the ASCII unit separator (0x1f) - -- and the release evaluates BDS.CLI.main(). +invariant NativeArgv { + -- The Rust launcher is the native bds-cli executable, so operating-system + -- argv is parsed directly without the bDS2 release-eval environment shim. }