mod audit; mod commands; mod config; mod crypto; mod db; mod models; mod output; use anyhow::Result; /// Load .env from current or parent directories (best-effort, no error if missing). fn load_dotenv() { let _ = dotenvy::dotenv(); } use clap::{Parser, Subcommand}; use tracing_subscriber::EnvFilter; use output::resolve_output_mode; #[derive(Parser)] #[command( name = "secrets", version, about = "Secrets & config manager backed by PostgreSQL — optimised for AI agents", after_help = "QUICK START: # 1. Configure database (once per device) secrets config set-db \"postgres://postgres:@:/secrets\" # 2. Initialize master key (once per device) secrets init # Discover what namespaces / kinds exist secrets search --summary --limit 20 # Precise lookup (JSON output for easy parsing) secrets search -n refining --kind service --name gitea -o json # Extract a single metadata field directly secrets search -n refining --kind service --name gitea -f metadata.url # Pipe-friendly (non-TTY defaults to json-compact automatically) secrets search -n refining --kind service | jq '.[].name' # Run a command with secrets injected into its child process environment secrets run -n refining --kind service --name gitea -- printenv" )] struct Cli { /// Database URL, overrides saved config (one-time override) #[arg(long, global = true, default_value = "")] db_url: String, /// Enable verbose debug output #[arg(long, short, global = true)] verbose: bool, #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { /// Initialize master key on this device (run once per device). /// /// Prompts for a master password, derives a key with Argon2id, and stores /// it in the OS Keychain. Use the same password on every device. /// /// NOTE: Run `secrets config set-db ` first if database is not configured. #[command(after_help = "PREREQUISITE: Database must be configured first. Run: secrets config set-db EXAMPLES: # First device: generates a new Argon2id salt and stores master key secrets init # Subsequent devices: reuses existing salt from the database secrets init")] Init, /// Add or update a record (upsert). Use -m for plaintext metadata, -s for secrets. #[command(after_help = "EXAMPLES: # Add a server secrets add -n refining --kind server --name my-server \\ --tag aliyun --tag shanghai \\ -m ip=10.0.0.1 -m desc=\"Example ECS\" \\ -s username=root -s ssh_key=@./keys/server.pem # Add a service credential secrets add -n refining --kind service --name gitea \\ --tag gitea \\ -m url=https://code.example.com -m default_org=myorg \\ -s token= # Add typed JSON metadata secrets add -n refining --kind service --name gitea \\ -m port:=3000 \\ -m enabled:=true \\ -m domains:='[\"code.example.com\",\"git.example.com\"]' \\ -m tls:='{\"enabled\":true,\"redirect_http\":true}' # Add with token read from a file secrets add -n ricnsmart --kind service --name mqtt \\ -m host=mqtt.example.com -m port=1883 \\ -s password=@./mqtt_password.txt # Add typed JSON secrets secrets add -n refining --kind service --name deploy-bot \\ -s enabled:=true \\ -s retry_count:=3 \\ -s scopes:='[\"repo\",\"workflow\"]' \\ -s extra:='{\"region\":\"ap-east-1\",\"verify_tls\":true}' # Write a multiline file into a nested secret field secrets add -n refining --kind server --name my-server \\ -s credentials:content@./keys/server.pem # Shared PEM (key_ref): store key once, reference from multiple servers secrets add -n refining --kind key --name my-shared-key \\ --tag aliyun -s content=@./keys/shared.pem secrets add -n refining --kind server --name i-abc123 \\ -m ip=10.0.0.1 -m key_ref=my-shared-key -s username=ecs-user")] Add { /// Namespace, e.g. refining, ricnsmart #[arg(short, long)] namespace: String, /// Kind of record: server, service, key, ... #[arg(long)] kind: String, /// Human-readable unique name, e.g. gitea, i-example0abcd1234efgh #[arg(long)] name: String, /// Tag for categorization (repeatable), e.g. --tag aliyun --tag hongkong #[arg(long = "tag")] tags: Vec, /// Plaintext metadata: key=value, key:=, key=@file, or nested:path@file. /// Use key_ref= to reference a shared key entry (kind=key); run merges its secrets. #[arg(long = "meta", short = 'm')] meta: Vec, /// Secret entry: key=value, key:=, key=@file, or nested:path@file #[arg(long = "secret", short = 's')] secrets: Vec, /// Output format: text (default on TTY), json, json-compact #[arg(short, long = "output")] output: Option, }, /// Search / read records. This is the primary read command for AI agents. /// /// Supports fuzzy search (-q), exact lookup (--name), field extraction (-f), /// summary view (--summary), pagination (--limit / --offset), and structured /// output (-o json / json-compact). When stdout is not a TTY, output /// defaults to json-compact automatically. #[command(after_help = "EXAMPLES: # Discover all records (summary, safe default limit) secrets search --summary --limit 20 # Filter by namespace and kind secrets search -n refining --kind service # Exact lookup — returns 0 or 1 record secrets search -n refining --kind service --name gitea # Fuzzy keyword search (matches name, namespace, kind, tags, metadata) secrets search -q mqtt # Extract a single metadata field value secrets search -n refining --kind service --name gitea -f metadata.url # Multiple fields at once secrets search -n refining --kind service --name gitea \\ -f metadata.url -f metadata.default_org # Run a command with decrypted secrets only when needed secrets run -n refining --kind service --name gitea -- printenv # Paginate large result sets secrets search -n refining --summary --limit 10 --offset 0 secrets search -n refining --summary --limit 10 --offset 10 # Sort by most recently updated secrets search --sort updated --limit 5 --summary # Non-TTY / pipe: output is json-compact by default secrets search -n refining --kind service | jq '.[].name'")] Search { /// Filter by namespace, e.g. refining, ricnsmart #[arg(short, long)] namespace: Option, /// Filter by kind, e.g. server, service #[arg(long)] kind: Option, /// Exact name filter, e.g. gitea, i-example0abcd1234efgh #[arg(long)] name: Option, /// Filter by tag, e.g. --tag aliyun (repeatable for AND intersection) #[arg(long)] tag: Vec, /// Fuzzy keyword (matches name, namespace, kind, tags, metadata text) #[arg(short, long)] query: Option, /// Extract metadata field value(s) directly: metadata. (repeatable) #[arg(short = 'f', long = "field")] fields: Vec, /// Return lightweight summary only (namespace, kind, name, tags, desc, updated_at) #[arg(long)] summary: bool, /// Maximum number of records to return [default: 50] #[arg(long, default_value = "50")] limit: u32, /// Skip this many records (for pagination) #[arg(long, default_value = "0")] offset: u32, /// Sort order: name (default), updated, created #[arg(long, default_value = "name")] sort: String, /// Output format: text (default on TTY), json, json-compact #[arg(short, long = "output")] output: Option, }, /// Delete one record precisely, or bulk-delete by namespace. /// /// With --name: deletes exactly that record (--kind also required). /// Without --name: bulk-deletes all records matching namespace + optional --kind. /// Use --dry-run to preview bulk deletes before committing. #[command(after_help = "EXAMPLES: # Delete a single record (exact match) secrets delete -n refining --kind service --name legacy-mqtt # Preview what a bulk delete would remove (no writes) secrets delete -n refining --dry-run # Bulk-delete all records in a namespace secrets delete -n ricnsmart # Bulk-delete only server records in a namespace secrets delete -n ricnsmart --kind server # JSON output secrets delete -n refining --kind service -o json")] Delete { /// Namespace, e.g. refining #[arg(short, long)] namespace: String, /// Kind filter, e.g. server, service (required with --name; optional for bulk) #[arg(long)] kind: Option, /// Exact name of the record to delete (omit for bulk delete) #[arg(long)] name: Option, /// Preview what would be deleted without making any changes (bulk mode only) #[arg(long)] dry_run: bool, /// Output format: text (default on TTY), json, json-compact #[arg(short, long = "output")] output: Option, }, /// Incrementally update an existing record (merge semantics; record must exist). /// /// Only the fields you pass are changed — everything else is preserved. /// Use --add-tag / --remove-tag to modify tags without touching other fields. #[command(after_help = "EXAMPLES: # Update a single metadata field (all other fields unchanged) secrets update -n refining --kind server --name my-server -m ip=10.0.0.1 # Rotate a secret token secrets update -n refining --kind service --name gitea -s token= # Update typed JSON metadata secrets update -n refining --kind service --name gitea \\ -m deploy:strategy:='{\"type\":\"rolling\",\"batch\":2}' \\ -m runtime:max_open_conns:=20 # Add a tag and rotate password at the same time secrets update -n refining --kind service --name gitea \\ --add-tag production -s token= # Remove a deprecated metadata field and a stale secret key secrets update -n refining --kind service --name mqtt \\ --remove-meta old_port --remove-secret old_password # Remove a nested field secrets update -n refining --kind server --name my-server \\ --remove-secret credentials:content # Remove a tag secrets update -n refining --kind service --name gitea --remove-tag staging # Update a nested secret field from a file secrets update -n refining --kind server --name my-server \\ -s credentials:content@./keys/server.pem # Update nested typed JSON fields secrets update -n refining --kind service --name deploy-bot \\ -s auth:config:='{\"issuer\":\"gitea\",\"rotate\":true}' \\ -s auth:retry:=5 # Rotate shared PEM (all servers with key_ref=my-shared-key get the new key) secrets update -n refining --kind key --name my-shared-key \\ -s content=@./keys/new-shared.pem")] Update { /// Namespace, e.g. refining, ricnsmart #[arg(short, long)] namespace: String, /// Kind of record: server, service, key, ... #[arg(long)] kind: String, /// Human-readable unique name #[arg(long)] name: String, /// Add a tag (repeatable; does not affect existing tags) #[arg(long = "add-tag")] add_tags: Vec, /// Remove a tag (repeatable) #[arg(long = "remove-tag")] remove_tags: Vec, /// Set or overwrite a metadata field: key=value, key:=, key=@file, or nested:path@file. /// Use key_ref= to reference a shared key entry (kind=key). #[arg(long = "meta", short = 'm')] meta: Vec, /// Delete a metadata field by key or nested path, e.g. old_port or credentials:content #[arg(long = "remove-meta")] remove_meta: Vec, /// Set or overwrite a secret field: key=value, key:=, key=@file, or nested:path@file #[arg(long = "secret", short = 's')] secrets: Vec, /// Delete a secret field by key or nested path, e.g. old_password or credentials:content #[arg(long = "remove-secret")] remove_secrets: Vec, /// Output format: text (default on TTY), json, json-compact #[arg(short, long = "output")] output: Option, }, /// Manage CLI configuration (database connection, etc.) #[command(after_help = "EXAMPLES: # Configure the database URL (run once per device; persisted to config file) secrets config set-db \"postgres://postgres:@:/secrets\" # Show current config (password is masked) secrets config show # Print path to the config file secrets config path")] Config { #[command(subcommand)] action: ConfigAction, }, /// Show the change history for a record. #[command(after_help = "EXAMPLES: # Show last 20 versions for a service record secrets history -n refining --kind service --name gitea # Show last 5 versions secrets history -n refining --kind service --name gitea --limit 5")] History { #[arg(short, long)] namespace: String, #[arg(long)] kind: String, #[arg(long)] name: String, /// Number of history entries to show [default: 20] #[arg(long, default_value = "20")] limit: u32, /// Output format: text (default on TTY), json, json-compact #[arg(short, long = "output")] output: Option, }, /// Roll back a record to a previous version. #[command(after_help = "EXAMPLES: # Roll back to the most recent snapshot (undo last change) secrets rollback -n refining --kind service --name gitea # Roll back to a specific version number secrets rollback -n refining --kind service --name gitea --to-version 3")] Rollback { #[arg(short, long)] namespace: String, #[arg(long)] kind: String, #[arg(long)] name: String, /// Target version to restore. Omit to restore the most recent snapshot. #[arg(long)] to_version: Option, /// Output format: text (default on TTY), json, json-compact #[arg(short, long = "output")] output: Option, }, /// Run a command with secrets injected as environment variables. /// /// Secrets are available only to the child process; the current shell /// environment is not modified. The process exit code is propagated. /// /// Use -s/--secret to inject only specific fields. Use --dry-run to preview /// which variables would be injected without executing the command. #[command(after_help = "EXAMPLES: # Run a script with a single service's secrets injected secrets run -n refining --kind service --name gitea -- ./deploy.sh # Inject only specific fields (minimal exposure) secrets run -n refining --kind service --name aliyun \\ -s access_key_id -s access_key_secret -- aliyun ecs DescribeInstances # Run with a tag filter (all matched records merged) secrets run --tag production -- env | grep GITEA # With prefix secrets run -n refining --kind service --name gitea --prefix GITEA -- printenv # Preview which variables would be injected (no command executed) secrets run -n refining --kind service --name gitea --dry-run # Preview with field filter and JSON output secrets run -n refining --kind service --name gitea -s token --dry-run -o json # metadata.key_ref entries get key secrets merged (e.g. server + shared PEM)")] Run { #[arg(short, long)] namespace: Option, #[arg(long)] kind: Option, #[arg(long)] name: Option, #[arg(long)] tag: Vec, /// Only inject these secret field names (repeatable). Omit to inject all fields. #[arg(long = "secret", short = 's')] secret_fields: Vec, /// Prefix to prepend to every variable name (uppercased automatically) #[arg(long, default_value = "")] prefix: String, /// Preview variables that would be injected without executing the command #[arg(long)] dry_run: bool, /// Output format for --dry-run: json (default), json-compact, text #[arg(short, long = "output")] output: Option, /// Command and arguments to execute with injected environment #[arg(last = true)] command: Vec, }, /// Check for a newer version and update the binary in-place. /// /// Downloads the latest release and replaces the current binary. No database connection or master key required. /// Release URL defaults to the upstream server; override via SECRETS_UPGRADE_URL for self-hosted or fork. #[command(after_help = "EXAMPLES: # Check for updates only (no download) secrets upgrade --check # Download and install the latest version secrets upgrade")] Upgrade { /// Only check if a newer version is available; do not download #[arg(long)] check: bool, }, /// Export records to a file (JSON, TOML, or YAML). /// /// Decrypts and exports all matched records. Requires master key unless --no-secrets is used. #[command(after_help = "EXAMPLES: # Export everything to JSON secrets export --file backup.json # Export a specific namespace to TOML secrets export -n refining --file refining.toml # Export a specific kind secrets export -n refining --kind service --file services.yaml # Export by tag secrets export --tag production --file prod.json # Export schema only (no decryption needed) secrets export --no-secrets --file schema.json # Print to stdout in YAML secrets export -n refining --format yaml")] Export { /// Filter by namespace #[arg(short, long)] namespace: Option, /// Filter by kind, e.g. server, service #[arg(long)] kind: Option, /// Exact name filter #[arg(long)] name: Option, /// Filter by tag (repeatable) #[arg(long)] tag: Vec, /// Fuzzy keyword search #[arg(short, long)] query: Option, /// Output file path (format inferred from extension: .json / .toml / .yaml / .yml) #[arg(long)] file: Option, /// Explicit format: json, toml, or yaml (overrides file extension; required for stdout) #[arg(long)] format: Option, /// Omit secrets from output (no master key required) #[arg(long)] no_secrets: bool, }, /// Import records from a file (JSON, TOML, or YAML). /// /// Reads an export file and inserts or updates entries. Requires master key to re-encrypt secrets. #[command(after_help = "EXAMPLES: # Import a JSON backup (conflict = error by default) secrets import backup.json # Import and overwrite existing records secrets import --force refining.toml # Preview what would be imported (no writes) secrets import --dry-run backup.yaml # JSON output for the import summary secrets import backup.json -o json")] Import { /// Input file path (format inferred from extension: .json / .toml / .yaml / .yml) file: String, /// Overwrite existing records on conflict (default: error and abort) #[arg(long)] force: bool, /// Preview operations without writing to the database #[arg(long)] dry_run: bool, /// Output format: text (default on TTY), json, json-compact #[arg(short, long = "output")] output: Option, }, } #[derive(Subcommand)] enum ConfigAction { /// Save database URL to config file (~/.config/secrets/config.toml) SetDb { /// PostgreSQL connection string, e.g. postgres://user:pass@:/dbname url: String, }, /// Show current configuration (password masked) Show, /// Print path to the config file Path, } #[tokio::main] async fn main() -> Result<()> { load_dotenv(); let cli = Cli::parse(); let filter = if cli.verbose { EnvFilter::new("secrets=debug") } else { EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("secrets=warn")) }; tracing_subscriber::fmt() .with_env_filter(filter) .with_target(false) .init(); // config subcommand needs no database or master key if let Commands::Config { action } = cli.command { return commands::config::run(action).await; } // upgrade needs no database or master key either if let Commands::Upgrade { check } = cli.command { return commands::upgrade::run(check).await; } let db_url = config::resolve_db_url(&cli.db_url)?; let pool = db::create_pool(&db_url).await?; db::migrate(&pool).await?; // init needs a pool but sets up the master key — handle before loading it if let Commands::Init = cli.command { return commands::init::run(&pool).await; } // All remaining commands require the master key from the OS Keychain, // except delete which operates on plaintext metadata only. match cli.command { Commands::Init | Commands::Config { .. } | Commands::Upgrade { .. } => unreachable!(), Commands::Add { namespace, kind, name, tags, meta, secrets, output, } => { let master_key = crypto::load_master_key()?; let _span = tracing::info_span!("cmd", command = "add", %namespace, %kind, %name).entered(); let out = resolve_output_mode(output.as_deref())?; commands::add::run( &pool, commands::add::AddArgs { namespace: &namespace, kind: &kind, name: &name, tags: &tags, meta_entries: &meta, secret_entries: &secrets, output: out, }, &master_key, ) .await?; } Commands::Search { namespace, kind, name, tag, query, fields, summary, limit, offset, sort, output, } => { let _span = tracing::info_span!("cmd", command = "search").entered(); let out = resolve_output_mode(output.as_deref())?; commands::search::run( &pool, commands::search::SearchArgs { namespace: namespace.as_deref(), kind: kind.as_deref(), name: name.as_deref(), tags: &tag, query: query.as_deref(), fields: &fields, summary, limit, offset, sort: &sort, output: out, }, ) .await?; } Commands::Delete { namespace, kind, name, dry_run, output, } => { let _span = tracing::info_span!("cmd", command = "delete", %namespace, ?kind, ?name).entered(); let out = resolve_output_mode(output.as_deref())?; commands::delete::run( &pool, commands::delete::DeleteArgs { namespace: &namespace, kind: kind.as_deref(), name: name.as_deref(), dry_run, output: out, }, ) .await?; } Commands::Update { namespace, kind, name, add_tags, remove_tags, meta, remove_meta, secrets, remove_secrets, output, } => { let master_key = crypto::load_master_key()?; let _span = tracing::info_span!("cmd", command = "update", %namespace, %kind, %name).entered(); let out = resolve_output_mode(output.as_deref())?; commands::update::run( &pool, commands::update::UpdateArgs { namespace: &namespace, kind: &kind, name: &name, add_tags: &add_tags, remove_tags: &remove_tags, meta_entries: &meta, remove_meta: &remove_meta, secret_entries: &secrets, remove_secrets: &remove_secrets, output: out, }, &master_key, ) .await?; } Commands::History { namespace, kind, name, limit, output, } => { let out = resolve_output_mode(output.as_deref())?; commands::history::run( &pool, commands::history::HistoryArgs { namespace: &namespace, kind: &kind, name: &name, limit, output: out, }, ) .await?; } Commands::Rollback { namespace, kind, name, to_version, output, } => { let master_key = crypto::load_master_key()?; let out = resolve_output_mode(output.as_deref())?; commands::rollback::run( &pool, commands::rollback::RollbackArgs { namespace: &namespace, kind: &kind, name: &name, to_version, output: out, }, &master_key, ) .await?; } Commands::Run { namespace, kind, name, tag, secret_fields, prefix, dry_run, output, command, } => { let master_key = crypto::load_master_key()?; let out = resolve_output_mode(output.as_deref())?; if !dry_run && command.is_empty() { anyhow::bail!( "No command specified. Usage: secrets run [filter flags] -- [args]" ); } commands::run::run_exec( &pool, commands::run::RunArgs { namespace: namespace.as_deref(), kind: kind.as_deref(), name: name.as_deref(), tags: &tag, secret_fields: &secret_fields, prefix: &prefix, dry_run, output: out, command: &command, }, &master_key, ) .await?; } Commands::Export { namespace, kind, name, tag, query, file, format, no_secrets, } => { let master_key = if no_secrets { None } else { Some(crypto::load_master_key()?) }; let _span = tracing::info_span!("cmd", command = "export").entered(); commands::export_cmd::run( &pool, commands::export_cmd::ExportArgs { namespace: namespace.as_deref(), kind: kind.as_deref(), name: name.as_deref(), tags: &tag, query: query.as_deref(), file: file.as_deref(), format: format.as_deref(), no_secrets, }, master_key.as_ref(), ) .await?; } Commands::Import { file, force, dry_run, output, } => { let master_key = crypto::load_master_key()?; let _span = tracing::info_span!("cmd", command = "import").entered(); let out = resolve_output_mode(output.as_deref())?; commands::import_cmd::run( &pool, commands::import_cmd::ImportArgs { file: &file, force, dry_run, output: out, }, &master_key, ) .await?; } } Ok(()) }