Install the CLI with an overwriting symbolic link.

This commit is contained in:
2026-08-03 11:04:02 +02:00
parent 36cbaa0cc5
commit 3b1a65359c
5 changed files with 69 additions and 79 deletions

4
CLI.md
View File

@@ -19,7 +19,7 @@ Typical uses: scripted rebuilds and site generation, publishing from CI or cron,
## Installing the CLI
Install the launcher from the desktop app under **Settings → Data**, or run `bds-cli install` from a packaged binary. Both place a small forwarding launcher in `~/.local/bin` that executes the packaged CLI next to the shared runtime. Make sure `~/.local/bin` is on your `PATH`.
Install the command from the desktop app under **Settings → Data**, or run `bds-cli install` from a packaged binary. Both replace `~/.local/bin/bds-cli` with a symbolic link to that package's CLI, keeping the command on the selected package and beside its shared runtime. Make sure `~/.local/bin` is on your `PATH`.
## Global flags and output
@@ -54,7 +54,7 @@ All commands operate on the active project from the shared project registry unle
| `lua <script> [args…]` | Run an enabled utility Lua script from the active project in the sandboxed runtime |
| `server` | Start the authenticated headless SSH server (see below) |
| `tui` | Start the interactive terminal UI locally |
| `install` | Install the forwarding launcher in `~/.local/bin` |
| `install` | Link `~/.local/bin/bds-cli` to this package's CLI, replacing the existing entry |
## Creating content from the command line

View File

