Implement the shared automation CLI.
This commit is contained in:
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
1520
crates/bds-cli/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
188
crates/bds-cli/tests/process.rs
Normal file
188
crates/bds-cli/tests/process.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ libsqlite3-sys = { workspace = true }
|
||||
mlua = { workspace = true }
|
||||
url = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -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)
|
||||
|
||||
73
crates/bds-core/src/engine/cli_launcher.rs
Normal file
73
crates/bds-core/src/engine/cli_launcher.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
34
crates/bds-core/src/util/app_paths.rs
Normal file
34
crates/bds-core/src/util/app_paths.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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í
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user