Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli(profile): isolated profiles; copy-on-switch; explicit default
anon committed 8 months ago
commit 829b7cba1802cdcc6a1648e7894c9a7e21b0cf37
parent 13fd2ed92139426d31f3973c7e3024265fc5107a
1 file changed +303 -210
modified crates/radicle-cli/src/commands/profile.rs
@@ -1,13 +1,15 @@
+
use std::env;
use std::ffi::OsString;
use std::fs;
+
use std::io;
use std::path::{Path, PathBuf};

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

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

pub const HELP: Help = Help {
    name: "profile",
@@ -16,327 +18,418 @@ pub const HELP: Help = Help {
    usage: r#"
Usage

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

Options

+
    --from <name>     Create from existing profile (for: new)
+
    --force, -f       Overwrite existing profile or reserved name (for: new)
    --print-env       Print shell export for RAD_PROFILE (for: switch)
    --yes, -y         Confirm removal (for: remove)
    --help            Print help
+

+
Notes
+

+
    • 'default' is the pseudo-profile representing the root (~/.radicle).
+
      'rad profile switch default' persists the current root into the active profile,
+
      clears the root, and unsets the active profile marker.
"#,
};

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

#[derive(Debug)]
-
enum Op {
-
    New { name: String },
-
    Switch { name: String, print_env: bool },
-
    List,
-
    Current,
-
    Remove { name: String, yes: bool },
+
struct Operation {
+
    name: OperationName,
+
    target: Option<String>,
+
    from: Option<String>,
+
    force: bool,
+
    print_env: bool,
+
    yes: bool,
}

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

+
#[derive(Error, Debug)]
+
pub enum ProfileError {
+
    #[error("home directory not available")]
+
    Home,
+
    #[error("profiles root not found: {0}")]
+
    ProfilesRoot(PathBuf),
+
    #[error("active profile marker error: {0}")]
+
    ActiveMarker(PathBuf),
+
    #[error("profile not found: {0}")]
+
    NotFound(String),
+
    #[error("profile already exists: {0}")]
+
    AlreadyExists(String),
+
    #[error("cannot remove active profile: {0}")]
+
    RemoveActive(String),
+
    #[error("cannot remove pseudo-profile 'default'")]
+
    RemoveDefault,
+
    #[error("confirmation required, use -y/--yes")]
+
    ConfirmationRequired,
+
    #[error("io error: {0}")]
+
    Io(#[from] io::Error),
+
    #[error("invalid state: {0}")]
+
    InvalidState(&'static str),
}

+
type Result<T> = std::result::Result<T, ProfileError>;
+

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 p = Parser::from_args(v);
+
        let mut op = None;
+
        let mut target = None;
+
        let mut from = None;
+
        let mut force = false;
        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())),
+
                Value(val) if op.is_none() => {
+
                    let s = term::args::string(&val);
+
                    op = Some(match s.as_str() {
+
                        "new" => OperationName::New,
+
                        "switch" => OperationName::Switch,
+
                        "list" => OperationName::List,
+
                        "current" => OperationName::Current,
+
                        "remove" => OperationName::Remove,
+
                        other => anyhow::bail!("unknown subcommand: {other}"),
+
                    });
+
                }
+
                Value(val) => {
+
                    target = Some(term::args::string(&val));
+
                }
+
                Long("from") => {
+
                    from = Some(term::args::string(&p.value()?));
+
                }
+
                Long("force") | Short('f') => {
+
                    force = true;
+
                }
+
                Long("print-env") => {
+
                    print_env = true;
+
                }
+
                Long("yes") | Short('y') => {
+
                    yes = true;
+
                }
+
                Long("help") | Short('h') => return Err(term::args::Error::Help.into()),
+
                _ => anyhow::bail!("unexpected argument"),
            }
        }

