Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
Multiprofile: add 'rad profile' command (copy-on-switch, no symlinks); wire into CLI
✗ CI failure anon committed 8 months ago
commit 13fd2ed92139426d31f3973c7e3024265fc5107a
parent 55cdd880bfee08124d5b6a38cc05036402c7ab6e
4 failed (4 total) View logs
4 files changed +352 -0
modified crates/radicle-cli/src/commands.rs
@@ -60,3 +60,5 @@ pub mod rad_unfollow;
pub mod rad_unseed;
#[path = "commands/watch.rs"]
pub mod rad_watch;
+
#[path = "commands/profile.rs"]
+
pub mod rad_profile;
modified crates/radicle-cli/src/commands/help.rs
@@ -39,6 +39,7 @@ const COMMANDS: &[Help] = &[
    rad_remote::HELP,
    rad_stats::HELP,
    rad_sync::HELP,
+
    rad_profile::HELP
];

#[derive(Default)]
added crates/radicle-cli/src/commands/profile.rs
@@ -0,0 +1,342 @@
+
use std::ffi::OsString;
+
use std::fs;
+
use std::path::{Path, PathBuf};
+

+
use anyhow::{anyhow, Context as _};
+
use lexopt::prelude::*;
+
use radicle::profile;
+

+
use crate::terminal as term;
+
use crate::terminal::{args, Args, Context, Help};
+

+
pub const HELP: Help = Help {
+
    name: "profile",
+
    description: "Manage Radicle CLI profiles (config.json and keys/* per user)",
+
    version: env!("RADICLE_VERSION"),
+
    usage: r#"
+
Usage
+

+
    rad profile new <name>
+
    rad profile switch <name> [--print-env]
+
    rad profile list
+
    rad profile current
+
    rad profile remove <name> [-y]
+

+
Options
+

+
    --print-env       Print shell export for RAD_PROFILE (for: switch)
+
    --yes, -y         Confirm removal (for: remove)
+
    --help            Print help
+
"#,
+
};
+

+
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+
enum OpTag {
+
    #[default]
+
    List,
+
    New,
+
    Switch,
+
    Current,
+
    Remove,
+
}
+

+
#[derive(Debug)]
+
enum Op {
+
    New { name: String },
+
    Switch { name: String, print_env: bool },
+
    List,
+
    Current,
+
    Remove { name: String, yes: bool },
+
}
+

+
#[derive(Debug)]
+
pub struct Options {
+
    op: Op,
+
}
+

+
impl Args for Options {
+
    fn from_args(v: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
+
        let mut p = lexopt::Parser::from_args(v);
+
        let mut tag: Option<OpTag> = None;
+
        let mut name: Option<String> = None;
+
        let mut print_env = false;
+
        let mut yes = false;
+

+
        while let Some(arg) = p.next()? {
+
            match arg {
+
                Long("help") | Short('h') => return Err(args::Error::Help.into()),
+
                Long("print-env") => print_env = true,
+
                Long("yes") | Short('y') => yes = true,
+
                Value(val) if tag.is_none() => match val.to_string_lossy().as_ref() {
+
                    "new" => tag = Some(OpTag::New),
+
                    "switch" | "use" => tag = Some(OpTag::Switch),
+
                    "list" | "ls" => tag = Some(OpTag::List),
+
                    "current" | "cur" => tag = Some(OpTag::Current),
+
                    "remove" | "rm" => tag = Some(OpTag::Remove),
+
                    u => anyhow::bail!("unknown operation '{}'", u),
+
                },
+
                Value(val) if name.is_none() => name = Some(args::string(&val)),
+
                _ => return Err(anyhow!(arg.unexpected())),
+
            }
+
        }
+

+
        let op = match tag.unwrap_or_default() {
+
            OpTag::New => Op::New {
+
                name: name.ok_or(anyhow!("name required, see `rad profile new --help`"))?,
+
            },
+
            OpTag::Switch => Op::Switch {
+
                name: name.ok_or(anyhow!("name required, see `rad profile switch --help`"))?,
+
                print_env,
+
            },
+
            OpTag::List => Op::List,
+
            OpTag::Current => Op::Current,
+
            OpTag::Remove => Op::Remove {
+
                name: name.ok_or(anyhow!("name required, see `rad profile remove --help`"))?,
+
                yes,
+
            },
+
        };
+

+
        Ok((Options { op }, vec![]))
+
    }
+
}
+