@@ -19,7 +19,7 @@ The project is under active development. Core blogging workflows are broadly ava
- A localized Tags workspace manages tags and category settings; its category table includes the main language and every configured translation, whose titles are used by matching category archives and menu entries.
- A localized OPML menu editor manages pages, submenus, and category archives with protected Home ordering, keyboard-accessible tree controls, drag-and-drop, and bDS2-compatible persistence.
- Project-scoped typed domain events synchronize desktop views and cached runtime settings with shared-engine and CLI mutations even when Preferences is closed; persisted CLI notifications are consumed once, and the selected UI language is shared through settings.
- Headless `bds-cli` automation for rebuild/repair/render, with live terminal progress through metadata comparison, site validation, and page rendering; publishing and Git sync; post/media/gallery creation; effective shared settings with secret-presence redaction; projects; utility Lua tasks; JSON I/O; airplane-mode AI routing; and guarded launcher installation from Settings → Data or `bds-cli install`.
- Headless `bds-cli` automation for rebuild/repair/render, with live terminal progress through metadata comparison, site validation, and page rendering; publishing and Git sync; post/media/gallery creation; effective shared settings with secret-presence redaction; projects; utility Lua tasks; JSON I/O; airplane-mode AI routing; and package-linked CLI installation from Settings → Data or `bds-cli install`.
- Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, uniquely identified inert write proposals, clean duplicate-pending rejection, explicit desktop approval, and opt-in Claude Code/Copilot configuration.
- A fully localized Ratatui terminal workspace, available locally through `bds-cli tui`/`BDS_MODE=tui` and remotely through authenticated SSH shell sessions, with shared post/template/script editing and publishing, project/search/command overlays, settings, tags, Git, reports, task progress, live multi-client locale updates, and airplane-mode AI gating.
- `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection.
@@ -35,7 +35,7 @@ RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from
The packaged Apple Silicon application requires macOS 26 or newer.
Packaged executables share native `bds-core` and `bds-server` dynamic libraries. ONNX Runtime is statically contained in `bds-core`; the package does not ship or download a separate ONNX library. The **Install CLI** action writes a small forwarding launcher to `~/.local/bin`, so the command continues to execute the packaged CLI beside the same runtime libraries instead of copying them.
Packaged executables share native `bds-core` and `bds-server` dynamic libraries. ONNX Runtime is statically contained in `bds-core`; the package does not ship or download a separate ONNX library. The **Install CLI** action replaces `~/.local/bin/bds-cli` with a symbolic link to the packaged CLI, so the command executes beside the same runtime libraries and always follows the package selected by the latest installation.
Local macOS packages are ad-hoc signed without hardened runtime. A Developer ID and notarization remain optional release-channel steps for downloads that should pass Gatekeeper without a user override.

View File

@@ -1859,11 +1859,15 @@ mod tests {
assert!(fixture.run(&["install"], "").is_err());
}
#[cfg(unix)]
#[test]
fn launcher_install_is_idempotent_and_refuses_overwrite() {
fn launcher_install_is_idempotent_and_overwrites_existing_file() {
let fixture = Fixture::new(false);
let executable = fixture._root.path().join("packaged-bds-cli");
std::fs::write(&executable, b"binary").unwrap();
let target = fixture.home_dir.join(".local/bin/bds-cli");
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
std::fs::write(&target, b"old launcher").unwrap();
let context = RunContext {
executable_path: executable.clone(),
..fixture.context("")
@@ -1878,12 +1882,10 @@ mod tests {
context,
)
.unwrap();
let target = fixture.home_dir.join(".local/bin/bds-cli");
assert!(!target.is_symlink());
assert!(
std::fs::read_to_string(target)
.unwrap()
.contains(executable.canonicalize().unwrap().to_str().unwrap())
assert!(target.is_symlink());
assert_eq!(
std::fs::read_link(target).unwrap(),
executable.canonicalize().unwrap()
);
}
}

View File

@@ -2,8 +2,7 @@ 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.
/// Link the user-facing command to a packaged `bds-cli` binary.
pub fn install_launcher(executable: &Path, home_dir: &Path) -> EngineResult<PathBuf> {
if !executable.is_file() {
return Err(EngineError::Validation(format!(
@@ -14,47 +13,27 @@ pub fn install_launcher(executable: &Path, home_dir: &Path) -> EngineResult<Path
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.cmd"
"bds-cli.exe"
} else {
"bds-cli"
});
let source = executable.canonicalize()?;
let launcher = launcher_contents(&source);
#[cfg(unix)]
if target.is_symlink() && target.canonicalize().ok().as_ref() == Some(&source) {
std::fs::remove_file(&target)?;
}
if target.exists() {
if std::fs::read(&target).ok().as_deref() != Some(launcher.as_bytes()) {
return Err(EngineError::Conflict(format!(
"refusing to overwrite existing launcher at {}",
target.display()
)));
}
return Ok(target);
}
std::fs::write(&target, launcher)?;
#[cfg(unix)]
if let Err(error) = std::fs::remove_file(&target)
&& error.kind() != std::io::ErrorKind::NotFound
{
use std::os::unix::fs::PermissionsExt as _;
let mut permissions = std::fs::metadata(&target)?.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&target, permissions)?;
}
Ok(target)
return Err(error.into());
}
#[cfg(unix)]
fn launcher_contents(executable: &Path) -> String {
let quoted = executable.to_string_lossy().replace('\'', "'\"'\"'");
format!("#!/bin/sh\nexec '{quoted}' \"$@\"\n")
}
std::os::unix::fs::symlink(source, &target)?;
#[cfg(windows)]
fn launcher_contents(executable: &Path) -> String {
let escaped = executable.to_string_lossy().replace('%', "%%");
format!("@echo off\r\n\"{escaped}\" %*\r\n")
std::os::windows::fs::symlink_file(source, &target)?;
#[cfg(not(any(unix, windows)))]
return Err(EngineError::Validation(
"installing the CLI is not supported on this platform".to_string(),
));
Ok(target)
}
/// Resolve the CLI shipped beside the desktop executable and install it.
@@ -72,41 +51,50 @@ pub fn install_packaged_launcher(home_dir: &Path) -> EngineResult<PathBuf> {
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!(!target.is_symlink());
let launcher = std::fs::read_to_string(&target).unwrap();
assert!(launcher.contains(executable.canonicalize().unwrap().to_str().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");
}
#[cfg(unix)]
#[test]
fn replaces_the_previous_installer_symlink_with_a_forwarding_launcher() {
fn install_replaces_existing_launcher_with_symlink() {
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 = home.join(".local/bin/bds-cli");
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
std::os::unix::fs::symlink(&executable, &target).unwrap();
std::fs::write(&target, b"old launcher").unwrap();
let target = install_launcher(&executable, &home).unwrap();
assert!(target.is_symlink());
assert_eq!(
std::fs::read_link(&target).unwrap(),
executable.canonicalize().unwrap()
);
assert_eq!(install_launcher(&executable, &home).unwrap(), target);
let replacement = root.path().join("replacement-bds-cli");
std::fs::write(&replacement, b"new binary").unwrap();
assert_eq!(install_launcher(&replacement, &home).unwrap(), target);
assert_eq!(
std::fs::read_link(target).unwrap(),
replacement.canonicalize().unwrap()
);
}
#[cfg(unix)]
#[test]
fn install_replaces_broken_symlink() {
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 = home.join(".local/bin/bds-cli");
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
std::os::unix::fs::symlink(root.path().join("missing-bds-cli"), &target).unwrap();
assert_eq!(install_launcher(&executable, &home).unwrap(), target);
assert!(!target.is_symlink());
assert!(
std::fs::read_to_string(target)
.unwrap()
.starts_with("#!/bin/sh")
assert!(target.is_symlink());
assert_eq!(
std::fs::read_link(target).unwrap(),
executable.canonicalize().unwrap()
);
}
}

View File

@@ -215,15 +215,15 @@ rule InstallLauncher {
when: CliInstallRequested() or CliCommandExecuted(command)
requires: command = install
-- Settings/Data, the future TUI settings action, and `bds-cli install`
-- share one guarded installer. A forwarding launcher written to
-- ~/.local/bin/bds-cli executes the packaged native CLI in place, so it
-- uses the same packaged runtime as the desktop app. Existing unrelated
-- files are never overwritten; outside a packaged release the action
-- reports that the packaged CLI executable is required.
-- share one installer. A symbolic link at ~/.local/bin/bds-cli targets
-- the packaged native CLI in place, so it uses the same packaged runtime
-- as the desktop app. Every install replaces an existing file or symbolic
-- link at that path so it always targets the requested package. Outside a
-- packaged release the action reports that the packaged CLI is required.
ensures: LauncherInstalled()
}
invariant NativeArgv {
-- The forwarding launcher preserves operating-system argv for the native
-- bds-cli executable without the bDS2 release-eval environment shim.
-- The symbolic link invokes the native bds-cli executable directly with
-- operating-system argv, without the bDS2 release-eval environment shim.
}