Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/lab/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ tracing-subscriber.workspace = true
tracing-appender = "0.2"

clap.workspace = true
clap_complete = { workspace = true, optional = true }
clap_complete.workspace = true
owo-colors.workspace = true
is-terminal.workspace = true
indicatif.workspace = true
Expand Down
64 changes: 31 additions & 33 deletions crates/lab/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
//! `lab-apis` client (or a lab-local subsystem), and formats output.
//! See `crates/lab/src/cli/CLAUDE.md` for the rulebook.

pub mod completions;
pub mod docs;
pub mod doctor;
pub mod extract;
pub mod gateway;
pub mod health;
pub mod help;
pub mod helpers;
pub mod install;
pub mod logs;
pub mod marketplace;
#[cfg(feature = "mcpregistry")]
Expand Down Expand Up @@ -76,17 +76,12 @@ pub enum Command {
Nodes(nodes::NodesArgs),
/// Quick reachability check for configured services.
Health,
/// Install one or more services into `.mcp.json`.
Install(install::InstallArgs),
/// Uninstall services from `.mcp.json`.
Uninstall(install::UninstallArgs),
/// First-time setup wizard.
Init,
/// Open the web-based first-run wizard (or settings) — lab-bg3e.3.
Setup(setup::SetupArgs),
/// Print the service + action catalog.
Help(help::HelpArgs),
/// Generate shell completions.
Completions(completions::CompletionsArgs),
/// Scan a local or SSH appdata path and extract service credentials.
Extract(extract::ExtractCmd),
/// Manage proxied upstream MCP gateways.
Expand All @@ -102,29 +97,6 @@ pub enum Command {
Registry(mcpregistry::RegistryArgs),
/// Component versioning and deployment.
Stash(stash::StashArgs),
/// Radarr movie collection manager.
/// Sonarr TV series manager.
/// Prowlarr indexer manager.
/// Plex media server.
/// Tautulli Plex analytics.
/// `SABnzbd` download client.
/// qBittorrent download client.
/// Tailscale VPN network.
/// Linkding bookmark manager.
/// Memos note-taking service.
/// Beads issue tracker.
/// Bytestash snippet manager.
/// Arcane Docker management UI.
/// Unraid server management.
/// `UniFi` network management.
/// Overseerr media request manager.
/// Gotify push notifications.
/// `OpenAI` API client.
/// Upstream OpenACP daemon.
/// Google NotebookLM client.
/// Qdrant vector database.
/// HF Text Embeddings Inference.
/// Apprise notification dispatcher.
/// Deploy the local lab release binary to SSH targets.
#[cfg(feature = "deploy")]
Deploy(deploy::DeployArgs),
Expand All @@ -142,11 +114,9 @@ pub async fn dispatch(cli: Cli, config: LabConfig) -> Result<ExitCode> {
Command::Docs(args) => docs::run(args),
Command::Nodes(args) => nodes::run(args, format, &config).await,
Command::Health => health::run(format).await,
Command::Install(args) => install::run_install(&args).map(|()| ExitCode::SUCCESS),
Command::Uninstall(args) => install::run_uninstall(&args).map(|()| ExitCode::SUCCESS),
Command::Init => install::run_init().map(|()| ExitCode::SUCCESS),
Command::Setup(args) => setup::run(args, format).await,
Command::Help(args) => help::run(args, format),
Command::Completions(args) => completions::run(&args),
Command::Extract(cmd) => cmd.run(color).await.map(|()| ExitCode::SUCCESS),
Command::Gateway(args) => gateway::run(args, format, &config).await,
Command::Oauth(args) => oauth::run(args, &config).await,
Expand Down Expand Up @@ -213,6 +183,34 @@ mod tests {
})
));
}

#[test]
fn cli_rejects_legacy_install_uninstall_init_stubs() {
for command in ["install", "uninstall", "init"] {
let err =
Cli::try_parse_from(["labby", command]).expect_err("legacy stub must be gone");
assert!(
err.to_string().contains("unrecognized subcommand"),
"{command}: {err}"
);
}
}

#[test]
fn replacement_setup_commands_parse() {
let cli = Cli::try_parse_from(["labby", "setup"]).expect("setup parses");
assert!(matches!(cli.command, Command::Setup(_)));

let cli = Cli::try_parse_from(["labby", "setup", "install-plugin", "gateway", "-y"])
.expect("setup install-plugin parses");
assert!(matches!(cli.command, Command::Setup(_)));
}

#[test]
fn cli_parses_completions_subcommand() {
let cli = Cli::parse_from(["labby", "completions", "bash"]);
assert!(matches!(cli.command, Command::Completions(_)));
}
}

/// Deploy dispatch extracted to a helper so the match arm stays a single expression,
Expand Down
120 changes: 65 additions & 55 deletions crates/lab/src/cli/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use anyhow::{Context, Result};
use clap::Args;
use owo_colors::{OwoColorize, XtermColors};

use crate::config::{backup_env, env_is_up_to_date, write_env};
use crate::config::{env_merge, write_service_creds};
use crate::output::{ColorPolicy, OutputFormat, RenderEnv, print};
use lab_apis::extract::{ExtractClient, ExtractReport, ScanTarget, Uri};

