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 = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bds-core",
|
"bds-core",
|
||||||
"tokio",
|
"clap",
|
||||||
|
"dirs 5.0.1",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -802,6 +806,7 @@ dependencies = [
|
|||||||
"deunicode",
|
"deunicode",
|
||||||
"diesel",
|
"diesel",
|
||||||
"diesel_migrations",
|
"diesel_migrations",
|
||||||
|
"dirs 5.0.1",
|
||||||
"fluent-bundle",
|
"fluent-bundle",
|
||||||
"fluent-syntax",
|
"fluent-syntax",
|
||||||
"htmd",
|
"htmd",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ sha2 = "0.10"
|
|||||||
deunicode = "1"
|
deunicode = "1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
walkdir = "2"
|
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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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-core` — data, engines, rendering, AI, publishing, and Lua
|
||||||
- `crates/bds-editor` — reusable syntax-highlighting editor
|
- `crates/bds-editor` — reusable syntax-highlighting editor
|
||||||
- `crates/bds-ui` — desktop application and platform integration
|
- `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
|
- `specs` — authoritative Allium behavior specifications
|
||||||
- `fixtures` — compatibility projects and generated-site fixtures
|
- `fixtures` — compatibility projects and generated-site fixtures
|
||||||
- `locales` — UI and native-menu translations
|
- `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-core` | Models, SQLite, filesystem formats, engines, rendering, generation, AI, publishing, and Lua |
|
||||||
| `bds-editor` | Reusable Ropey/Syntect/Cosmic Text editor widget |
|
| `bds-editor` | Reusable Ropey/Syntect/Cosmic Text editor widget |
|
||||||
| `bds-ui` | Iced application, native menus/dialogs, platform lifecycle, and embedded preview |
|
| `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
|
## Current Core Status
|
||||||
|
|
||||||
|
|||||||
@@ -81,17 +81,17 @@ Open:
|
|||||||
- OPML/menu editor UI.
|
- OPML/menu editor UI.
|
||||||
- Replace the Menu Editor placeholder.
|
- Replace the Menu Editor placeholder.
|
||||||
|
|
||||||
### CLI and MCP — Open; domain events — Done
|
### CLI and domain events — Done; MCP — Open
|
||||||
|
|
||||||
Done:
|
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.
|
- 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:
|
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`.
|
- MCP tools/resources and proposal-based writes from `mcp.allium`.
|
||||||
- Replace the MCP settings placeholder.
|
- Replace the MCP settings placeholder.
|
||||||
|
|
||||||
|
|||||||
@@ -7,4 +7,10 @@ license.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
bds-core = { workspace = true }
|
bds-core = { workspace = true }
|
||||||
anyhow = { 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() {
|
use std::io::Read as _;
|
||||||
println!("bds-cli: headless automation surface (not yet implemented)");
|
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 }
|
mlua = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
|
dirs = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ impl DbConnection {
|
|||||||
.batch_execute("ROLLBACK TO bds_operation; RELEASE bds_operation")
|
.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| {
|
self.with(|conn| {
|
||||||
diesel::sql_query("SELECT file FROM pragma_database_list WHERE name = 'main'")
|
diesel::sql_query("SELECT file FROM pragma_database_list WHERE name = 'main'")
|
||||||
.get_result::<DatabasePathRow>(conn)
|
.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> {
|
) -> EngineResult<T> {
|
||||||
let (result, events) = domain_events::capture_current_thread(operation)
|
let (result, events) = domain_events::capture_current_thread(operation)
|
||||||
.map_err(|message| EngineError::Validation(message.to_string()))?;
|
.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)?;
|
record_cli_event(conn, event)?;
|
||||||
}
|
}
|
||||||
result
|
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<()> {
|
pub fn record_cli_event(conn: &Connection, event: &DomainEvent) -> EngineResult<()> {
|
||||||
record_cli_event_at(conn, event, now_unix_ms())
|
record_cli_event_at(conn, event, now_unix_ms())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ fn import_gallery_image(
|
|||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
|
|
||||||
let title = if ai_available {
|
let title = if ai_available {
|
||||||
enrich_image(
|
enrich_imported_image(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
data_dir,
|
data_dir,
|
||||||
&imported,
|
&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,
|
conn: &crate::db::DbConnection,
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
imported: &crate::model::Media,
|
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(
|
pub fn generate_starter_site_with_progress(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
output_dir: &Path,
|
output_dir: &Path,
|
||||||
|
|||||||
@@ -34,6 +34,116 @@ pub struct MediaRebuildReport {
|
|||||||
pub errors: Vec<String>,
|
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).
|
/// Supported image MIME types for import (per media_processing.allium).
|
||||||
const SUPPORTED_IMAGE_TYPES: &[&str] = &[
|
const SUPPORTED_IMAGE_TYPES: &[&str] = &[
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
@@ -959,6 +1069,95 @@ mod tests {
|
|||||||
assert_eq!(sc.tags, vec!["updated-tag"]);
|
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]
|
#[test]
|
||||||
fn replace_media_file_preserves_identity_and_regenerates_artifacts() {
|
fn replace_media_file_preserves_identity_and_regenerates_artifacts() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
|
|||||||
@@ -228,6 +228,62 @@ pub fn repair_metadata_diff_item(
|
|||||||
Ok(())
|
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(
|
fn diff_project(
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
project: &crate::model::Project,
|
project: &crate::model::Project,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod ai;
|
|||||||
pub mod auto_translation;
|
pub mod auto_translation;
|
||||||
pub mod blogmark;
|
pub mod blogmark;
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
|
pub mod cli_launcher;
|
||||||
pub mod cli_sync;
|
pub mod cli_sync;
|
||||||
pub mod domain_events;
|
pub mod domain_events;
|
||||||
pub mod error;
|
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}`
|
/// 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.
|
/// 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('.') {
|
if let Some(dot_pos) = stem.rfind('.') {
|
||||||
let suffix = &stem[dot_pos + 1..];
|
let suffix = &stem[dot_pos + 1..];
|
||||||
suffix.len() == 2 && suffix.chars().all(|c| c.is_ascii_lowercase())
|
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";
|
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.
|
/// Result of a full reindex operation.
|
||||||
pub struct ReindexReport {
|
pub struct ReindexReport {
|
||||||
pub posts_indexed: usize,
|
pub posts_indexed: usize,
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ use crate::model::Media;
|
|||||||
use crate::util::{media_sidecar_path, thumbnail_path};
|
use crate::util::{media_sidecar_path, thumbnail_path};
|
||||||
|
|
||||||
/// Thumbnail sizes per media_processing.allium.
|
/// 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.
|
/// Types of media validation issues.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -125,8 +130,7 @@ fn check_media_item(
|
|||||||
|
|
||||||
// 3. Missing thumbnails — only for image types
|
// 3. Missing thumbnails — only for image types
|
||||||
if is_image_mime(&media.mime_type) {
|
if is_image_mime(&media.mime_type) {
|
||||||
let ext = thumbnail_extension(&media.mime_type);
|
for (size, ext) in THUMBNAIL_VARIANTS {
|
||||||
for size in THUMBNAIL_SIZES {
|
|
||||||
let thumb_rel = thumbnail_path(&media.id, size, ext);
|
let thumb_rel = thumbnail_path(&media.id, size, ext);
|
||||||
let thumb_path = data_dir.join(&thumb_rel);
|
let thumb_path = data_dir.join(&thumb_rel);
|
||||||
if !thumb_path.exists() {
|
if !thumb_path.exists() {
|
||||||
@@ -160,15 +164,6 @@ fn is_image_mime(mime: &str) -> bool {
|
|||||||
mime.starts_with("image/")
|
mime.starts_with("image/")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn thumbnail_extension(mime: &str) -> &str {
|
|
||||||
match mime {
|
|
||||||
"image/png" => "png",
|
|
||||||
"image/gif" => "gif",
|
|
||||||
"image/webp" => "webp",
|
|
||||||
_ => "jpg",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -182,10 +177,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn thumbnail_ext_defaults_to_jpg() {
|
fn thumbnail_variants_match_the_generator_formats() {
|
||||||
assert_eq!(thumbnail_extension("image/jpeg"), "jpg");
|
assert_eq!(
|
||||||
assert_eq!(thumbnail_extension("image/png"), "png");
|
THUMBNAIL_VARIANTS,
|
||||||
assert_eq!(thumbnail_extension("image/webp"), "webp");
|
&[
|
||||||
assert_eq!(thumbnail_extension("image/tiff"), "jpg"); // fallback
|
("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;
|
pub mod atomic_write;
|
||||||
mod checksum;
|
mod checksum;
|
||||||
pub mod frontmatter;
|
pub mod frontmatter;
|
||||||
@@ -7,6 +8,7 @@ mod slug;
|
|||||||
pub mod thumbnail;
|
pub mod thumbnail;
|
||||||
pub mod timestamp;
|
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 atomic_write::{atomic_write, atomic_write_str};
|
||||||
pub use checksum::{content_hash, file_hash};
|
pub use checksum::{content_hash, file_hash};
|
||||||
pub use paths::*;
|
pub use paths::*;
|
||||||
|
|||||||
@@ -470,3 +470,48 @@ fn cli_mutation_persists_the_shared_event_for_the_desktop_process() {
|
|||||||
);
|
);
|
||||||
assert!(notifications[0].from_cli);
|
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"
|
product-name = "Blogging Desktop Server"
|
||||||
identifier = "de.rfc1437.ruds"
|
identifier = "de.rfc1437.ruds"
|
||||||
description = "A desktop application for writing and publishing static blogs."
|
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"] }]
|
deep-link-protocols = [{ schemes = ["ruds"] }]
|
||||||
icons = [
|
icons = [
|
||||||
"assets/app-icons/bds.icns",
|
"assets/app-icons/bds.icns",
|
||||||
|
|||||||
@@ -903,10 +903,7 @@ impl BdsApp {
|
|||||||
let os_locale = detect_os_locale();
|
let os_locale = detect_os_locale();
|
||||||
|
|
||||||
// Open or create the database
|
// Open or create the database
|
||||||
let db_path = dirs::data_dir()
|
let db_path = bds_core::util::application_database_path();
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
|
||||||
.join("bds")
|
|
||||||
.join("bds.db");
|
|
||||||
|
|
||||||
// Ensure parent directory exists
|
// Ensure parent directory exists
|
||||||
if let Some(parent) = db_path.parent() {
|
if let Some(parent) = db_path.parent() {
|
||||||
@@ -946,10 +943,7 @@ impl BdsApp {
|
|||||||
// If no projects exist, ensure the default project per spec
|
// If no projects exist, ensure the default project per spec
|
||||||
let init_task = if projects.is_empty() {
|
let init_task = if projects.is_empty() {
|
||||||
if let Some(ref db) = db {
|
if let Some(ref db) = db {
|
||||||
let default_data = dirs::home_dir()
|
let default_data = bds_core::util::default_project_data_dir();
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
|
||||||
.join("bds")
|
|
||||||
.join("my-blog");
|
|
||||||
match engine::project::ensure_default_project(db.conn(), Some(&default_data)) {
|
match engine::project::ensure_default_project(db.conn(), Some(&default_data)) {
|
||||||
Ok(project) => Task::done(Message::ProjectsLoaded(vec![project])),
|
Ok(project) => Task::done(Message::ProjectsLoaded(vec![project])),
|
||||||
Err(_) => Task::none(),
|
Err(_) => Task::none(),
|
||||||
@@ -5972,6 +5966,22 @@ impl BdsApp {
|
|||||||
let _ = open::that(dir);
|
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) => {
|
SettingsMsg::FocusSection(section) => {
|
||||||
state.focus_section(section);
|
state.focus_section(section);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ pub enum SettingsMsg {
|
|||||||
RebuildSearchIndex,
|
RebuildSearchIndex,
|
||||||
RegenerateThumbnails,
|
RegenerateThumbnails,
|
||||||
OpenDataFolder,
|
OpenDataFolder,
|
||||||
|
InstallCli,
|
||||||
/// Navigate to a specific section from sidebar; expand it, collapse all others.
|
/// Navigate to a specific section from sidebar; expand it, collapse all others.
|
||||||
FocusSection(SettingsSection),
|
FocusSection(SettingsSection),
|
||||||
}
|
}
|
||||||
@@ -996,7 +997,12 @@ fn section_data<'a>(locale: UiLocale) -> Element<'a, Message> {
|
|||||||
.style(inputs::secondary_button)
|
.style(inputs::secondary_button)
|
||||||
.padding([6, 16]);
|
.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)
|
.spacing(12)
|
||||||
.padding([0, 16])
|
.padding([0, 16])
|
||||||
.into()
|
.into()
|
||||||
|
|||||||
@@ -391,6 +391,8 @@ settings-rebuildTemplates = Vorlagen neu aufbauen
|
|||||||
settings-rebuildLinks = Links neu aufbauen
|
settings-rebuildLinks = Links neu aufbauen
|
||||||
settings-regenerateThumbnails = Vorschaubilder regenerieren
|
settings-regenerateThumbnails = Vorschaubilder regenerieren
|
||||||
settings-openDataFolder = Datenordner öffnen
|
settings-openDataFolder = Datenordner öffnen
|
||||||
|
settings-installCli = CLI installieren
|
||||||
|
settings-cliInstalled = CLI unter { $path } installiert
|
||||||
settings-contentPlaceholder = Inhaltseinstellungen erscheinen hier
|
settings-contentPlaceholder = Inhaltseinstellungen erscheinen hier
|
||||||
settings-technologyPlaceholder = Technologieeinstellungen erscheinen hier
|
settings-technologyPlaceholder = Technologieeinstellungen erscheinen hier
|
||||||
settings-mcpPlaceholder = MCP-Server-Einstellungen erscheinen hier
|
settings-mcpPlaceholder = MCP-Server-Einstellungen erscheinen hier
|
||||||
|
|||||||
@@ -391,6 +391,8 @@ settings-rebuildTemplates = Rebuild Templates
|
|||||||
settings-rebuildLinks = Rebuild Links
|
settings-rebuildLinks = Rebuild Links
|
||||||
settings-regenerateThumbnails = Regenerate Thumbnails
|
settings-regenerateThumbnails = Regenerate Thumbnails
|
||||||
settings-openDataFolder = Open Data Folder
|
settings-openDataFolder = Open Data Folder
|
||||||
|
settings-installCli = Install CLI
|
||||||
|
settings-cliInstalled = CLI installed at { $path }
|
||||||
settings-contentPlaceholder = Content settings will appear here
|
settings-contentPlaceholder = Content settings will appear here
|
||||||
settings-technologyPlaceholder = Technology settings will appear here
|
settings-technologyPlaceholder = Technology settings will appear here
|
||||||
settings-mcpPlaceholder = MCP server 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-rebuildLinks = Reconstruir enlaces
|
||||||
settings-regenerateThumbnails = Regenerar miniaturas
|
settings-regenerateThumbnails = Regenerar miniaturas
|
||||||
settings-openDataFolder = Abrir carpeta de datos
|
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-contentPlaceholder = La configuración de contenido aparecerá aquí
|
||||||
settings-technologyPlaceholder = La configuración tecnológica aparecerá aquí
|
settings-technologyPlaceholder = La configuración tecnológica aparecerá aquí
|
||||||
settings-mcpPlaceholder = La configuración del servidor MCP 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-rebuildLinks = Reconstruire les liens
|
||||||
settings-regenerateThumbnails = Régénérer les miniatures
|
settings-regenerateThumbnails = Régénérer les miniatures
|
||||||
settings-openDataFolder = Ouvrir le dossier de données
|
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-contentPlaceholder = Les paramètres de contenu apparaîtront ici
|
||||||
settings-technologyPlaceholder = Les paramètres technologiques apparaîtront ici
|
settings-technologyPlaceholder = Les paramètres technologiques apparaîtront ici
|
||||||
settings-mcpPlaceholder = Les paramètres du serveur MCP 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-rebuildLinks = Ricostruisci link
|
||||||
settings-regenerateThumbnails = Rigenera miniature
|
settings-regenerateThumbnails = Rigenera miniature
|
||||||
settings-openDataFolder = Apri cartella dati
|
settings-openDataFolder = Apri cartella dati
|
||||||
|
settings-installCli = Installa CLI
|
||||||
|
settings-cliInstalled = CLI installata in { $path }
|
||||||
settings-contentPlaceholder = Le impostazioni del contenuto appariranno qui
|
settings-contentPlaceholder = Le impostazioni del contenuto appariranno qui
|
||||||
settings-technologyPlaceholder = Le impostazioni tecnologiche appariranno qui
|
settings-technologyPlaceholder = Le impostazioni tecnologiche appariranno qui
|
||||||
settings-mcpPlaceholder = Le impostazioni del server MCP appariranno qui
|
settings-mcpPlaceholder = Le impostazioni del server MCP appariranno qui
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
-- allium: 1
|
-- allium: 1
|
||||||
-- Workspace CLI tool (issue #25)
|
-- Workspace CLI tool (RuDS issue #19; distilled from bDS2 issue #25)
|
||||||
-- Scope: extension (Bucket G — MCP + Automation)
|
-- Scope: extension (Bucket G — MCP + Automation)
|
||||||
-- Distilled from: lib/bds/cli.ex, lib/bds/cli/commands.ex, lib/bds/cli/install.ex,
|
-- Distilled from: lib/bds/cli.ex, lib/bds/cli/commands.ex, lib/bds/cli/install.ex,
|
||||||
-- rel/overlays/cli/bin/bds-cli
|
-- rel/overlays/cli/bin/bds-cli
|
||||||
|
|
||||||
entity CliInvocation {
|
entity CliInvocation {
|
||||||
command: rebuild | repair | render | upload | push | pull | post | media
|
command: rebuild | repair | render | upload | push | pull | post | media
|
||||||
| gallery | config | project | tui | lua
|
| gallery | config | project | tui | lua | install
|
||||||
incremental: Boolean
|
incremental: Boolean
|
||||||
force: Boolean
|
force: Boolean
|
||||||
|
airplane: Boolean
|
||||||
|
json_output: Boolean
|
||||||
exit_code: Integer
|
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
|
-- help/version output. Unknown commands and invalid options exit 1
|
||||||
-- with formatted errors on stderr.
|
-- with formatted errors on stderr.
|
||||||
}
|
}
|
||||||
@@ -24,8 +26,8 @@ surface CliSurface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
invariant SharedDatabase {
|
invariant SharedDatabase {
|
||||||
-- The CLI boots the application in BDS_MODE=cli: the same repo,
|
-- The native CLI resolves the same OS application data path, settings,
|
||||||
-- settings, and cache database as the GUI/TUI app — but with no
|
-- project registry, and cache database as the GUI/TUI app — but with no
|
||||||
-- HTTP listener, no SSH daemon, no window, and no sync watcher.
|
-- HTTP listener, no SSH daemon, no window, and no sync watcher.
|
||||||
-- Console logging is redirected to the rotating log file so stdout
|
-- Console logging is redirected to the rotating log file so stdout
|
||||||
-- carries only command output.
|
-- carries only command output.
|
||||||
@@ -47,6 +49,24 @@ rule ExitCode {
|
|||||||
ensures: CliInvocation.exit_code.updated()
|
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 {
|
rule RebuildFull {
|
||||||
when: CliCommandExecuted(command: rebuild)
|
when: CliCommandExecuted(command: rebuild)
|
||||||
requires: not CliInvocation.incremental
|
requires: not CliInvocation.incremental
|
||||||
@@ -158,18 +178,16 @@ rule RunLuaTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rule InstallLauncher {
|
rule InstallLauncher {
|
||||||
when: CliInstallRequested()
|
when: CliInstallRequested() or CliCommandExecuted(command: install)
|
||||||
-- Install buttons in the GUI settings (Data section) and the TUI
|
-- Settings/Data, the future TUI settings action, and `bds-cli install`
|
||||||
-- settings (data form action field) both call
|
-- share one guarded installer. A launcher pointing at the packaged native
|
||||||
-- BDS.UI.SettingsForm.run_action("install_cli"): a shim exec'ing
|
-- CLI is written to ~/.local/bin/bds-cli. Existing unrelated files are
|
||||||
-- the release's cli/bin/bds-cli launcher is written to
|
-- never overwritten; outside a packaged release the action reports that
|
||||||
-- ~/.local/bin/bds-cli. Outside a packaged release the action
|
-- the packaged CLI executable is required.
|
||||||
-- reports that installing requires the packaged application.
|
|
||||||
ensures: LauncherInstalled()
|
ensures: LauncherInstalled()
|
||||||
}
|
}
|
||||||
|
|
||||||
invariant LauncherArgv {
|
invariant NativeArgv {
|
||||||
-- bin/bds eval cannot forward arguments, so the launcher passes the
|
-- The Rust launcher is the native bds-cli executable, so operating-system
|
||||||
-- argv in BDS_CLI_ARGV joined with the ASCII unit separator (0x1f)
|
-- argv is parsed directly without the bDS2 release-eval environment shim.
|
||||||
-- and the release evaluates BDS.CLI.main().
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user