-
        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`"))?,
+
        let name = op.ok_or_else(|| anyhow::anyhow!("missing subcommand"))?;
+
        if matches!(name, OperationName::New | OperationName::Switch | OperationName::Remove)
+
            && target.is_none()
+
        {
+
            anyhow::bail!("missing <name>");
+
        }
+

+
        Ok((
+
            Options(Operation {
+
                name,
+
                target,
+
                from,
+
                force,
                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![]))
+
            }),
+
            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)?;
+
pub fn run(opts: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
    run_inner(opts, ctx).map_err(|e| anyhow::anyhow!(e))
+
}
+

+
fn run_inner(opts: Options, ctx: impl term::Context) -> Result<()> {
+
    let home = resolve_home(ctx)?;
+

+
    match opts.0.name {
+
        OperationName::New => {
+
            let name = opts.0.target.as_ref().unwrap();
+
            if name == "default" && !opts.0.force {
+
                return Err(ProfileError::AlreadyExists("default".into()));
            }
-
            if print_env {
-
                println!("export RAD_PROFILE={}", name);
+
            create_profile(&home, name, opts.0.from.as_deref(), opts.0.force)?;
+
            write_active(&home, name)?;
+
            clear_root_for_auth(&home)?;
+
            term::success!("Profile {} created", term::format::tertiary(name));
+
        }
+
        OperationName::Switch => {
+
            let name = opts.0.target.as_ref().unwrap();
+
            if name == "default" {
+
                if let Some(current) = read_active(&home)? {
+
                    persist_root_into_profile(&home, &current)?;
+
                }
+
                clear_root_for_auth(&home)?;
+
                clear_active(&home)?;
+
                if opts.0.print_env {
+
                    println!("unset RAD_PROFILE");
+
                } else {
+
                    term::success!("Switched to {}", term::format::tertiary("default"));
+
                }
            } else {
-
                term::success!("Switched to {}", term::format::tertiary(&name));
+
                let active = read_active(&home)?;
+
                if let Some(current) = active.as_deref() {
+
                    persist_root_into_profile(&home, current)?;
+
                }
+
                copy_profile_into_root(&home, name)?;
+
                write_active(&home, name)?;
+
                if opts.0.print_env {
+
                    println!("export RAD_PROFILE={name}");
+
                } else {
+
                    term::success!("Switched to {}", term::format::tertiary(name));
+
                }
            }
        }
-
        Op::List => list_profiles()?,
-
        Op::Current => {
-
            println!("{}", active()?);
+
        OperationName::List => {
+
            list_profiles(&home)?;
        }
-
        Op::Remove { name, yes } => {
-
            remove_profile(&name, yes)?;
-
            term::success!("Profile {} removed", term::format::tertiary(&name));
+
        OperationName::Current => {
+
            let cur = read_active(&home)?.unwrap_or_else(|| "default".to_string());
+
            println!("{cur}");
+
        }
+
        OperationName::Remove => {
+
            let name = opts.0.target.as_ref().unwrap();
+
            if name == "default" {
+
                return Err(ProfileError::RemoveDefault);
+
            }
+
            let cur = read_active(&home)?;
+
            if cur.as_deref() == Some(name) {
+
                return Err(ProfileError::RemoveActive(name.clone()));
+
            }
+
            if !opts.0.yes {
+
                return Err(ProfileError::ConfirmationRequired);
+
            }
+
            remove_profile(&home, name)?;
+
            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 resolve_home(ctx: impl term::Context) -> Result<PathBuf> {
+
    if let Ok(p) = ctx.profile() {
+
        return Ok(p.home().path().to_path_buf());
+
    }
+
    if let Ok(rad_home) = env::var("RAD_HOME") {
+
        return Ok(PathBuf::from(rad_home));
+
    }
+
    if let Ok(home) = env::var("HOME") {
+
        return Ok(PathBuf::from(home).join(".radicle"));
+
    }
+
    Err(ProfileError::Home)
}

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

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

-
fn keys_root() -> anyhow::Result<PathBuf> {
-
    Ok(rad_home()?.join("keys"))
+
fn root_config(home: &Path) -> PathBuf {
+
    home.join("config.json")
}

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

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

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

-
fn exists(p: &Path) -> bool {
-
    fs::symlink_metadata(p).is_ok()
+
fn profile_keys(home: &Path, name: &str) -> PathBuf {
+
    profile_dir(home, name).join("keys")
}

-
fn ensure_roots() -> anyhow::Result<()> {
-
    let pr = profiles_root()?;
-
    if !pr.exists() {
-
        fs::create_dir_all(&pr)?;
-
    }
+
fn ensure_profiles_root(home: &Path) -> Result<()> {
+
    fs::create_dir_all(profiles_root(home))?;
    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)?)?;
+
fn create_profile(home: &Path, name: &str, from: Option<&str>, force: bool) -> Result<()> {
+
    ensure_profiles_root(home)?;
+
    let dst_dir = profile_dir(home, name);
+
    if dst_dir.exists() && !force {
+
        return Err(ProfileError::AlreadyExists(name.to_string()));
    }
-
    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(&dst_dir)?;
+
    fs::create_dir_all(profile_keys(home, name))?;
+
    if let Some(src) = from {
+
        let src_cfg = profile_config(home, src);
+
        let src_keys = profile_keys(home, src);
+
        let dst_cfg = profile_config(home, name);
+
        let dst_keys = profile_keys(home, name);
+
        if src_cfg.exists() {
+
            if let Some(parent) = dst_cfg.parent() {
+
                fs::create_dir_all(parent)?;
+
            }
+
            fs::copy(src_cfg, dst_cfg)?;
+
        }
+
        if src_keys.exists() {
+
            copy_dir(&src_keys, &dst_keys)?;
+
        }
    }
-
    fs::create_dir_all(profile_keys(name)?)?;
    Ok(())
}

-
fn set_active(name: &str) -> anyhow::Result<()> {
-
    fs::write(active_file()?, format!("{}\n", name))?;
+
fn remove_profile(home: &Path, name: &str) -> Result<()> {
+
    let dir = profile_dir(home, name);
+
    if !dir.exists() {
+
        return Err(ProfileError::NotFound(name.to_string()));
+
    }
+
    fs::remove_dir_all(dir)?;
    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());
-
        }
+
fn read_active(home: &Path) -> Result<Option<String>> {
+
    let path = active_file(home);
+
    match fs::read_to_string(&path) {
+
        Ok(s) => Ok(Some(s.trim().to_string())),
+
        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
+
        Err(_) => Err(ProfileError::ActiveMarker(path)),
    }
-
    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();
+
fn write_active(home: &Path, name: &str) -> Result<()> {
+
    let path = active_file(home);
+
    if let Some(parent) = path.parent() {
+
        fs::create_dir_all(parent)?;
    }
+
    fs::write(path, format!("{name}\n"))?;
    Ok(())
}

-
fn copy_file_atomic(src: &Path, dst: &Path) -> anyhow::Result<()> {
-
    if !exists(src) {
-
        anyhow::bail!("missing {}", src.display());
+
fn clear_active(home: &Path) -> Result<()> {
+
    let path = active_file(home);
+
    if path.exists() {
+
        fs::remove_file(path)?;
    }
-
    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());
+
fn persist_root_into_profile(home: &Path, name: &str) -> Result<()> {
+
    let src_cfg = root_config(home);
+
    let src_keys = root_keys(home);
+
    let dst_cfg = profile_config(home, name);
+
    let dst_keys = profile_keys(home, name);
+

+
    if let Some(parent) = dst_cfg.parent() {
+
        fs::create_dir_all(parent)?;
    }
-
    if exists(dst) {
-
        fs::remove_dir_all(dst)?;
+
    fs::create_dir_all(&dst_keys)?;
+

+
    if src_cfg.exists() {
+
        fs::copy(src_cfg, dst_cfg)?;
    }
-
    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)?;
-
        }
+
    if src_keys.exists() {
+
        copy_dir(&src_keys, &dst_keys)?;
    }
    Ok(())
}

-
fn save_root_into(name: &str) -> anyhow::Result<()> {
-
    let csrc = cfg_root()?;
-
    let ksrc = keys_root()?;
-
    if !exists(&csrc) && !exists(&ksrc) {
-
        return Ok(());
+
fn copy_profile_into_root(home: &Path, name: &str) -> Result<()> {
+
    let src_cfg = profile_config(home, name);
+
    let src_keys = profile_keys(home, name);
+
    let dst_cfg = root_config(home);
+
    let dst_keys = root_keys(home);
+

+
    if dst_cfg.exists() {
+
        fs::remove_file(&dst_cfg)?;
    }
-
    ensure_profile_dir(name)?;
-
    let cdst = profile_cfg(name)?;
-
    let kdst = profile_keys(name)?;
-
    if exists(&csrc) {
-
        copy_file_atomic(&csrc, &cdst)?;
+
    if dst_keys.exists() {
+
        fs::remove_dir_all(&dst_keys)?;
    }
-
    if exists(&ksrc) {
-
        copy_dir_replace(&ksrc, &kdst)?;
+
    if src_cfg.exists() {
+
        if let Some(parent) = dst_cfg.parent() {
+
            fs::create_dir_all(parent)?;
+
        }
+
        fs::copy(src_cfg, dst_cfg)?;
+
    }
+
    if src_keys.exists() {
+
        fs::create_dir_all(&dst_keys)?;
+
        copy_dir(&src_keys, &dst_keys)?;
    }
    Ok(())
}

-
fn apply_profile_to_root(name: &str) -> anyhow::Result<()> {
-
    let pd = profile_dir(name)?;
-
    if !pd.exists() {
-
        anyhow::bail!("profile '{}' not found", name);
+
fn clear_root_for_auth(home: &Path) -> Result<()> {
+
    let cfg = root_config(home);
+
    let keys = root_keys(home);
+
    if cfg.exists() {
+
        fs::remove_file(cfg)?;
    }
-
    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()?)?;
+
    if keys.exists() {
+
        fs::remove_dir_all(keys)?;
    }
    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?;
+
fn list_profiles(home: &Path) -> Result<()> {
+
    ensure_profiles_root(home)?;
+
    let active = read_active(home)?;
+
    let mut names = Vec::<String>::new();
+
    for entry in fs::read_dir(profiles_root(home))? {
+
        let e = entry?;
        if e.file_type()?.is_dir() {
-
            v.push(e.file_name().to_string_lossy().into_owned());
+
            if let Some(n) = e.file_name().to_str() {
+
                names.push(n.to_string());
+
            }
        }
    }
-
    v.sort();
-
    for n in v {
-
        if n == cur {
-
            println!("* {}", n);
+
    names.sort();
+

+
    
+
    if active.is_none() {
+
        println!("* default");
+
    } else {
+
        println!("  default");
+
    }
+

+
    for n in names {
+
        if active.as_deref() == Some(n.as_str()) {
+
            println!("* {n}");
        } else {
-
            println!("  {}", n);
+
            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");
+
fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
+
    fs::create_dir_all(dst)?;
+
    for entry in fs::read_dir(src)? {
+
        let e = entry?;
+
        let ty = e.file_type()?;
+
        let to = dst.join(e.file_name());
+
        if ty.is_dir() {
+
            copy_dir(&e.path(), &to)?;
+
        } else {
+
            fs::copy(e.path(), &to)?;
+
        }
    }
-
    fs::remove_dir_all(dir)?;
    Ok(())
}

\ No newline at end of file