nodus 0.15.1

Local-first CLI for managing project-scoped agent packages.
Documentation
use crate::adapters::Adapter;
use crate::cli::handlers::CommandContext;
use crate::cli::output::{display_dependency, format_adapters};
use crate::install_paths::InstallPaths;
use crate::manifest::{DependencyComponent, DependencyKind, RequestedGitRef};
use crate::members::{MembersSummary, MembersUpdateRequest};

pub(crate) struct AddCommand {
    pub(crate) url: String,
    pub(crate) global: bool,
    pub(crate) dev: bool,
    pub(crate) tag: Option<String>,
    pub(crate) branch: Option<String>,
    pub(crate) version: Option<String>,
    pub(crate) revision: Option<String>,
    pub(crate) adapter: Vec<Adapter>,
    pub(crate) component: Vec<DependencyComponent>,
    pub(crate) exclude_component: Vec<DependencyComponent>,
    pub(crate) sync_on_launch: bool,
    pub(crate) no_sync_on_launch: bool,
    pub(crate) accept_all_dependencies: bool,
    pub(crate) dry_run: bool,
}

pub(crate) struct MembersUpdateCommand {
    pub(crate) package: String,
    pub(crate) members: Vec<String>,
    pub(crate) operation: crate::members::MembersOperation,
    pub(crate) allow_high_sensitivity: bool,
    pub(crate) dry_run: bool,
}

pub(crate) fn handle_add(context: &CommandContext<'_>, command: AddCommand) -> anyhow::Result<()> {
    let AddCommand {
        url,
        global,
        dev,
        tag,
        branch,
        version,
        revision,
        adapter,
        component,
        exclude_component,
        sync_on_launch,
        no_sync_on_launch,
        accept_all_dependencies,
        dry_run,
    } = command;
    if global && sync_on_launch {
        anyhow::bail!("`nodus add --global` does not support `--sync-on-launch`");
    }
    let sync_on_launch = if global {
        false
    } else {
        sync_on_launch && !no_sync_on_launch
    };
    let install_paths = if global {
        InstallPaths::global(context.cache_root)?
    } else {
        InstallPaths::project(context.cwd)
    };
    let components = DependencyComponent::selected_with_exclusions(&component, &exclude_component)
        .map_err(anyhow::Error::msg)?;
    let options = crate::git::AddDependencyOptions {
        git_ref: requested_git_ref(tag.as_deref(), branch.as_deref(), revision.as_deref())?,
        version_req: version
            .as_deref()
            .map(semver::VersionReq::parse)
            .transpose()?,
        kind: if dev {
            DependencyKind::DevDependency
        } else {
            DependencyKind::Dependency
        },
        adapters: &adapter,
        components: &components,
        sync_on_launch,
        accept_all_dependencies,
    };
    let summary = if dry_run {
        crate::git::add_dependency_at_paths_with_adapters_dry_run(
            &install_paths,
            context.cache_root,
            &url,
            options,
            context.reporter,
        )?
    } else {
        crate::git::add_dependency_at_paths_with_adapters(
            &install_paths,
            context.cache_root,
            &url,
            options,
            context.reporter,
        )?
    };
    if !summary.dependency_members.is_empty() {
        let intro = if dry_run {
            "dependency child selection:"
        } else {
            "dependency child packages:"
        };
        context.reporter.line(intro)?;
        context
            .reporter
            .line(format!("  config: {}", summary.dependency_preview))?;
        for member in &summary.dependency_members {
            let status = if member.enabled {
                "enabled"
            } else {
                "disabled"
            };
            context
                .reporter
                .line(format!("  {} ({status})", member.id))?;
        }
        if summary
            .dependency_members
            .iter()
            .all(|member| !member.enabled)
        {
            let message = if dry_run {
                "multiple child packages were detected; Nodus would record the wrapper only. Rerun with `--accept-all-dependencies` or use `nodus members ...` after install to enable the child packages you want."
            } else {
                "multiple child packages were detected; Nodus recorded the wrapper only. Use `nodus members ...` to enable the child packages you want."
            };
            context.reporter.note(message)?;
        }
    }
    let message = if dry_run {
        format!(
            "dry run: would add {} {} with adapters [{}]; would write {} managed files",
            display_dependency(summary.kind, &summary.alias),
            summary.reference,
            format_adapters(&summary.adapters),
            summary.managed_file_count,
        )
    } else {
        format!(
            "added {} {} with adapters [{}]; wrote {} managed files",
            display_dependency(summary.kind, &summary.alias),
            summary.reference,
            format_adapters(&summary.adapters),
            summary.managed_file_count,
        )
    };
    context.reporter.finish(message)?;
    Ok(())
}