Expand Down Expand Up @@ -81,8 +81,12 @@ impl ExtractCmd {
fn apply_report(&self, report: &ExtractReport, color_policy: ColorPolicy) -> Result<()> {
let target = self.resolve_env_path()?;

let merge_request = merge_request_from_creds(&report.creds, self.force);
let preview = env_merge::preview(&target, &merge_request)
.with_context(|| format!("preview {}", target.display()))?;

// Rule 8: idempotence check — skip backup and write if nothing would change.
if env_is_up_to_date(&target, &report.creds) {
if preview.written == 0 && preview.skipped.is_empty() {
eprintln!("{}", "Already up to date — nothing to write.".dimmed());
return Ok(());
}
Expand All @@ -104,21 +108,18 @@ impl ExtractCmd {
return Ok(());
}

// Rule 1: backup before any write.
let backup = backup_env(&target).with_context(|| format!("backup {}", target.display()))?;
if backup.exists() {
// Canonical merge owns backup, atomic write, permissions, and retention.
let outcome = write_service_creds(&target, &report.creds, self.force)
.with_context(|| format!("write {}", target.display()))?;
if let Some(backup) = &outcome.backup_path {
eprintln!(
" {} {}",
"backup →".dimmed(),
backup.display().to_string().dimmed()
);
}

// Rules 2–7: atomic merge write.
let warnings = write_env(&target, &report.creds, self.force)
.with_context(|| format!("write {}", target.display()))?;

for w in &warnings {
for w in &outcome.skipped {
eprintln!(" {} {}", "⚠".color(XtermColors::FlushOrange), w);
}
eprintln!(
Expand All @@ -131,39 +132,40 @@ impl ExtractCmd {

fn diff_report(&self, report: &ExtractReport) -> Result<()> {
let target = self.resolve_env_path()?;
let preview = env_merge::preview(
&target,
&merge_request_from_creds(&report.creds, self.force),
)
.with_context(|| format!("preview {}", target.display()))?;

let existing_raw = if target.exists() {
std::fs::read_to_string(&target)
.with_context(|| format!("read {}", target.display()))?
} else {
String::new()
};
let existing: std::collections::HashMap<String, String> = existing_raw
.lines()
.filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
.filter_map(|l| {
l.split_once('=')
.map(|(k, v)| (k.trim().to_owned(), v.trim().to_owned()))
})
.collect();

let mut any = false;
for cred in &report.creds {
let svc_upper = cred.service.to_uppercase();
if let Some(url) = &cred.url {
let key = format!("{svc_upper}_URL");
print_diff_line(&key, url, &existing);
any = true;
}
if let Some(secret) = &cred.secret {
print_diff_line(&cred.env_field, secret, &existing);
any = true;
}
}

if !any {
if preview.changes.is_empty() {
eprintln!("{}", "No credentials found — nothing to diff.".dimmed());
}
for change in preview.changes {
match change.status {
env_merge::PreviewStatus::Add => eprintln!(
" {} {}",
"+".color(XtermColors::BrightGreen).bold(),
change.key
),
env_merge::PreviewStatus::Update => eprintln!(
" {} {}",
"~".color(XtermColors::FlushOrange).bold(),
change.key
),
env_merge::PreviewStatus::Unchanged => {
eprintln!(" {} {}", "=".dimmed(), change.key)
}
env_merge::PreviewStatus::Conflict => eprintln!(
" {} {}",
"!".color(XtermColors::FlushOrange).bold(),
change.key
),
};
}
for warning in preview.skipped {
eprintln!(" {} {}", "⚠".color(XtermColors::FlushOrange), warning);
}
Ok(())
}

Expand Down Expand Up @@ -194,22 +196,30 @@ impl ExtractCmd {
}
}

fn print_diff_line(key: &str, value: &str, existing: &std::collections::HashMap<String, String>) {
match existing.get(key) {
None => eprintln!(
" {} {}={}",
"+".color(XtermColors::BrightGreen).bold(),
key.color(XtermColors::BrightGreen),
value
),
Some(ev) if ev == value => {
// Same value already present — silent in diff output.
fn merge_request_from_creds(
creds: &[lab_apis::extract::ServiceCreds],
force: bool,
) -> env_merge::MergeRequest {
let mut entries = Vec::new();
for cred in creds {
let svc_upper = cred.service.to_uppercase();
if let Some(url) = &cred.url {
entries.push(env_merge::EnvEntry::new(
format!("{svc_upper}_URL"),
url.clone(),
));
}
if let Some(secret) = &cred.secret {
entries.push(env_merge::EnvEntry::new(
cred.env_field.clone(),
secret.clone(),
));
}
Some(ev) => eprintln!(
" {} {} (was {ev:?})",
"~".color(XtermColors::FlushOrange).bold(),
format!("{key}={value}").color(XtermColors::FlushOrange)
),
}
env_merge::MergeRequest {
entries,
force,
expected_mtime: None,
}
}

Expand Down
47 changes: 0 additions & 47 deletions crates/lab/src/cli/install.rs

This file was deleted.

Loading
Loading