use crate::application::export_env::ExportEnvService;
use crate::application::file_artifacts::{
FileArtifactEntry, FileArtifactLocation, FileArtifactService, FileRestorePlanEntry,
FileRestoreStatus,
};
use crate::application::get_secret::GetSecretService;
use crate::application::import_env_file::ImportReport;
use crate::application::init_service::InitService;
use crate::application::list_services::ListServicesService;
use crate::application::run_command::RunCommandService;
use crate::application::search_secrets::SearchSecretsService;
use crate::application::set_secret::SetSecretService;
use crate::application::sync_service::SyncService;
use crate::application::unset_secret::UnsetSecretService;
#[cfg(feature = "server")]
use crate::cli::args::RemoteCommands;
use crate::cli::args::{Cli, Commands, EnvCommands, FileCommands, MemberCommands};
use crate::cli::style::Palette;
use anyhow::Context;
#[cfg(feature = "server")]
use base64::Engine as _;
use clap::CommandFactory;
#[cfg(feature = "server")]
use ed25519_dalek::Signer;
use kagi_crypto::xchacha_crypto::XChaChaEncryptor;
use kagi_domain::config::{DEFAULT_ENV_NAME, KagiConfig, MonorepoServiceMapping, MonorepoSettings};
use kagi_domain::repository::secret_repo::SecretRepository;
use kagi_domain::runner::CommandRunner;
use kagi_store::env_injector::SystemCommandRunner;
use kagi_store::fs_store::FileStore;
use kagi_store::key_manager::KeyManager;
#[cfg(feature = "server")]
use kagi_store::key_manager::MemberMetadata;
#[cfg(feature = "server")]
use kagi_store::key_manager::default_member_name;
#[cfg(feature = "server")]
use kagi_sync::domain::project_token::base64_encode_url;
#[cfg(feature = "server")]
use kagi_sync::domain::remote_config::RemoteMetadata;
#[cfg(feature = "server")]
use kagi_sync::infrastructure::remote_client::MemberJoinRequest;
#[cfg(feature = "server")]
use kagi_sync::infrastructure::remote_client::TokenIssueResponse;
#[cfg(feature = "server")]
use kagi_sync::infrastructure::remote_local::RemoteLocalStore;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[cfg(feature = "server")]
use std::collections::BTreeSet;
use std::fs;
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
#[cfg(feature = "server")]
use std::str::FromStr;
const ROTATION_JOURNAL_VERSION: u8 = 1;
#[derive(Debug, Serialize, Deserialize)]
struct RotationJournal {
version: u8,
project_id: String,
access_json: String,
files: BTreeMap<String, String>,
}
fn draw_key_table(items: &[(String, String, Option<String>)], show_values: bool, c: &Palette) {
draw_key_table_with_indent(items, show_values, c, "");
}
fn is_upper_snake_key(key: &str) -> bool {
let mut chars = key.chars();
let Some(first) = chars.next() else {
return false;
};
(first.is_ascii_uppercase() || first == '_')
&& chars.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
}
fn upper_snake_key(key: &str) -> String {
let mut out = String::new();
let mut last_was_underscore = false;
for ch in key.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_uppercase());
last_was_underscore = false;
} else if !last_was_underscore {
out.push('_');
last_was_underscore = true;
}
}
out.trim_matches('_').to_string()
}
fn normalize_manual_secret_key(key: String) -> String {
if is_upper_snake_key(&key) {
return key;
}
let normalized = upper_snake_key(&key);
if normalized.is_empty() {
key
} else {
normalized
}
}
fn normalize_manual_secret_key_with_tip(key: String, operation: &str, c: &Palette) -> String {
let normalized = normalize_manual_secret_key(key.clone());
if normalized != key {
eprintln!(
"{} {} {} -> {}",
c.prefix(),
c.info(&format!("{operation}: normalized key")),
c.key(&key),
c.key(&normalized)
);
}
normalized
}
fn draw_key_table_with_indent(
items: &[(String, String, Option<String>)],
show_values: bool,
c: &Palette,
indent: &str,
) {
let max_key = items
.iter()
.map(|(k, _, _)| k.len())
.max()
.unwrap_or(0)
.max(3);
let max_val = items
.iter()
.map(|(_, v, _)| {
if show_values {
if v.is_empty() { 7 } else { v.len() }
} else {
8
}
})
.max()
.unwrap_or(0)
.max(5);
let has_desc = items.iter().any(|(_, _, d)| d.is_some());
let max_desc = if has_desc {
items
.iter()
.filter_map(|(_, _, d)| d.as_ref().map(std::string::String::len))
.max()
.unwrap_or(0)
.max(4)
} else {
0
};
let border = if has_desc {
format!(
"┌─{:─<width1$}─┬─{:─<width2$}─┬─{:─<width3$}─┐",
"",
"",
"",
width1 = max_key,
width2 = max_val,
width3 = max_desc
)
} else {
format!(
"┌─{:─<width1$}─┬─{:─<width2$}─┐",
"",
"",
width1 = max_key,
width2 = max_val
)
};
println!("{}{}", indent, c.accent(&border));
print!("{}{}", indent, c.accent("│ "));
print!("{}", c.prompt("Key"));
print!("{}", " ".repeat(max_key.saturating_sub(3)));
print!("{}", c.accent(" │ "));
print!("{}", c.prompt("Value"));
print!("{}", " ".repeat(max_val.saturating_sub(5)));
if has_desc {
print!("{}", c.accent(" │ "));
print!("{}", c.prompt("Desc"));
print!("{}", " ".repeat(max_desc.saturating_sub(4)));
}
println!("{}", c.accent(" │"));
let sep = if has_desc {
format!(
"├─{:─<width1$}─┼─{:─<width2$}─┼─{:─<width3$}─┤",
"",
"",
"",
width1 = max_key,
width2 = max_val,
width3 = max_desc
)
} else {
format!(
"├─{:─<width1$}─┼─{:─<width2$}─┤",
"",
"",
width1 = max_key,
width2 = max_val
)
};
println!("{}{}", indent, c.accent(&sep));
for (key, value, desc) in items {
print!("{}{}", indent, c.accent("│ "));
print!("{}", c.key(key));
let key_padding = max_key.saturating_sub(key.len());
if key_padding > 0 {
print!("{}", " ".repeat(key_padding));
}
print!("{}", c.accent(" │ "));
if !show_values {
print!("{}", c.muted("********"));
let padding = max_val.saturating_sub(8);
if padding > 0 {
print!("{}", c.muted(&" ".repeat(padding)));
}
} else if value.is_empty() {
print!("{}", c.commented("(empty)"));
let padding = max_val.saturating_sub(7);
if padding > 0 {
print!("{}", c.muted(&" ".repeat(padding)));
}
} else {
print!("{}", c.success(value));
let padding = max_val.saturating_sub(value.len());
if padding > 0 {
print!("{}", c.muted(&" ".repeat(padding)));
}
}
if has_desc {
print!("{}", c.accent(" │ "));
if let Some(d) = desc {
print!("{}", c.muted(d));
let padding = max_desc.saturating_sub(d.len());
if padding > 0 {
print!("{}", " ".repeat(padding));
}
} else {
print!("{}", " ".repeat(max_desc));
}
}
println!("{}", c.accent(" │"));
}
let bottom = if has_desc {
format!(
"└─{:─<width1$}─┴─{:─<width2$}─┴─{:─<width3$}─┘",
"",
"",
"",
width1 = max_key,
width2 = max_val,
width3 = max_desc
)
} else {
format!(
"└─{:─<width1$}─┴─{:─<width2$}─┘",
"",
"",
width1 = max_key,
width2 = max_val
)
};
println!("{}{}", indent, c.accent(&bottom));
}
fn list_service_scopes(
list_service: &ListServicesService<FileStore>,
service: &str,
) -> anyhow::Result<Vec<String>> {
let prefix = format!("{service}/");
let mut scopes: Vec<String> = list_service
.execute(None)?
.into_iter()
.map(|(name, _, _)| name)
.filter(|name| name.starts_with(&prefix))
.collect();
scopes.sort();
Ok(scopes)
}
fn draw_service_envs(
list_service: &ListServicesService<FileStore>,
service: &str,
show_values: bool,
c: &Palette,
) -> anyhow::Result<()> {
let scopes = list_service_scopes(list_service, service)?;
if scopes.is_empty() {
println!("{} {}", c.prefix(), c.muted("no services found"));
return Ok(());
}
println!("{}", c.accent(service));
for scope in scopes {
let env = scope.split_once('/').map_or(scope.as_str(), |(_, env)| env);
println!(" {}", c.accent(env));
let items = list_service.execute(Some(&scope))?;
if items.is_empty() {
println!(
" {} {}",
c.prefix(),
c.muted(&format!("no secrets in {env}"))
);
} else {
draw_key_table_with_indent(&items, show_values, c, " ");
}
}
Ok(())
}
fn draw_all_service_envs(
list_service: &ListServicesService<FileStore>,
show_values: bool,
c: &Palette,
) -> anyhow::Result<()> {
let mut scopes: Vec<String> = list_service
.execute(None)?
.into_iter()
.map(|(name, _, _)| name)
.collect();
scopes.sort();
if scopes.is_empty() {
println!("{} {}", c.prefix(), c.muted("no services found"));
return Ok(());
}
let mut current_service: Option<String> = None;
for scope in scopes {
let (service, env) = scope
.split_once('/')
.map_or(("(root)", scope.as_str()), |(service, env)| (service, env));
if current_service.as_deref() != Some(service) {
current_service = Some(service.to_string());
println!("{}", c.accent(service));
}
println!(" {}", c.accent(env));
let items = list_service.execute(Some(&scope))?;
if items.is_empty() {
println!(
" {} {}",
c.prefix(),
c.muted(&format!("no secrets in {env}"))
);
} else {
draw_key_table_with_indent(&items, show_values, c, " ");
}
}
Ok(())
}
fn service_scopes_from_store(store: &FileStore, service: &str) -> anyhow::Result<Vec<String>> {
let prefix = format!("{service}/");
let mut scopes: Vec<String> = store
.list_services()?
.into_iter()
.filter(|name| name.starts_with(&prefix))
.collect();
scopes.sort();
Ok(scopes)
}
fn env_file_name(scope: &str) -> anyhow::Result<String> {
let env = scope.split_once('/').map_or(scope, |(_, env)| env);
if env.is_empty() || env.contains('/') || env.contains('\\') {
Err(anyhow::anyhow!("invalid environment name: {env}"))
} else {
Ok(format!(".env.{env}"))
}
}
fn write_export_file(out_dir: &Path, scope: &str, content: &str) -> anyhow::Result<PathBuf> {
fs::create_dir_all(out_dir)?;
let path = out_dir.join(env_file_name(scope)?);
fs::write(&path, content)?;
Ok(path)
}
fn has_encrypted_store(path: &Path) -> anyhow::Result<bool> {
if !path.exists() {
return Ok(false);
}
for entry in fs::read_dir(path)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if has_encrypted_store(&path)? {
return Ok(true);
}
} else if path.extension().is_some_and(|ext| ext == "enc") {
return Ok(true);
}
}
Ok(false)
}
fn scope_name(service: Option<&str>, env: &str) -> String {
match service {
Some(service) => format!("{service}/{env}"),
None => env.to_string(),
}
}
fn service_from_scope(scope: &str) -> Option<&str> {
scope.split_once('/').map(|(service, _)| service)
}
fn normalized_relative_path(path: &Path) -> String {
path.components()
.filter_map(|part| part.as_os_str().to_str())
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("/")
}
fn display_path(path: &Path) -> String {
let display = path.display().to_string();
#[cfg(windows)]
{
if let Some(rest) = display.strip_prefix("\\\\?\\UNC\\") {
return format!("\\\\{rest}");
}
if let Some(rest) = display.strip_prefix("\\\\?\\") {
return rest.to_string();
}
}
display
}
fn mapping_matches(relative_path: &str, mapping_path: &str) -> bool {
let mapping_path = mapping_path
.replace('\\', "/")
.trim_matches('/')
.to_string();
!mapping_path.is_empty()
&& (relative_path == mapping_path || relative_path.starts_with(&(mapping_path + "/")))
}
fn infer_monorepo_service(config: &KagiConfig, relative_path: &str) -> Option<String> {
let monorepo = config.settings.monorepo.as_ref()?;
monorepo
.services
.iter()
.filter(|mapping| mapping_matches(relative_path, &mapping.path))
.max_by_key(|mapping| mapping.path.replace('\\', "/").split('/').count())
.map(|mapping| mapping.service.clone())
}
fn infer_legacy_nested_service(relative: &Path) -> Option<String> {
relative
.components()
.next()
.map(|c| c.as_os_str().to_string_lossy().to_string())
}
fn infer_service_from_config(config: &KagiConfig, relative: &Path) -> Option<String> {
let relative_path = normalized_relative_path(relative);
infer_monorepo_service(config, &relative_path).or_else(|| {
if config.settings.nested.is_allowed(&relative_path) {
infer_legacy_nested_service(relative)
} else {
None
}
})
}
fn write_monorepo_mappings(
config_path: &Path,
candidates: &[crate::application::env_migration::EnvFileCandidate],
) -> anyhow::Result<usize> {
let mut config: KagiConfig = serde_json::from_str(&fs::read_to_string(config_path)?)?;
if !matches!(
config.settings.nested,
kagi_domain::config::NestedMode::Bool(true)
) {
return Ok(0);
}
let mut mappings = std::collections::BTreeMap::new();
for candidate in candidates {
if let (Some(path), Some(service)) = (&candidate.service_path, &candidate.service_name) {
mappings.insert(path.clone(), service.clone());
}
}
mappings = disambiguate_monorepo_service_names(mappings);
if mappings.is_empty() {
return Ok(0);
}
config.settings.monorepo = Some(MonorepoSettings {
max_depth: crate::application::env_migration::DEFAULT_ENV_SCAN_DEPTH,
services: mappings
.into_iter()
.map(|(path, service)| MonorepoServiceMapping { path, service })
.collect(),
});
let mapping_count = config
.settings
.monorepo
.as_ref()
.map_or(0, |monorepo| monorepo.services.len());
fs::write(config_path, serde_json::to_string_pretty(&config)?)?;
Ok(mapping_count)
}
fn disambiguate_monorepo_service_names(
mappings: std::collections::BTreeMap<String, String>,
) -> std::collections::BTreeMap<String, String> {
let mut service_counts = std::collections::BTreeMap::<String, usize>::new();
for service in mappings.values() {
*service_counts.entry(service.clone()).or_default() += 1;
}
mappings
.into_iter()
.map(|(path, service)| {
if service_counts.get(&service).copied().unwrap_or(0) > 1 {
let suffix = short_monorepo_path_hash(&path);
(path, format!("{service}-{suffix}"))
} else {
(path, service)
}
})
.collect()
}
fn short_monorepo_path_hash(path: &str) -> String {
let normalized = path.replace('\\', "/");
let mut hash = 0xcbf29ce484222325u64;
for byte in normalized.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{:06x}", hash & 0xff_ffff)
}
fn run_status(c: &Palette) -> anyhow::Result<()> {
let cwd = std::env::current_dir()?;
let (base_path, inferred_service) = resolve_kagi_base()?;
let config_path = base_path.join(kagi_domain::config::KAGI_CONFIG_FILE);
validate_v2_config(&config_path)?;
let config: KagiConfig = serde_json::from_str(&fs::read_to_string(&config_path)?)?;
let project_root = base_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| base_path.clone());
let relative = cwd.strip_prefix(&project_root).unwrap_or(Path::new(""));
let relative = normalized_relative_path(relative);
let envs = if config.settings.envs.is_empty() {
DEFAULT_ENV_NAME.to_string()
} else {
config.settings.envs.join(", ")
};
let mapping_count = config
.settings
.monorepo
.as_ref()
.map_or(0, |monorepo| monorepo.services.len());
println!("{} {}", c.prefix(), c.accent("status"));
println!(" repo: {}", c.muted(&project_root.display().to_string()));
println!(" kagi: {}", c.muted(&base_path.display().to_string()));
println!(
" cwd: {}",
c.muted(if relative.is_empty() { "." } else { &relative })
);
println!(" project: {}", c.accent(&config.project_id));
println!(" default env: {}", c.accent(&config.settings.default_env));
println!(" envs: {}", c.accent(&envs));
println!(
" inferred service: {}",
inferred_service
.as_deref()
.map(|service| c.accent(service))
.unwrap_or_else(|| c.muted("(none)"))
);
println!(
" stored scopes: {}",
c.accent(&config.services.len().to_string())
);
println!(
" monorepo mappings: {}",
c.accent(&mapping_count.to_string())
);
println!(
" remote: {}",
if config.settings.sync.is_some() {
c.accent("configured")
} else {
c.muted("not configured")
}
);
Ok(())
}
fn root_or_default_service_scope(default_envs: &[String], default_env: &str, name: &str) -> String {
if default_envs.iter().any(|env| env == name) {
name.to_string()
} else {
scope_name(Some(name), default_env)
}
}
fn ensure_default_envs_for_scope(store: &FileStore, scope: &str) -> anyhow::Result<()> {
if let Some(service) = service_from_scope(scope) {
store
.ensure_service_envs(service)
.map_err(|e| anyhow::anyhow!("Failed to initialize default envs: {e}"))?;
}
Ok(())
}
struct TargetContext<'a> {
default_envs: &'a [String],
default_env: &'a str,
inferred_service: Option<String>,
service_flag: Option<String>,
}
struct SecretArgs {
first: Option<String>,
second: Option<String>,
third: Option<String>,
fourth: Option<String>,
}
fn parse_secret_target(
ctx: TargetContext<'_>,
args: SecretArgs,
usage: &str,
) -> anyhow::Result<(String, String, String)> {
match (
ctx.service_flag,
ctx.inferred_service,
args.first,
args.second,
args.third,
args.fourth,
) {
(Some(service), _, Some(env), Some(key), Some(value), None) => {
Ok((scope_name(Some(&service), &env), key, value))
}
(Some(service), _, Some(key), Some(value), None, None) => {
Ok((scope_name(Some(&service), ctx.default_env), key, value))
}
(None, Some(service), Some(env_or_key), Some(key_or_value), Some(value), None) => {
Ok((scope_name(Some(&service), &env_or_key), key_or_value, value))
}
(None, Some(service), Some(key), Some(value), None, None) => {
Ok((scope_name(Some(&service), ctx.default_env), key, value))
}
(None, None, Some(service), Some(env), Some(key), Some(value)) => {
Ok((scope_name(Some(&service), &env), key, value))
}
(None, None, Some(env), Some(key), Some(value), None) => Ok((
root_or_default_service_scope(ctx.default_envs, ctx.default_env, &env),
key,
value,
)),
_ => Err(anyhow::anyhow!(usage.to_string())),
}
}
fn parse_scope_args(
default_envs: &[String],
default_env: &str,
inferred_service: Option<String>,
service_flag: Option<String>,
first: Option<String>,
second: Option<String>,
) -> anyhow::Result<String> {
match (service_flag, inferred_service, first, second) {
(Some(service), _, Some(env), None) => Ok(scope_name(Some(&service), &env)),
(Some(service), _, None, None) => Ok(scope_name(Some(&service), default_env)),
(None, Some(service), Some(env), None) => Ok(scope_name(Some(&service), &env)),
(None, Some(service), None, None) => Ok(scope_name(Some(&service), default_env)),
(None, None, Some(service), Some(env)) => Ok(scope_name(Some(&service), &env)),
(None, None, Some(name), None) => Ok(root_or_default_service_scope(
default_envs,
default_env,
&name,
)),
_ => Err(anyhow::anyhow!(
"No environment specified. Provide an environment name."
)),
}
}
fn split_file_item_args(args: Vec<String>, item: &str) -> anyhow::Result<(Vec<String>, String)> {
if args.is_empty() {
return Err(anyhow::anyhow!(
"Usage: kagi file <command> [scope] <{item}>"
));
}
if args.len() > 3 {
return Err(anyhow::anyhow!(
"Too many arguments. Use [service] [env] <{item}> or [env] <{item}> when service is inferred."
));
}
let mut args = args;
let value = args.pop().unwrap();
Ok((args, value))
}
fn parse_file_scope_parts(
default_envs: &[String],
default_env: &str,
inferred_service: Option<String>,
service_flag: Option<String>,
scope_parts: Vec<String>,
) -> anyhow::Result<String> {
if scope_parts.len() > 2 {
return Err(anyhow::anyhow!(
"Too many scope arguments. Use [service] [env] or [env] when service is inferred."
));
}
if service_flag.is_none() && inferred_service.is_none() && scope_parts.is_empty() {
return Ok(default_env.to_string());
}
parse_scope_args(
default_envs,
default_env,
inferred_service,
service_flag,
scope_parts.first().cloned(),
scope_parts.get(1).cloned(),
)
}
fn resolve_file_service_and_scope(
service_flag: Option<String>,
scope_parts: Vec<String>,
) -> anyhow::Result<(FileArtifactService, String)> {
let (base_path, inferred) = resolve_kagi_base()?;
let config_path = base_path.join(kagi_domain::config::KAGI_CONFIG_FILE);
validate_v2_config(&config_path)?;
recover_pending_rotation(&base_path)?;
let config: KagiConfig = serde_json::from_str(&fs::read_to_string(&config_path)?)?;
let scope = parse_file_scope_parts(
&config.settings.envs,
&config.settings.default_env,
inferred,
service_flag,
scope_parts,
)?;
let project_key = load_project_key(&base_path)?;
let file_service = FileArtifactService::new(base_path, &project_key)?;
Ok((file_service, scope))
}
fn resolve_file_service_for_list(
service_flag: Option<String>,
scope_parts: Vec<String>,
all: bool,
) -> anyhow::Result<(FileArtifactService, Option<String>)> {
let (base_path, inferred) = resolve_kagi_base()?;
let config_path = base_path.join(kagi_domain::config::KAGI_CONFIG_FILE);
validate_v2_config(&config_path)?;
recover_pending_rotation(&base_path)?;
let config: KagiConfig = serde_json::from_str(&fs::read_to_string(&config_path)?)?;
let project_key = load_project_key(&base_path)?;
let file_service = FileArtifactService::new(base_path, &project_key)?;
if all || (service_flag.is_none() && inferred.is_none() && scope_parts.is_empty()) {
return Ok((file_service, None));
}
let scope = parse_file_scope_parts(
&config.settings.envs,
&config.settings.default_env,
inferred,
service_flag,
scope_parts,
)?;
Ok((file_service, Some(scope)))
}
enum ScopeSelection {
One(String),
Service(String),
}
enum GetSelection {
All,
Service(String),
Scope(String),
Key(String, String),
}
#[cfg(any(feature = "tui", test))]
fn should_open_get_tui(
plain: bool,
tty: bool,
service_flag: &Option<String>,
first: &Option<String>,
second: &Option<String>,
third: &Option<String>,
) -> bool {
!plain
&& tty
&& service_flag.is_none()
&& first.is_none()
&& second.is_none()
&& third.is_none()
}
fn is_env_scope(
services: &[String],
default_envs: &[String],
service: Option<&str>,
env: &str,
) -> bool {
default_envs.iter().any(|configured| configured == env)
|| service
.map(|service| services.contains(&scope_name(Some(service), env)))
.unwrap_or_else(|| services.contains(&env.to_string()))
}
fn parse_get_selection(
services: &[String],
ctx: TargetContext<'_>,
first: Option<String>,
second: Option<String>,
third: Option<String>,
) -> anyhow::Result<GetSelection> {
match (ctx.service_flag, ctx.inferred_service, first, second, third) {
(Some(service), _, None, None, None) => Ok(GetSelection::Service(service)),
(Some(service), _, Some(env_or_key), None, None) => {
if is_env_scope(services, ctx.default_envs, Some(&service), &env_or_key) {
Ok(GetSelection::Scope(scope_name(Some(&service), &env_or_key)))
} else {
Ok(GetSelection::Key(
scope_name(Some(&service), ctx.default_env),
env_or_key,
))
}
}
(Some(service), _, Some(env), Some(key), None) => {
Ok(GetSelection::Key(scope_name(Some(&service), &env), key))
}
(None, Some(service), None, None, None) => Ok(GetSelection::Service(service)),
(None, Some(service), Some(env_or_key), None, None) => {
if is_env_scope(services, ctx.default_envs, Some(&service), &env_or_key) {
Ok(GetSelection::Scope(scope_name(Some(&service), &env_or_key)))
} else {
Ok(GetSelection::Key(
scope_name(Some(&service), ctx.default_env),
env_or_key,
))
}
}
(None, Some(service), Some(env), Some(key), None) => {
if is_env_scope(services, ctx.default_envs, Some(&service), &env) {
Ok(GetSelection::Key(scope_name(Some(&service), &env), key))
} else {
Err(anyhow::anyhow!(
"Unknown environment '{env}'. Use `kagi get {service}` to list available environments."
))
}
}
(None, None, None, None, None) => Ok(GetSelection::All),
(None, None, Some(name), None, None) => {
if is_env_scope(services, ctx.default_envs, None, &name) {
Ok(GetSelection::Scope(name))
} else {
Ok(GetSelection::Service(name))
}
}
(None, None, Some(name), Some(env_or_key), None) => {
if is_env_scope(services, ctx.default_envs, None, &name) {
Ok(GetSelection::Key(name, env_or_key))
} else if is_env_scope(services, ctx.default_envs, Some(&name), &env_or_key) {
Ok(GetSelection::Scope(scope_name(Some(&name), &env_or_key)))
} else {
Ok(GetSelection::Key(
scope_name(Some(&name), ctx.default_env),
env_or_key,
))
}
}
(None, None, Some(service), Some(env), Some(key)) => {
Ok(GetSelection::Key(scope_name(Some(&service), &env), key))
}
_ => Err(anyhow::anyhow!(
"Usage: kagi get [--show] [--service <service>] [env|key] or kagi get <service> [env|key] [key]"
)),
}
}
fn parse_export_selection(
default_envs: &[String],
inferred_service: Option<String>,
service_flag: Option<String>,
first: Option<String>,
second: Option<String>,
) -> anyhow::Result<ScopeSelection> {
match (service_flag, inferred_service, first, second) {
(Some(service), _, Some(env), None) => {
Ok(ScopeSelection::One(scope_name(Some(&service), &env)))
}
(Some(service), _, None, None) => Ok(ScopeSelection::Service(service)),
(None, Some(service), Some(env), None) => {
Ok(ScopeSelection::One(scope_name(Some(&service), &env)))
}
(None, Some(service), None, None) => Ok(ScopeSelection::Service(service)),
(None, None, Some(service), Some(env)) => {
Ok(ScopeSelection::One(scope_name(Some(&service), &env)))
}
(None, None, Some(name), None) => {
if default_envs.iter().any(|env| env == &name) {
Ok(ScopeSelection::One(name))
} else {
Ok(ScopeSelection::Service(name))
}
}
_ => Err(anyhow::anyhow!(
"Usage: kagi export [--service <service>] [env] or kagi export <service> [env]"
)),
}
}
fn confirm_secret_output(tty: bool, operation: &str, c: &Palette) -> anyhow::Result<()> {
if !tty || !io::stdin().is_terminal() {
return Err(anyhow::anyhow!(
"{operation} prints decrypted secrets and requires an interactive terminal. Use `kagi run` for scripts that need secrets injected into a child process."
));
}
eprint!(
"{} {} {} [y/N]: ",
c.prefix(),
c.warning("warning:"),
c.info(&format!("{operation} will print decrypted secrets."))
);
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim().eq_ignore_ascii_case("y") {
Ok(())
} else {
Err(anyhow::anyhow!("aborted"))
}
}
fn confirm_env_delete(tty: bool, env: &str, c: &Palette) -> anyhow::Result<()> {
if !tty || !io::stdin().is_terminal() {
return Err(anyhow::anyhow!(
"kagi env remove deletes encrypted environment stores and requires an interactive terminal."
));
}
eprintln!(
"{} {} {}",
c.prefix(),
c.warning("warning:"),
c.info(&format!(
"this will delete '{env}' from every service. Type '{env}' to confirm."
))
);
eprint!("{} {} ", c.prefix(), c.prompt("confirm:"));
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim() == env {
Ok(())
} else {
Err(anyhow::anyhow!("aborted"))
}
}
fn confirm_unset(tty: bool, scope: &str, key: &str, c: &Palette) -> anyhow::Result<()> {
if !tty || !io::stdin().is_terminal() {
return Err(anyhow::anyhow!(
"kagi unset deletes encrypted secrets and requires an interactive terminal."
));
}
eprint!(
"{} {} {} [y/N]: ",
c.prefix(),
c.warning("warning:"),
c.info(&format!("this will delete '{scope}.{key}' permanently."))
);
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim().eq_ignore_ascii_case("y") {
Ok(())
} else {
Err(anyhow::anyhow!("aborted"))
}
}
fn confirm_import_preview(
service_name: &str,
file: &str,
imported: &[String],
overwritten: &[String],
c: &Palette,
) -> anyhow::Result<bool> {
eprintln!(
"{} {} {} keys from {} into {}",
c.prefix(),
c.info("preview:"),
c.accent(&imported.len().to_string()),
c.accent(file),
c.accent(service_name)
);
if !overwritten.is_empty() {
eprintln!(
"{} {} {} keys will overwrite existing values",
c.prefix(),
c.warning("warning:"),
c.warning(&overwritten.len().to_string())
);
}
for key in imported {
let status = if overwritten.contains(key) {
c.warning("overwrite")
} else {
c.success("new")
};
eprintln!(" {}.{} {}", c.accent(service_name), c.key(key), status);
}
eprint!("{} {} [y/N]: ", c.prefix(), c.prompt("import?"));
std::io::stderr().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
Ok(input.trim().eq_ignore_ascii_case("y"))
}
fn print_import_report(report: &ImportReport, service_name: &str, file: &str, c: &Palette) {
println!(
"{} {} {} keys from {}",
c.prefix(),
c.success("Imported"),
c.success(&report.imported.len().to_string()),
c.accent(file)
);
if !report.overwritten.is_empty() {
println!(
"{} {} {} keys overwritten",
c.prefix(),
c.warning("warning:"),
c.warning(&report.overwritten.len().to_string())
);
}
for key in &report.imported {
let overwritten_marker = if report.overwritten.contains(key) {
c.warning(" (overwritten)")
} else {
String::new()
};
println!(
" {}.{}{}",
c.accent(service_name),
c.key(key),
overwritten_marker
);
}
}
fn print_import_dry_run(report: &ImportReport, service_name: &str, file: &str, c: &Palette) {
println!(
"{} {} {} {}",
c.prefix(),
c.info("dry run:"),
c.accent(file),
c.muted(&format!("-> {service_name}"))
);
println!(
"{} {} {}",
c.prefix(),
c.success("would import"),
c.accent(&report.imported.len().to_string())
);
if !report.imported.is_empty() {
eprintln!("{} {}", c.prefix(), c.muted("keys:"));
for key in &report.imported {
eprintln!(" {} {}", c.success("+"), c.key(key));
}
}
if !report.overwritten.is_empty() {
eprintln!("{} {}", c.prefix(), c.warning("would overwrite:"));
for key in &report.overwritten {
eprintln!(" {} {}", c.warning("~"), c.key(key));
}
}
}
fn print_file_list(files: &[FileArtifactEntry], c: &Palette) {
if files.is_empty() {
println!("{} {}", c.prefix(), c.muted("no encrypted files found"));
return;
}
let mut current_scope: Option<&str> = None;
for file in files {
if current_scope != Some(file.scope.as_str()) {
current_scope = Some(&file.scope);
println!("{}", c.accent(&file.scope));
}
let alias = file
.alias
.as_deref()
.map(|alias| format!(" alias:{alias}"))
.unwrap_or_default();
println!(
" {} {} {}{}",
c.key(&file.locator()),
c.muted(&format!("{} bytes", file.size)),
c.muted(file.display_name()),
c.muted(&alias)
);
}
}
fn print_restore_plan(plan: &[FileRestorePlanEntry], c: &Palette) {
println!("{} kagi will restore {} file(s):", c.prefix(), plan.len());
for item in plan {
println!();
println!(" {}", c.key(&item.entry.locator()));
println!(" -> {}", c.accent(&display_path(&item.target)));
let status = match item.status {
FileRestoreStatus::Missing => "status: missing, will create".to_string(),
FileRestoreStatus::Same => "status: exists, same, skip".to_string(),
FileRestoreStatus::Different if item.backup_path.is_some() => {
"status: exists, different, backup will be created".to_string()
}
FileRestoreStatus::Different => "status: exists, different, will overwrite".to_string(),
FileRestoreStatus::BlockedExisting => {
"status: exists, different, requires --force".to_string()
}
};
println!(" {}", c.muted(&status));
if let Some(backup_path) = &item.backup_path {
println!(" backup: {}", c.muted(&display_path(backup_path)));
}
}
}
fn confirm_restore_all(tty: bool, c: &Palette) -> anyhow::Result<()> {
if !tty || !io::stdin().is_terminal() {
return Err(anyhow::anyhow!(
"kagi file restore --all requires an interactive terminal after preview. Run `kagi file restore --all --dry-run` to review without writing."
));
}
eprint!("{} {} Continue? [y/N]: ", c.prefix(), c.warning("warning:"));
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim().eq_ignore_ascii_case("y") {
Ok(())
} else {
Err(anyhow::anyhow!("aborted"))
}
}
fn confirm_file_remove(tty: bool, scope: &str, name: &str, c: &Palette) -> anyhow::Result<()> {
if !tty || !io::stdin().is_terminal() {
return Err(anyhow::anyhow!(
"kagi file remove deletes encrypted files and requires an interactive terminal. Use --force to remove without prompting."
));
}
eprint!(
"{} {} {} [y/N]: ",
c.prefix(),
c.warning("warning:"),
c.info(&format!(
"this will remove encrypted file '{scope}.{name}' permanently."
))
);
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim().eq_ignore_ascii_case("y") {
Ok(())
} else {
Err(anyhow::anyhow!("aborted"))
}
}
fn confirm_member_remove(tty: bool, member_id: &str, c: &Palette) -> anyhow::Result<()> {
if !tty || !io::stdin().is_terminal() {
return Err(anyhow::anyhow!(
"kagi member remove changes project access and requires an interactive terminal."
));
}
eprintln!(
"{} {} {}",
c.prefix(),
c.warning("warning:"),
c.info(&format!(
"this will remove member '{member_id}' and rotate the project key. Type '{member_id}' to confirm."
))
);
eprint!("{} {} ", c.prefix(), c.prompt("confirm:"));
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim() == member_id {
Ok(())
} else {
Err(anyhow::anyhow!("aborted"))
}
}
fn print_member_approval_instruction(member_id: &str, c: &Palette) {
println!("{} {}", c.prefix(), c.muted("Ask an active member to run:"));
println!(
" {} {} {}",
c.accent("kagi"),
c.accent("member approve"),
c.key(member_id)
);
}
#[cfg(feature = "server")]
fn warn_join_request_cleanup_failed(member_id: &str, error: &dyn std::error::Error, c: &Palette) {
eprintln!(
"{} {} failed to clean up pending member request {} after server rejection: {}",
c.prefix(),
c.warning("warning:"),
c.accent(member_id),
error
);
}
#[cfg(feature = "server")]
fn add_pending_remote_approval(
mut meta: RemoteMetadata,
member_id: &str,
token_id: &str,
) -> RemoteMetadata {
let mut accepted_member_ids = meta.pending_accepted_member_ids.unwrap_or_default();
if !accepted_member_ids.iter().any(|id| id == member_id) {
accepted_member_ids.push(member_id.to_string());
}
meta.pending_accepted_member_ids = Some(accepted_member_ids);
let mut token_ids = meta.pending_token_ids.unwrap_or_default();
if !token_ids.iter().any(|id| id == token_id) {
token_ids.push(token_id.to_string());
}
meta.pending_token_ids = Some(token_ids);
meta
}
#[cfg(feature = "server")]
fn apply_server_member_approval(
key_manager: &KeyManager,
remote_store: &RemoteLocalStore,
meta: RemoteMetadata,
member_id: &str,
response: TokenIssueResponse,
) -> anyhow::Result<MemberMetadata> {
let pending_member = key_manager
.find_member(member_id)?
.filter(|member| member.status == "pending")
.ok_or_else(|| {
anyhow::anyhow!(
"server issued a token for `{member_id}` but the local pending member request is missing. Recovery: run `kagi remote pull`; if the member is still pending, rerun `kagi member approve {member_id}`."
)
})?;
if pending_member
.signing_public_key
.as_deref()
.is_none_or(|key| key.trim().is_empty())
{
return Err(anyhow::anyhow!(
"server member request for `{member_id}` is missing signing_public_key. Ask the member to rerun `kagi member request` with the updated CLI."
));
}
let recipient = age::x25519::Recipient::from_str(&pending_member.recipient)
.map_err(|e| anyhow::anyhow!("invalid member recipient: {e}"))?;
let encrypted = kagi_sync::infrastructure::remote_envelope::encrypt_bytes(
response.project_token.as_bytes(),
&recipient,
)
.map_err(|e| anyhow::anyhow!("failed to encrypt token: {e}"))?;
let wrapped_token = base64::engine::general_purpose::STANDARD.encode(&encrypted);
let remote_meta = add_pending_remote_approval(meta, member_id, &response.token_id);
remote_store.save_remote_metadata(&remote_meta).with_context(|| {
format!(
"server issued a token for `{member_id}` but kagi could not save pending remote approval metadata. Recovery: fix local kagi data directory permissions, then rerun `kagi member approve {member_id}`."
)
})?;
let member = key_manager
.approve_join_request_with_wrapped_token(member_id, &wrapped_token)
.with_context(|| {
format!(
"server issued a token for `{member_id}` and pending remote approval metadata was saved, but kagi could not persist local access state. Recovery: fix .kagi/access.json or filesystem permissions, then rerun `kagi member approve {member_id}`."
)
})?;
Ok(member)
}
#[cfg(feature = "server")]
async fn member_join_server_mode(
key_manager: &KeyManager,
member: &MemberMetadata,
config: &serde_json::Value,
sync: &serde_json::Value,
allow_insecure: bool,
c: &Palette,
) -> anyhow::Result<()> {
let project_id = config["project_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing project_id"))?
.to_string();
let remote_url = sync
.get("remote")
.and_then(|v| v.as_str())
.or_else(|| config.get("kagi_url").and_then(|v| v.as_str()))
.ok_or_else(|| anyhow::anyhow!("missing remote URL"))?
.to_string();
let remote_store = RemoteLocalStore::new(local_data_dir()?);
let meta = remote_store
.load_remote_metadata(&project_id)?
.ok_or_else(|| anyhow::anyhow!("no remote metadata found"))?;
let token = match remote_store.load_token(&project_id)? {
Some(token) => token,
None => {
let claim_secret = remote_store
.load_claim_secret(&project_id)?
.ok_or_else(|| anyhow::anyhow!(
"Server token required to join project. Try 'kagi remote pull' first to obtain a token."
))?;
let client = kagi_sync::infrastructure::remote_client::RemoteClient::new_pinned(
remote_url.to_string(),
&meta.server_fingerprint,
allow_insecure,
)
.await?;
let identity = key_manager.load_or_create_identity()?;
let active_member_id = key_manager.member_id()?;
client
.get_token_from_claim_secret(
&project_id,
&active_member_id,
&claim_secret,
&identity,
)
.await?
}
};
let client = kagi_sync::infrastructure::remote_client::RemoteClient::new_pinned(
remote_url.to_string(),
&meta.server_fingerprint,
allow_insecure,
)
.await?;
let identity = key_manager.load_or_create_identity()?;
let join_request = MemberJoinRequest {
member_id: member.member_id.clone(),
name: member.name.clone(),
recipient: member.recipient.clone(),
signing_public_key: member
.signing_public_key
.clone()
.ok_or_else(|| anyhow::anyhow!("pending member missing signing_public_key"))?,
};
if let Err(e) = client
.send_member_join_request(&project_id, &token, &join_request, &identity)
.await
{
if let Err(cleanup_error) = key_manager.delete_join_request(&member.member_id) {
warn_join_request_cleanup_failed(&member.member_id, &cleanup_error, c);
}
return Err(e.into());
}
println!(
"{} {} {}",
c.prefix(),
c.success("Request sent to server"),
c.accent(&member.member_id)
);
print_member_approval_instruction(&member.member_id, c);
Ok(())
}
#[cfg(feature = "server")]
async fn fetch_server_join_requests(
key_manager: &KeyManager,
config: &serde_json::Value,
allow_insecure: bool,
) -> anyhow::Result<Vec<MemberMetadata>> {
let project_id = config["project_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing project_id"))?
.to_string();
let remote_url = config
.get("settings")
.and_then(|s| s.get("sync"))
.and_then(|s| s.get("remote"))
.and_then(|v| v.as_str())
.or_else(|| config.get("kagi_url").and_then(|v| v.as_str()))
.ok_or_else(|| anyhow::anyhow!("missing remote URL"))?
.to_string();
let local_data_dir = local_data_dir()?;
let remote_store =
kagi_sync::infrastructure::remote_local::RemoteLocalStore::new(local_data_dir);
let token = remote_store
.load_token(&project_id)?
.ok_or_else(|| anyhow::anyhow!("no project token found"))?;
let meta = remote_store
.load_remote_metadata(&project_id)?
.ok_or_else(|| anyhow::anyhow!("no remote metadata found"))?;
let identity = key_manager.load_or_create_identity()?;
let request_id = format!(r"kgr_{}", nanoid::nanoid!(12));
let plaintext = kagi_sync::domain::envelope::RequestPlaintext {
version: 1,
request_id: request_id.clone(),
issued_at: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
operation: "status".into(),
method: "POST".into(),
path: format!("/v1/projects/{project_id}/status"),
project_id: Some(project_id.to_string()),
token: Some(token),
claim_secret: None,
payload: serde_json::json!({ "local_revision": meta.local_revision.unwrap_or(0) }),
};
let client = kagi_sync::infrastructure::remote_client::RemoteClient::new_pinned(
remote_url.to_string(),
&meta.server_fingerprint,
allow_insecure,
)
.await?;
let data = client.send_request(&plaintext, &identity).await?;
let requests = data
.get("join_requests")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut members = Vec::new();
for req in requests {
if let Some(member_id) = req.get("member_id").and_then(|v| v.as_str()) {
let name = req.get("name").and_then(|v| v.as_str()).unwrap_or("");
let recipient = req.get("recipient").and_then(|v| v.as_str()).unwrap_or("");
let signing_public_key = req
.get("signing_public_key")
.and_then(|v| v.as_str())
.map(str::to_string);
members.push(MemberMetadata {
member_id: member_id.to_string(),
name: name.to_string(),
recipient: recipient.to_string(),
status: "pending".to_string(),
wrapped_key: None,
wrapped_token: None,
signing_public_key,
});
}
}
Ok(members)
}
#[cfg(feature = "server")]
async fn member_approve_server_mode(
key_manager: &KeyManager,
member_id: &str,
config: &serde_json::Value,
allow_insecure: bool,
c: &Palette,
) -> anyhow::Result<()> {
let project_id = config["project_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing project_id"))?
.to_string();
let remote_url = config
.get("settings")
.and_then(|s| s.get("sync"))
.and_then(|s| s.get("remote"))
.and_then(|v| v.as_str())
.or_else(|| config.get("kagi_url").and_then(|v| v.as_str()))
.ok_or_else(|| anyhow::anyhow!("missing remote URL"))?
.to_string();
let local_data_dir = local_data_dir()?;
let remote_store =
kagi_sync::infrastructure::remote_local::RemoteLocalStore::new(local_data_dir);
let token = remote_store
.load_token(&project_id)?
.ok_or_else(|| anyhow::anyhow!("Server token required to approve member"))?;
let meta = remote_store
.load_remote_metadata(&project_id)?
.ok_or_else(|| anyhow::anyhow!("no remote metadata found"))?;
let client = kagi_sync::infrastructure::remote_client::RemoteClient::new_pinned(
remote_url.to_string(),
&meta.server_fingerprint,
allow_insecure,
)
.await?;
let identity = key_manager.load_or_create_identity()?;
if key_manager
.find_member(member_id)?
.filter(|member| member.status == "pending")
.is_none()
{
let server_requests =
fetch_server_join_requests(key_manager, config, allow_insecure).await?;
let server_request = server_requests
.into_iter()
.find(|r| r.member_id == member_id)
.ok_or_else(|| anyhow::anyhow!("member request not found on server: {member_id}"))?;
key_manager.create_pending_member_from_server(
member_id,
&server_request.name,
&server_request.recipient,
server_request.signing_public_key.as_deref(),
)?;
}
let response = client
.send_member_token_issue(&project_id, &token, member_id, &identity)
.await?;
let member =
apply_server_member_approval(key_manager, &remote_store, meta, member_id, response)?;
println!(
"{} {} {}",
c.prefix(),
c.success("Member approved. Token will be activated on next push."),
c.accent(&member.member_id)
);
Ok(())
}
pub struct DoctorCheck {
pub name: &'static str,
pub ok: bool,
pub detail: String,
}
pub fn collect_doctor_checks(base_path: &Path) -> anyhow::Result<(Vec<DoctorCheck>, usize, usize)> {
let mut checks: Vec<DoctorCheck> = Vec::new();
let mut warnings = 0;
let mut errors = 0;
let kagi_json_path = base_path.join("kagi.json");
if !kagi_json_path.exists() {
checks.push(DoctorCheck {
name: "kagi.json exists",
ok: false,
detail: "missing .kagi/kagi.json — run `kagi init`".to_string(),
});
errors += 1;
} else {
let content = fs::read_to_string(&kagi_json_path)?;
match serde_json::from_str::<serde_json::Value>(&content) {
Ok(json) => {
let version = json.get("version").and_then(|v| v.as_str()).unwrap_or("");
if version == "2" || version == "3" {
checks.push(DoctorCheck {
name: "kagi.json format",
ok: true,
detail: "valid v2/v3 format".to_string(),
});
} else {
checks.push(DoctorCheck {
name: "kagi.json format",
ok: false,
detail: format!("unsupported version '{version}'"),
});
errors += 1;
}
}
Err(e) => {
checks.push(DoctorCheck {
name: "kagi.json format",
ok: false,
detail: format!("invalid JSON: {e}"),
});
errors += 1;
}
}
}
let access_json_path = base_path.join("access.json");
if !access_json_path.exists() {
checks.push(DoctorCheck {
name: "access.json exists",
ok: false,
detail: "missing .kagi/access.json".to_string(),
});
errors += 1;
} else {
let content = fs::read_to_string(&access_json_path)?;
match serde_json::from_str::<serde_json::Value>(&content) {
Ok(_) => checks.push(DoctorCheck {
name: "access.json format",
ok: true,
detail: "valid JSON".to_string(),
}),
Err(e) => {
checks.push(DoctorCheck {
name: "access.json format",
ok: false,
detail: format!("invalid JSON: {e}"),
});
errors += 1;
}
}
}
let secrets_path = base_path.join("secrets");
if !secrets_path.exists() {
checks.push(DoctorCheck {
name: "secrets directory",
ok: false,
detail: "missing .kagi/secrets/".to_string(),
});
warnings += 1;
} else {
checks.push(DoctorCheck {
name: "secrets directory",
ok: true,
detail: "exists".to_string(),
});
}
let key_result = load_project_key(base_path);
match &key_result {
Ok(_) => checks.push(DoctorCheck {
name: "project key",
ok: true,
detail: "loadable".to_string(),
}),
Err(e) => {
checks.push(DoctorCheck {
name: "project key",
ok: false,
detail: format!("cannot load: {e}"),
});
errors += 1;
}
}
if let Ok(key) = &key_result {
match crate::application::file_artifacts::validate_file_artifacts(base_path, key) {
Ok(count) => checks.push(DoctorCheck {
name: "encrypted files",
ok: true,
detail: if count == 0 {
"none".to_string()
} else {
format!("{count} file artifact(s)")
},
}),
Err(e) => {
checks.push(DoctorCheck {
name: "encrypted files",
ok: false,
detail: format!("invalid file artifacts: {e}"),
});
errors += 1;
}
}
}
if let Ok(key) = &key_result {
let store = store_from_project_key(base_path.to_path_buf(), key)?;
match store.list_services() {
Ok(services) => {
let mut decrypt_ok = 0;
let mut decrypt_fail = 0;
for service in services {
match store.load(&service) {
Ok(_) => decrypt_ok += 1,
Err(e) => {
decrypt_fail += 1;
if decrypt_fail <= 3 {
checks.push(DoctorCheck {
name: "service decrypt",
ok: false,
detail: format!("{service}: {e}"),
});
}
}
}
}
if decrypt_fail == 0 {
checks.push(DoctorCheck {
name: "service decrypt",
ok: true,
detail: format!("{decrypt_ok} services decrypted"),
});
} else {
errors += 1;
if decrypt_fail > 3 {
checks.push(DoctorCheck {
name: "service decrypt",
ok: false,
detail: format!("... and {} more failures", decrypt_fail - 3),
});
}
}
}
Err(e) => {
checks.push(DoctorCheck {
name: "service list",
ok: false,
detail: format!("cannot list: {e}"),
});
errors += 1;
}
}
}
let journal_path = KeyManager::new(base_path.to_path_buf())
.rotation_journal_path()
.unwrap_or_else(|_| base_path.join("rotation.journal.json"));
if journal_path.exists() {
checks.push(DoctorCheck {
name: "rotation journal",
ok: false,
detail: "pending rotation journal found — run `kagi doctor --fix` to recover"
.to_string(),
});
warnings += 1;
} else {
checks.push(DoctorCheck {
name: "rotation journal",
ok: true,
detail: "no pending journal".to_string(),
});
}
Ok((checks, warnings, errors))
}
fn run_doctor(base_path: &Path, fix: bool, tty: bool, c: &Palette) -> anyhow::Result<()> {
let (checks, warnings, errors) = collect_doctor_checks(base_path)?;
println!("{} {}", c.prefix(), c.accent("Kagi Doctor"));
for check in checks {
if check.ok {
println!(
"{} {} {}",
c.prefix(),
c.success("✓"),
c.muted(&format!("{} — {}", check.name, check.detail))
);
} else {
println!(
"{} {} {}",
c.prefix(),
c.error("✗"),
c.info(&format!("{} — {}", check.name, check.detail))
);
}
}
let summary = if errors > 0 {
format!("{errors} error(s), {warnings} warning(s)")
} else if warnings > 0 {
format!("{warnings} warning(s)")
} else {
"all checks passed".to_string()
};
println!("{} {}", c.prefix(), c.accent(&summary));
let journal_path = KeyManager::new(base_path.to_path_buf())
.rotation_journal_path()
.unwrap_or_else(|_| base_path.join("rotation.journal.json"));
if fix && journal_path.exists() {
if !tty || !io::stdin().is_terminal() {
return Err(anyhow::anyhow!(
"kagi doctor --fix requires an interactive terminal."
));
}
eprint!(
"{} {} {} [y/N]: ",
c.prefix(),
c.warning("warning:"),
c.info("recover pending rotation journal?")
);
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim().eq_ignore_ascii_case("y") {
let recovered = recover_pending_rotation(base_path)?;
if recovered {
println!(
"{} {}",
c.prefix(),
c.success("recovered pending rotation.")
);
} else {
println!(
"{} {}",
c.prefix(),
c.warning("no pending rotation to recover.")
);
}
let (_checks_after, warnings_after, errors_after) = collect_doctor_checks(base_path)?;
let summary = if errors_after > 0 {
format!("{errors_after} error(s), {warnings_after} warning(s)")
} else if warnings_after > 0 {
format!("{warnings_after} warning(s)")
} else {
"all checks passed".to_string()
};
println!("{} {}", c.prefix(), c.accent(&summary));
if errors_after > 0 {
std::process::exit(2);
} else if warnings_after > 0 {
std::process::exit(1);
}
return Ok(());
} else {
println!("{} {}", c.prefix(), c.muted("skipped."));
}
}
if errors > 0 {
std::process::exit(2);
} else if warnings > 0 {
std::process::exit(1);
}
Ok(())
}
fn resolve_kagi_base() -> anyhow::Result<(PathBuf, Option<String>)> {
let cwd = std::env::current_dir()?;
let local = cwd.join(".kagi");
if local.is_dir() {
return Ok((local, None));
}
let mut current = cwd.as_path();
loop {
let kagi = current.join(".kagi");
if kagi.is_dir() {
let config_path = kagi.join(kagi_domain::config::KAGI_CONFIG_FILE);
let relative = cwd
.strip_prefix(current)
.unwrap_or(std::path::Path::new(""));
let inferred = if let Ok(content) = std::fs::read_to_string(&config_path)
&& let Ok(config) = serde_json::from_str::<KagiConfig>(&content)
{
infer_service_from_config(&config, relative)
} else {
None
};
return Ok((kagi, inferred));
}
match current.parent() {
Some(parent) => current = parent,
None => break,
}
}
Err(anyhow::anyhow!(
"No .kagi directory found in current or parent directories. Run `kagi init` to create one."
))
}
fn resolve_store() -> anyhow::Result<(FileStore, Option<String>)> {
let (base_path, inferred_service) = resolve_kagi_base()?;
let config_path = base_path.join(kagi_domain::config::KAGI_CONFIG_FILE);
if !config_path.exists() {
return Err(anyhow::anyhow!(
"Found .kagi at {} but {} is missing. \
This may be an old repository or it was created manually. \
Run `kagi init` to create a proper repository.",
base_path.display(),
kagi_domain::config::KAGI_CONFIG_FILE
));
}
validate_v2_config(&config_path)?;
recover_pending_rotation(&base_path)?;
let project_key = load_project_key(&base_path)?;
let store = store_from_project_key(base_path, &project_key)?;
Ok((store, inferred_service))
}
fn validate_v2_config(config_path: &Path) -> anyhow::Result<()> {
let content = fs::read_to_string(config_path)?;
let value: serde_json::Value = serde_json::from_str(&content)?;
let version = value.get("version").and_then(|v| v.as_str()).unwrap_or("");
if (version != "2" && version != "3")
|| value
.get("project_id")
.and_then(|v| v.as_str())
.is_none_or(|id| id.trim().is_empty())
{
return Err(anyhow::anyhow!(
"Unsupported kagi repository format. This version requires a v2 team-ready .kagi/kagi.json with project_id. Run `kagi init --force` to create a new repository."
));
}
Ok(())
}
fn load_project_key(base_path: &Path) -> anyhow::Result<zeroize::Zeroizing<Vec<u8>>> {
let key_manager = KeyManager::new(base_path.to_path_buf());
key_manager.load().context(
"Failed to load project key. \
Did you run `kagi init`? \
If this is a shared repository, run `kagi join` and ask an active member to approve it, \
or set KAGI_PROJECT_KEY / KAGI_PROJECT_KEY_FILE for CI.",
)
}
fn store_from_project_key(base_path: PathBuf, project_key: &[u8]) -> anyhow::Result<FileStore> {
let key_array: [u8; 32] = project_key
.try_into()
.map_err(|_| anyhow::anyhow!("Invalid project key length"))?;
let encryptor = XChaChaEncryptor::new(&key_array);
Ok(FileStore::new(base_path, Box::new(encryptor)))
}
pub(crate) fn recover_pending_rotation(base_path: &Path) -> anyhow::Result<bool> {
let key_manager = KeyManager::new(base_path.to_path_buf());
recover_pending_rotation_with_key_manager(base_path, &key_manager)
}
fn recover_pending_rotation_with_key_manager(
base_path: &Path,
key_manager: &KeyManager,
) -> anyhow::Result<bool> {
let journal_path = key_manager.rotation_journal_path()?;
if !journal_path.exists() {
return Ok(false);
}
let content = fs::read_to_string(&journal_path)?;
let journal: RotationJournal = serde_json::from_str(&content)?;
if journal.version != ROTATION_JOURNAL_VERSION {
return Err(anyhow::anyhow!(
"unsupported rotation journal version: {}",
journal.version
));
}
if journal.project_id != key_manager.project_id()? {
return Err(anyhow::anyhow!(
"rotation journal belongs to another kagi project"
));
}
apply_rotation_journal(base_path, &journal)?;
fs::remove_file(journal_path)?;
Ok(true)
}
fn write_rotation_journal(path: &Path, journal: &RotationJournal) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
set_private_dir_permissions(parent)?;
}
atomic_write(path, &serde_json::to_string_pretty(journal)?)?;
set_private_file_permissions(path)?;
Ok(())
}
fn apply_rotation_journal(base_path: &Path, journal: &RotationJournal) -> anyhow::Result<()> {
for (file, content) in &journal.files {
if !is_valid_rotation_file(file) {
return Err(anyhow::anyhow!("invalid path in rotation journal: {file}"));
}
atomic_write(&base_path.join(file), content)?;
}
atomic_write(&base_path.join("access.json"), &journal.access_json)?;
Ok(())
}
fn is_valid_rotation_file(file: &str) -> bool {
file.starts_with("secrets/")
&& !file.starts_with('/')
&& !file.contains('\\')
&& file
.split('/')
.all(|part| !part.is_empty() && part != "." && part != "..")
}
fn atomic_write(path: &Path, content: &str) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp_path = path.with_extension(format!(
"{}.tmp",
path.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("kagi")
));
{
let mut file = fs::File::create(&tmp_path)?;
file.write_all(content.as_bytes())?;
file.sync_all()?;
}
fs::rename(&tmp_path, path)?;
set_private_file_permissions(path)?;
Ok(())
}
fn set_private_file_permissions(_path: &Path) -> anyhow::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(_path, fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
fn set_private_dir_permissions(_path: &Path) -> anyhow::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(_path, fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
fn rotate_project_key(base_path: &Path, remove_member_id: Option<&str>) -> anyhow::Result<usize> {
recover_pending_rotation(base_path)?;
let key_manager = KeyManager::new(base_path.to_path_buf());
let old_key = key_manager
.load()
.context("Failed to load current project key")?;
let old_store = store_from_project_key(base_path.to_path_buf(), &old_key)?;
let scopes = old_store
.list_services()
.map_err(|e| anyhow::anyhow!("Failed to list encrypted stores: {e}"))?;
let mut services = Vec::new();
for scope in scopes {
services.push(
old_store
.load(&scope)
.map_err(|e| anyhow::anyhow!("Failed to decrypt {scope}: {e}"))?,
);
}
let new_key = KeyManager::generate_project_key();
let new_store = store_from_project_key(base_path.to_path_buf(), &new_key)?;
let mut files = BTreeMap::new();
for service in &services {
let (file, content) = new_store
.encrypted_service_content(service)
.map_err(|e| anyhow::anyhow!("Failed to re-encrypt {}: {}", service.name, e))?;
files.insert(file, content);
}
let journal = RotationJournal {
version: ROTATION_JOURNAL_VERSION,
project_id: key_manager.project_id()?,
access_json: key_manager.rotated_access_json(&new_key, remove_member_id)?,
files,
};
let journal_path = key_manager.rotation_journal_path()?;
write_rotation_journal(&journal_path, &journal)?;
apply_rotation_journal(base_path, &journal)?;
key_manager.cache_project_key(&new_key)?;
fs::remove_file(journal_path)?;
Ok(services.len())
}
pub async fn run(cli: Cli) -> anyhow::Result<()> {
let tty = io::stdout().is_terminal();
let c = Palette::new(tty);
#[cfg(feature = "server")]
let allow_insecure = cli.allow_insecure_http
|| std::env::var("KAGI_ALLOW_INSECURE_HTTP")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
match cli.command {
Commands::Init {
envs,
nested,
force,
no_migrate,
} => {
let cwd = std::env::current_dir()?;
let local = cwd.join(".kagi");
if local.is_dir() && !force {
return Err(anyhow::anyhow!(
".kagi/ already exists in this directory. Use --force to overwrite."
));
}
if local.is_dir() && force {
if has_encrypted_store(&local.join("secrets"))? {
eprintln!(
"{} {}",
c.prefix(),
c.warning(
"warning: overwriting existing .kagi/ will delete all stored secrets."
)
);
}
std::fs::remove_dir_all(&local)?;
}
let service = InitService::with_nested_and_envs(local.clone(), nested, envs.clone());
service.execute()?;
if let Some(envs) = envs {
let display_envs =
if envs.is_empty() || envs.iter().all(|env| env.trim().is_empty()) {
kagi_domain::config::STANDARD_ENV_NAMES.join(", ")
} else {
envs.join(", ")
};
println!(
"{} {} {} {}",
c.prefix(),
c.success("Initialized .kagi/"),
c.muted("with"),
c.accent(&display_envs)
);
} else {
println!("{} {}", c.prefix(), c.success("Initialized .kagi/"));
}
println!(
"{} {}",
c.prefix(),
c.muted(
"Commit .kagi/; local keys stay on this device. Do not commit real .env files."
)
);
if !no_migrate {
let config_path = local.join(kagi_domain::config::KAGI_CONFIG_FILE);
let config: KagiConfig = serde_json::from_str(&fs::read_to_string(&config_path)?)?;
let candidates =
crate::application::env_migration::scan_env_files(&cwd, &config.settings.envs);
let mapping_count = write_monorepo_mappings(&config_path, &candidates)?;
if mapping_count > 0 {
eprintln!(
"{} {} {}",
c.prefix(),
c.info("detected workspace layout:"),
c.accent(&format!("{mapping_count} service mapping(s)"))
);
}
if !candidates.is_empty() {
if tty && io::stdin().is_terminal() {
eprintln!(
"{} {} {}",
c.prefix(),
c.warning("note:"),
c.info(&format!(
"found {} .env file(s). Run migration? [y/N]",
candidates.len()
))
);
for candidate in &candidates {
let label = match &candidate.service_name {
Some(s) => {
format!(
"{} -> service '{}' env '{}'",
candidate.path.display(),
s,
candidate.env_name
)
}
None => {
format!(
"{} -> root env '{}'",
candidate.path.display(),
candidate.env_name
)
}
};
eprintln!(" {}", c.muted(&label));
}
eprint!("{} {} ", c.prefix(), c.prompt("migrate?"));
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim().eq_ignore_ascii_case("y") {
let project_key = load_project_key(&local)?;
let store = store_from_project_key(local.clone(), &project_key)?;
let import_service =
crate::application::import_env_file::ImportEnvFileService::new(
store,
);
for candidate in &candidates {
let scope = match &candidate.service_name {
Some(s) => format!("{}/{}", s, candidate.env_name),
None => candidate.env_name.clone(),
};
match import_service.execute(
&scope,
candidate.path.to_str().unwrap(),
true,
) {
Ok(report) => {
println!(
"{} {} {} {}",
c.prefix(),
c.success("migrated"),
c.accent(&report.imported.len().to_string()),
c.muted(&format!(
"keys from {}",
candidate.path.display()
))
);
}
Err(e) => {
eprintln!(
"{} {} failed to migrate {}: {}",
c.prefix(),
c.warning("warning:"),
candidate.path.display(),
e
);
}
}
}
}
} else {
eprintln!(
"{} {} {}",
c.prefix(),
c.warning("note:"),
c.info(&format!(
"found {} .env file(s). Run `kagi init` in an interactive terminal to migrate, or use `kagi import`.",
candidates.len()
))
);
}
}
}
}
Commands::Doctor { fix, plain } => {
let (base_path, _inferred) = resolve_kagi_base()?;
#[cfg(feature = "tui")]
if !plain && tty {
return crate::cli::tui::run_tui_doctor(&base_path, fix);
}
#[cfg(not(feature = "tui"))]
let _ = plain;
run_doctor(&base_path, fix, tty, &c)?;
}
Commands::Status => {
run_status(&c)?;
}
Commands::Set {
service: service_name,
desc,
first,
second,
third,
fourth,
} => {
let (store, inferred) = resolve_store()?;
let default_envs = store
.default_envs()
.map_err(|e| anyhow::anyhow!("Failed to read default envs: {e}"))?;
let default_env = store
.default_env()
.unwrap_or_else(|_| DEFAULT_ENV_NAME.to_string());
let (scope, key, value) = parse_secret_target(
TargetContext {
default_envs: &default_envs,
default_env: &default_env,
inferred_service: inferred,
service_flag: service_name,
},
SecretArgs {
first,
second,
third,
fourth,
},
"Usage: kagi set [--service <service>] [env] <key> <value> or kagi set <service> <env> <key> <value>",
)?;
let key = normalize_manual_secret_key_with_tip(key, "set", &c);
ensure_default_envs_for_scope(&store, &scope)?;
let set_service = SetSecretService::new(store);
set_service.execute(&scope, &key, &value, desc.as_deref())?;
println!(
"{} {} {}.{}",
c.prefix(),
c.info("set"),
c.accent(&scope),
c.key(&key)
);
}
Commands::Get {
service: service_name,
show_values,
plain,
first,
second,
third,
} => {
let (store, inferred) = resolve_store()?;
#[cfg(feature = "tui")]
if should_open_get_tui(plain, tty, &service_name, &first, &second, &third) {
return crate::cli::tui::run_tui_get(store, show_values);
}
#[cfg(not(feature = "tui"))]
let _ = plain;
let default_envs = store
.default_envs()
.map_err(|e| anyhow::anyhow!("Failed to read default envs: {e}"))?;
let default_env = store
.default_env()
.unwrap_or_else(|_| DEFAULT_ENV_NAME.to_string());
let services = store
.list_services()
.map_err(|e| anyhow::anyhow!("Failed to list services: {e}"))?;
let selection = parse_get_selection(
&services,
TargetContext {
default_envs: &default_envs,
default_env: &default_env,
inferred_service: inferred,
service_flag: service_name,
},
first,
second,
third,
)?;
match selection {
GetSelection::All => {
if show_values {
confirm_secret_output(tty, "kagi get --show", &c)?;
}
let list_service = ListServicesService::new(store);
draw_all_service_envs(&list_service, show_values, &c)?;
}
GetSelection::Service(service) => {
if show_values {
confirm_secret_output(tty, "kagi get --show", &c)?;
}
let list_service = ListServicesService::new(store);
draw_service_envs(&list_service, &service, show_values, &c)?;
}
GetSelection::Scope(scope) => {
if show_values {
confirm_secret_output(tty, "kagi get --show", &c)?;
}
let list_service = ListServicesService::new(store);
let items = list_service.execute(Some(&scope))?;
if items.is_empty() {
draw_key_table(&[], show_values, &c);
println!(
"{} {}",
c.prefix(),
c.muted(&format!("no secrets in {scope}"))
);
} else {
draw_key_table(&items, show_values, &c);
}
}
GetSelection::Key(scope, key) => {
let key = normalize_manual_secret_key_with_tip(key, "get", &c);
confirm_secret_output(tty, "kagi get", &c)?;
let secret_desc = store
.load(&scope)
.ok()
.and_then(|s| s.get_secret(&key).and_then(|sec| sec.description.clone()));
let get_service = GetSecretService::new(store);
let value = get_service.execute(&scope, &key)?;
if let Some(desc) = secret_desc {
println!("{} {} = {}", c.muted(&desc), c.key(&key), c.success(&value));
} else {
println!("{value}");
}
}
}
}
Commands::Unset {
service: service_name,
first,
second,
third,
} => {
let (store, inferred) = resolve_store()?;
let default_envs = store
.default_envs()
.map_err(|e| anyhow::anyhow!("Failed to read default envs: {e}"))?;
let default_env = store
.default_env()
.unwrap_or_else(|_| DEFAULT_ENV_NAME.to_string());
let services = store
.list_services()
.map_err(|e| anyhow::anyhow!("Failed to list services: {e}"))?;
let selection = parse_get_selection(
&services,
TargetContext {
default_envs: &default_envs,
default_env: &default_env,
inferred_service: inferred,
service_flag: service_name,
},
first,
second,
third,
)?;
match selection {
GetSelection::Key(scope, key) => {
confirm_unset(tty, &scope, &key, &c)?;
let unset_service = UnsetSecretService::new(store);
let existed = unset_service.execute(&scope, &key)?;
if existed {
println!(
"{} {} {}.{}",
c.prefix(),
c.success("unset"),
c.accent(&scope),
c.key(&key)
);
} else {
println!(
"{} {} {}.{} {}",
c.prefix(),
c.warning("warning:"),
c.accent(&scope),
c.key(&key),
c.muted("did not exist")
);
}
}
_ => {
return Err(anyhow::anyhow!(
"Usage: kagi unset [--service <service>] [env] <key>\n\nUnset only supports a single key. Use 'kagi env remove' to remove an entire environment."
));
}
}
}
Commands::Search {
query,
values,
plain,
} => {
let (store, _inferred) = resolve_store()?;
#[cfg(feature = "tui")]
if !plain && tty {
return crate::cli::tui::run_tui_search(store, query, values);
}
#[cfg(not(feature = "tui"))]
let _ = plain;
let search_service = SearchSecretsService::new(store);
let results = if values {
confirm_secret_output(tty, "search", &c)?;
search_service.search_values(&query)?
} else {
search_service.search_keys(&query)?
};
if results.is_empty() {
println!("{} {}", c.prefix(), c.muted("no matches found."));
} else {
let items: Vec<(String, String, Option<String>)> = results
.into_iter()
.map(|r| {
(
format!("{}.{}", r.scope, r.key),
String::new(),
r.description,
)
})
.collect();
draw_key_table(&items, false, &c);
}
}
Commands::Run {
service: service_name,
args,
} => {
if args.is_empty() {
return Err(anyhow::anyhow!("No command provided"));
}
let (store, inferred) = resolve_store()?;
let services = store
.list_services()
.map_err(|e| anyhow::anyhow!("Failed to list services: {e}"))?;
let default_envs = store
.default_envs()
.map_err(|e| anyhow::anyhow!("Failed to read default envs: {e}"))?;
let default_env = store
.default_env()
.unwrap_or_else(|_| DEFAULT_ENV_NAME.to_string());
let (scope, cmd, run_args, allow_empty_inferred_scope) =
if let Some(service) = service_name {
let env_scope = scope_name(Some(&service), &args[0]);
if services.contains(&env_scope) || default_envs.contains(&args[0]) {
if args.len() < 2 {
return Err(anyhow::anyhow!("No command provided"));
}
(env_scope, args[1].clone(), args[2..].to_vec(), false)
} else {
(
scope_name(Some(&service), &default_env),
args[0].clone(),
args[1..].to_vec(),
false,
)
}
} else if let Some(service) = inferred {
let env_scope = scope_name(Some(&service), &args[0]);
if services.contains(&env_scope) || default_envs.contains(&args[0]) {
if args.len() < 2 {
return Err(anyhow::anyhow!("No command provided"));
}
(env_scope, args[1].clone(), args[2..].to_vec(), false)
} else {
(
scope_name(Some(&service), &default_env),
args[0].clone(),
args[1..].to_vec(),
true,
)
}
} else if services.contains(&args[0]) {
if args.len() < 2 {
return Err(anyhow::anyhow!("No command provided"));
}
(args[0].clone(), args[1].clone(), args[2..].to_vec(), false)
} else if args.len() >= 2
&& (services.contains(&scope_name(Some(&args[0]), &args[1]))
|| default_envs.contains(&args[1]))
{
if args.len() < 3 {
return Err(anyhow::anyhow!("No command provided"));
}
(
scope_name(Some(&args[0]), &args[1]),
args[2].clone(),
args[3..].to_vec(),
false,
)
} else if services.contains(&scope_name(Some(&args[0]), &default_env)) {
if args.len() < 2 {
return Err(anyhow::anyhow!("No command provided"));
}
(
scope_name(Some(&args[0]), &default_env),
args[1].clone(),
args[2..].to_vec(),
false,
)
} else {
(String::new(), args[0].clone(), args[1..].to_vec(), true)
};
ensure_default_envs_for_scope(&store, &scope)?;
let runner = SystemCommandRunner::new();
if allow_empty_inferred_scope && (scope.is_empty() || !services.contains(&scope)) {
if scope.is_empty() {
eprintln!(
"{} {} {}",
c.prefix(),
c.warning("notice:"),
c.info("no environment or service scope specified")
);
} else {
eprintln!(
"{} {} {} {}",
c.prefix(),
c.warning("notice:"),
c.info("no secrets found for inferred scope"),
c.accent(&scope)
);
}
eprintln!(
"{} {}",
c.prefix(),
c.info("Running command without injected environment variables.")
);
let exit_code = runner.run(&[], &cmd, &run_args)?;
std::process::exit(exit_code);
}
let run_service = RunCommandService::new(store, runner);
let exit_code = run_service.execute(&scope, &cmd, &run_args)?;
std::process::exit(exit_code);
}
Commands::Export {
service: service_name,
out,
plain,
first,
second,
} => {
let (store, inferred) = resolve_store()?;
let default_envs = store
.default_envs()
.map_err(|e| anyhow::anyhow!("Failed to read default envs: {e}"))?;
let selection =
parse_export_selection(&default_envs, inferred, service_name, first, second)?;
#[cfg(feature = "tui")]
if !plain && tty {
let scopes = match &selection {
ScopeSelection::One(scope) => vec![scope.clone()],
ScopeSelection::Service(service) => service_scopes_from_store(&store, service)?,
};
return crate::cli::tui::run_tui_export(store, scopes, out);
}
#[cfg(not(feature = "tui"))]
let _ = plain;
confirm_secret_output(tty, "kagi export", &c)?;
let scopes = match &selection {
ScopeSelection::One(scope) => vec![scope.clone()],
ScopeSelection::Service(service) => {
if out.is_none() {
return Err(anyhow::anyhow!(
"Exporting all environments for a service requires --out <dir>. Use `kagi export {service} <env>` for stdout."
));
}
service_scopes_from_store(&store, service)?
}
};
let export_service = ExportEnvService::new(store);
for scope in scopes {
let output = export_service.execute(&scope)?;
if let Some(ref out) = out {
let path = write_export_file(Path::new(out), &scope, &output)?;
println!(
"{} {} {}",
c.prefix(),
c.success("exported"),
c.accent(&path.display().to_string())
);
} else {
println!("{output}");
}
}
}
Commands::Backup { out } => {
run_backup(&out, &c)?;
}
Commands::Restore { from, force } => {
run_restore(&from, force, &c)?;
}
Commands::File { command } => match command {
FileCommands::Add {
service,
name,
force,
allow_large,
external,
args,
} => {
let (scope_parts, path) = split_file_item_args(args, "path")?;
let (file_service, scope) = resolve_file_service_and_scope(service, scope_parts)?;
let added = file_service.add_file(
&scope,
Path::new(&path),
name.as_deref(),
force,
allow_large,
if external {
FileArtifactLocation::Home
} else {
FileArtifactLocation::Repo
},
)?;
println!(
"{} {} {} to {}",
c.prefix(),
if added.replaced {
c.success("replaced file")
} else {
c.success("added file")
},
c.accent(&added.entry.locator()),
c.accent(&added.entry.scope)
);
println!(
"{} {} {}",
c.prefix(),
c.muted("restore path:"),
c.muted(&added.entry.locator())
);
}
FileCommands::List { service, all, args } => {
let (file_service, scope) = resolve_file_service_for_list(service, args, all)?;
let files = file_service.list_files(scope.as_deref())?;
print_file_list(&files, &c);
}
FileCommands::Show { service, args } => {
let (scope_parts, name) = split_file_item_args(args, "name")?;
let (file_service, scope) = resolve_file_service_and_scope(service, scope_parts)?;
confirm_secret_output(tty, "kagi file show", &c)?;
let bytes = file_service.read_file(&scope, &name)?;
io::stdout().write_all(&bytes)?;
}
FileCommands::Restore {
service,
out,
force,
all,
dry_run,
args,
} => {
if all {
if out.is_some() {
return Err(anyhow::anyhow!(
"kagi file restore --all cannot be combined with --out"
));
}
let (file_service, scope) =
resolve_file_service_for_list(service, args, false)?;
let plan = file_service.plan_restore_files(scope.as_deref(), force)?;
print_restore_plan(&plan, &c);
if dry_run {
return Ok(());
}
if plan.iter().any(|entry| !entry.can_apply()) {
return Err(anyhow::anyhow!(
"restore plan contains files that require --force"
));
}
confirm_restore_all(tty, &c)?;
let restored = file_service.restore_planned_files(&plan)?;
let changed = restored.iter().filter(|file| file.changed).count();
println!(
"{} {}",
c.prefix(),
c.success(&format!("restored {changed} file(s)"))
);
return Ok(());
}
if dry_run {
return Err(anyhow::anyhow!(
"kagi file restore --dry-run is only supported with --all"
));
}
let (scope_parts, name) = split_file_item_args(args, "name")?;
let (file_service, scope) = resolve_file_service_and_scope(service, scope_parts)?;
let restored = file_service.restore_file(
&scope,
&name,
out.as_deref().map(Path::new),
force,
)?;
println!(
"{} {} {} to {}",
c.prefix(),
c.success("restored"),
c.accent(&restored.entry.locator()),
c.accent(&display_path(&restored.path))
);
if let Some(backup_path) = &restored.backup_path {
println!(
"{} {} {}",
c.prefix(),
c.muted("backup:"),
c.muted(&display_path(backup_path))
);
}
println!(
"{} {}",
c.prefix(),
c.warning("plaintext file restored; keep it out of git")
);
}
FileCommands::Remove {
service,
force,
args,
} => {
let (scope_parts, name) = split_file_item_args(args, "name")?;
let (file_service, scope) = resolve_file_service_and_scope(service, scope_parts)?;
if !force {
confirm_file_remove(tty, &scope, &name, &c)?;
}
let removed = file_service.remove_file(&scope, &name)?;
println!(
"{} {} {} from {}",
c.prefix(),
c.success("removed"),
c.accent(&removed.name),
c.accent(&removed.scope)
);
}
},
Commands::Import {
service: service_name,
first,
second,
file,
force,
dry_run,
upper_snake,
} => {
let (store, inferred) = resolve_store()?;
let default_envs = store
.default_envs()
.map_err(|e| anyhow::anyhow!("Failed to read default envs: {e}"))?;
let default_env = store
.default_env()
.unwrap_or_else(|_| DEFAULT_ENV_NAME.to_string());
let service_name = parse_scope_args(
&default_envs,
&default_env,
inferred,
service_name,
first,
second,
)?;
if !dry_run {
ensure_default_envs_for_scope(&store, &service_name)?;
}
let import_service =
crate::application::import_env_file::ImportEnvFileService::new(store);
let import_options = crate::application::import_env_file::ImportOptions { upper_snake };
let preview =
import_service.preview_with_options(&service_name, &file, import_options)?;
if dry_run {
print_import_dry_run(&preview, &service_name, &file, &c);
return Ok(());
}
let interactive_import = tty && io::stdin().is_terminal() && !force;
#[cfg(feature = "tui")]
if interactive_import {
if crate::cli::tui::run_tui_import(
preview.imported.clone(),
preview.overwritten.clone(),
service_name.clone(),
file.clone(),
)? {
let report = import_service.execute_with_options(
&service_name,
&file,
true,
import_options,
)?;
print_import_report(&report, &service_name, &file, &c);
} else {
println!("{} {}", c.prefix(), c.error("aborted."));
}
return Ok(());
}
if interactive_import
&& !confirm_import_preview(
&service_name,
&file,
&preview.imported,
&preview.overwritten,
&c,
)?
{
println!("{} {}", c.prefix(), c.error("aborted."));
return Ok(());
}
if !force && !interactive_import && !preview.overwritten.is_empty() {
eprintln!(
"{} {} the following keys already exist in {} and will be overwritten:",
c.prefix(),
c.warning("warning:"),
c.accent(&service_name)
);
for key in &preview.overwritten {
eprintln!(" {} {}", c.error("-"), c.error(key));
}
println!("{} {}", c.prefix(), c.error("aborted."));
return Ok(());
}
let report = import_service.execute_with_options(
&service_name,
&file,
force || interactive_import,
import_options,
)?;
print_import_report(&report, &service_name, &file, &c);
}
Commands::Sync {
service: service_name,
example,
sources,
envs,
plain,
} => {
let (store, inferred) = resolve_store()?;
if let Some(service) = service_name.as_ref().or(inferred.as_ref()) {
store
.ensure_service_envs(service)
.map_err(|e| anyhow::anyhow!("Failed to initialize default envs: {e}"))?;
}
let sync_service = SyncService::new(store);
let scoped_envs: Vec<String> = match service_name.or(inferred) {
Some(service) => envs
.iter()
.map(|env| scope_name(Some(&service), env))
.collect(),
None => envs,
};
let report = sync_service.execute(&example, &sources, &scoped_envs)?;
#[cfg(feature = "tui")]
if !plain && tty {
return crate::cli::tui::run_tui_sync(report.env_reports.into_iter().collect());
}
#[cfg(not(feature = "tui"))]
let _ = plain;
for (env_name, env_report) in &report.env_reports {
println!(
"{} {} {}",
c.prefix(),
c.success("synced"),
c.accent(env_name)
);
if !env_report.added.is_empty() {
println!(
" {} {} keys added",
c.success("+"),
c.success(&env_report.added.len().to_string())
);
for key in &env_report.added {
println!(" {} = {}", c.key(key), c.muted("(from example)"));
}
}
if !env_report.commented.is_empty() {
println!(
" {} {} keys added (commented)",
c.commented("#"),
c.commented(&env_report.commented.len().to_string())
);
for key in &env_report.commented {
println!(" {} {}", c.key(key), c.commented("(needs value)"));
}
}
if !env_report.skipped.is_empty() {
println!(
" {} {} keys skipped (already exist)",
c.muted("-"),
c.muted(&env_report.skipped.len().to_string())
);
}
}
}
Commands::Env { command } => {
let (store, _) = resolve_store()?;
match command {
EnvCommands::List { plain } => {
let envs = store.default_envs()?;
#[cfg(feature = "tui")]
if !plain && tty {
return crate::cli::tui::run_tui_env_list(envs);
}
#[cfg(not(feature = "tui"))]
let _ = plain;
for env in envs {
println!("{}", c.accent(&env));
}
}
EnvCommands::Add { env } => {
store.add_env(&env)?;
println!(
"{} {} {}",
c.prefix(),
c.success("added environment"),
c.accent(&env)
);
}
EnvCommands::Rename { old, new } => {
store.rename_env(&old, &new)?;
println!(
"{} {} {} {} {}",
c.prefix(),
c.success("renamed environment"),
c.accent(&old),
c.muted("to"),
c.accent(&new)
);
}
EnvCommands::Remove { env, plain } => {
#[cfg(feature = "tui")]
{
let mut tui_confirmed = false;
if !plain && tty {
if !crate::cli::tui::run_tui_env_del(&store, &env)? {
return Ok(());
}
tui_confirmed = true;
}
if !tui_confirmed {
confirm_env_delete(tty, &env, &c)?;
}
}
#[cfg(not(feature = "tui"))]
{
let _ = plain;
confirm_env_delete(tty, &env, &c)?;
}
store.delete_env(&env)?;
println!(
"{} {} {}",
c.prefix(),
c.success("deleted environment"),
c.accent(&env)
);
}
}
}
Commands::Member { command } => {
let (base_path, _) = resolve_kagi_base()?;
let key_manager = KeyManager::new(base_path.clone());
match command {
MemberCommands::List { plain } => {
#[cfg(feature = "tui")]
if !plain && tty {
return crate::cli::tui::run_tui_member_list();
}
#[cfg(not(feature = "tui"))]
let _ = plain;
let members = key_manager.list_members()?;
let requests = key_manager.list_join_requests()?;
#[cfg(feature = "server")]
let mut requests = requests;
let config_path = base_path.join(kagi_domain::config::KAGI_CONFIG_FILE);
let config: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&config_path)?)?;
let is_server_mode = config
.get("settings")
.and_then(|s| s.get("sync"))
.and_then(|s| s.get("mode"))
.and_then(|v| v.as_str())
== Some("server");
if is_server_mode {
#[cfg(feature = "server")]
match fetch_server_join_requests(&key_manager, &config, allow_insecure)
.await
{
Ok(server_requests) => {
for sr in server_requests {
if !requests.iter().any(|r| r.member_id == sr.member_id) {
requests.push(sr);
}
}
requests.sort_by(|a, b| a.member_id.cmp(&b.member_id));
}
Err(e) => {
eprintln!(
"{} warning: could not fetch server member requests: {}",
c.prefix(),
e
);
}
}
}
println!("{}", c.warning("Members"));
if members.is_empty() {
println!(" {}", c.muted("none"));
} else {
for member in members {
let status = if member.status == "active" {
c.success(&member.status)
} else {
c.muted(&member.status)
};
println!(
" {} {} {}",
c.accent(&member.member_id),
c.key(&member.name),
status
);
}
}
println!("{}", c.warning("Member Requests"));
if requests.is_empty() {
println!(" {}", c.muted("none"));
} else {
for member in requests {
println!(
" {} {} {}",
c.accent(&member.member_id),
c.key(&member.name),
c.warning(&member.status)
);
}
}
}
MemberCommands::Request { name } => {
let member = key_manager.create_join_request(name)?;
let config_path = base_path.join(kagi_domain::config::KAGI_CONFIG_FILE);
let config: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&config_path)?)?;
let sync = config.get("settings").and_then(|s| s.get("sync"));
let is_server_mode =
sync.and_then(|s| s.get("mode")).and_then(|v| v.as_str()) == Some("server");
if !is_server_mode {
println!(
"{} {} {}",
c.prefix(),
c.success("created member request"),
c.accent(&member.member_id)
);
print_member_approval_instruction(&member.member_id, &c);
return Ok(());
}
#[cfg(feature = "server")]
{
let sync = sync.ok_or_else(|| anyhow::anyhow!("missing sync settings"))?;
member_join_server_mode(
&key_manager,
&member,
&config,
sync,
allow_insecure,
&c,
)
.await?;
}
#[cfg(not(feature = "server"))]
{
return Err(anyhow::anyhow!("server mode not available"));
}
}
MemberCommands::Approve { member_id } => {
let config_path = base_path.join(kagi_domain::config::KAGI_CONFIG_FILE);
let config: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&config_path)?)?;
let is_server_mode = config
.get("settings")
.and_then(|s| s.get("sync"))
.and_then(|s| s.get("mode"))
.and_then(|v| v.as_str())
== Some("server");
#[cfg(feature = "tui")]
let member_id = if member_id.is_none() && tty {
if let Some(id) =
crate::cli::tui::run_tui_member_approve(base_path.clone())?
{
id
} else {
return Ok(());
}
} else {
member_id.unwrap_or_default()
};
#[cfg(not(feature = "tui"))]
let member_id = member_id.unwrap_or_default();
if member_id.is_empty() {
return Err(anyhow::anyhow!("Usage: kagi member approve <member_id>"));
}
if is_server_mode {
#[cfg(feature = "server")]
{
member_approve_server_mode(
&key_manager,
&member_id,
&config,
allow_insecure,
&c,
)
.await?;
}
#[cfg(not(feature = "server"))]
{
return Err(anyhow::anyhow!("server mode not available"));
}
} else {
let member = key_manager.approve_join_request(&member_id)?;
println!(
"{} {} {}",
c.prefix(),
c.success("approved member"),
c.accent(&member.member_id)
);
}
}
MemberCommands::Remove { member_id } => {
#[cfg(feature = "tui")]
let member_id = if member_id.is_none() && tty {
if let Some(id) = crate::cli::tui::run_tui_member_del(base_path.clone())? {
id
} else {
return Ok(());
}
} else {
member_id.unwrap_or_default()
};
#[cfg(not(feature = "tui"))]
let member_id = member_id.unwrap_or_default();
if member_id.is_empty() {
return Err(anyhow::anyhow!("Usage: kagi member remove <member_id>"));
}
confirm_member_remove(tty, &member_id, &c)?;
let count = rotate_project_key(&base_path, Some(&member_id))?;
println!(
"{} {} {} {}",
c.prefix(),
c.success("removed member and rotated project key"),
c.accent(&member_id),
c.muted(&format!("({count} stores rewritten)"))
);
}
}
}
#[cfg(feature = "server")]
Commands::Serve {
db,
key_file,
bind,
max_body,
allow_insecure_http,
} => {
let db_path = if db.is_empty() {
std::env::current_dir()?.join("kagi.db")
} else {
PathBuf::from(db)
};
let key_file_path = if key_file.is_empty() {
default_server_key_path()?
} else {
PathBuf::from(key_file)
};
let bind_addr: std::net::SocketAddr = bind
.parse()
.map_err(|e| anyhow::anyhow!("invalid bind address: {e}"))?;
let max_body_size = parse_max_body(&max_body);
let env_override = std::env::var("KAGI_ALLOW_INSECURE_HTTP")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let allow_insecure = allow_insecure_http || env_override;
if (bind_addr.ip().is_unspecified() || !bind_addr.ip().is_loopback()) && !allow_insecure
{
return Err(anyhow::anyhow!(
"Binding to non-localhost address {bind_addr} requires HTTPS. Use a reverse proxy with TLS, or pass --allow-insecure-http for local testing only."
));
}
println!("kagi: database: {}", db_path.display());
println!("kagi: key file: {}", key_file_path.display());
kagi_server::server::serve(bind_addr, &db_path, &key_file_path, max_body_size).await?;
}
#[cfg(feature = "server")]
Commands::Remote { command } => {
handle_remote_command(command, tty, allow_insecure, &c).await?;
}
Commands::Completions { shell } => {
let shell = shell.parse::<clap_complete::Shell>().map_err(|_| {
anyhow::anyhow!(
"unsupported shell '{shell}'. Supported: bash, zsh, fish, elvish, powershell"
)
})?;
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
clap_complete::generate(shell, &mut cmd, name, &mut io::stdout());
}
}
Ok(())
}
#[cfg(feature = "server")]
async fn handle_remote_command(
command: RemoteCommands,
tty: bool,
allow_insecure: bool,
c: &Palette,
) -> anyhow::Result<()> {
match command {
RemoteCommands::Login { remote, token } => {
remote_login(&remote, &token, c, allow_insecure).await?;
}
RemoteCommands::Register { remote } => {
project_join_remote(std::env::current_dir()?, &remote, c, allow_insecure).await?;
}
RemoteCommands::Push => {
remote_push(c, allow_insecure).await?;
}
RemoteCommands::Pull { token } => {
remote_pull(token, c, allow_insecure).await?;
}
RemoteCommands::Status => {
remote_status(c, allow_insecure).await?;
}
RemoteCommands::Projects { remote, plain } => {
let remote_url = resolve_admin_remote(remote).await?;
let (requests, projects) = load_project_list(&remote_url, allow_insecure).await?;
#[cfg(feature = "tui")]
if !plain && tty {
return crate::cli::tui::run_tui_project_list(requests, projects);
}
#[cfg(not(feature = "tui"))]
let _ = plain;
print_remote_projects(requests, projects, c);
}
RemoteCommands::Approve { remote, project_id } => {
let remote_url = resolve_admin_remote(remote).await?;
project_approve_remote(&remote_url, &project_id, c, allow_insecure).await?;
}
RemoteCommands::Remove { remote, project_id } => {
let remote_url = resolve_admin_remote(remote).await?;
project_del_remote(&remote_url, &project_id, c, allow_insecure).await?;
}
RemoteCommands::Tokens { remote, plain } => {
remote_tokens(remote, plain, tty, c, allow_insecure).await?;
}
RemoteCommands::RevokeToken { remote, token_id } => {
remote_revoke_token(remote, token_id, c, allow_insecure).await?;
}
RemoteCommands::Audit {
remote,
project_id,
limit,
plain,
} => {
let remote_url = resolve_admin_remote(remote).await?;
#[cfg(feature = "tui")]
if !plain && tty {
let events =
load_audit_events(&remote_url, project_id.as_deref(), limit, allow_insecure)
.await?;
return crate::cli::tui::run_tui_audit_log(events);
}
#[cfg(not(feature = "tui"))]
let _ = plain;
remote_audit(&remote_url, project_id.as_deref(), limit, c, allow_insecure).await?;
}
}
Ok(())
}
#[cfg(feature = "server")]
fn print_remote_projects(
requests: Vec<serde_json::Value>,
projects: Vec<serde_json::Value>,
c: &Palette,
) {
if requests.is_empty() && projects.is_empty() {
println!(
"{} {}",
c.prefix(),
c.muted("No projects or pending requests found.")
);
return;
}
if !requests.is_empty() {
println!("{} {}", c.prefix(), c.warning("Pending requests:"));
for r in &requests {
let id = r["project_id"].as_str().unwrap_or("unknown");
let name = r["requester_name"].as_str().unwrap_or("");
let created_at = r["created_at"].as_str().unwrap_or("");
println!(
" {} {} {}",
c.accent(id),
c.muted(&format!("by {name}")),
c.muted(created_at)
);
}
}
if !projects.is_empty() {
println!("{} {}", c.prefix(), c.muted("Active projects:"));
for p in &projects {
let id = p["project_id"].as_str().unwrap_or("unknown");
let revision = p["revision"].as_i64().unwrap_or(0);
let created_at = p["created_at"].as_str().unwrap_or("");
println!(
" {} rev={} created={}",
c.accent(id),
revision,
c.muted(created_at)
);
}
}
}
#[cfg(feature = "server")]
async fn remote_tokens(
remote: Option<String>,
plain: bool,
tty: bool,
c: &Palette,
allow_insecure: bool,
) -> anyhow::Result<()> {
let (base_path, _) = resolve_kagi_base()?;
let config_path = base_path.join(kagi_domain::config::KAGI_CONFIG_FILE);
let config: serde_json::Value = serde_json::from_str(&fs::read_to_string(&config_path)?)?;
let project_id = config["project_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing project_id"))?;
let remote_url = config["settings"]["sync"]["remote"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing remote URL. Run kagi remote register first."))?;
let remote_url = remote.unwrap_or_else(|| remote_url.to_string());
let local_data_dir = local_data_dir()?;
let remote_store =
kagi_sync::infrastructure::remote_local::RemoteLocalStore::new(local_data_dir);
let token = remote_store
.load_token(project_id)?
.ok_or_else(|| anyhow::anyhow!("no project token found"))?;
let meta = remote_store
.load_remote_metadata(project_id)?
.ok_or_else(|| anyhow::anyhow!("no remote metadata found"))?;
let key_manager = KeyManager::new(base_path.clone());
let identity = key_manager.load_or_create_identity()?;
let client = kagi_sync::infrastructure::remote_client::RemoteClient::new_pinned(
remote_url,
&meta.server_fingerprint,
allow_insecure,
)
.await?;
let data = client
.send_list_tokens(project_id, &token, &identity)
.await?;
let tokens = data
.get("tokens")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("invalid response: missing tokens"))?
.clone();
#[cfg(feature = "tui")]
if !plain && tty {
return crate::cli::tui::run_tui_token_list(tokens);
}
#[cfg(not(feature = "tui"))]
let _ = (plain, tty);
if tokens.is_empty() {
println!("{} {}", c.prefix(), c.muted("no tokens found."));
} else {
println!(
"{} {}",
c.prefix(),
c.accent(&format!("{} token(s)", tokens.len()))
);
for t in &tokens {
let id = t["token_id"].as_str().unwrap_or("?");
let caps: Vec<String> = t["capabilities"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(std::string::ToString::to_string))
.collect()
})
.unwrap_or_default();
let status = t["status"].as_str().unwrap_or("?");
let member = t["member_id"].as_str().unwrap_or("?");
let created = t["created_at"].as_str().unwrap_or("?");
println!(
" {} {} | {} | {} | {}",
c.key(id),
c.muted(&format!("[{status}]")),
c.accent(member),
c.info(&caps.join(", ")),
c.muted(created)
);
}
}
Ok(())
}
#[cfg(feature = "server")]
async fn remote_revoke_token(
remote: Option<String>,
token_id: String,
c: &Palette,
allow_insecure: bool,
) -> anyhow::Result<()> {
let (base_path, _) = resolve_kagi_base()?;
let config_path = base_path.join(kagi_domain::config::KAGI_CONFIG_FILE);
let config: serde_json::Value = serde_json::from_str(&fs::read_to_string(&config_path)?)?;
let project_id = config["project_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing project_id"))?;
let remote_url = config["settings"]["sync"]["remote"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing remote URL. Run kagi remote register first."))?;
let remote_url = remote.unwrap_or_else(|| remote_url.to_string());
let local_data_dir = local_data_dir()?;
let remote_store =
kagi_sync::infrastructure::remote_local::RemoteLocalStore::new(local_data_dir);
let token = remote_store
.load_token(project_id)?
.ok_or_else(|| anyhow::anyhow!("no project token found"))?;
let meta = remote_store
.load_remote_metadata(project_id)?
.ok_or_else(|| anyhow::anyhow!("no remote metadata found"))?;
let key_manager = KeyManager::new(base_path.clone());
let identity = key_manager.load_or_create_identity()?;
let client = kagi_sync::infrastructure::remote_client::RemoteClient::new_pinned(
remote_url,
&meta.server_fingerprint,
allow_insecure,
)
.await?;
let data = client
.send_revoke_tokens(
project_id,
&token,
std::slice::from_ref(&token_id),
&identity,
)
.await?;
let revoked = data
.get("revoked_token_ids")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(std::string::ToString::to_string))
.collect::<Vec<String>>()
})
.unwrap_or_default();
println!(
"{} {}",
c.prefix(),
c.success(&format!("revoked {} token(s)", revoked.len()))
);
for id in revoked {
println!(" {}", c.key(&id));
}
Ok(())
}
#[cfg(feature = "server")]
fn project_state_file(
path: String,
content: String,
) -> kagi_sync::domain::project_state::ProjectFile {
let content_hash = {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
hex::encode(hasher.finalize())
};
kagi_sync::domain::project_state::ProjectFile {
path,
content,
sha256: Some(content_hash),
}
}
#[cfg(feature = "server")]
fn collect_project_state_files_for_push(
base_path: &Path,
store: &FileStore,
) -> anyhow::Result<Vec<kagi_sync::domain::project_state::ProjectFile>> {
let mut files = Vec::new();
for scope in store.list_services()? {
let (file_name, content) = store.raw_service_content(&scope)?;
files.push(project_state_file(file_name, content));
}
for (file_name, content) in
crate::application::file_artifacts::collect_encrypted_file_artifacts(base_path)?
{
files.push(project_state_file(file_name, content));
}
Ok(files)
}
#[cfg(feature = "server")]
async fn remote_push(c: &Palette, allow_insecure: bool) -> anyhow::Result<()> {
let (base_path, _) = resolve_kagi_base()?;
let config_path = base_path.join(kagi_domain::config::KAGI_CONFIG_FILE);
let config: serde_json::Value = serde_json::from_str(&fs::read_to_string(&config_path)?)?;
let project_id = config["project_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing project_id"))?;
let remote_url = config["settings"]["sync"]["remote"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing remote URL. Run kagi remote register first."))?;
let local_data_dir = local_data_dir()?;
let remote_store =
kagi_sync::infrastructure::remote_local::RemoteLocalStore::new(local_data_dir);
let token = remote_store
.load_token(project_id)?
.ok_or_else(|| anyhow::anyhow!("no project token found"))?;
let meta = remote_store
.load_remote_metadata(project_id)?
.ok_or_else(|| anyhow::anyhow!("no remote metadata found"))?;
let base_revision = meta.local_revision.unwrap_or(0);
let key_manager = KeyManager::new(base_path.clone());
let identity = key_manager.load_or_create_identity()?;
let member_id = key_manager.member_id()?;
let signing_key = key_manager.ensure_signing_key(&member_id)?;
let signing_public_key =
base64::engine::general_purpose::STANDARD.encode(signing_key.verifying_key().to_bytes());
let store = resolve_store()?.0;
let kagi_json = fs::read_to_string(&config_path)?;
let access_json =
fs::read_to_string(base_path.join("access.json")).unwrap_or_else(|_| "{}".to_string());
let files = collect_project_state_files_for_push(&base_path, &store)?;
let project_state = kagi_sync::domain::project_state::ProjectState {
project_id: project_id.to_string(),
revision: base_revision,
kagi_json,
access_json,
files,
};
let previous_manifest_hash = if base_revision > 0 {
Some(meta.last_manifest_hash.clone().ok_or_else(|| {
anyhow::anyhow!(
"missing local manifest hash for revision {base_revision}; run kagi remote pull before pushing"
)
})?)
} else {
None
};
let manifest = kagi_sync::domain::manifest::ProjectStateManifest {
version: 1,
project_id: project_id.to_string(),
revision: base_revision + 1,
previous_manifest_hash,
kagi_json_hash: kagi_sync::domain::manifest::hash_json(&project_state.kagi_json),
access_json_hash: kagi_sync::domain::manifest::hash_json(&project_state.access_json),
file_hashes: project_state
.files
.iter()
.map(|f| kagi_sync::domain::manifest::FileHash {
path: f.path.clone(),
sha256: f.sha256.clone().unwrap_or_default(),
})
.collect(),
timestamp: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
signer_member_id: member_id.clone(),
signer_public_key: signing_public_key,
};
let manifest_json = serde_json::to_string(&manifest)?;
let manifest_hash = manifest.compute_hash();
let signature = signing_key.sign(manifest_hash.as_bytes());
let signature_b64 = base64::engine::general_purpose::STANDARD.encode(signature.to_bytes());
let mut payload = serde_json::json!({
"base_revision": base_revision,
"state": project_state,
"manifest": manifest_json,
"manifest_signature": signature_b64,
});
if let Some(ref token_ids) = meta.pending_token_ids {
payload["activate_token_ids"] = serde_json::json!(token_ids);
}
if let Some(ref member_ids) = meta.pending_accepted_member_ids {
payload["accepted_join_member_ids"] = serde_json::json!(member_ids);
}
let request_id = format!(r"kgr_{}", nanoid::nanoid!(12));
let plaintext = kagi_sync::domain::envelope::RequestPlaintext {
version: 1,
request_id: request_id.clone(),
issued_at: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
operation: "push".into(),
method: "POST".into(),
path: format!("/v1/projects/{project_id}/push"),
project_id: Some(project_id.to_string()),
token: Some(token),
claim_secret: None,
payload,
};
let client = kagi_sync::infrastructure::remote_client::RemoteClient::new_pinned(
remote_url.to_string(),
&meta.server_fingerprint,
allow_insecure,
)
.await?;
let data = client.send_request(&plaintext, &identity).await?;
let new_revision = data["revision"].as_i64().unwrap_or(base_revision + 1);
remote_store.save_remote_metadata(&kagi_sync::domain::remote_config::RemoteMetadata {
version: 1,
project_id: project_id.to_string(),
remote: remote_url.to_string(),
server_key_id: meta.server_key_id.clone(),
server_fingerprint: meta.server_fingerprint.clone(),
local_revision: Some(new_revision),
last_pulled_at: meta.last_pulled_at,
last_pushed_at: Some(
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
),
last_manifest_hash: Some(manifest_hash),
pending_token_ids: None,
pending_accepted_member_ids: None,
})?;
println!(
"{} {} revision {}",
c.prefix(),
c.success("pushed"),
c.accent(&new_revision.to_string())
);
Ok(())
}
#[cfg(feature = "server")]
async fn remote_pull(
token: Option<String>,
c: &Palette,
allow_insecure: bool,
) -> anyhow::Result<()> {
if let Some(token_str) = token {
return pull_with_token(&token_str, c, allow_insecure).await;
}
let (base_path, _) = resolve_kagi_base()?;
let config_path = base_path.join(kagi_domain::config::KAGI_CONFIG_FILE);
let config: serde_json::Value = serde_json::from_str(&fs::read_to_string(&config_path)?)?;
let project_id = config["project_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing project_id"))?;
let remote_url = config["settings"]["sync"]["remote"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing remote URL"))?;
let local_access_json =
fs::read_to_string(base_path.join("access.json")).unwrap_or_else(|_| "{}".to_string());
let local_data_dir = local_data_dir()?;
let remote_store =
kagi_sync::infrastructure::remote_local::RemoteLocalStore::new(local_data_dir);
let meta = remote_store
.load_remote_metadata(project_id)?
.ok_or_else(|| anyhow::anyhow!("no remote metadata found"))?;
let key_manager = KeyManager::new(base_path.clone());
let identity = key_manager.load_or_create_identity()?;
let request_id = format!(r"kgr_{}", nanoid::nanoid!(12));
let token = match remote_store.load_token(project_id)? {
Some(t) => t,
None => {
let claim_secret = remote_store.load_claim_secret(project_id)?.ok_or_else(|| {
anyhow::anyhow!("no claim secret found; run `kagi remote register` first")
})?;
let member_id = key_manager.member_id()?;
let claim_plaintext = kagi_sync::domain::envelope::RequestPlaintext {
version: 1,
request_id: request_id.clone(),
issued_at: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
operation: "pull".into(),
method: "POST".into(),
path: format!("/v1/projects/{project_id}/pull"),
project_id: Some(project_id.to_string()),
token: None,
claim_secret: Some(claim_secret.clone()),
payload: serde_json::json!({ "member_id": member_id }),
};
let client = kagi_sync::infrastructure::remote_client::RemoteClient::new_pinned(
remote_url.to_string(),
&meta.server_fingerprint,
allow_insecure,
)
.await?;
let data = client.send_request(&claim_plaintext, &identity).await?;
if let Some(wrapped_b64) = data.get("wrapped_project_token").and_then(|v| v.as_str()) {
let wrapped = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(wrapped_b64)
.map_err(|e| anyhow::anyhow!("invalid wrapped token: {e}"))?;
let decrypted =
kagi_sync::infrastructure::remote_envelope::decrypt_bytes(&wrapped, &identity)
.map_err(|e| anyhow::anyhow!("failed to decrypt wrapped token: {e}"))?;
String::from_utf8(decrypted).map_err(|e| anyhow::anyhow!("invalid token: {e}"))?
} else {
return Err(anyhow::anyhow!(
"no project token available; run `kagi remote register` first or ask admin to approve"
));
}
}
};
let parsed_token = kagi_sync::domain::project_token::ProjectToken::parse(&token)
.ok_or_else(|| anyhow::anyhow!("token from server is malformed"))?;
let known_revision = meta.local_revision.unwrap_or(0);
let plaintext = kagi_sync::domain::envelope::RequestPlaintext {
version: 1,
request_id: request_id.clone(),
issued_at: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
operation: "pull".into(),
method: "POST".into(),
path: format!("/v1/projects/{project_id}/pull"),
project_id: Some(project_id.to_string()),
token: Some(token.clone()),
claim_secret: None,
payload: serde_json::json!({ "known_revision": known_revision }),
};
let client = kagi_sync::infrastructure::remote_client::RemoteClient::new_pinned(
remote_url.to_string(),
&meta.server_fingerprint,
allow_insecure,
)
.await?;
let data = client.send_request(&plaintext, &identity).await?;
let state = data["state"].clone();
let _manifest_hash = verify_pulled_manifest(
&data,
&state,
project_id,
known_revision,
meta.last_manifest_hash.as_deref(),
&local_access_json,
parsed_token.payload.bootstrap_signer_public_key.as_deref(),
)?;
let remote_revision = data["revision"].as_i64().unwrap_or(0);
let pulled_access_json = state
.get("access_json")
.and_then(|v| v.as_str())
.unwrap_or("{}");
let has_pending = meta
.pending_token_ids
.as_ref()
.is_some_and(|v| !v.is_empty())
|| meta
.pending_accepted_member_ids
.as_ref()
.is_some_and(|v| !v.is_empty());
let would_change_state =
remote_revision != known_revision || pulled_access_json != local_access_json;
if has_pending && would_change_state {
return Err(anyhow::anyhow!(
"Cannot pull while member approval metadata is pending. Run `kagi remote push` to publish the approval, or resolve the pending member approval before pulling."
));
}
apply_pulled_state(&base_path, &state)?;
let token = key_manager.unwrap_member_token()?.unwrap_or(token);
remote_store.save_token(project_id, &token)?;
remote_store.delete_claim_secret(project_id)?;
remote_store.save_remote_metadata(&kagi_sync::domain::remote_config::RemoteMetadata {
version: 1,
project_id: project_id.to_string(),
remote: remote_url.to_string(),
server_key_id: meta.server_key_id.clone(),
server_fingerprint: meta.server_fingerprint.clone(),
local_revision: Some(remote_revision),
last_pulled_at: Some(
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
),
last_pushed_at: meta.last_pushed_at,
last_manifest_hash: data
.get("manifest_hash")
.and_then(|value| value.as_str())
.map(str::to_string)
.or(meta.last_manifest_hash),
pending_token_ids: meta.pending_token_ids,
pending_accepted_member_ids: meta.pending_accepted_member_ids,
})?;
println!(
"{} {} revision {}",
c.prefix(),
c.success("pulled"),
c.accent(&remote_revision.to_string())
);
Ok(())
}
#[cfg(feature = "server")]
async fn remote_status(c: &Palette, allow_insecure: bool) -> anyhow::Result<()> {
let (base_path, _) = resolve_kagi_base()?;
let config_path = base_path.join(kagi_domain::config::KAGI_CONFIG_FILE);
let config: serde_json::Value = serde_json::from_str(&fs::read_to_string(&config_path)?)?;
let project_id = config["project_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing project_id"))?;
let remote_url = config["settings"]["sync"]["remote"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing remote URL"))?;
let local_data_dir = local_data_dir()?;
let remote_store =
kagi_sync::infrastructure::remote_local::RemoteLocalStore::new(local_data_dir);
let token = remote_store
.load_token(project_id)?
.ok_or_else(|| anyhow::anyhow!("no project token found"))?;
let meta = remote_store
.load_remote_metadata(project_id)?
.ok_or_else(|| anyhow::anyhow!("no remote metadata found"))?;
let local_revision = meta.local_revision.unwrap_or(0);
let key_manager = KeyManager::new(base_path);
let identity = key_manager.load_or_create_identity()?;
let request_id = format!(r"kgr_{}", nanoid::nanoid!(12));
let plaintext = kagi_sync::domain::envelope::RequestPlaintext {
version: 1,
request_id: request_id.clone(),
issued_at: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
operation: "status".into(),
method: "POST".into(),
path: format!("/v1/projects/{project_id}/status"),
project_id: Some(project_id.to_string()),
token: Some(token),
claim_secret: None,
payload: serde_json::json!({ "local_revision": local_revision }),
};
let client = kagi_sync::infrastructure::remote_client::RemoteClient::new_pinned(
remote_url.to_string(),
&meta.server_fingerprint,
allow_insecure,
)
.await?;
let data = client.send_request(&plaintext, &identity).await?;
let remote_revision = data["remote_revision"].as_i64().unwrap_or(0);
let state_str = data["state"].as_str().unwrap_or("unknown");
let pending_joins = data["pending_join_count"].as_i64().unwrap_or(0);
println!(
"{} {} local={} remote={}",
c.prefix(),
c.info(state_str),
c.accent(&local_revision.to_string()),
c.accent(&remote_revision.to_string())
);
if pending_joins > 0 {
println!(
"{} {} pending member request(s)",
c.prefix(),
c.warning(&pending_joins.to_string())
);
}
Ok(())
}
#[cfg(feature = "server")]
fn parse_max_body(s: &str) -> usize {
let s = s.to_lowercase();
if s.ends_with("mb") {
s.trim_end_matches("mb").parse::<usize>().unwrap_or(10) * 1024 * 1024
} else if s.ends_with("kb") {
s.trim_end_matches("kb").parse::<usize>().unwrap_or(10) * 1024
} else {
s.parse::<usize>().unwrap_or(10 * 1024 * 1024)
}
}
#[cfg(feature = "server")]
fn default_server_key_path() -> anyhow::Result<PathBuf> {
let base = local_data_dir()?;
Ok(base.join("server/server.key.json"))
}
fn local_data_dir() -> anyhow::Result<PathBuf> {
#[cfg(test)]
{
Ok(std::env::temp_dir().join("kagi-tests"))
}
#[cfg(not(test))]
{
if let Ok(path) = std::env::var("KAGI_HOME") {
return Ok(PathBuf::from(path));
}
directories::ProjectDirs::from("dev", "kagi", "kagi")
.map(|dirs| dirs.data_dir().to_path_buf())
.ok_or_else(|| anyhow::anyhow!("failed to resolve local data directory"))
}
}
#[cfg(feature = "server")]
fn remove_stale_pulled_secret_files(
base_path: &Path,
expected_files: &BTreeSet<String>,
) -> anyhow::Result<()> {
fn visit_dir(
base_path: &Path,
dir: &Path,
expected_files: &BTreeSet<String>,
) -> anyhow::Result<()> {
if !dir.exists() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
visit_dir(base_path, &path, expected_files)?;
if fs::read_dir(&path)?.next().is_none() {
fs::remove_dir(&path)?;
}
} else if path.extension().is_some_and(|ext| ext == "enc") {
let relative_path = path
.strip_prefix(base_path)
.map_err(|e| anyhow::anyhow!("failed to inspect local secret path: {e}"))?
.to_string_lossy()
.replace('\\', "/");
if !expected_files.contains(&relative_path) {
fs::remove_file(path)?;
}
}
}
Ok(())
}
visit_dir(base_path, &base_path.join("secrets"), expected_files)?;
visit_dir(base_path, &base_path.join("files"), expected_files)
}
#[cfg(feature = "server")]
fn apply_pulled_state(base_path: &Path, state: &serde_json::Value) -> anyhow::Result<()> {
let project_state: kagi_sync::domain::project_state::ProjectState =
serde_json::from_value(state.clone())?;
let mut expected_files = BTreeSet::new();
for file in &project_state.files {
kagi_sync::domain::project_state::validate_file_path(&file.path)
.map_err(|err| anyhow::anyhow!("invalid remote file path {}: {}", file.path, err))?;
expected_files.insert(file.path.clone());
}
let kagi_json_empty = serde_json::from_str::<serde_json::Value>(&project_state.kagi_json)
.map(|v| {
v.as_object()
.map(serde_json::Map::is_empty)
.unwrap_or(false)
})
.unwrap_or(false);
let access_json_empty = serde_json::from_str::<serde_json::Value>(&project_state.access_json)
.map(|v| {
v.as_object()
.map(serde_json::Map::is_empty)
.unwrap_or(false)
})
.unwrap_or(false);
let is_empty_remote = project_state.revision == 0
&& kagi_json_empty
&& access_json_empty
&& project_state.files.is_empty();
if is_empty_remote {
let local_kagi = base_path.join("kagi.json");
let local_access = base_path.join("access.json");
if local_kagi.exists() && local_access.exists() {
} else {
return Err(anyhow::anyhow!(
"remote project is empty; run `kagi init` first, or ask the owner to push first"
));
}
} else {
atomic_write(&base_path.join("kagi.json"), &project_state.kagi_json)?;
atomic_write(&base_path.join("access.json"), &project_state.access_json)?;
}
for file in project_state.files {
let file_path = base_path.join(&file.path);
fs::create_dir_all(file_path.parent().unwrap())?;
atomic_write(&file_path, &file.content)?;
}
if !is_empty_remote {
remove_stale_pulled_secret_files(base_path, &expected_files)?;
}
Ok(())
}
#[cfg(feature = "server")]
fn is_empty_json_object(input: Option<&str>) -> bool {
input
.and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok())
.and_then(|value| value.as_object().map(serde_json::Map::is_empty))
.unwrap_or(false)
}
#[cfg(feature = "server")]
fn is_empty_genesis_state(state: &serde_json::Value, project_id: &str) -> bool {
state.get("project_id").and_then(|v| v.as_str()) == Some(project_id)
&& state.get("revision").and_then(serde_json::Value::as_i64) == Some(0)
&& is_empty_json_object(state.get("kagi_json").and_then(|v| v.as_str()))
&& is_empty_json_object(state.get("access_json").and_then(|v| v.as_str()))
&& state
.get("files")
.and_then(|v| v.as_array())
.map(std::vec::Vec::is_empty)
.unwrap_or(false)
}
#[cfg(feature = "server")]
fn verify_pulled_manifest(
data: &serde_json::Value,
state: &serde_json::Value,
project_id: &str,
known_revision: i64,
last_manifest_hash: Option<&str>,
access_json_str: &str,
trusted_bootstrap_signer_public_key: Option<&str>,
) -> anyhow::Result<String> {
let remote_revision = data
.get("revision")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow::anyhow!("server response missing revision"))?;
let manifest_str = match data.get("manifest").and_then(|v| v.as_str()) {
Some(manifest_str) => manifest_str,
None => {
if data.get("manifest_hash").is_some() {
return Err(anyhow::anyhow!(
"server returned manifest_hash but no manifest"
));
}
if remote_revision == 0
&& known_revision == 0
&& is_empty_genesis_state(state, project_id)
{
return Ok(String::new());
}
return Err(anyhow::anyhow!("server response missing manifest"));
}
};
let manifest: kagi_sync::domain::manifest::ProjectStateManifest =
serde_json::from_str(manifest_str)
.map_err(|e| anyhow::anyhow!("invalid manifest from server: {e}"))?;
let expected_hash = manifest.compute_hash();
let server_hash = data
.get("manifest_hash")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("server response missing manifest_hash"))?;
if expected_hash != server_hash {
return Err(anyhow::anyhow!(
"manifest hash mismatch: computed {expected_hash} vs server {server_hash}"
));
}
if manifest.project_id != project_id {
return Err(anyhow::anyhow!(
"manifest project_id mismatch: {} vs {}",
manifest.project_id,
project_id
));
}
if manifest.revision != remote_revision {
return Err(anyhow::anyhow!(
"manifest revision mismatch: {} vs {}",
manifest.revision,
remote_revision
));
}
if manifest.revision < known_revision {
return Err(anyhow::anyhow!(
"server rolled back revision: {} < local {}",
manifest.revision,
known_revision
));
}
if manifest.revision == known_revision {
if let Some(last_hash) = last_manifest_hash {
if expected_hash != last_hash {
return Err(anyhow::anyhow!(
"manifest replay detected: revision {} hash changed but expected {}",
manifest.revision,
last_hash
));
}
} else {
return Err(anyhow::anyhow!(
"manifest replay detected: revision {} already known locally",
manifest.revision
));
}
}
if manifest.revision > known_revision && known_revision > 0 {
let last_hash = last_manifest_hash.ok_or_else(|| {
anyhow::anyhow!("manifest chain missing local hash for revision {known_revision}")
})?;
if manifest.previous_manifest_hash.as_deref() != Some(last_hash) {
return Err(anyhow::anyhow!(
"manifest chain mismatch: expected previous hash {} got {}",
last_hash,
manifest
.previous_manifest_hash
.as_deref()
.unwrap_or("<missing>")
));
}
}
let kagi_json_str = state
.get("kagi_json")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("state missing kagi_json"))?;
let remote_access_json_str = state
.get("access_json")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("state missing access_json"))?;
let expected_kagi_hash = kagi_sync::domain::manifest::hash_json(kagi_json_str);
let expected_access_hash = kagi_sync::domain::manifest::hash_json(remote_access_json_str);
if manifest.kagi_json_hash != expected_kagi_hash {
return Err(anyhow::anyhow!("manifest kagi_json hash mismatch"));
}
if manifest.access_json_hash != expected_access_hash {
return Err(anyhow::anyhow!("manifest access_json hash mismatch"));
}
let files = state
.get("files")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("state missing files"))?;
let mut state_file_hashes = BTreeMap::new();
for file_value in files {
let path = file_value
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("state file missing path"))?;
let content = file_value
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("state file {path} missing content"))?;
let expected_file_hash = {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
hex::encode(hasher.finalize())
};
if state_file_hashes
.insert(path.to_string(), expected_file_hash)
.is_some()
{
return Err(anyhow::anyhow!(
"state contains duplicate file path: {path}"
));
}
}
let mut manifest_paths = BTreeSet::new();
for manifest_file in &manifest.file_hashes {
if !manifest_paths.insert(manifest_file.path.clone()) {
return Err(anyhow::anyhow!(
"manifest contains duplicate file path: {}",
manifest_file.path
));
}
let expected_file_hash = state_file_hashes.get(&manifest_file.path).ok_or_else(|| {
anyhow::anyhow!("manifest references missing file: {}", manifest_file.path)
})?;
if manifest_file.sha256 != *expected_file_hash {
return Err(anyhow::anyhow!(
"manifest file hash mismatch for {}: expected {} got {}",
manifest_file.path,
expected_file_hash,
manifest_file.sha256
));
}
}
let state_paths: BTreeSet<String> = state_file_hashes.keys().cloned().collect();
if state_paths != manifest_paths {
let missing_paths: Vec<String> = manifest_paths.difference(&state_paths).cloned().collect();
if !missing_paths.is_empty() {
return Err(anyhow::anyhow!(
"manifest references missing files: {}",
missing_paths.join(", ")
));
}
let extra_paths: Vec<String> = state_paths.difference(&manifest_paths).cloned().collect();
if !extra_paths.is_empty() {
return Err(anyhow::anyhow!(
"state contains extra files not in manifest: {}",
extra_paths.join(", ")
));
}
}
let signature_b64 = data
.get("manifest_signature")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("manifest present but manifest_signature missing"))?;
let signature_bytes = base64::engine::general_purpose::STANDARD
.decode(signature_b64)
.map_err(|e| anyhow::anyhow!("invalid manifest signature: {e}"))?;
if signature_bytes.len() != 64 {
return Err(anyhow::anyhow!(
"manifest signature must be 64 bytes, got {}",
signature_bytes.len()
));
}
let signature = ed25519_dalek::Signature::from_slice(&signature_bytes)
.map_err(|e| anyhow::anyhow!("invalid signature: {e}"))?;
let public_key_bytes = base64::engine::general_purpose::STANDARD
.decode(&manifest.signer_public_key)
.map_err(|e| anyhow::anyhow!("invalid signer public key: {e}"))?;
if public_key_bytes.len() != 32 {
return Err(anyhow::anyhow!(
"signer public key must be 32 bytes, got {}",
public_key_bytes.len()
));
}
let mut pk_arr = [0u8; 32];
pk_arr.copy_from_slice(&public_key_bytes);
let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(&pk_arr)
.map_err(|e| anyhow::anyhow!("invalid verifying key: {e}"))?;
let access: serde_json::Value =
serde_json::from_str(access_json_str).unwrap_or(serde_json::Value::Null);
let empty_members = vec![];
let members = access
.get("members")
.and_then(|m| m.as_array())
.unwrap_or(&empty_members);
let known_public_key = members
.iter()
.find(|m| {
m.get("member_id")
.and_then(|v| v.as_str())
.map(|id| id == manifest.signer_member_id)
.unwrap_or(false)
})
.and_then(|m| m.get("signing_public_key"))
.and_then(|v| v.as_str());
let trusted_public_key = known_public_key.or(trusted_bootstrap_signer_public_key);
let trusted_public_key = trusted_public_key.ok_or_else(|| {
anyhow::anyhow!(
"manifest signed by unknown member {} (no trusted signing key available)",
manifest.signer_member_id
)
})?;
if trusted_public_key != manifest.signer_public_key {
return Err(anyhow::anyhow!(
"manifest signer_public_key does not match trusted key for {}: expected {} got {}",
manifest.signer_member_id,
trusted_public_key,
manifest.signer_public_key
));
}
use ed25519_dalek::Verifier;
verifying_key
.verify(expected_hash.as_bytes(), &signature)
.map_err(|e| anyhow::anyhow!("manifest signature verification failed: {e}"))?;
Ok(expected_hash)
}
#[cfg(feature = "server")]
async fn pull_with_token(token_str: &str, c: &Palette, allow_insecure: bool) -> anyhow::Result<()> {
let token = kagi_sync::domain::project_token::ProjectToken::parse(token_str)
.ok_or_else(|| anyhow::anyhow!("invalid project token"))?;
let remote_url = token.payload.remote.clone();
let project_id = token.payload.project_id.clone();
let key_manager = KeyManager::new(std::env::current_dir()?.join(".kagi"));
let identity = key_manager.load_or_create_identity()?;
let base_path = std::env::current_dir()?.join(".kagi");
let local_access_json =
fs::read_to_string(base_path.join("access.json")).unwrap_or_else(|_| "{}".to_string());
let local_data_dir = local_data_dir()?;
let remote_store =
kagi_sync::infrastructure::remote_local::RemoteLocalStore::new(local_data_dir);
let existing_meta = remote_store
.load_remote_metadata(&project_id)
.ok()
.flatten();
let known_revision = existing_meta
.as_ref()
.and_then(|m| m.local_revision)
.unwrap_or(0);
let last_manifest_hash = existing_meta
.as_ref()
.and_then(|m| m.last_manifest_hash.as_deref());
let pending_token_ids = existing_meta
.as_ref()
.and_then(|m| m.pending_token_ids.clone());
let pending_accepted_member_ids = existing_meta
.as_ref()
.and_then(|m| m.pending_accepted_member_ids.clone());
let request_id = format!(r"kgr_{}", nanoid::nanoid!(12));
let plaintext = kagi_sync::domain::envelope::RequestPlaintext {
version: 1,
request_id: request_id.clone(),
issued_at: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
operation: "pull".into(),
method: "POST".into(),
path: format!("/v1/projects/{project_id}/pull"),
project_id: Some(project_id.clone()),
token: Some(token_str.to_string()),
claim_secret: None,
payload: serde_json::json!({ "known_revision": known_revision }),
};
let remote_client = kagi_sync::infrastructure::remote_client::RemoteClient::new_pinned(
remote_url.to_string(),
&token.payload.server_fingerprint,
allow_insecure,
)
.await?;
let data = remote_client.send_request(&plaintext, &identity).await?;
let remote_revision = data["revision"].as_i64().unwrap_or(0);
let state = data["state"].clone();
let _manifest_hash = verify_pulled_manifest(
&data,
&state,
&project_id,
known_revision,
last_manifest_hash,
&local_access_json,
token.payload.bootstrap_signer_public_key.as_deref(),
)?;
let pulled_access_json = state
.get("access_json")
.and_then(|v| v.as_str())
.unwrap_or("{}");
let has_pending = pending_token_ids.as_ref().is_some_and(|v| !v.is_empty())
|| pending_accepted_member_ids
.as_ref()
.is_some_and(|v| !v.is_empty());
let would_change_state =
remote_revision != known_revision || pulled_access_json != local_access_json;
if has_pending && would_change_state {
return Err(anyhow::anyhow!(
"Cannot pull while member approval metadata is pending. Run `kagi remote push` to publish the approval, or resolve the pending member approval before pulling."
));
}
apply_pulled_state(&base_path, &state)?;
let token = key_manager
.unwrap_member_token()?
.unwrap_or_else(|| token_str.to_string());
remote_store.save_token(&project_id, &token)?;
remote_store.save_remote_metadata(&kagi_sync::domain::remote_config::RemoteMetadata {
version: 1,
project_id: project_id.clone(),
remote: remote_url.to_string(),
server_key_id: remote_client.server_key_id().to_string(),
server_fingerprint: remote_client.fingerprint().to_string(),
local_revision: Some(remote_revision),
last_pulled_at: Some(
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
),
last_pushed_at: None,
last_manifest_hash: data
.get("manifest_hash")
.and_then(|value| value.as_str())
.map(str::to_string),
pending_token_ids,
pending_accepted_member_ids,
})?;
println!(
"{} {} revision {}",
c.prefix(),
c.success("pulled"),
c.accent(&remote_revision.to_string())
);
Ok(())
}
#[cfg(feature = "server")]
fn resolve_admin_token(fingerprint: &str) -> anyhow::Result<String> {
if let Ok(token) = std::env::var("KAGI_ADMIN_TOKEN") {
validate_admin_token_for_fingerprint(&token, fingerprint)?;
return Ok(token);
}
if admin_keyring_disabled() {
let remote_store =
kagi_sync::infrastructure::remote_local::RemoteLocalStore::new(local_data_dir()?);
if let Some(token) = remote_store.load_admin_token(fingerprint)? {
validate_admin_token_for_fingerprint(&token, fingerprint)?;
return Ok(token);
}
return Err(anyhow::anyhow!(
"admin token not found for server {fingerprint}. Run `kagi remote login --remote <url> --token <token>` or set KAGI_ADMIN_TOKEN."
));
}
let entry = kagi_store::key_manager::keyring_admin_entry(fingerprint).map_err(|e| {
anyhow::anyhow!("keyring unavailable: {e}. admin token requires OS keychain.")
})?;
match entry.get_password() {
Ok(token) => {
validate_admin_token_for_fingerprint(&token, fingerprint)?;
Ok(token)
}
Err(_) => Err(anyhow::anyhow!(
"admin token not found for server {fingerprint}. Run `kagi remote login --remote <url> --token <token>` or set KAGI_ADMIN_TOKEN."
)),
}
}
#[cfg(feature = "server")]
fn validate_admin_token_for_fingerprint(token: &str, fingerprint: &str) -> anyhow::Result<()> {
let parsed = kagi_sync::domain::project_token::ProjectToken::parse(token)
.ok_or_else(|| anyhow::anyhow!("invalid admin token"))?;
if !token.starts_with("kagi_admin_v1_")
|| parsed.payload.project_id != "admin"
|| !parsed
.payload
.capabilities
.iter()
.any(|capability| capability == "admin")
{
return Err(anyhow::anyhow!("invalid admin token"));
}
if parsed.payload.server_fingerprint != fingerprint {
return Err(anyhow::anyhow!(
"admin token belongs to server {}, but remote fingerprint is {}",
parsed.payload.server_fingerprint,
fingerprint
));
}
Ok(())
}
#[cfg(feature = "server")]
async fn remote_login(
remote_url: &str,
token: &str,
c: &Palette,
allow_insecure: bool,
) -> anyhow::Result<()> {
let remote_client = kagi_sync::infrastructure::remote_client::RemoteClient::new(
remote_url.to_string(),
allow_insecure,
)
.await
.map_err(|e| anyhow::anyhow!("failed to connect to remote: {e}"))?;
let fingerprint = remote_client.fingerprint();
validate_admin_token_for_fingerprint(token, fingerprint)?;
let local_data_dir = local_data_dir()?;
let remote_store =
kagi_sync::infrastructure::remote_local::RemoteLocalStore::new(local_data_dir);
if admin_keyring_disabled() {
remote_store
.save_admin_token(fingerprint, token)
.map_err(|e| anyhow::anyhow!("failed to save admin token: {e}"))?;
} else {
let entry = kagi_store::key_manager::keyring_admin_entry(fingerprint).map_err(|e| {
anyhow::anyhow!("keyring unavailable: {e}. admin token requires OS keychain.")
})?;
entry
.set_password(token)
.map_err(|e| anyhow::anyhow!("failed to save admin token to keyring: {e}"))?;
}
remote_store
.save_admin_remote(fingerprint, remote_url)
.map_err(|e| anyhow::anyhow!("failed to save admin remote config: {e}"))?;
println!(
"{} admin token saved for server {} ({})",
c.success("ok"),
c.accent(fingerprint),
c.muted(remote_url)
);
Ok(())
}
#[cfg(feature = "server")]
fn admin_keyring_disabled() -> bool {
std::env::var_os("KAGI_DISABLE_KEYRING").is_some() || std::env::var_os("KAGI_HOME").is_some()
}
#[cfg(feature = "server")]
async fn remote_audit(
remote_url: &str,
project_id: Option<&str>,
limit: i64,
c: &Palette,
allow_insecure: bool,
) -> anyhow::Result<()> {
let events = load_audit_events(remote_url, project_id, limit, allow_insecure).await?;
if events.is_empty() {
println!("{} {}", c.prefix(), c.muted("no audit events found."));
} else {
println!(
"{} {}",
c.prefix(),
c.accent(&format!("{} event(s)", events.len()))
);
for e in events {
let ts = e["created_at"].as_str().unwrap_or("?");
let event_type = e["event_type"].as_str().unwrap_or("?");
let pid = e["project_id"].as_str().unwrap_or("-");
let actor = e["actor_token_id"].as_str().unwrap_or("-");
let meta = e["metadata_json"].as_str().unwrap_or("");
println!(
" {} {} {} {} {}",
c.muted(ts),
c.accent(event_type),
c.info(pid),
c.muted(&format!("({actor})")),
c.muted(meta)
);
}
}
Ok(())
}
#[cfg(feature = "server")]
async fn load_audit_events(
remote_url: &str,
project_id: Option<&str>,
limit: i64,
allow_insecure: bool,
) -> anyhow::Result<Vec<serde_json::Value>> {
let remote_client = kagi_sync::infrastructure::remote_client::RemoteClient::new(
remote_url.to_string(),
allow_insecure,
)
.await?;
let token = resolve_admin_token(remote_client.fingerprint())?;
let key_manager = KeyManager::new(local_data_dir()?.join("identities"));
let identity = key_manager.load_or_create_identity()?;
let data = remote_client
.send_audit_query(&token, project_id, limit, &identity)
.await?;
let events = data
.get("events")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("invalid response: missing events"))?;
Ok(events.clone())
}
#[cfg(feature = "server")]
async fn resolve_admin_remote(remote: Option<String>) -> anyhow::Result<String> {
if let Some(url) = remote {
return Ok(url);
}
let local_data_dir = local_data_dir()?;
let admins_dir = local_data_dir.join("admins");
let remote_store =
kagi_sync::infrastructure::remote_local::RemoteLocalStore::new(local_data_dir);
if !admins_dir.exists() {
return Err(anyhow::anyhow!(
"no saved admin remote found. Run `kagi remote login --remote <url> --token <token>` first, or pass --remote."
));
}
let mut found = None;
for entry in fs::read_dir(&admins_dir)? {
let entry = entry?;
let fingerprint = entry.file_name().to_string_lossy().to_string();
if let Some(remote) = remote_store.load_admin_remote(&fingerprint)? {
found = Some(remote);
break;
}
}
found.ok_or_else(|| {
anyhow::anyhow!(
"no saved admin remote found. Run `kagi remote login --remote <url> --token <token>` first, or pass --remote."
)
})
}
#[cfg(feature = "server")]
async fn project_join_remote(
cwd: PathBuf,
remote_url: &str,
c: &Palette,
allow_insecure: bool,
) -> anyhow::Result<()> {
let local = cwd.join(".kagi");
if !local.is_dir() {
return Err(anyhow::anyhow!(
"no .kagi/ directory found. Run `kagi init` first."
));
}
let config_path = local.join(kagi_domain::config::KAGI_CONFIG_FILE);
let mut config: serde_json::Value = serde_json::from_str(&fs::read_to_string(&config_path)?)?;
let existing_project_id = config["project_id"]
.as_str()
.map(std::string::ToString::to_string);
let key_manager = KeyManager::new(local.clone());
let identity = key_manager.load_or_create_identity()?;
let recipient = identity.to_public();
let name = default_member_name();
let member_id = key_manager.member_id()?;
let remote_client = kagi_sync::infrastructure::remote_client::RemoteClient::new(
remote_url.to_string(),
allow_insecure,
)
.await?;
let claim_secret = format!(r"kgs_{}", nanoid::nanoid!(24));
let claim_secret_hash = {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(claim_secret.as_bytes());
format!("cs:{}", base64_encode_url(&hasher.finalize()))
};
let request_id = format!(r"kgr_{}", nanoid::nanoid!(12));
let plaintext = kagi_sync::domain::envelope::RequestPlaintext {
version: 1,
request_id: request_id.clone(),
issued_at: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
operation: "create_project_request".into(),
method: "POST".into(),
path: "/v1/projects/requests".into(),
project_id: existing_project_id.clone(),
token: None,
claim_secret: None,
payload: serde_json::json!({
"requester_member_id": member_id,
"requester_name": name,
"requester_recipient": recipient.to_string(),
"claim_secret_hash": claim_secret_hash,
}),
};
let data = remote_client.send_request(&plaintext, &identity).await?;
let project_id = data["project_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing project_id in response"))?;
config["settings"]["sync"]["mode"] = serde_json::Value::String("server".to_string());
config["settings"]["sync"]["remote"] = serde_json::Value::String(remote_url.to_string());
if existing_project_id.is_none() {
config["project_id"] = serde_json::Value::String(project_id.to_string());
}
fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
let local_data_dir = local_data_dir()?;
let remote_store =
kagi_sync::infrastructure::remote_local::RemoteLocalStore::new(local_data_dir);
remote_store.save_remote_metadata(&kagi_sync::domain::remote_config::RemoteMetadata {
version: 1,
project_id: project_id.to_string(),
remote: remote_url.to_string(),
server_key_id: remote_client.server_key_id().to_string(),
server_fingerprint: remote_client.fingerprint().to_string(),
local_revision: Some(0),
last_pulled_at: None,
last_pushed_at: None,
last_manifest_hash: None,
pending_token_ids: None,
pending_accepted_member_ids: None,
})?;
remote_store.save_claim_secret(project_id, &claim_secret)?;
println!(
"{} {} {}. {}",
c.prefix(),
c.success("Requested project"),
c.accent(project_id),
c.muted("Waiting for admin approval.")
);
Ok(())
}
#[cfg(feature = "server")]
async fn load_project_list(
remote_url: &str,
allow_insecure: bool,
) -> anyhow::Result<(Vec<serde_json::Value>, Vec<serde_json::Value>)> {
let remote_client = kagi_sync::infrastructure::remote_client::RemoteClient::new(
remote_url.to_string(),
allow_insecure,
)
.await?;
let token = resolve_admin_token(remote_client.fingerprint())?;
let identity = {
let key_manager = KeyManager::new(std::env::current_dir()?.join(".kagi"));
key_manager.load_or_create_identity()?
};
let request_id = format!(r"kgr_{}", nanoid::nanoid!(12));
let requests_plaintext = kagi_sync::domain::envelope::RequestPlaintext {
version: 1,
request_id: request_id.clone(),
issued_at: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
operation: "list_project_requests".into(),
method: "POST".into(),
path: "/v1/projects/requests/list".into(),
project_id: Some("admin".into()),
token: Some(token.clone()),
claim_secret: None,
payload: serde_json::json!({}),
};
let requests_data = remote_client
.send_request(&requests_plaintext, &identity)
.await?;
let requests = requests_data["requests"]
.as_array()
.cloned()
.unwrap_or_default();
let request_id = format!(r"kgr_{}", nanoid::nanoid!(12));
let projects_plaintext = kagi_sync::domain::envelope::RequestPlaintext {
version: 1,
request_id: request_id.clone(),
issued_at: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
operation: "list_projects".into(),
method: "POST".into(),
path: "/v1/projects/list".into(),
project_id: Some("admin".into()),
token: Some(token),
claim_secret: None,
payload: serde_json::json!({}),
};
let projects_data = remote_client
.send_request(&projects_plaintext, &identity)
.await?;
let projects = projects_data["projects"]
.as_array()
.cloned()
.unwrap_or_default();
Ok((requests, projects))
}
#[cfg(feature = "server")]
async fn project_approve_remote(
remote_url: &str,
project_id: &str,
c: &Palette,
allow_insecure: bool,
) -> anyhow::Result<()> {
let remote_client = kagi_sync::infrastructure::remote_client::RemoteClient::new(
remote_url.to_string(),
allow_insecure,
)
.await?;
let token = resolve_admin_token(remote_client.fingerprint())?;
let request_id = format!(r"kgr_{}", nanoid::nanoid!(12));
let plaintext = kagi_sync::domain::envelope::RequestPlaintext {
version: 1,
request_id: request_id.clone(),
issued_at: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
operation: "approve_project_request".into(),
method: "POST".into(),
path: format!("/v1/projects/requests/{project_id}/approve"),
project_id: Some("admin".into()),
token: Some(token),
claim_secret: None,
payload: serde_json::json!({ "remote": remote_url }),
};
let identity = {
let key_manager = KeyManager::new(std::env::current_dir()?.join(".kagi"));
key_manager.load_or_create_identity()?
};
let data = remote_client.send_request(&plaintext, &identity).await?;
let approved_project_id = data["project_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing project_id in response"))?;
println!(
"{} {} {}",
c.prefix(),
c.success("Approved project"),
c.accent(approved_project_id),
);
Ok(())
}
#[cfg(feature = "server")]
async fn project_del_remote(
remote_url: &str,
project_id: &str,
c: &Palette,
allow_insecure: bool,
) -> anyhow::Result<()> {
let remote_client = kagi_sync::infrastructure::remote_client::RemoteClient::new(
remote_url.to_string(),
allow_insecure,
)
.await?;
let token = resolve_admin_token(remote_client.fingerprint())?;
let request_id = format!(r"kgr_{}", nanoid::nanoid!(12));
let plaintext = kagi_sync::domain::envelope::RequestPlaintext {
version: 1,
request_id: request_id.clone(),
issued_at: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
operation: "delete_project".into(),
method: "POST".into(),
path: format!("/v1/projects/{project_id}/delete"),
project_id: Some(project_id.into()),
token: Some(token),
claim_secret: None,
payload: serde_json::json!({}),
};
let identity = {
let key_manager = KeyManager::new(std::env::current_dir()?.join(".kagi"));
key_manager.load_or_create_identity()?
};
remote_client.send_request(&plaintext, &identity).await?;
println!(
"{} {} {}",
c.prefix(),
c.success("Deleted project"),
c.accent(project_id)
);
Ok(())
}
fn run_backup(out: &str, c: &Palette) -> anyhow::Result<()> {
let out_dir = Path::new(out);
if out_dir.exists() {
return Err(anyhow::anyhow!(
"output directory '{out}' already exists. Choose a different path."
));
}
let (base_path, _) = resolve_kagi_base()?;
let local_data_dir = local_data_dir()?;
let key_manager = KeyManager::new(base_path.clone());
let project_id = key_manager.project_id()?;
let kagi_backup = out_dir.join("kagi");
let home_backup = out_dir.join("home");
let canonical_out = canonicalize_existing_path_prefix(out_dir)?;
let canonical_base = base_path
.canonicalize()
.unwrap_or_else(|_| base_path.to_path_buf());
let canonical_home = local_data_dir
.canonicalize()
.unwrap_or_else(|_| local_data_dir.clone());
if canonical_out.starts_with(&canonical_base) || canonical_out.starts_with(&canonical_home) {
return Err(anyhow::anyhow!(
"output directory '{out}' is inside the source tree. Choose a different path."
));
}
copy_dir_all(&base_path, &kagi_backup)?;
let mut copied_home_files = Vec::new();
if local_data_dir.exists() {
fs::create_dir_all(&home_backup)?;
for rel_path in ["identities", "admins"] {
let src = local_data_dir.join(rel_path);
if src.exists() {
let dst = home_backup.join(rel_path);
copy_dir_all(&src, &dst)?;
copied_home_files.push(rel_path.to_string());
}
}
let projects_src = local_data_dir.join("projects");
if projects_src.exists() {
let project_src = projects_src.join(&project_id);
if project_src.exists() {
let dst = home_backup.join(format!("projects/{project_id}"));
fs::create_dir_all(dst.parent().unwrap())?;
copy_dir_all(&project_src, &dst)?;
copied_home_files.push(format!("projects/{project_id}"));
}
}
}
let mut keyring_exported = false;
if let Ok(Some(key)) = key_manager.load_keyring_project_key(&project_id) {
let key_path = out_dir.join("keyring_project_key.hex");
fs::write(&key_path, hex::encode(key.as_slice()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?;
}
keyring_exported = true;
}
let mut manifest = serde_json::Map::new();
manifest.insert("version".to_string(), serde_json::json!(1));
manifest.insert("project_id".to_string(), serde_json::json!(project_id));
let now = std::time::SystemTime::now();
let created_at = format!(
"{}",
now.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
);
manifest.insert("created_at".to_string(), serde_json::json!(created_at));
manifest.insert(
"keyring_exported".to_string(),
serde_json::json!(keyring_exported),
);
manifest.insert(
"home_files".to_string(),
serde_json::json!(copied_home_files),
);
let manifest_path = out_dir.join("manifest.json");
fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)?;
let mut checksums = serde_json::Map::new();
collect_checksums(out_dir, out_dir, &mut checksums)?;
let checksum_path = out_dir.join("checksums.json");
fs::write(&checksum_path, serde_json::to_string_pretty(&checksums)?)?;
println!(
"{} {} {}",
c.prefix(),
c.success("backup created"),
c.accent(
&out_dir
.canonicalize()
.unwrap_or_else(|_| out_dir.to_path_buf())
.display()
.to_string()
)
);
if keyring_exported {
println!(
"{} {} {}",
c.prefix(),
c.warning("note:"),
c.info("project key was exported from OS keychain to 'keyring_project_key.hex'. Store this backup securely.")
);
}
println!(
"{} {} {}",
c.prefix(),
c.muted("manifest:"),
c.muted(&manifest_path.display().to_string())
);
Ok(())
}
fn run_restore(from: &str, force: bool, c: &Palette) -> anyhow::Result<()> {
let from_dir = Path::new(from);
if !from_dir.is_dir() {
return Err(anyhow::anyhow!("'{from}' is not a directory"));
}
let manifest_path = from_dir.join("manifest.json");
let checksum_path = from_dir.join("checksums.json");
if !manifest_path.exists() {
return Err(anyhow::anyhow!("missing manifest.json in backup directory"));
}
if !checksum_path.exists() {
return Err(anyhow::anyhow!(
"missing checksums.json in backup directory"
));
}
let manifest: serde_json::Value = serde_json::from_str(&fs::read_to_string(&manifest_path)?)?;
let expected_checksums: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&checksum_path)?)?;
let version = manifest["version"].as_u64().unwrap_or(0);
if version != 1 {
return Err(anyhow::anyhow!("unsupported backup version: {version}"));
}
let mut actual_checksums = serde_json::Map::new();
collect_checksums(from_dir, from_dir, &mut actual_checksums)?;
let expected = expected_checksums
.as_object()
.ok_or_else(|| anyhow::anyhow!("invalid checksums.json"))?;
for (path, expected_hash) in expected {
let expected_hash = expected_hash.as_str().unwrap_or("");
let actual_hash = actual_checksums
.get(path)
.and_then(|v| v.as_str())
.unwrap_or("");
if actual_hash != expected_hash {
return Err(anyhow::anyhow!(
"checksum mismatch for {path}: expected {expected_hash}, got {actual_hash}"
));
}
}
let project_id = manifest["project_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing project_id in manifest"))?;
let keyring_exported = manifest["keyring_exported"].as_bool().unwrap_or(false);
let kagi_backup = from_dir.join("kagi");
let cwd = std::env::current_dir()?;
let target_kagi = cwd.join(".kagi");
if target_kagi.exists() && !force {
return Err(anyhow::anyhow!(
".kagi/ already exists in current directory. Use --force to overwrite."
));
}
if kagi_backup.exists() {
if target_kagi.exists() {
fs::remove_dir_all(&target_kagi)?;
}
copy_dir_all(&kagi_backup, &target_kagi)?;
}
let home_backup = from_dir.join("home");
let local_data_dir = local_data_dir()?;
if home_backup.exists()
&& let Some(home_files) = manifest["home_files"].as_array()
{
for rel_path in home_files {
if let Some(rel_path) = rel_path.as_str() {
if rel_path.starts_with('/') || rel_path.contains("..") {
return Err(anyhow::anyhow!(
"invalid path in backup manifest: '{rel_path}'. Refusing to restore."
));
}
let src = home_backup.join(rel_path);
let dst = local_data_dir.join(rel_path);
if src.exists() {
if dst.exists() && !force {
return Err(anyhow::anyhow!(
"{} already exists. Use --force to overwrite.",
dst.display()
));
}
if dst.exists() {
fs::remove_dir_all(&dst)?;
}
copy_dir_all(&src, &dst)?;
}
}
}
}
if keyring_exported {
let key_path = from_dir.join("keyring_project_key.hex");
if key_path.exists() {
let key_hex = fs::read_to_string(&key_path)?;
let key_manager = KeyManager::new(target_kagi.clone());
let key = decode_hex(key_hex.trim())?;
key_manager.save_keyring_project_key(project_id, &key)?;
#[cfg(unix)]
{
let mut file = fs::OpenOptions::new().write(true).open(&key_path)?;
let len = file.metadata()?.len();
file.write_all(&vec![0u8; len as usize])?;
}
let _ = fs::remove_file(&key_path);
println!(
"{} {} {}",
c.prefix(),
c.success("restored project key to OS keychain"),
c.muted("(exported key file wiped)")
);
}
}
println!(
"{} {} {}",
c.prefix(),
c.success("restored from backup"),
c.accent(from)
);
println!(
"{} {} {}",
c.prefix(),
c.info("run"),
c.accent("kagi doctor")
);
Ok(())
}
fn canonicalize_existing_path_prefix(path: &Path) -> anyhow::Result<PathBuf> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
};
if let Ok(canonical) = absolute.canonicalize() {
return Ok(canonical);
}
let mut current = absolute.as_path();
let mut missing = Vec::new();
while !current.exists() {
let Some(name) = current.file_name() else {
break;
};
missing.push(name.to_os_string());
current = current.parent().ok_or_else(|| {
anyhow::anyhow!(
"failed to inspect output directory '{}'",
absolute.display()
)
})?;
}
let mut canonical = current
.canonicalize()
.unwrap_or_else(|_| current.to_path_buf());
for component in missing.iter().rev() {
canonical.push(component);
}
Ok(canonical)
}
fn copy_dir_all(src: &Path, dst: &Path) -> anyhow::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let path = entry.path();
let dest = dst.join(entry.file_name());
if path.is_dir() {
copy_dir_all(&path, &dest)?;
} else {
fs::copy(&path, &dest)?;
}
}
Ok(())
}
fn collect_checksums(
base: &Path,
dir: &Path,
out: &mut serde_json::Map<String, serde_json::Value>,
) -> anyhow::Result<()> {
use sha2::{Digest, Sha256};
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_checksums(base, &path, out)?;
} else {
let rel = path.strip_prefix(base).unwrap_or(&path);
let rel_str = rel.to_string_lossy().to_string();
let content = fs::read(&path)?;
let hash = Sha256::digest(&content);
let hash_hex = hex::encode(hash);
out.insert(rel_str, serde_json::json!(hash_hex));
}
}
Ok(())
}
fn decode_hex(s: &str) -> anyhow::Result<zeroize::Zeroizing<Vec<u8>>> {
let bytes = hex::decode(s.trim())?;
Ok(zeroize::Zeroizing::new(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
use kagi_domain::config::KAGI_CONFIG_FILE;
use kagi_domain::entity::secret::Secret;
use kagi_domain::entity::service::Service;
use kagi_domain::repository::secret_repo::SecretRepository;
use tempfile::TempDir;
#[cfg(feature = "server")]
fn fixed_signing_key() -> ed25519_dalek::SigningKey {
ed25519_dalek::SigningKey::from_bytes(&[7u8; 32])
}
#[cfg(feature = "server")]
fn public_key_b64(signing_key: &ed25519_dalek::SigningKey) -> String {
use base64::{Engine as _, engine::general_purpose::STANDARD};
STANDARD.encode(signing_key.verifying_key().to_bytes())
}
#[cfg(feature = "server")]
fn sha256_hex(value: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
#[cfg(feature = "server")]
fn signed_manifest_fixture(
revision: i64,
previous_manifest_hash: Option<String>,
access_json: &str,
files: &[(&str, &str)],
) -> (serde_json::Value, serde_json::Value, String, String) {
use base64::{Engine as _, engine::general_purpose::STANDARD};
use ed25519_dalek::Signer as _;
let signing_key = fixed_signing_key();
let signer_public_key = public_key_b64(&signing_key);
let file_hashes = files
.iter()
.map(|(path, content)| kagi_sync::domain::manifest::FileHash {
path: (*path).to_string(),
sha256: sha256_hex(content),
})
.collect();
let manifest = kagi_sync::domain::manifest::ProjectStateManifest {
version: 1,
project_id: "kgp_test".into(),
revision,
previous_manifest_hash,
kagi_json_hash: kagi_sync::domain::manifest::hash_json("{}"),
access_json_hash: kagi_sync::domain::manifest::hash_json(access_json),
file_hashes,
timestamp: "2026-01-01T00:00:00Z".into(),
signer_member_id: "kgm_test".into(),
signer_public_key: signer_public_key.clone(),
};
let manifest_json = serde_json::to_string(&manifest).unwrap();
let manifest_hash = manifest.compute_hash();
let signature = signing_key.sign(manifest_hash.as_bytes());
let signature_b64 = STANDARD.encode(signature.to_bytes());
let state_files: Vec<serde_json::Value> = files
.iter()
.map(|(path, content)| serde_json::json!({"path": path, "content": content}))
.collect();
let state = serde_json::json!({
"project_id": "kgp_test",
"revision": revision,
"kagi_json": "{}",
"access_json": access_json,
"files": state_files,
});
let data = serde_json::json!({
"revision": revision,
"manifest": manifest_json,
"manifest_hash": manifest_hash,
"manifest_signature": signature_b64,
});
(data, state, manifest_hash, signer_public_key)
}
#[test]
fn test_env_file_name_uses_bun_style_defaults() {
assert_eq!(
env_file_name("api/development").unwrap(),
".env.development"
);
assert_eq!(env_file_name("api/production").unwrap(), ".env.production");
assert_eq!(env_file_name("api/test").unwrap(), ".env.test");
assert_eq!(env_file_name("api/staging").unwrap(), ".env.staging");
}
#[test]
fn test_get_tui_only_opens_without_explicit_target() {
assert!(should_open_get_tui(false, true, &None, &None, &None, &None));
assert!(!should_open_get_tui(true, true, &None, &None, &None, &None));
assert!(!should_open_get_tui(
false, false, &None, &None, &None, &None
));
assert!(!should_open_get_tui(
false,
true,
&Some("api".into()),
&None,
&None,
&None
));
assert!(!should_open_get_tui(
false,
true,
&None,
&Some("api".into()),
&None,
&None
));
}
#[test]
#[cfg(feature = "server")]
fn test_validate_admin_token_matches_server_fingerprint() {
let token =
kagi_sync::domain::project_token::ProjectToken::generate_admin_token("kgs_ok".into());
validate_admin_token_for_fingerprint(&token.full_token, "kgs_ok").unwrap();
}
#[test]
#[cfg(feature = "server")]
fn test_validate_admin_token_rejects_wrong_server_fingerprint() {
let token =
kagi_sync::domain::project_token::ProjectToken::generate_admin_token("kgs_ok".into());
let err = validate_admin_token_for_fingerprint(&token.full_token, "kgs_other").unwrap_err();
assert!(err.to_string().contains("admin token belongs to server"));
}
#[test]
#[cfg(feature = "server")]
fn test_validate_admin_token_rejects_project_token() {
let token = kagi_sync::domain::project_token::ProjectToken::generate(
"http://localhost:13816".into(),
"kgp_test".into(),
"kgs_ok".into(),
vec!["pull".into()],
None,
);
let err = validate_admin_token_for_fingerprint(&token.full_token, "kgs_ok").unwrap_err();
assert!(err.to_string().contains("invalid admin token"));
}
#[test]
#[cfg(feature = "server")]
fn test_verify_pulled_manifest_allows_empty_genesis_state() {
let data = serde_json::json!({"revision": 0});
let state = serde_json::json!({
"project_id": "kgp_test",
"revision": 0,
"kagi_json": "{}",
"access_json": "{}",
"files": [],
});
let hash = verify_pulled_manifest(&data, &state, "kgp_test", 0, None, "{}", None).unwrap();
assert_eq!(hash, "");
}
#[test]
#[cfg(feature = "server")]
fn test_verify_pulled_manifest_rejects_missing_manifest_after_genesis() {
let data = serde_json::json!({"revision": 1});
let state = serde_json::json!({
"project_id": "kgp_test",
"revision": 1,
"kagi_json": "{}",
"access_json": "{}",
"files": [],
});
let err =
verify_pulled_manifest(&data, &state, "kgp_test", 0, None, "{}", None).unwrap_err();
assert!(err.to_string().contains("server response missing manifest"));
}
#[test]
#[cfg(feature = "server")]
fn test_verify_pulled_manifest_rejects_duplicate_state_paths() {
let (data, mut state, _hash, signer_public_key) =
signed_manifest_fixture(1, None, "{}", &[("services/api/development.env", "one")]);
state["files"] = serde_json::json!([
{"path": "services/api/development.env", "content": "one"},
{"path": "services/api/development.env", "content": "two"},
]);
let err = verify_pulled_manifest(
&data,
&state,
"kgp_test",
0,
None,
"{}",
Some(&signer_public_key),
)
.unwrap_err();
assert!(err.to_string().contains("duplicate file path"));
}
#[test]
#[cfg(feature = "server")]
fn test_verify_pulled_manifest_requires_trusted_signer() {
let (data, state, _hash, _signer_public_key) = signed_manifest_fixture(1, None, "{}", &[]);
let err =
verify_pulled_manifest(&data, &state, "kgp_test", 0, None, "{}", None).unwrap_err();
assert!(err.to_string().contains("unknown member"));
}
#[test]
#[cfg(feature = "server")]
fn test_verify_pulled_manifest_accepts_bootstrap_signer() {
let (data, state, manifest_hash, signer_public_key) =
signed_manifest_fixture(1, None, "{}", &[]);
let verified_hash = verify_pulled_manifest(
&data,
&state,
"kgp_test",
0,
None,
"{}",
Some(&signer_public_key),
)
.unwrap();
assert_eq!(verified_hash, manifest_hash);
}
#[test]
#[cfg(feature = "server")]
fn test_verify_pulled_manifest_rejects_broken_hash_chain() {
let (data, state, _hash, signer_public_key) =
signed_manifest_fixture(2, Some("wrong_previous_hash".into()), "{}", &[]);
let err = verify_pulled_manifest(
&data,
&state,
"kgp_test",
1,
Some("known_previous_hash"),
"{}",
Some(&signer_public_key),
)
.unwrap_err();
assert!(err.to_string().contains("manifest chain mismatch"));
}
#[test]
#[cfg(feature = "server")]
fn test_apply_pulled_state_rejects_path_escape() {
let dir = TempDir::new().unwrap();
let base = dir.path().join(".kagi");
fs::create_dir(&base).unwrap();
let state = serde_json::json!({
"project_id": "kgp_test",
"revision": 1,
"kagi_json": "{}",
"access_json": "{}",
"files": [
{
"path": "../outside.enc",
"content": "{}"
}
]
});
let err = apply_pulled_state(&base, &state).unwrap_err();
assert!(err.to_string().contains("invalid remote file path"));
assert!(!dir.path().join("outside.enc").exists());
}
#[test]
#[cfg(feature = "server")]
fn test_apply_pulled_state_preserves_local_on_empty_remote() {
let dir = TempDir::new().unwrap();
let base = dir.path().join(".kagi");
fs::create_dir(&base).unwrap();
fs::write(base.join("kagi.json"), "{\"project_id\":\"kgp_test\"}").unwrap();
fs::write(base.join("access.json"), "{\"members\":[]}").unwrap();
let state = serde_json::json!({
"project_id": "kgp_test",
"revision": 0,
"kagi_json": "{}",
"access_json": "{}",
"files": []
});
apply_pulled_state(&base, &state).unwrap();
assert_eq!(
fs::read_to_string(base.join("kagi.json")).unwrap(),
"{\"project_id\":\"kgp_test\"}"
);
assert_eq!(
fs::read_to_string(base.join("access.json")).unwrap(),
"{\"members\":[]}"
);
}
#[test]
#[cfg(feature = "server")]
fn test_apply_pulled_state_fails_when_local_missing() {
let dir = TempDir::new().unwrap();
let base = dir.path().join(".kagi");
fs::create_dir(&base).unwrap();
let state = serde_json::json!({
"project_id": "kgp_test",
"revision": 0,
"kagi_json": "{}",
"access_json": "{}",
"files": []
});
let err = apply_pulled_state(&base, &state).unwrap_err();
assert!(
err.to_string().contains("remote project is empty"),
"expected error about empty remote, got: {err}",
);
}
#[test]
#[cfg(feature = "server")]
fn test_apply_pulled_state_removes_local_secret_files_absent_from_remote() {
let dir = TempDir::new().unwrap();
let base = dir.path().join(".kagi");
fs::create_dir_all(base.join("secrets/api")).unwrap();
fs::write(base.join("secrets/api/development.enc"), "stale").unwrap();
fs::write(base.join("secrets/api/production.enc"), "old").unwrap();
fs::write(base.join("secrets/readme.txt"), "local note").unwrap();
let state = serde_json::json!({
"project_id": "kgp_test",
"revision": 2,
"kagi_json": "{\"project_id\":\"kgp_test\"}",
"access_json": "{\"members\":[]}",
"files": [
{
"path": "secrets/api/production.enc",
"content": "remote"
}
]
});
apply_pulled_state(&base, &state).unwrap();
assert!(!base.join("secrets/api/development.enc").exists());
assert_eq!(
fs::read_to_string(base.join("secrets/api/production.enc")).unwrap(),
"remote"
);
assert_eq!(
fs::read_to_string(base.join("secrets/readme.txt")).unwrap(),
"local note"
);
}
#[test]
#[cfg(feature = "server")]
fn test_collect_project_state_files_for_push_includes_file_artifacts() {
let dir = TempDir::new().unwrap();
let base = dir.path().join(".kagi");
fs::create_dir_all(base.join("files")).unwrap();
fs::write(
base.join("kagi.json"),
serde_json::json!({
"version": "3",
"project_id": "kgp_test",
"services": {},
"settings": {
"envs": ["development"],
"default_env": "development"
}
})
.to_string(),
)
.unwrap();
fs::write(base.join("files/index.enc"), "index").unwrap();
fs::write(base.join("files/kgf_test.enc"), "blob").unwrap();
let key = [7_u8; 32];
let store = store_from_project_key(base.clone(), &key).unwrap();
let files = collect_project_state_files_for_push(&base, &store).unwrap();
assert!(
files
.iter()
.any(|file| file.path == "files/index.enc" && file.content == "index")
);
assert!(
files
.iter()
.any(|file| file.path == "files/kgf_test.enc" && file.content == "blob")
);
}
#[test]
#[cfg(feature = "server")]
fn test_apply_pulled_state_removes_local_file_artifacts_absent_from_remote() {
let dir = TempDir::new().unwrap();
let base = dir.path().join(".kagi");
fs::create_dir_all(base.join("files")).unwrap();
fs::write(base.join("files/index.enc"), "stale-index").unwrap();
fs::write(base.join("files/kgf_keep.enc"), "old").unwrap();
fs::write(base.join("files/kgf_stale.enc"), "stale").unwrap();
fs::write(base.join("files/readme.txt"), "local note").unwrap();
let state = serde_json::json!({
"project_id": "kgp_test",
"revision": 2,
"kagi_json": "{\"project_id\":\"kgp_test\"}",
"access_json": "{\"members\":[]}",
"files": [
{
"path": "files/index.enc",
"content": "remote-index"
},
{
"path": "files/kgf_keep.enc",
"content": "remote-blob"
}
]
});
apply_pulled_state(&base, &state).unwrap();
assert_eq!(
fs::read_to_string(base.join("files/index.enc")).unwrap(),
"remote-index"
);
assert_eq!(
fs::read_to_string(base.join("files/kgf_keep.enc")).unwrap(),
"remote-blob"
);
assert!(!base.join("files/kgf_stale.enc").exists());
assert_eq!(
fs::read_to_string(base.join("files/readme.txt")).unwrap(),
"local note"
);
}
#[test]
fn test_rotation_journal_is_local_and_recoverable() {
let dir = TempDir::new().unwrap();
let local = TempDir::new().unwrap();
let base = dir.path().join(".kagi");
fs::create_dir(&base).unwrap();
let config = KagiConfig {
version: "2".into(),
project_id: "kgp_test".into(),
services: Default::default(),
settings: Default::default(),
};
fs::write(
base.join(KAGI_CONFIG_FILE),
serde_json::to_string(&config).unwrap(),
)
.unwrap();
let key_manager =
KeyManager::new_with_local_data_dir(base.clone(), local.path().to_path_buf());
key_manager
.initialize_project("kgp_test", "kgm_test")
.unwrap();
let old_key = key_manager.load().unwrap();
let old_store = store_from_project_key(base.clone(), &old_key).unwrap();
let mut service = Service::new("api/development");
service.set_secret(Secret::new("MESSAGE", "hello"));
old_store.save(&service).unwrap();
let new_key = KeyManager::generate_project_key();
let new_store = store_from_project_key(base.clone(), &new_key).unwrap();
let (file, content) = new_store.encrypted_service_content(&service).unwrap();
let journal = RotationJournal {
version: ROTATION_JOURNAL_VERSION,
project_id: key_manager.project_id().unwrap(),
access_json: key_manager.rotated_access_json(&new_key, None).unwrap(),
files: BTreeMap::from([(file, content)]),
};
let journal_path = key_manager.rotation_journal_path().unwrap();
write_rotation_journal(&journal_path, &journal).unwrap();
assert!(journal_path.exists());
assert!(!journal_path.starts_with(&base));
assert!(!base.join("rotation.json").exists());
recover_pending_rotation_with_key_manager(&base, &key_manager).unwrap();
assert!(!journal_path.exists());
let recovered_store = store_from_project_key(base, &new_key).unwrap();
let recovered = recovered_store.load("api/development").unwrap();
assert_eq!(recovered.get_secret("MESSAGE").unwrap().value, "hello");
}
}