Install the CLI with an overwriting symbolic link.
This commit is contained in:
4
CLI.md
4
CLI.md
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user