Implement the shared automation CLI.

This commit is contained in:
2026-07-19 14:25:15 +02:00
parent a2abb901cd
commit fdfae200a0
33 changed files with 2411 additions and 58 deletions

7
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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"

1520
crates/bds-cli/src/lib.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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)
}
}
}

View File

@@ -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)
);
}

View File

@@ -37,6 +37,7 @@ libsqlite3-sys = { workspace = true }
mlua = { workspace = true }
url = { workspace = true }
base64 = { workspace = true }
dirs = { workspace = true }
[dev-dependencies]
tempfile = "3"

View File

@@ -53,7 +53,9 @@ impl DbConnection {
.batch_execute("ROLLBACK TO bds_operation; RELEASE bds_operation")
}
pub(crate) fn database_path(&self) -> diesel::QueryResult<std::path::PathBuf> {
/// 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<std::path::PathBuf> {
self.with(|conn| {
diesel::sql_query("SELECT file FROM pragma_database_list WHERE name = 'main'")
.get_result::<DatabasePathRow>(conn)

View File

@@ -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<PathBuf> {
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<PathBuf> {
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");
}
}

View File

@@ -21,12 +21,74 @@ pub fn run_cli_mutation<T>(
) -> EngineResult<T> {
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::<DomainEvent>::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())
}

View File

@@ -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,

View File

@@ -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<usize> {
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,

View File

@@ -34,6 +34,116 @@ pub struct MediaRebuildReport {
pub errors: Vec<String>,
}
#[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<MediaLinkRebuildReport> {
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<MediaLinkRebuildReport> {
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<ThumbnailRepairReport> {
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();

View File

@@ -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,

View File

@@ -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;

View File

@@ -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())

View File

@@ -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,

View File

@@ -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")
]
);
}
}

View File

@@ -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")
);
}
}

View File

@@ -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::*;

View File

@@ -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);
}

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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í

View File

@@ -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

View File

@@ -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

View File

@@ -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.
}