+
pub fn run(options: Options, _ctx: impl Context) -> anyhow::Result<()> {
+
    ensure_roots()?;
+
    match options.op {
+
        Op::New { name } => {
+
            create_profile(&name)?;
+
            set_active(&name)?;
+
            clear_root()?;
+
            term::success!("Profile {} created and activated; root cleared", term::format::tertiary(&name));
+
        }
+
        Op::Switch { name, print_env } => {
+
            let cur = active()?;
+
            if cur != name {
+
                ensure_profile_dir(&cur)?;
+
                save_root_into(&cur)?;
+
                apply_profile_to_root(&name)?;
+
                set_active(&name)?;
+
            } else {
+
                apply_profile_to_root(&name)?;
+
            }
+
            if print_env {
+
                println!("export RAD_PROFILE={}", name);
+
            } else {
+
                term::success!("Switched to {}", term::format::tertiary(&name));
+
            }
+
        }
+
        Op::List => list_profiles()?,
+
        Op::Current => {
+
            println!("{}", active()?);
+
        }
+
        Op::Remove { name, yes } => {
+
            remove_profile(&name, yes)?;
+
            term::success!("Profile {} removed", term::format::tertiary(&name));
+
        }
+
    }
+
    Ok(())
+
}
+

+
fn rad_home() -> anyhow::Result<PathBuf> {
+
    Ok(profile::home()?.path().to_path_buf())
+
}
+

+
fn profiles_root() -> anyhow::Result<PathBuf> {
+
    Ok(rad_home()?.join("profiles"))
+
}
+

+
fn active_file() -> anyhow::Result<PathBuf> {
+
    Ok(rad_home()?.join(".active_profile"))
+
}
+

+
fn cfg_root() -> anyhow::Result<PathBuf> {
+
    Ok(rad_home()?.join("config.json"))
+
}
+

+
fn keys_root() -> anyhow::Result<PathBuf> {
+
    Ok(rad_home()?.join("keys"))
+
}
+

+
fn profile_dir(name: &str) -> anyhow::Result<PathBuf> {
+
    Ok(profiles_root()?.join(name))
+
}
+

+
fn profile_cfg(name: &str) -> anyhow::Result<PathBuf> {
+
    Ok(profile_dir(name)?.join("config.json"))
+
}
+

+
fn profile_keys(name: &str) -> anyhow::Result<PathBuf> {
+
    Ok(profile_dir(name)?.join("keys"))
+
}
+

+
fn exists(p: &Path) -> bool {
+
    fs::symlink_metadata(p).is_ok()
+
}
+

+
fn ensure_roots() -> anyhow::Result<()> {
+
    let pr = profiles_root()?;
+
    if !pr.exists() {
+
        fs::create_dir_all(&pr)?;
+
    }
+
    Ok(())
+
}
+

+
fn ensure_profile_dir(name: &str) -> anyhow::Result<()> {
+
    let dir = profile_dir(name)?;
+
    if !dir.exists() {
+
        fs::create_dir_all(profile_keys(name)?)?;
+
    } else {
+
        fs::create_dir_all(profile_keys(name)?)?;
+
    }
+
    Ok(())
+
}
+

+
fn create_profile(name: &str) -> anyhow::Result<()> {
+
    let dir = profile_dir(name)?;
+
    if dir.exists() {
+
        anyhow::bail!("profile already exists");
+
    }
+
    fs::create_dir_all(profile_keys(name)?)?;
+
    Ok(())
+
}
+

+
fn set_active(name: &str) -> anyhow::Result<()> {
+
    fs::write(active_file()?, format!("{}\n", name))?;
+
    Ok(())
+
}
+

+
fn active() -> anyhow::Result<String> {
+
    if let Ok(v) = std::env::var("RAD_PROFILE") {
+
        return Ok(v);
+
    }
+
    let f = active_file()?;
+
    if exists(&f) {
+
        let s = fs::read_to_string(f)?;
+
        let s = s.trim();
+
        if !s.is_empty() {
+
            return Ok(s.to_owned());
+
        }
+
    }
+
    Ok("default".to_owned())
+
}
+

+
fn clear_root() -> anyhow::Result<()> {
+
    let c = cfg_root()?;
+
    if exists(&c) {
+
        fs::remove_file(&c).ok();
+
    }
+
    let k = keys_root()?;
+
    if exists(&k) {
+
        fs::remove_dir_all(&k).ok();
+
    }
+
    Ok(())
+
}
+