pub(crate) fn handle_members_list(
    context: &CommandContext<'_>,
    package: Option<String>,
) -> anyhow::Result<()> {
    let summaries = crate::members::list_dependency_members_in_dir(
        context.cwd,
        context.cache_root,
        package.as_deref(),
    )?;
    if summaries.is_empty() {
        context
            .reporter
            .note("no dependencies expose selectable child packages")?;
        return Ok(());
    }

    context.reporter.line("dependency child packages:")?;
    for summary in &summaries {
        emit_members_summary(context, summary)?;
    }
    Ok(())
}

pub(crate) fn handle_members_update(
    context: &CommandContext<'_>,
    command: MembersUpdateCommand,
) -> anyhow::Result<()> {
    let MembersUpdateCommand {
        package,
        members,
        operation,
        allow_high_sensitivity,
        dry_run,
    } = command;
    let summary = crate::members::update_dependency_members_in_dir(
        context.cwd,
        context.cache_root,
        MembersUpdateRequest {
            package: &package,
            requested_members: &members,
            operation,
            allow_high_sensitivity,
            dry_run,
        },
        context.reporter,
    )?;
    let intro = if dry_run {
        "dependency child selection:"
    } else {
        "dependency child packages:"
    };
    context.reporter.line(intro)?;
    emit_members_summary(context, &summary.members)?;
    let message = if dry_run {
        format!(
            "dry run: would update child package selection for {} and would write {} managed files",
            display_dependency(summary.kind, &summary.alias),
            summary.managed_file_count,
        )
    } else {
        format!(
            "updated child package selection for {} and wrote {} managed files",
            display_dependency(summary.kind, &summary.alias),
            summary.managed_file_count,
        )
    };
    context.reporter.finish(message)?;
    Ok(())
}

pub(crate) fn handle_remove(
    context: &CommandContext<'_>,
    package: String,
    global: bool,
    dry_run: bool,
) -> anyhow::Result<()> {
    let install_paths = if global {
        InstallPaths::global(context.cache_root)?
    } else {
        InstallPaths::project(context.cwd)
    };
    let summary = if dry_run {
        crate::git::remove_dependency_at_paths_dry_run(
            &install_paths,
            context.cache_root,
            &package,
            context.reporter,
        )?
    } else {
        crate::git::remove_dependency_at_paths(
            &install_paths,
            context.cache_root,
            &package,
            context.reporter,
        )?
    };
    let message = if dry_run {
        format!(
            "dry run: would remove {} and would write {} managed files",
            display_dependency(summary.kind, &summary.alias),
            summary.managed_file_count,
        )
    } else {
        format!(
            "removed {} and wrote {} managed files",
            display_dependency(summary.kind, &summary.alias),
            summary.managed_file_count,
        )
    };
    context.reporter.finish(message)?;
    Ok(())
}

fn emit_members_summary(
    context: &CommandContext<'_>,
    summary: &MembersSummary,
) -> anyhow::Result<()> {
    context.reporter.line(format!(
        "  {}",
        display_dependency(summary.kind, &summary.alias)
    ))?;
    context
        .reporter
        .line(format!("    config: {}", summary.dependency_preview))?;
    for member in &summary.members {
        let status = if member.enabled {
            "enabled"
        } else {
            "disabled"
        };
        context
            .reporter
            .line(format!("    {} ({status})", member.id))?;
    }
    Ok(())
}

pub(crate) fn handle_update(
    context: &CommandContext<'_>,
    allow_high_sensitivity: bool,
    dry_run: bool,
) -> anyhow::Result<()> {
    let summary = if dry_run {
        crate::update::update_direct_dependencies_in_dir_dry_run(
            context.cwd,
            context.cache_root,
            allow_high_sensitivity,
            context.reporter,
        )?
    } else {
        crate::update::update_direct_dependencies_in_dir(
            context.cwd,
            context.cache_root,
            allow_high_sensitivity,
            context.reporter,
        )?
    };
    let message = if dry_run {
        format!(
            "dry run: would update {} dependencies; would write {} managed files",
            summary.updated_count, summary.managed_file_count
        )
    } else {
        format!(
            "updated {} dependencies; wrote {} managed files",
            summary.updated_count, summary.managed_file_count
        )
    };
    context.reporter.finish(message)?;
    Ok(())
}

fn requested_git_ref<'a>(
    tag: Option<&'a str>,
    branch: Option<&'a str>,
    revision: Option<&'a str>,
) -> anyhow::Result<Option<RequestedGitRef<'a>>> {
    match (tag, branch, revision) {
        (Some(tag), None, None) => Ok(Some(RequestedGitRef::Tag(tag))),
        (None, Some(branch), None) => Ok(Some(RequestedGitRef::Branch(branch))),
        (None, None, Some(revision)) => Ok(Some(RequestedGitRef::Revision(revision))),
        (None, None, None) => Ok(None),
        _ => anyhow::bail!(
            "git dependency must not declare more than one of `tag`, `branch`, or `revision`"
        ),
    }
}