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
|
## 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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user