refactor: remove remote module install, extract bread-sync, make CI real
Security: - Remove `bread modules install github:…`. Remote fetch pulled unreviewed third-party Lua and ran it with full bread.exec() privileges in an unsandboxed runtime. Module install is now local-only; parse_source rejects github:/git: with an explicit message. bread-sync extracted from the workspace (parked for its own project): - Removed from workspace members (now excluded); see bread-sync/EXTRACTION.md - Removed the entire `bread sync` CLI surface and now-unused deps (bread-sync, reqwest, tar, flate2; tempfile demoted to dev-dependency) - Removed the sync.status IPC method from breadd plus its integration tests - Moved the generic `expand_path` helper into bread-shared (with unit tests) CI now actually runs and gates quality: - Trigger on master/dev (was `main` — CI had never run, not once) - Added `cargo fmt --check` and `clippy -D warnings`; fixed 4 clippy warnings - Dropped the macOS matrix entry (breadd is Linux-only: udev/rtnetlink); added the libudev-dev system dependency the Linux build needs Hardening / honesty: - New ipc test: daemon survives repeated reloads and the event pipeline resumes (the prior suite only had a single happy-path reload check) - Docs scrubbed of sync across README/Documentation/Overview/DAEMON - "production-ready" and "compositor-agnostic" claims reworded to match reality rather than aspiration Note: bread-sync/src/export.rs held pre-existing local WIP authored outside this change set and is intentionally excluded from this commit.
This commit is contained in:
parent
3be8eec065
commit
cc456b78fe
14 changed files with 202 additions and 1946 deletions
|
|
@ -1,10 +1,6 @@
|
|||
mod modules_mgmt;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use bread_sync::{
|
||||
config::{bread_config_dir, SyncConfig},
|
||||
delegates, machine, packages, apply_import, stage_export, SyncRepo,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use serde_json::{json, Value};
|
||||
|
|
@ -62,11 +58,6 @@ enum Commands {
|
|||
#[command(subcommand)]
|
||||
subcommand: ModulesCommand,
|
||||
},
|
||||
/// Manage sync (snapshot and restore system state)
|
||||
Sync {
|
||||
#[command(subcommand)]
|
||||
subcommand: SyncCommand,
|
||||
},
|
||||
/// List available profiles
|
||||
ProfileList,
|
||||
/// Activate a profile
|
||||
|
|
@ -91,9 +82,9 @@ enum Commands {
|
|||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum ModulesCommand {
|
||||
/// Install a module from a source
|
||||
/// Install a module from a local directory
|
||||
Install {
|
||||
/// Source: github:user/repo[@ref] or /path/to/dir
|
||||
/// Path to a local module directory
|
||||
source: String,
|
||||
},
|
||||
/// Remove an installed module
|
||||
|
|
@ -105,66 +96,10 @@ enum ModulesCommand {
|
|||
},
|
||||
/// List all installed modules
|
||||
List,
|
||||
/// Update one or all installed modules
|
||||
Update {
|
||||
/// Module name (omit to update all)
|
||||
name: Option<String>,
|
||||
},
|
||||
/// Show full manifest details for a module
|
||||
Info { name: String },
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum SyncCommand {
|
||||
/// Initialize sync for this machine
|
||||
Init {
|
||||
/// Git remote URL
|
||||
#[arg(long)]
|
||||
remote: Option<String>,
|
||||
},
|
||||
/// Snapshot and push current state
|
||||
Push {
|
||||
/// Custom commit message
|
||||
#[arg(long)]
|
||||
message: Option<String>,
|
||||
},
|
||||
/// Pull and apply latest state
|
||||
Pull {
|
||||
/// Also install packages from manifest
|
||||
#[arg(long)]
|
||||
install_packages: bool,
|
||||
},
|
||||
/// Show what has changed since last push
|
||||
Status,
|
||||
/// Show file-level diff vs last commit (or vs remote with --remote)
|
||||
Diff {
|
||||
#[arg(long)]
|
||||
remote: bool,
|
||||
},
|
||||
/// List known machines from sync repo
|
||||
Machines,
|
||||
/// Create a portable export archive (no git auth required)
|
||||
Export {
|
||||
/// Output path: directory or .tar.gz file. Defaults to ./bread-export-<machine>-<date>.tar.gz
|
||||
#[arg(long, short)]
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
/// Apply a portable export archive to this machine
|
||||
Import {
|
||||
/// Path to a bread export directory or .tar.gz file
|
||||
from: PathBuf,
|
||||
/// Also install packages from the package manifests
|
||||
#[arg(long)]
|
||||
install_packages: bool,
|
||||
/// Skip cloning git repositories to their original locations
|
||||
#[arg(long)]
|
||||
no_clone_repos: bool,
|
||||
/// Skip confirmation prompt
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
|
@ -202,9 +137,6 @@ async fn main() -> Result<()> {
|
|||
Commands::Modules { subcommand } => {
|
||||
handle_modules_cmd(subcommand, &socket).await?;
|
||||
}
|
||||
Commands::Sync { subcommand } => {
|
||||
handle_sync_cmd(subcommand, &socket).await?;
|
||||
}
|
||||
Commands::ProfileList => {
|
||||
let response = send_request(&socket, "profile.list", json!({})).await?;
|
||||
print_json(&response)?;
|
||||
|
|
@ -257,7 +189,7 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> {
|
|||
|
||||
match cmd {
|
||||
ModulesCommand::Install { source } => {
|
||||
let manifest = install_module(&source, &mods_dir).await?;
|
||||
let manifest = install_module(&source, &mods_dir)?;
|
||||
println!("installed {} v{}", manifest.name, manifest.version);
|
||||
try_daemon_reload(socket).await;
|
||||
}
|
||||
|
|
@ -312,39 +244,6 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
ModulesCommand::Update { name } => {
|
||||
let targets: Vec<_> = if let Some(n) = name {
|
||||
vec![modules_mgmt::read_module_manifest(&n, &mods_dir)?]
|
||||
} else {
|
||||
modules_mgmt::list_modules(&mods_dir)?
|
||||
};
|
||||
|
||||
let mut updated_any = false;
|
||||
for manifest in targets {
|
||||
if manifest.source.starts_with("github:") {
|
||||
let old_ver = manifest.version.clone();
|
||||
let new_manifest = install_module(&manifest.source, &mods_dir).await?;
|
||||
if new_manifest.version == old_ver {
|
||||
println!("{} already up to date", manifest.name);
|
||||
} else {
|
||||
println!(
|
||||
"updated {} v{} → v{}",
|
||||
manifest.name, old_ver, new_manifest.version
|
||||
);
|
||||
updated_any = true;
|
||||
}
|
||||
} else {
|
||||
eprintln!(
|
||||
"cannot update local module '{}' — reinstall manually",
|
||||
manifest.name
|
||||
);
|
||||
}
|
||||
}
|
||||
if updated_any {
|
||||
try_daemon_reload(socket).await;
|
||||
}
|
||||
}
|
||||
|
||||
ModulesCommand::Info { name } => {
|
||||
let m = modules_mgmt::read_module_manifest(&name, &mods_dir)?;
|
||||
let status = match send_request(socket, "modules.list", json!({})).await {
|
||||
|
|
@ -371,74 +270,12 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn install_module(
|
||||
fn install_module(
|
||||
source: &str,
|
||||
mods_dir: &std::path::Path,
|
||||
) -> Result<modules_mgmt::ModuleManifest> {
|
||||
match modules_mgmt::parse_source(source)? {
|
||||
modules_mgmt::InstallSource::LocalPath(path) => {
|
||||
modules_mgmt::install_from_local(&path, source, mods_dir)
|
||||
}
|
||||
modules_mgmt::InstallSource::GitHub {
|
||||
user,
|
||||
repo,
|
||||
git_ref,
|
||||
} => install_from_github(&user, &repo, git_ref.as_deref(), source, mods_dir).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn install_from_github(
|
||||
user: &str,
|
||||
repo: &str,
|
||||
git_ref: Option<&str>,
|
||||
source_str: &str,
|
||||
mods_dir: &Path,
|
||||
) -> Result<modules_mgmt::ModuleManifest> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("bread-cli/0.1")
|
||||
.build()?;
|
||||
|
||||
let ref_to_use = match git_ref {
|
||||
Some(r) => r.to_string(),
|
||||
None => {
|
||||
let url = format!("https://api.github.com/repos/{user}/{repo}");
|
||||
let resp: Value = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to reach GitHub API")?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to parse GitHub API response")?;
|
||||
resp.get("default_branch")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("main")
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let tarball_url = format!("https://api.github.com/repos/{user}/{repo}/tarball/{ref_to_use}");
|
||||
let bytes = client
|
||||
.get(&tarball_url)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to download module archive")?
|
||||
.bytes()
|
||||
.await
|
||||
.context("failed to read module archive")?;
|
||||
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(&bytes[..]));
|
||||
archive.unpack(tmp.path())?;
|
||||
|
||||
// GitHub extracts to a single subdirectory (e.g. "user-repo-sha/")
|
||||
let root = std::fs::read_dir(tmp.path())?
|
||||
.filter_map(|e| e.ok())
|
||||
.find(|e| e.path().is_dir())
|
||||
.map(|e| e.path())
|
||||
.ok_or_else(|| anyhow::anyhow!("no directory found in extracted archive"))?;
|
||||
|
||||
modules_mgmt::install_from_local(&root, source_str, mods_dir)
|
||||
let path = modules_mgmt::parse_source(source)?;
|
||||
modules_mgmt::install_from_local(&path, source, mods_dir)
|
||||
}
|
||||
|
||||
/// Notify the daemon to reload modules. Prints a warning if the daemon is unreachable.
|
||||
|
|
@ -451,576 +288,6 @@ async fn try_daemon_reload(socket: &Path) {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn handle_sync_cmd(cmd: SyncCommand, socket: &Path) -> Result<()> {
|
||||
let cfg_dir = bread_config_dir();
|
||||
|
||||
match cmd {
|
||||
SyncCommand::Init { remote } => cmd_sync_init(&cfg_dir, remote).await?,
|
||||
SyncCommand::Push { message } => cmd_sync_push(&cfg_dir, message).await?,
|
||||
SyncCommand::Pull { install_packages } => {
|
||||
cmd_sync_pull(&cfg_dir, install_packages, socket).await?
|
||||
}
|
||||
SyncCommand::Status => cmd_sync_status(&cfg_dir).await?,
|
||||
SyncCommand::Diff { remote } => cmd_sync_diff(&cfg_dir, remote).await?,
|
||||
SyncCommand::Machines => cmd_sync_machines(&cfg_dir).await?,
|
||||
SyncCommand::Export { output } => cmd_sync_export(&cfg_dir, output).await?,
|
||||
SyncCommand::Import { from, install_packages, no_clone_repos, yes } => {
|
||||
cmd_sync_import(&cfg_dir, from, install_packages, !no_clone_repos, yes, &socket).await?
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_sync_init(cfg_dir: &Path, remote: Option<String>) -> Result<()> {
|
||||
let sync_toml = cfg_dir.join("sync.toml");
|
||||
if sync_toml.exists() {
|
||||
eprintln!(
|
||||
"bread: sync already initialized. Edit {} to reconfigure.",
|
||||
sync_toml.display()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let remote_url = match remote {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
print!("Sync remote URL (leave empty for local-only, e.g. git@github.com:you/config): ");
|
||||
io::stdout().flush()?;
|
||||
let mut line = String::new();
|
||||
io::stdin().read_line(&mut line)?;
|
||||
line.trim().to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let default_hostname = machine::hostname();
|
||||
print!("Machine name [{}]: ", default_hostname);
|
||||
io::stdout().flush()?;
|
||||
let mut name_line = String::new();
|
||||
io::stdin().read_line(&mut name_line)?;
|
||||
let machine_name = {
|
||||
let t = name_line.trim();
|
||||
if t.is_empty() {
|
||||
default_hostname
|
||||
} else {
|
||||
t.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
print!("Machine tags (comma-separated, e.g. mobile,battery): ");
|
||||
io::stdout().flush()?;
|
||||
let mut tags_line = String::new();
|
||||
io::stdin().read_line(&mut tags_line)?;
|
||||
let tags: Vec<String> = tags_line
|
||||
.trim()
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
|
||||
let config = SyncConfig {
|
||||
remote: bread_sync::config::RemoteConfig {
|
||||
url: remote_url.clone(),
|
||||
branch: "main".to_string(),
|
||||
},
|
||||
machine: bread_sync::config::MachineConfig {
|
||||
name: machine_name.clone(),
|
||||
tags,
|
||||
},
|
||||
packages: bread_sync::config::PackagesConfig::default(),
|
||||
delegates: bread_sync::config::DelegatesConfig::default(),
|
||||
};
|
||||
config.save(cfg_dir)?;
|
||||
|
||||
println!();
|
||||
println!("sync initialized");
|
||||
println!(" machine: {}", machine_name);
|
||||
if remote_url.is_empty() {
|
||||
println!(" remote: (local-only — use 'bread sync export' to create a portable snapshot)");
|
||||
} else {
|
||||
println!(" remote: {}", remote_url);
|
||||
if !remote_url.starts_with('/') && !remote_url.starts_with('.') {
|
||||
println!(" note: remote will be created on first push");
|
||||
}
|
||||
}
|
||||
println!(" config: {}", cfg_dir.join("sync.toml").display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_sync_push(cfg_dir: &Path, message: Option<String>) -> Result<()> {
|
||||
let config = load_sync_config(cfg_dir)?;
|
||||
let repo_path = SyncConfig::local_repo_path();
|
||||
|
||||
let repo = if repo_path.exists() {
|
||||
SyncRepo::open(&repo_path)?
|
||||
} else {
|
||||
SyncRepo::init(&repo_path)?
|
||||
};
|
||||
|
||||
// Snapshot bread/ directory
|
||||
let bread_dest = repo_path.join("bread");
|
||||
delegates::sync_dir(cfg_dir, &bread_dest, &[".git".to_string()])?;
|
||||
|
||||
// Snapshot delegate configs
|
||||
let configs_dir = repo_path.join("configs");
|
||||
let delegate_paths = delegates::resolve_include_paths(&config.delegates.include);
|
||||
for (basename, src_path) in &delegate_paths {
|
||||
if src_path.exists() {
|
||||
let dst = configs_dir.join(basename);
|
||||
delegates::sync_dir(src_path, &dst, &config.delegates.exclude)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot packages
|
||||
if config.packages.enabled {
|
||||
let packages_dir = repo_path.join("packages");
|
||||
for manager in &config.packages.managers {
|
||||
let dest_file = packages_dir.join(format!("{manager}.txt"));
|
||||
if let Err(e) = packages::snapshot(manager, &dest_file) {
|
||||
eprintln!("bread: warning: package snapshot for {manager} failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write machine profile
|
||||
let machines_dir = repo_path.join("machines");
|
||||
machine::MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone())
|
||||
.write(&machines_dir)?;
|
||||
|
||||
let commit_msg = message.unwrap_or_else(|| {
|
||||
format!(
|
||||
"sync: {} {}",
|
||||
config.machine.name,
|
||||
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ")
|
||||
)
|
||||
});
|
||||
|
||||
if repo.commit(&commit_msg)?.is_none() {
|
||||
println!("nothing to commit — already up to date");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("committed sync for {}", config.machine.name);
|
||||
println!(" snapshot: {}", repo_path.display());
|
||||
println!(" tip: run 'bread sync export' to create a portable snapshot");
|
||||
if config.packages.enabled {
|
||||
println!(" packages: {}", config.packages.managers.join(", "));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_sync_pull(cfg_dir: &Path, install_packages: bool, socket: &Path) -> Result<()> {
|
||||
let config = load_sync_config(cfg_dir)?;
|
||||
let repo_path = SyncConfig::local_repo_path();
|
||||
|
||||
if !repo_path.exists() {
|
||||
eprintln!("bread: no local snapshot found. Run 'bread sync push' first.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Apply bread/ → ~/.config/bread/
|
||||
let bread_src = repo_path.join("bread");
|
||||
if bread_src.exists() {
|
||||
delegates::sync_dir(&bread_src, cfg_dir, &[])?;
|
||||
}
|
||||
|
||||
// Apply configs/ entries back to their original locations
|
||||
let configs_dir = repo_path.join("configs");
|
||||
if configs_dir.exists() {
|
||||
let delegate_paths = delegates::resolve_include_paths(&config.delegates.include);
|
||||
for (basename, dst_path) in &delegate_paths {
|
||||
let src = configs_dir.join(basename);
|
||||
if src.exists() {
|
||||
delegates::sync_dir(&src, dst_path, &config.delegates.exclude)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Package installs
|
||||
if config.packages.enabled {
|
||||
let packages_dir = repo_path.join("packages");
|
||||
if install_packages {
|
||||
run_package_installs(&packages_dir, &config.packages.managers)?;
|
||||
} else {
|
||||
// Check if packages differ
|
||||
let has_package_files = config
|
||||
.packages
|
||||
.managers
|
||||
.iter()
|
||||
.any(|m| packages_dir.join(format!("{m}.txt")).exists());
|
||||
if has_package_files {
|
||||
println!(
|
||||
"note: run 'bread sync pull --install-packages' to install missing packages"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notify daemon
|
||||
try_daemon_reload(socket).await;
|
||||
|
||||
println!("applied sync for {}", config.machine.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_sync_status(cfg_dir: &Path) -> Result<()> {
|
||||
let config = load_sync_config(cfg_dir)?;
|
||||
let repo_path = SyncConfig::local_repo_path();
|
||||
|
||||
if !repo_path.exists() {
|
||||
println!("bread sync status");
|
||||
println!(" not yet committed — run 'bread sync push'");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let repo = SyncRepo::open(&repo_path)?;
|
||||
|
||||
let last_commit = repo
|
||||
.last_commit_time()
|
||||
.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_else(|| "never".to_string());
|
||||
|
||||
println!("bread sync status");
|
||||
println!(" machine {}", config.machine.name);
|
||||
println!(" snapshot {}", repo_path.display());
|
||||
println!(" last commit {}", last_commit);
|
||||
|
||||
let local_changes = repo.local_changes()?;
|
||||
println!();
|
||||
println!("uncommitted changes:");
|
||||
if local_changes.is_empty() {
|
||||
println!(" none");
|
||||
} else {
|
||||
for (ch, path) in &local_changes {
|
||||
println!(" {} {}", ch, path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_sync_diff(cfg_dir: &Path, _vs_remote: bool) -> Result<()> {
|
||||
let _config = load_sync_config(cfg_dir)?;
|
||||
let repo_path = SyncConfig::local_repo_path();
|
||||
|
||||
if !repo_path.exists() {
|
||||
eprintln!("bread: sync repo not initialized. Run: bread sync push");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let repo = SyncRepo::open(&repo_path)?;
|
||||
let diff = repo.working_diff()?;
|
||||
print!("{}", diff);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_sync_machines(cfg_dir: &Path) -> Result<()> {
|
||||
let _ = load_sync_config(cfg_dir)?;
|
||||
let repo_path = SyncConfig::local_repo_path();
|
||||
let machines_dir = repo_path.join("machines");
|
||||
|
||||
let profiles = machine::MachineProfile::list(&machines_dir)?;
|
||||
for p in &profiles {
|
||||
let tags = if p.tags.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" tags: {}", p.tags.join(", "))
|
||||
};
|
||||
println!(" {:20} last sync: {}{}", p.name, &p.last_sync[..16], tags);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_sync_export(cfg_dir: &Path, output: Option<PathBuf>) -> Result<()> {
|
||||
// Load sync config if available; fall back to machine defaults.
|
||||
let config = match SyncConfig::load(cfg_dir) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
let name = machine::hostname();
|
||||
SyncConfig {
|
||||
remote: bread_sync::config::RemoteConfig {
|
||||
url: String::new(),
|
||||
branch: "main".to_string(),
|
||||
},
|
||||
machine: bread_sync::config::MachineConfig { name, tags: vec![] },
|
||||
packages: bread_sync::config::PackagesConfig::default(),
|
||||
delegates: bread_sync::config::DelegatesConfig::default(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let date = chrono::Utc::now().format("%Y-%m-%d");
|
||||
let export_name = format!("bread-export-{}-{}", config.machine.name, date);
|
||||
|
||||
// Decide: tarball or directory?
|
||||
let (staging_path, make_tarball, final_path) = match &output {
|
||||
Some(p) if p.extension().and_then(|e| e.to_str()) == Some("gz") => {
|
||||
// User wants a .tar.gz at a specific path
|
||||
let staging = std::env::temp_dir().join(&export_name);
|
||||
(staging, true, p.clone())
|
||||
}
|
||||
Some(p) if p.is_dir() || !p.exists() => {
|
||||
// User wants a directory
|
||||
let dir = if p.is_dir() { p.join(&export_name) } else { p.clone() };
|
||||
(dir.clone(), false, dir)
|
||||
}
|
||||
Some(p) => {
|
||||
anyhow::bail!("output path {} already exists and is not a directory", p.display());
|
||||
}
|
||||
None => {
|
||||
// Default: .tar.gz in current directory
|
||||
let tarball = std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join(format!("{export_name}.tar.gz"));
|
||||
let staging = std::env::temp_dir().join(&export_name);
|
||||
(staging, true, tarball)
|
||||
}
|
||||
};
|
||||
|
||||
// Stage everything into the staging directory
|
||||
let manifest = stage_export(cfg_dir, &config, &staging_path)
|
||||
.context("failed to stage export")?;
|
||||
|
||||
// Optionally pack into a tarball
|
||||
if make_tarball {
|
||||
create_tarball(&staging_path, &final_path)
|
||||
.context("failed to create tarball")?;
|
||||
std::fs::remove_dir_all(&staging_path).ok();
|
||||
}
|
||||
|
||||
println!("exported to {}", final_path.display());
|
||||
println!(" machine: {}", manifest.machine);
|
||||
if !manifest.configs.is_empty() {
|
||||
println!(" configs: {}", manifest.configs.join(", "));
|
||||
}
|
||||
if !manifest.path_map.is_empty() {
|
||||
let file_count = manifest.path_map.iter().filter(|r| r.is_file).count();
|
||||
let dir_count = manifest.path_map.iter().filter(|r| !r.is_file).count();
|
||||
if file_count > 0 {
|
||||
println!(" dotfiles: {} file(s)", file_count);
|
||||
}
|
||||
if dir_count > manifest.configs.len() {
|
||||
println!(" dirs: {} total", dir_count);
|
||||
}
|
||||
}
|
||||
if !manifest.packages.is_empty() {
|
||||
println!(" packages: {}", manifest.packages.join(", "));
|
||||
}
|
||||
if !manifest.repos.is_empty() {
|
||||
println!(" repos: {} git repositories tracked", manifest.repos.len());
|
||||
}
|
||||
if manifest.system {
|
||||
println!(" system: udev / modprobe / sysctl (see restore.sh for sudo commands)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_sync_import(
|
||||
cfg_dir: &Path,
|
||||
from: PathBuf,
|
||||
install_packages: bool,
|
||||
clone_repos: bool,
|
||||
yes: bool,
|
||||
socket: &Path,
|
||||
) -> Result<()> {
|
||||
// Determine staging directory
|
||||
let is_tarball = from.extension().and_then(|e| e.to_str()) == Some("gz");
|
||||
|
||||
let (staging, _tmp_guard) = if is_tarball {
|
||||
let tmp = tempfile::tempdir().context("failed to create temp dir")?;
|
||||
extract_tarball(&from, tmp.path()).context("failed to extract tarball")?;
|
||||
// GitHub-style tarballs extract into a single subdirectory; unwrap if needed
|
||||
let inner = find_single_subdir(tmp.path()).unwrap_or_else(|| tmp.path().to_path_buf());
|
||||
(inner, Some(tmp))
|
||||
} else if from.is_dir() {
|
||||
(from.clone(), None)
|
||||
} else {
|
||||
anyhow::bail!("'{}' is not a directory or .tar.gz file", from.display());
|
||||
};
|
||||
|
||||
// Read manifest for summary
|
||||
let manifest_path = staging.join("manifest.toml");
|
||||
if !manifest_path.exists() {
|
||||
anyhow::bail!("not a bread export: manifest.toml not found in {}", staging.display());
|
||||
}
|
||||
let manifest_raw = std::fs::read_to_string(&manifest_path)?;
|
||||
let manifest: bread_sync::ExportManifest = toml::from_str(&manifest_raw)
|
||||
.context("failed to parse manifest.toml")?;
|
||||
|
||||
println!("bread import: {} (exported {})", manifest.machine, &manifest.exported_at[..16]);
|
||||
println!(" configs: {}", if manifest.configs.is_empty() { "-".to_string() } else { manifest.configs.join(", ") });
|
||||
println!(" packages: {}", if manifest.packages.is_empty() { "-".to_string() } else { manifest.packages.join(", ") });
|
||||
if !manifest.repos.is_empty() {
|
||||
println!(" repos: {} git repositories found", manifest.repos.len());
|
||||
if clone_repos {
|
||||
println!(" (will be cloned to their original locations)");
|
||||
} else {
|
||||
println!(" (skipping clone — remove --no-clone-repos to restore)");
|
||||
}
|
||||
}
|
||||
if manifest.system {
|
||||
println!(" note: system files (udev/modprobe/sysctl) will NOT be applied automatically");
|
||||
}
|
||||
|
||||
if !yes {
|
||||
print!("\nApply to ~/.config and ~/.local? (y/n): ");
|
||||
io::stdout().flush()?;
|
||||
let mut line = String::new();
|
||||
io::stdin().read_line(&mut line)?;
|
||||
if !line.trim().eq_ignore_ascii_case("y") {
|
||||
println!("aborted");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let applied = apply_import(&staging, cfg_dir, install_packages, clone_repos)
|
||||
.context("import failed")?;
|
||||
|
||||
println!();
|
||||
for item in &applied {
|
||||
println!(" + {item}");
|
||||
}
|
||||
|
||||
if manifest.system {
|
||||
println!();
|
||||
println!("system files were NOT applied automatically. To restore them:");
|
||||
println!(" {}/restore.sh", staging.display());
|
||||
}
|
||||
|
||||
// Notify daemon
|
||||
try_daemon_reload(socket).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_tarball(src_dir: &Path, dest: &Path) -> Result<()> {
|
||||
use flate2::{write::GzEncoder, Compression};
|
||||
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let file = std::fs::File::create(dest)
|
||||
.with_context(|| format!("failed to create {}", dest.display()))?;
|
||||
let encoder = GzEncoder::new(file, Compression::default());
|
||||
let mut archive = tar::Builder::new(encoder);
|
||||
|
||||
let base_name = src_dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("bread-export");
|
||||
|
||||
// Walk the staging directory and append every file
|
||||
append_dir_recursive(&mut archive, src_dir, src_dir, base_name)?;
|
||||
|
||||
archive.finish()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_dir_recursive(
|
||||
archive: &mut tar::Builder<flate2::write::GzEncoder<std::fs::File>>,
|
||||
root: &Path,
|
||||
current: &Path,
|
||||
base_name: &str,
|
||||
) -> Result<()> {
|
||||
for entry in std::fs::read_dir(current).context("failed to read dir for tarball")? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let rel = path.strip_prefix(root).unwrap_or(&path);
|
||||
let tar_path = PathBuf::from(base_name).join(rel);
|
||||
|
||||
if path.is_dir() {
|
||||
archive.append_dir(&tar_path, &path)?;
|
||||
append_dir_recursive(archive, root, &path, base_name)?;
|
||||
} else if path.is_file() {
|
||||
archive.append_path_with_name(&path, &tar_path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_tarball(src: &Path, dest: &Path) -> Result<()> {
|
||||
use flate2::read::GzDecoder;
|
||||
|
||||
let file = std::fs::File::open(src)
|
||||
.with_context(|| format!("failed to open {}", src.display()))?;
|
||||
let decoder = GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(dest)
|
||||
.with_context(|| format!("failed to extract {}", src.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// If a directory contains exactly one subdirectory and nothing else, return it.
|
||||
fn find_single_subdir(dir: &Path) -> Option<PathBuf> {
|
||||
let entries: Vec<_> = std::fs::read_dir(dir)
|
||||
.ok()?
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
if entries.len() == 1 && entries[0].path().is_dir() {
|
||||
Some(entries[0].path())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn load_sync_config(cfg_dir: &Path) -> Result<SyncConfig> {
|
||||
match SyncConfig::load(cfg_dir) {
|
||||
Ok(c) => Ok(c),
|
||||
Err(_) => {
|
||||
eprintln!("bread: sync not initialized. Run: bread sync init");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_package_installs(packages_dir: &Path, managers: &[String]) -> Result<()> {
|
||||
for manager in managers {
|
||||
let file = packages_dir.join(format!("{manager}.txt"));
|
||||
if !file.exists() {
|
||||
continue;
|
||||
}
|
||||
let content = std::fs::read_to_string(&file)?;
|
||||
match manager.as_str() {
|
||||
"pacman" => {
|
||||
let pkgs = packages::parse_pacman(&content);
|
||||
if pkgs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut cmd = std::process::Command::new("sudo");
|
||||
cmd.args(["pacman", "-S", "--needed"]).args(&pkgs);
|
||||
let _ = cmd.status();
|
||||
}
|
||||
"pip" => {
|
||||
let mut cmd = std::process::Command::new("pip");
|
||||
cmd.args(["install", "--user", "-r"]).arg(&file);
|
||||
let _ = cmd.status();
|
||||
}
|
||||
"npm" => {
|
||||
let pkgs = packages::parse_npm(&content);
|
||||
for pkg in pkgs {
|
||||
let _ = std::process::Command::new("npm")
|
||||
.args(["install", "-g", &pkg])
|
||||
.status();
|
||||
}
|
||||
}
|
||||
"cargo" => {
|
||||
let pkgs = packages::parse_cargo(&content);
|
||||
for pkg in pkgs {
|
||||
let _ = std::process::Command::new("cargo")
|
||||
.args(["install", &pkg])
|
||||
.status();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers (shared with original commands)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -15,44 +15,31 @@ pub struct ModuleManifest {
|
|||
pub installed_at: String,
|
||||
}
|
||||
|
||||
/// Parsed install source.
|
||||
pub enum InstallSource {
|
||||
GitHub {
|
||||
user: String,
|
||||
repo: String,
|
||||
git_ref: Option<String>,
|
||||
},
|
||||
LocalPath(PathBuf),
|
||||
}
|
||||
|
||||
/// Parse a source string into an `InstallSource`.
|
||||
pub fn parse_source(source: &str) -> Result<InstallSource> {
|
||||
if let Some(rest) = source.strip_prefix("github:") {
|
||||
let (repo_part, ref_part) = rest
|
||||
.split_once('@')
|
||||
.map(|(r, v)| (r, Some(v.to_string())))
|
||||
.unwrap_or((rest, None));
|
||||
let (user, repo) = repo_part.split_once('/').ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"bread: invalid github source '{}'. Expected 'github:user/repo[@ref]'",
|
||||
source
|
||||
)
|
||||
})?;
|
||||
Ok(InstallSource::GitHub {
|
||||
user: user.to_string(),
|
||||
repo: repo.to_string(),
|
||||
git_ref: ref_part,
|
||||
})
|
||||
} else if source.starts_with('/')
|
||||
/// Resolve a module source string to a local directory path.
|
||||
///
|
||||
/// Only local paths are accepted. Remote fetching (`github:user/repo`) was
|
||||
/// removed: it pulled arbitrary, unsandboxed Lua that the daemon then runs with
|
||||
/// full `bread.exec()` privileges as the user. Installing a remote module now
|
||||
/// requires cloning it yourself, so the review step stays in the user's hands.
|
||||
pub fn parse_source(source: &str) -> Result<PathBuf> {
|
||||
if source.starts_with("github:") || source.starts_with("git:") {
|
||||
bail!(
|
||||
"bread: remote module installation has been removed for security \
|
||||
(it ran unreviewed third-party Lua with full exec privileges). \
|
||||
Clone the repository yourself, review it, then run \
|
||||
'bread modules install /path/to/checkout'"
|
||||
);
|
||||
}
|
||||
if source.starts_with('/')
|
||||
|| source.starts_with("./")
|
||||
|| source.starts_with("../")
|
||||
|| source.starts_with('~')
|
||||
{
|
||||
let expanded = bread_sync::config::expand_path(source);
|
||||
Ok(InstallSource::LocalPath(expanded))
|
||||
Ok(bread_shared::expand_path(source))
|
||||
} else {
|
||||
bail!(
|
||||
"bread: invalid module source '{}'. Use 'github:user/repo' or an absolute/relative path",
|
||||
"bread: invalid module source '{}'. Provide an absolute or relative \
|
||||
path to a local module directory",
|
||||
source
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue