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 ## 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 ## 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 | | `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) | | `server` | Start the authenticated headless SSH server (see below) |
| `tui` | Start the interactive terminal UI locally | | `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 ## 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 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. - 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. - 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. - 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. - 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. - `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. 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. 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()); assert!(fixture.run(&["install"], "").is_err());
} }
#[cfg(unix)]
#[test] #[test]
fn launcher_install_is_idempotent_and_refuses_overwrite() { fn launcher_install_is_idempotent_and_overwrites_existing_file() {
let fixture = Fixture::new(false); let fixture = Fixture::new(false);
let executable = fixture._root.path().join("packaged-bds-cli"); let executable = fixture._root.path().join("packaged-bds-cli");
std::fs::write(&executable, b"binary").unwrap(); 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 { let context = RunContext {
executable_path: executable.clone(), executable_path: executable.clone(),
..fixture.context("") ..fixture.context("")
@@ -1878,12 +1882,10 @@ mod tests {
context, context,
) )
.unwrap(); .unwrap();
let target = fixture.home_dir.join(".local/bin/bds-cli"); assert!(target.is_symlink());
assert!(!target.is_symlink()); assert_eq!(
assert!( std::fs::read_link(target).unwrap(),
std::fs::read_to_string(target) executable.canonicalize().unwrap()
.unwrap()
.contains(executable.canonicalize().unwrap().to_str().unwrap())
); );
} }
} }

View File

@@ -2,8 +2,7 @@ use std::path::{Path, PathBuf};
use crate::engine::{EngineError, EngineResult}; use crate::engine::{EngineError, EngineResult};
/// Install a recoverable launcher pointing at a packaged `bds-cli` binary. /// Link the user-facing command to a packaged `bds-cli` binary.
/// Existing unrelated files are never overwritten.
pub fn install_launcher(executable: &Path, home_dir: &Path) -> EngineResult<PathBuf> { pub fn install_launcher(executable: &Path, home_dir: &Path) -> EngineResult<PathBuf> {
if !executable.is_file() { if !executable.is_file() {
return Err(EngineError::Validation(format!( return Err(EngineError::Validation(format!(
@@ -14,49 +13,29 @@ pub fn install_launcher(executable: &Path, home_dir: &Path) -> EngineResult<Path
let bin_dir = home_dir.join(".local/bin"); let bin_dir = home_dir.join(".local/bin");
std::fs::create_dir_all(&bin_dir)?; std::fs::create_dir_all(&bin_dir)?;
let target = bin_dir.join(if cfg!(windows) { let target = bin_dir.join(if cfg!(windows) {
"bds-cli.cmd" "bds-cli.exe"
} else { } else {
"bds-cli" "bds-cli"
}); });
let source = executable.canonicalize()?; let source = executable.canonicalize()?;
let launcher = launcher_contents(&source); if let Err(error) = std::fs::remove_file(&target)
#[cfg(unix)] && error.kind() != std::io::ErrorKind::NotFound
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)]
{ {
use std::os::unix::fs::PermissionsExt as _; return Err(error.into());
let mut permissions = std::fs::metadata(&target)?.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&target, permissions)?;
} }
#[cfg(unix)]
std::os::unix::fs::symlink(source, &target)?;
#[cfg(windows)]
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) Ok(target)
} }
#[cfg(unix)]
fn launcher_contents(executable: &Path) -> String {
let quoted = executable.to_string_lossy().replace('\'', "'\"'\"'");
format!("#!/bin/sh\nexec '{quoted}' \"$@\"\n")
}
#[cfg(windows)]
fn launcher_contents(executable: &Path) -> String {
let escaped = executable.to_string_lossy().replace('%', "%%");
format!("@echo off\r\n\"{escaped}\" %*\r\n")
}
/// Resolve the CLI shipped beside the desktop executable and install it. /// Resolve the CLI shipped beside the desktop executable and install it.
pub fn install_packaged_launcher(home_dir: &Path) -> EngineResult<PathBuf> { pub fn install_packaged_launcher(home_dir: &Path) -> EngineResult<PathBuf> {
let app = std::env::current_exe()?; let app = std::env::current_exe()?;
@@ -72,41 +51,50 @@ pub fn install_packaged_launcher(home_dir: &Path) -> EngineResult<PathBuf> {
mod tests { mod tests {
use super::*; 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)] #[cfg(unix)]
#[test] #[test]
fn replaces_the_previous_installer_symlink_with_a_forwarding_launcher() { fn install_replaces_existing_launcher_with_symlink() {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
let executable = root.path().join("packaged-bds-cli"); let executable = root.path().join("packaged-bds-cli");
std::fs::write(&executable, b"binary").unwrap(); std::fs::write(&executable, b"binary").unwrap();
let home = root.path().join("home"); let home = root.path().join("home");
let target = home.join(".local/bin/bds-cli"); let target = home.join(".local/bin/bds-cli");
std::fs::create_dir_all(target.parent().unwrap()).unwrap(); 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_eq!(install_launcher(&executable, &home).unwrap(), target);
assert!(!target.is_symlink()); assert!(target.is_symlink());
assert!( assert_eq!(
std::fs::read_to_string(target) std::fs::read_link(target).unwrap(),
.unwrap() executable.canonicalize().unwrap()
.starts_with("#!/bin/sh")
); );
} }
} }

View File

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