+
fn copy_file_atomic(src: &Path, dst: &Path) -> anyhow::Result<()> {
+
    if !exists(src) {
+
        anyhow::bail!("missing {}", src.display());
+
    }
+
    if let Some(p) = dst.parent() {
+
        fs::create_dir_all(p)?;
+
    }
+
    let tmp = dst.with_extension("tmp");
+
    fs::copy(src, &tmp).with_context(|| format!("copy {} -> {}", src.display(), tmp.display()))?;
+
    if exists(dst) {
+
        fs::remove_file(dst).ok();
+
    }
+
    fs::rename(&tmp, dst)?;
+
    Ok(())
+
}
+

+
fn copy_dir_replace(src: &Path, dst: &Path) -> anyhow::Result<()> {
+
    if !exists(src) {
+
        anyhow::bail!("missing {}", src.display());
+
    }
+
    if exists(dst) {
+
        fs::remove_dir_all(dst)?;
+
    }
+
    fs::create_dir_all(dst)?;
+
    for e in fs::read_dir(src)? {
+
        let e = e?;
+
        let ft = e.file_type()?;
+
        let sp = e.path();
+
        let dp = dst.join(e.file_name());
+
        if ft.is_dir() {
+
            copy_dir_replace(&sp, &dp)?;
+
        } else if ft.is_file() {
+
            copy_file_atomic(&sp, &dp)?;
+
        }
+
    }
+
    Ok(())
+
}
+

+
fn save_root_into(name: &str) -> anyhow::Result<()> {
+
    let csrc = cfg_root()?;
+
    let ksrc = keys_root()?;
+
    if !exists(&csrc) && !exists(&ksrc) {
+
        return Ok(());
+
    }
+
    ensure_profile_dir(name)?;
+
    let cdst = profile_cfg(name)?;
+
    let kdst = profile_keys(name)?;
+
    if exists(&csrc) {
+
        copy_file_atomic(&csrc, &cdst)?;
+
    }
+
    if exists(&ksrc) {
+
        copy_dir_replace(&ksrc, &kdst)?;
+
    }
+
    Ok(())
+
}
+

+
fn apply_profile_to_root(name: &str) -> anyhow::Result<()> {
+
    let pd = profile_dir(name)?;
+
    if !pd.exists() {
+
        anyhow::bail!("profile '{}' not found", name);
+
    }
+
    let pc = profile_cfg(name)?;
+
    let pk = profile_keys(name)?;
+
    clear_root()?;
+
    if exists(&pc) {
+
        copy_file_atomic(&pc, &cfg_root()?)?;
+
    }
+
    if exists(&pk) {
+
        copy_dir_replace(&pk, &keys_root()?)?;
+
    }
+
    Ok(())
+
}
+

+
fn list_profiles() -> anyhow::Result<()> {
+
    let cur = active()?;
+
    let mut v = Vec::new();
+
    for e in fs::read_dir(profiles_root()?)? {
+
        let e = e?;
+
        if e.file_type()?.is_dir() {
+
            v.push(e.file_name().to_string_lossy().into_owned());
+
        }
+
    }
+
    v.sort();
+
    for n in v {
+
        if n == cur {
+
            println!("* {}", n);
+
        } else {
+
            println!("  {}", n);
+
        }
+
    }
+
    Ok(())
+
}
+

+
fn remove_profile(name: &str, yes: bool) -> anyhow::Result<()> {
+
    let cur = active().unwrap_or_else(|_| "default".to_owned());
+
    if name == cur {
+
        anyhow::bail!("cannot remove the active profile");
+
    }
+
    let dir = profile_dir(name)?;
+
    if !dir.exists() {
+
        anyhow::bail!("profile '{}' not found", name);
+
    }
+
    if !yes {
+
        anyhow::bail!("refusing to remove without -y");
+
    }
+
    fs::remove_dir_all(dir)?;
+
    Ok(())
+
}

\ No newline at end of file
modified crates/radicle-cli/src/main.rs
@@ -331,6 +331,13 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
            rad_stats::run,
            args.to_vec(),
        ),
+
        "profile" => {
+
            term::run_command_args::<rad_profile::Options, _>(
+
                rad_profile::HELP,
+
                rad_profile::run,
+
                args.to_vec(),
+
            );
+
        }
        "watch" => term::run_command_args::<rad_watch::Options, _>(
            rad_watch::HELP,
            rad_watch::run,