diff --git a/Cargo.lock b/Cargo.lock index 9dc1d47..2020da8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -682,6 +682,19 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "dispatch2" version = "0.3.0" @@ -1078,6 +1091,7 @@ dependencies = [ "clap_complete", "clap_mangen", "console", + "dialoguer", "indicatif", "karapace-core", "karapace-remote", @@ -1089,6 +1103,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "toml", "tracing", "tracing-subscriber", ] @@ -1110,7 +1125,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.18", "tracing", ] @@ -1125,7 +1140,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "tracing-subscriber", @@ -1142,7 +1157,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.18", "tracing", "ureq", ] @@ -1158,7 +1173,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.18", "tracing", ] @@ -1170,7 +1185,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.18", "toml", ] @@ -1203,7 +1218,7 @@ dependencies = [ "serde_json", "tar", "tempfile", - "thiserror", + "thiserror 2.0.18", "tracing", ] @@ -1902,6 +1917,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -2026,7 +2047,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ "quick-xml", - "thiserror", + "thiserror 2.0.18", "windows", "windows-version", ] @@ -2044,13 +2065,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] diff --git a/crates/karapace-cli/Cargo.toml b/crates/karapace-cli/Cargo.toml index 219ae6a..b82e621 100644 --- a/crates/karapace-cli/Cargo.toml +++ b/crates/karapace-cli/Cargo.toml @@ -21,9 +21,11 @@ tracing.workspace = true tracing-subscriber.workspace = true serde.workspace = true serde_json.workspace = true +toml.workspace = true tempfile.workspace = true indicatif.workspace = true console.workspace = true +dialoguer = "0.11" libc.workspace = true karapace-schema = { path = "../karapace-schema" } karapace-core = { path = "../karapace-core" } diff --git a/crates/karapace-cli/src/commands/archive.rs b/crates/karapace-cli/src/commands/archive.rs index f36dff8..7932e91 100644 --- a/crates/karapace-cli/src/commands/archive.rs +++ b/crates/karapace-cli/src/commands/archive.rs @@ -1,4 +1,4 @@ -use super::{resolve_env_id, EXIT_SUCCESS}; +use super::{resolve_env_id_pretty, EXIT_SUCCESS}; use karapace_core::{Engine, StoreLock}; use karapace_store::StoreLayout; use std::path::Path; @@ -7,7 +7,7 @@ pub fn run(engine: &Engine, store_path: &Path, env_id: &str) -> Result Resu let layout = StoreLayout::new(store_path); let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?; - let resolved = resolve_env_id(engine, env_id)?; + let resolved = if json { + resolve_env_id(engine, env_id)? + } else { + resolve_env_id_pretty(engine, env_id)? + }; let tar_hash = engine.commit(&resolved).map_err(|e| e.to_string())?; if json { let payload = serde_json::json!({ diff --git a/crates/karapace-cli/src/commands/destroy.rs b/crates/karapace-cli/src/commands/destroy.rs index 695baeb..ebc60ed 100644 --- a/crates/karapace-cli/src/commands/destroy.rs +++ b/crates/karapace-cli/src/commands/destroy.rs @@ -1,4 +1,4 @@ -use super::{resolve_env_id, EXIT_SUCCESS}; +use super::{resolve_env_id_pretty, EXIT_SUCCESS}; use karapace_core::{Engine, StoreLock}; use karapace_store::StoreLayout; use std::path::Path; @@ -7,7 +7,7 @@ pub fn run(engine: &Engine, store_path: &Path, env_id: &str) -> Result Result { - let resolved = resolve_env_id(engine, env_id)?; + let resolved = if json { + resolve_env_id(engine, env_id)? + } else { + resolve_env_id_pretty(engine, env_id)? + }; let report = karapace_core::diff_overlay(engine.store_layout(), &resolved).map_err(|e| e.to_string())?; diff --git a/crates/karapace-cli/src/commands/enter.rs b/crates/karapace-cli/src/commands/enter.rs index 7bc67bb..ffcfa25 100644 --- a/crates/karapace-cli/src/commands/enter.rs +++ b/crates/karapace-cli/src/commands/enter.rs @@ -1,4 +1,4 @@ -use super::{resolve_env_id, EXIT_SUCCESS}; +use super::{resolve_env_id_pretty, EXIT_SUCCESS}; use karapace_core::{Engine, StoreLock}; use karapace_store::StoreLayout; use std::path::Path; @@ -12,7 +12,7 @@ pub fn run( let layout = StoreLayout::new(store_path); let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?; - let resolved = resolve_env_id(engine, env_id)?; + let resolved = resolve_env_id_pretty(engine, env_id)?; if command.is_empty() { engine.enter(&resolved).map_err(|e| e.to_string())?; } else { diff --git a/crates/karapace-cli/src/commands/exec.rs b/crates/karapace-cli/src/commands/exec.rs index 24e7ff5..35257b2 100644 --- a/crates/karapace-cli/src/commands/exec.rs +++ b/crates/karapace-cli/src/commands/exec.rs @@ -1,4 +1,4 @@ -use super::{resolve_env_id, EXIT_SUCCESS}; +use super::{resolve_env_id_pretty, EXIT_SUCCESS}; use karapace_core::{Engine, StoreLock}; use karapace_store::StoreLayout; use std::path::Path; @@ -13,7 +13,7 @@ pub fn run( let layout = StoreLayout::new(store_path); let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?; - let resolved = resolve_env_id(engine, env_id)?; + let resolved = resolve_env_id_pretty(engine, env_id)?; engine.exec(&resolved, command).map_err(|e| e.to_string())?; Ok(EXIT_SUCCESS) } diff --git a/crates/karapace-cli/src/commands/freeze.rs b/crates/karapace-cli/src/commands/freeze.rs index e02171c..b4dd585 100644 --- a/crates/karapace-cli/src/commands/freeze.rs +++ b/crates/karapace-cli/src/commands/freeze.rs @@ -1,4 +1,4 @@ -use super::{resolve_env_id, EXIT_SUCCESS}; +use super::{resolve_env_id_pretty, EXIT_SUCCESS}; use karapace_core::{Engine, StoreLock}; use karapace_store::StoreLayout; use std::path::Path; @@ -7,7 +7,7 @@ pub fn run(engine: &Engine, store_path: &Path, env_id: &str) -> Result Result { - let resolved = resolve_env_id(engine, env_id)?; + let resolved = if json { + resolve_env_id(engine, env_id)? + } else { + resolve_env_id_pretty(engine, env_id)? + }; let meta = engine.inspect(&resolved).map_err(|e| e.to_string())?; if json { println!("{}", json_pretty(&meta)?); diff --git a/crates/karapace-cli/src/commands/mod.rs b/crates/karapace-cli/src/commands/mod.rs index 834af9d..239c20c 100644 --- a/crates/karapace-cli/src/commands/mod.rs +++ b/crates/karapace-cli/src/commands/mod.rs @@ -13,6 +13,7 @@ pub mod inspect; pub mod list; pub mod man_pages; pub mod migrate; +pub mod new; pub mod pull; pub mod push; pub mod rebuild; @@ -20,6 +21,7 @@ pub mod rename; pub mod restore; pub mod snapshots; pub mod stop; +pub mod tui; pub mod verify_store; use indicatif::{ProgressBar, ProgressStyle}; @@ -96,6 +98,75 @@ pub fn resolve_env_id(engine: &Engine, input: &str) -> Result { } } +fn format_env_suggestion(meta: &karapace_store::EnvMetadata) -> String { + let label = meta + .name + .as_deref() + .unwrap_or_else(|| meta.short_id.as_str()); + format!("{label} ({}) {}", meta.short_id, meta.state) +} + +pub fn resolve_env_id_pretty(engine: &Engine, input: &str) -> Result { + if input.len() == 64 { + return Ok(input.to_owned()); + } + + let envs = engine.list().map_err(|e| e.to_string())?; + for e in &envs { + if *e.env_id == *input || *e.short_id == *input || e.name.as_deref() == Some(input) { + return Ok(e.env_id.to_string()); + } + } + + let prefix_matches: Vec<_> = envs + .iter() + .filter(|e| e.env_id.starts_with(input) || e.short_id.starts_with(input)) + .collect(); + + match prefix_matches.len() { + 0 => { + let needle = input.to_lowercase(); + let mut suggestions: Vec<_> = envs + .iter() + .filter(|e| { + e.name + .as_deref() + .unwrap_or("") + .to_lowercase() + .contains(&needle) + || e.short_id.to_lowercase().contains(&needle) + }) + .collect(); + suggestions.truncate(5); + + if suggestions.is_empty() { + Err(format!("no environment matching '{input}'")) + } else { + let rendered = suggestions + .into_iter() + .map(|m| format!(" {}", format_env_suggestion(m))) + .collect::>() + .join("\n"); + Err(format!( + "no environment matching '{input}'\n\nDid you mean:\n{rendered}\n\nUse 'karapace list' to see all environments." + )) + } + } + 1 => Ok(prefix_matches[0].env_id.to_string()), + n => { + let rendered = prefix_matches + .iter() + .take(10) + .map(|m| format!(" {}", format_env_suggestion(m))) + .collect::>() + .join("\n"); + Err(format!( + "ambiguous env_id prefix '{input}': matches {n} environments\n\nMatches:\n{rendered}\n\nUse a longer prefix, a full env_id, or a unique name." + )) + } + } +} + pub fn make_remote_backend( remote_url: Option<&str>, ) -> Result { diff --git a/crates/karapace-cli/src/commands/new.rs b/crates/karapace-cli/src/commands/new.rs new file mode 100644 index 0000000..64fc256 --- /dev/null +++ b/crates/karapace-cli/src/commands/new.rs @@ -0,0 +1,177 @@ +use super::{json_pretty, EXIT_SUCCESS}; +use dialoguer::{Confirm, Input, Select}; +use karapace_schema::manifest::{ + parse_manifest_str, BaseSection, ManifestV1, MountsSection, RuntimeSection, SystemSection, +}; +use std::io::{stdin, IsTerminal}; +use std::path::{Path, PathBuf}; +use tempfile::NamedTempFile; + +const DEST_MANIFEST: &str = "karapace.toml"; + +fn template_source(name: &str) -> Option<&'static str> { + match name { + "minimal" => Some(include_str!("../../../../examples/minimal.toml")), + "dev" => Some(include_str!("../../../../examples/dev.toml")), + "gui-dev" => Some(include_str!("../../../../examples/gui-dev.toml")), + "rust-dev" => Some(include_str!("../../../../examples/rust-dev.toml")), + "ubuntu-dev" => Some(include_str!("../../../../examples/ubuntu-dev.toml")), + _ => None, + } +} + +fn load_template(name: &str) -> Result { + let src = template_source(name).ok_or_else(|| { + format!("unknown template '{name}' (expected: minimal, dev, gui-dev, rust-dev, ubuntu-dev)") + })?; + parse_manifest_str(src).map_err(|e| format!("template parse error: {e}")) +} + +fn write_atomic(dest: &Path, content: &str) -> Result<(), String> { + let dir = dest + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + let mut tmp = NamedTempFile::new_in(&dir).map_err(|e| format!("write temp file: {e}"))?; + use std::io::Write; + tmp.write_all(content.as_bytes()) + .map_err(|e| format!("write temp file: {e}"))?; + tmp.as_file() + .sync_all() + .map_err(|e| format!("fsync temp file: {e}"))?; + tmp.persist(dest) + .map_err(|e| format!("persist manifest: {}", e.error))?; + Ok(()) +} + +pub fn run(name: &str, template: Option<&str>, force: bool, json: bool) -> Result { + let dest = Path::new(DEST_MANIFEST); + let is_tty = stdin().is_terminal(); + + if dest.exists() && !force { + if is_tty { + let overwrite = Confirm::new() + .with_prompt(format!("overwrite ./{DEST_MANIFEST}?")) + .default(false) + .interact() + .map_err(|e| format!("prompt failed: {e}"))?; + if !overwrite { + return Err(format!( + "refusing to overwrite existing ./{DEST_MANIFEST} (pass --force)" + )); + } + } else { + return Err(format!( + "refusing to overwrite existing ./{DEST_MANIFEST} (pass --force)" + )); + } + } + + let mut manifest = match template { + Some(tpl) => load_template(tpl)?, + None => { + if !is_tty { + return Err("no --template provided and stdin is not a TTY".to_owned()); + } + + let image: String = Input::new() + .with_prompt("base image") + .default("rolling".to_owned()) + .interact_text() + .map_err(|e| format!("prompt failed: {e}"))?; + + ManifestV1 { + manifest_version: 1, + base: BaseSection { image }, + system: SystemSection::default(), + gui: Default::default(), + hardware: Default::default(), + mounts: MountsSection::default(), + runtime: RuntimeSection::default(), + } + } + }; + + if is_tty { + let packages: String = Input::new() + .with_prompt("packages (space-separated, empty to skip)") + .allow_empty(true) + .interact_text() + .map_err(|e| format!("prompt failed: {e}"))?; + if !packages.trim().is_empty() { + manifest + .system + .packages + .extend(packages.split_whitespace().map(str::to_owned)); + } + + let mount: String = Input::new() + .with_prompt("mount (format ':', empty to skip)") + .allow_empty(true) + .interact_text() + .map_err(|e| format!("prompt failed: {e}"))?; + if !mount.trim().is_empty() { + manifest + .mounts + .entries + .insert("workspace".to_owned(), mount); + } + + let backends = ["namespace", "oci", "mock"]; + let default_idx = backends + .iter() + .position(|b| *b == manifest.runtime.backend.as_str()) + .unwrap_or(0); + let idx = Select::new() + .with_prompt("runtime backend") + .items(&backends) + .default(default_idx) + .interact() + .map_err(|e| format!("prompt failed: {e}"))?; + manifest.runtime.backend = backends[idx].to_owned(); + + let isolated = Confirm::new() + .with_prompt("enable network isolation?") + .default(manifest.runtime.network_isolation) + .interact() + .map_err(|e| format!("prompt failed: {e}"))?; + manifest.runtime.network_isolation = isolated; + } else if template.is_none() { + return Err("interactive prompts require a TTY".to_owned()); + } + + let toml = + toml::to_string_pretty(&manifest).map_err(|e| format!("TOML serialization failed: {e}"))?; + write_atomic(dest, &toml)?; + + if json { + let payload = serde_json::json!({ + "status": "written", + "path": format!("./{DEST_MANIFEST}"), + "name": name, + "template": template, + }); + println!("{}", json_pretty(&payload)?); + } else { + println!("wrote ./{DEST_MANIFEST} for '{name}'"); + if let Some(tpl) = template { + println!("template: {tpl}"); + } + } + + Ok(EXIT_SUCCESS) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn templates_parse() { + for tpl in ["minimal", "dev", "gui-dev", "rust-dev", "ubuntu-dev"] { + let m = load_template(tpl).unwrap(); + assert_eq!(m.manifest_version, 1); + assert!(!m.base.image.is_empty()); + } + } +} diff --git a/crates/karapace-cli/src/commands/push.rs b/crates/karapace-cli/src/commands/push.rs index b947243..61617a4 100644 --- a/crates/karapace-cli/src/commands/push.rs +++ b/crates/karapace-cli/src/commands/push.rs @@ -1,5 +1,6 @@ use super::{ - json_pretty, make_remote_backend, resolve_env_id, spin_fail, spin_ok, spinner, EXIT_SUCCESS, + json_pretty, make_remote_backend, resolve_env_id, resolve_env_id_pretty, spin_fail, spin_ok, + spinner, EXIT_SUCCESS, }; use karapace_core::Engine; @@ -10,7 +11,11 @@ pub fn run( remote_url: Option<&str>, json: bool, ) -> Result { - let resolved = resolve_env_id(engine, env_id)?; + let resolved = if json { + resolve_env_id(engine, env_id)? + } else { + resolve_env_id_pretty(engine, env_id)? + }; let backend = make_remote_backend(remote_url)?; let pb = spinner("pushing environment…"); diff --git a/crates/karapace-cli/src/commands/rename.rs b/crates/karapace-cli/src/commands/rename.rs index 55c43ff..b7215aa 100644 --- a/crates/karapace-cli/src/commands/rename.rs +++ b/crates/karapace-cli/src/commands/rename.rs @@ -1,4 +1,4 @@ -use super::{resolve_env_id, EXIT_SUCCESS}; +use super::{resolve_env_id_pretty, EXIT_SUCCESS}; use karapace_core::{Engine, StoreLock}; use karapace_store::StoreLayout; use std::path::Path; @@ -7,7 +7,7 @@ pub fn run(engine: &Engine, store_path: &Path, env_id: &str, new_name: &str) -> let layout = StoreLayout::new(store_path); let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?; - let resolved = resolve_env_id(engine, env_id)?; + let resolved = resolve_env_id_pretty(engine, env_id)?; engine .rename(&resolved, new_name) .map_err(|e| e.to_string())?; diff --git a/crates/karapace-cli/src/commands/restore.rs b/crates/karapace-cli/src/commands/restore.rs index b2018c8..e444436 100644 --- a/crates/karapace-cli/src/commands/restore.rs +++ b/crates/karapace-cli/src/commands/restore.rs @@ -1,4 +1,4 @@ -use super::{json_pretty, resolve_env_id, EXIT_SUCCESS}; +use super::{json_pretty, resolve_env_id, resolve_env_id_pretty, EXIT_SUCCESS}; use karapace_core::{Engine, StoreLock}; use karapace_store::StoreLayout; use std::path::Path; @@ -13,7 +13,11 @@ pub fn run( let layout = StoreLayout::new(store_path); let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?; - let resolved = resolve_env_id(engine, env_id)?; + let resolved = if json { + resolve_env_id(engine, env_id)? + } else { + resolve_env_id_pretty(engine, env_id)? + }; engine .restore(&resolved, snapshot_hash) .map_err(|e| e.to_string())?; diff --git a/crates/karapace-cli/src/commands/snapshots.rs b/crates/karapace-cli/src/commands/snapshots.rs index 1c0aeb3..b741b49 100644 --- a/crates/karapace-cli/src/commands/snapshots.rs +++ b/crates/karapace-cli/src/commands/snapshots.rs @@ -1,4 +1,4 @@ -use super::{json_pretty, resolve_env_id, EXIT_SUCCESS}; +use super::{json_pretty, resolve_env_id, resolve_env_id_pretty, EXIT_SUCCESS}; use karapace_core::Engine; use karapace_store::StoreLayout; use std::path::Path; @@ -6,7 +6,11 @@ use std::path::Path; pub fn run(engine: &Engine, store_path: &Path, env_id: &str, json: bool) -> Result { let _layout = StoreLayout::new(store_path); - let resolved = resolve_env_id(engine, env_id)?; + let resolved = if json { + resolve_env_id(engine, env_id)? + } else { + resolve_env_id_pretty(engine, env_id)? + }; let snapshots = engine .list_snapshots(&resolved) .map_err(|e| e.to_string())?; diff --git a/crates/karapace-cli/src/commands/stop.rs b/crates/karapace-cli/src/commands/stop.rs index 0ad8ea5..4ff1cf6 100644 --- a/crates/karapace-cli/src/commands/stop.rs +++ b/crates/karapace-cli/src/commands/stop.rs @@ -1,4 +1,4 @@ -use super::{resolve_env_id, EXIT_SUCCESS}; +use super::{resolve_env_id_pretty, EXIT_SUCCESS}; use karapace_core::{Engine, StoreLock}; use karapace_store::StoreLayout; use std::path::Path; @@ -7,7 +7,7 @@ pub fn run(engine: &Engine, store_path: &Path, env_id: &str) -> Result Result { + if json { + return Err("JSON output is not supported for 'tui'".to_owned()); + } + karapace_tui::run(store_path)?; + Ok(EXIT_SUCCESS) +} diff --git a/crates/karapace-cli/src/main.rs b/crates/karapace-cli/src/main.rs index b75c5d7..b1895d0 100644 --- a/crates/karapace-cli/src/main.rs +++ b/crates/karapace-cli/src/main.rs @@ -36,6 +36,13 @@ struct Cli { #[derive(Debug, Subcommand)] enum Commands { + New { + name: String, + #[arg(long)] + template: Option, + #[arg(long, default_value_t = false)] + force: bool, + }, /// Build an environment from a manifest. Build { /// Path to manifest TOML file. @@ -164,6 +171,8 @@ enum Commands { #[arg(default_value = "man")] dir: PathBuf, }, + /// Launch the terminal UI. + Tui, /// Run diagnostic checks on the system and store. Doctor, /// Check store version and show migration guidance. @@ -202,6 +211,7 @@ fn main() -> ExitCode { | Commands::Enter { .. } | Commands::Exec { .. } | Commands::Rebuild { .. } + | Commands::Tui ); if needs_runtime && std::env::var("KARAPACE_SKIP_PREREQS").as_deref() != Ok("1") { let missing = karapace_runtime::check_namespace_prereqs(); @@ -212,6 +222,11 @@ fn main() -> ExitCode { } let result = match cli.command { + Commands::New { + name, + template, + force, + } => commands::new::run(&name, template.as_deref(), force, json_output), Commands::Build { manifest, name } => commands::build::run( &engine, &store_path, @@ -269,6 +284,7 @@ fn main() -> ExitCode { } Commands::Completions { shell } => commands::completions::run::(shell), Commands::ManPages { dir } => commands::man_pages::run::(&dir), + Commands::Tui => commands::tui::run(&store_path, json_output), Commands::Doctor => commands::doctor::run(&store_path, json_output), Commands::Migrate => commands::migrate::run(&store_path, json_output), };