start work on tui app and rework secrets system
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
/target
|
||||
/.direnv
|
||||
/result
|
||||
/src-old
|
||||
|
||||
1256
Cargo.lock
generated
Normal file
1256
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
Normal file
17
Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "sops-tui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
ratatui = { version = "0.29", features = ["crossterm"] }
|
||||
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
unicode-width = "0.2"
|
||||
futures = "0.3"
|
||||
color-eyre = "0.6.5"
|
||||
ratatui-form = "0.1.1"
|
||||
39
flake.lock
generated
39
flake.lock
generated
@@ -1,5 +1,20 @@
|
||||
{
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1770169865,
|
||||
"narHash": "sha256-iPiy13xzDQ9GjpOez+NNIjh/qjl7i4RDf9dF2x5mF9I=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "8254ccf3b5b5131890ee073776f2e61c6d1e55d4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
@@ -51,8 +66,30 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"crane": "crane",
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": "nixpkgs"
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1770347142,
|
||||
"narHash": "sha256-uz+ZSqXpXEPtdRPYwvgsum/CfNq7AUQ/0gZHqTigiPM=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "2859683cd9ef7858d324c5399b0d8d6652bf4044",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
153
flake.nix
153
flake.nix
@@ -4,86 +4,87 @@
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
|
||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||
|
||||
# crane.url = "github:ipetkov/crane";
|
||||
# flake-utils.url = "github:numtide/flake-utils";
|
||||
# rust-overlay = {
|
||||
# url = "github:oxalica/rust-overlay";
|
||||
# inputs.nixpkgs.follows = "nixpkgs";
|
||||
# };
|
||||
crane.url = "github:ipetkov/crane";
|
||||
rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ flake-parts, ... }@inputs:
|
||||
{
|
||||
flake-parts,
|
||||
crane,
|
||||
rust-overlay,
|
||||
...
|
||||
}@inputs:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
flake.flakeModules.default = ./nix/flakeModule.nix;
|
||||
perSystem =
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
system,
|
||||
...
|
||||
}:
|
||||
let
|
||||
craneLib = (crane.mkLib pkgs).overrideToolchain (
|
||||
p:
|
||||
p.rust-bin.nightly.latest.default.override {
|
||||
extensions = [
|
||||
"rustc-codegen-cranelift-preview"
|
||||
"rust-analyzer"
|
||||
"rust-src"
|
||||
];
|
||||
}
|
||||
);
|
||||
src = craneLib.cleanCargoSource ./.;
|
||||
commonArgs = {
|
||||
inherit src;
|
||||
strictDeps = true;
|
||||
};
|
||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||
individualCrateArgs = commonArgs // {
|
||||
inherit cargoArtifacts;
|
||||
inherit (craneLib.crateNameFromCargoToml { inherit src; }) version;
|
||||
};
|
||||
|
||||
# outputs =
|
||||
# {
|
||||
# nixpkgs,
|
||||
# crane,
|
||||
# flake-utils,
|
||||
# rust-overlay,
|
||||
# ...
|
||||
# }:
|
||||
# flake-utils.lib.eachDefaultSystem (
|
||||
# system:
|
||||
# let
|
||||
# pkgs = import nixpkgs {
|
||||
# inherit system;
|
||||
# overlays = [
|
||||
# (import rust-overlay)
|
||||
# ];
|
||||
# };
|
||||
# inherit (pkgs) lib;
|
||||
#
|
||||
# craneLib = (crane.mkLib pkgs).overrideToolchain (
|
||||
# p:
|
||||
# p.rust-bin.nightly.latest.default.override {
|
||||
# extensions = [
|
||||
# "rustc-codegen-cranelift-preview"
|
||||
# "rust-analyzer"
|
||||
# "rust-src"
|
||||
# ];
|
||||
# }
|
||||
# );
|
||||
# src = craneLib.cleanCargoSource ./.;
|
||||
# commonArgs = {
|
||||
# inherit src;
|
||||
# strictDeps = true;
|
||||
# };
|
||||
# cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||
# individualCrateArgs = commonArgs // {
|
||||
# inherit cargoArtifacts;
|
||||
# inherit (craneLib.crateNameFromCargoToml { inherit src; }) version;
|
||||
# };
|
||||
#
|
||||
# fileSetForCrate =
|
||||
# crate:
|
||||
# lib.fileset.toSource {
|
||||
# root = ./.;
|
||||
# fileset = lib.fileset.unions [
|
||||
# ./Cargo.toml
|
||||
# ./Cargo.lock
|
||||
# ];
|
||||
# };
|
||||
# server = craneLib.buildPackage (
|
||||
# individualCrateArgs
|
||||
# // {
|
||||
# }
|
||||
# );
|
||||
# in
|
||||
# {
|
||||
# devShells.default = craneLib.devShell {
|
||||
# packages = with pkgs; [
|
||||
# mold
|
||||
# llvmPackages.clang
|
||||
# llvmPackages.lld
|
||||
# sea-orm-cli
|
||||
# watchexec
|
||||
# pnpm
|
||||
# ];
|
||||
# };
|
||||
# }
|
||||
# );
|
||||
fileSetForCrate =
|
||||
crate:
|
||||
lib.fileset.toSource {
|
||||
root = ./.;
|
||||
fileset = lib.fileset.unions [
|
||||
./Cargo.toml
|
||||
./Cargo.lock
|
||||
];
|
||||
};
|
||||
server = craneLib.buildPackage (
|
||||
individualCrateArgs
|
||||
// {
|
||||
}
|
||||
);
|
||||
in
|
||||
{
|
||||
_module.args.pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ inputs.rust-overlay.overlays.default ];
|
||||
};
|
||||
devShells.default = craneLib.devShell {
|
||||
packages = with pkgs; [
|
||||
mold
|
||||
llvmPackages.clang
|
||||
llvmPackages.lld
|
||||
sea-orm-cli
|
||||
watchexec
|
||||
pnpm
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ in
|
||||
secret_map = lib.mapAttrs (
|
||||
name: value:
|
||||
let
|
||||
keys = lib.unique (
|
||||
sopskeys = lib.unique (
|
||||
lib.flatten (map (k: per_host_keys.${k}) value.hosts)
|
||||
++ (lib.optionals value.global all_keys)
|
||||
++ cfg.masterKeys
|
||||
@@ -76,19 +76,29 @@ in
|
||||
hosts
|
||||
global
|
||||
;
|
||||
inherit keys;
|
||||
keys = value.keys or [ ];
|
||||
inherit sopskeys;
|
||||
}
|
||||
) secrets;
|
||||
|
||||
rules = lib.mapAttrsToList (name: value: {
|
||||
path_regex = "${cfg.secretsDir}/${name}$";
|
||||
key_groups = [ { age = value.keys; } ];
|
||||
key_groups = [ { age = value.sopskeys; } ];
|
||||
}) secret_map;
|
||||
sops_secrets_map = lib.concatMapAttrs (
|
||||
sops_secrets_map =
|
||||
host:
|
||||
lib.mkMerge (
|
||||
lib.mapAttrsToList (
|
||||
name: value:
|
||||
let
|
||||
hasHost = (lib.elem "wawa" value.hosts) || value.global;
|
||||
hasHost = (lib.elem host value.hosts) || value.global;
|
||||
isYamlOrJson = value.format == "yaml" || value.format == "json";
|
||||
in
|
||||
if hasHost then
|
||||
(
|
||||
(
|
||||
[
|
||||
(
|
||||
if hasHost && !(isYamlOrJson && value.keys != [ ]) then
|
||||
{
|
||||
${name} = {
|
||||
inherit (value) format neededForUsers;
|
||||
@@ -97,13 +107,39 @@ in
|
||||
}
|
||||
else
|
||||
{ }
|
||||
) secret_map;
|
||||
)
|
||||
]
|
||||
++ (lib.map (v: {
|
||||
"${name}-${v}" = {
|
||||
inherit (value) format neededForUsers;
|
||||
sopsFile = inputs.self + "/${cfg.secretsDir}/${name}";
|
||||
key = v;
|
||||
};
|
||||
}) value.keys)
|
||||
)
|
||||
)
|
||||
) secret_map
|
||||
);
|
||||
in
|
||||
{
|
||||
secrets.nixosModule =
|
||||
{ ... }:
|
||||
flake.secretsManifest = {
|
||||
secretsDir = cfg.secretsDir;
|
||||
masterKeys = cfg.masterKeys;
|
||||
hosts = lib.mapAttrs (_: keys: { inherit keys; }) per_host_keys;
|
||||
secrets = secret_map;
|
||||
secrets_map = sops_secrets_map;
|
||||
};
|
||||
secrets.nixosModule = {
|
||||
default = host: {
|
||||
config = {
|
||||
sops.secrets = sops_secrets_map host;
|
||||
};
|
||||
};
|
||||
network =
|
||||
{ config, ... }:
|
||||
{
|
||||
config.sops.secrets = sops_secrets_map;
|
||||
config.sops.secrets = sops_secrets_map config.networking.hostName;
|
||||
};
|
||||
};
|
||||
perSystem =
|
||||
{ pkgs, self', ... }:
|
||||
|
||||
128
src/app.rs
Normal file
128
src/app.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use color_eyre::Report;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::{prelude::CrosstermBackend, widgets::ListState, DefaultTerminal, Terminal};
|
||||
use ratatui_form::{Email, Form};
|
||||
use std::{io, path::Path};
|
||||
|
||||
use crate::{
|
||||
event::{AppEvent, Event, EventHandler},
|
||||
form::FormState,
|
||||
manifest::{load_manifest, Manifest},
|
||||
};
|
||||
|
||||
// #[derive(Debug)]
|
||||
pub struct App {
|
||||
pub running: bool,
|
||||
pub list_state: ListState,
|
||||
pub events: EventHandler,
|
||||
pub isloadingmanifest: bool,
|
||||
pub manifest: Option<Manifest>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
running: true,
|
||||
list_state: ListState::default().with_selected(Some(0)),
|
||||
events: EventHandler::new(),
|
||||
isloadingmanifest: false,
|
||||
manifest: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
mut self,
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
) -> color_eyre::Result<()> {
|
||||
let project_root = Path::new("/home/nikkuss/dotfiles-new");
|
||||
self.events.send(AppEvent::LoadManifest);
|
||||
|
||||
while self.running {
|
||||
terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?;
|
||||
match self.events.next().await? {
|
||||
Event::Tick => self.tick(),
|
||||
Event::Crossterm(event) => match event {
|
||||
crossterm::event::Event::Key(key_event)
|
||||
if key_event.kind == crossterm::event::KeyEventKind::Press =>
|
||||
{
|
||||
self.handle_key_events(key_event)?
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::App(app_event) => match app_event {
|
||||
AppEvent::LoadManifest => {
|
||||
if !self.isloadingmanifest {
|
||||
let tx = self.events.clone_sender();
|
||||
tokio::spawn(async move {
|
||||
let result = load_manifest(&project_root).await;
|
||||
let _ = tx.send(Event::App(AppEvent::ManifestLoaded(result)));
|
||||
});
|
||||
}
|
||||
}
|
||||
AppEvent::ManifestLoaded(manifest) => {
|
||||
self.isloadingmanifest = false;
|
||||
match manifest {
|
||||
Ok(m) => {
|
||||
self.manifest = Some(m);
|
||||
}
|
||||
Err(e) => {
|
||||
self.display_error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::SelectionUp => self.selection_up(),
|
||||
AppEvent::SelectionDown => self.selection_down(),
|
||||
AppEvent::Quit => self.quit(),
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handles the key events and updates the state of [`App`].
|
||||
pub fn handle_key_events(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> {
|
||||
match key_event.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => self.events.send(AppEvent::Quit),
|
||||
KeyCode::Char('c' | 'C') if key_event.modifiers == KeyModifiers::CONTROL => {
|
||||
self.events.send(AppEvent::Quit)
|
||||
}
|
||||
KeyCode::Up => self.events.send(AppEvent::SelectionUp),
|
||||
KeyCode::Down => self.events.send(AppEvent::SelectionDown),
|
||||
KeyCode::Char('a') => self.create_secret(),
|
||||
// Other handlers you could add here.
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn selected_secret_name(&self) -> Option<String> {
|
||||
let selected = self.list_state.selected()?;
|
||||
if let Some(manifest) = &self.manifest {
|
||||
return manifest.secrets.keys().nth(selected).cloned();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Handles the tick event of the terminal.
|
||||
///
|
||||
/// The tick event is where you can update the state of your application with any logic that
|
||||
/// needs to be updated at a fixed frame rate. E.g. polling a server, updating an animation.
|
||||
pub fn tick(&self) {}
|
||||
|
||||
/// Set running to false to quit the application.
|
||||
pub fn quit(&mut self) {
|
||||
self.running = false;
|
||||
}
|
||||
|
||||
pub fn selection_down(&mut self) {
|
||||
self.list_state.select_next();
|
||||
}
|
||||
|
||||
pub fn selection_up(&mut self) {
|
||||
self.list_state.select_previous();
|
||||
}
|
||||
pub fn display_error(&mut self, display_error: Report) {
|
||||
// somehow display the error?
|
||||
}
|
||||
pub fn create_secret(&mut self) {}
|
||||
}
|
||||
125
src/event.rs
Normal file
125
src/event.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use color_eyre::eyre::OptionExt;
|
||||
use color_eyre::Result;
|
||||
use futures::{FutureExt, StreamExt};
|
||||
use ratatui::crossterm::event::Event as CrosstermEvent;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc::{self, UnboundedSender};
|
||||
|
||||
use crate::manifest::Manifest;
|
||||
|
||||
/// The frequency at which tick events are emitted.
|
||||
const TICK_FPS: f64 = 30.0;
|
||||
|
||||
/// Representation of all possible events.
|
||||
#[derive(Debug)]
|
||||
pub enum Event {
|
||||
Tick,
|
||||
Crossterm(CrosstermEvent),
|
||||
App(AppEvent),
|
||||
}
|
||||
|
||||
/// Application events.
|
||||
///
|
||||
/// You can extend this enum with your own custom events.
|
||||
#[derive(Debug)]
|
||||
pub enum AppEvent {
|
||||
SelectionUp,
|
||||
SelectionDown,
|
||||
LoadManifest,
|
||||
ManifestLoaded(Result<Manifest>),
|
||||
/// Quit the application.
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// Terminal event handler.
|
||||
#[derive(Debug)]
|
||||
pub struct EventHandler {
|
||||
/// Event sender channel.
|
||||
sender: mpsc::UnboundedSender<Event>,
|
||||
/// Event receiver channel.
|
||||
receiver: mpsc::UnboundedReceiver<Event>,
|
||||
}
|
||||
|
||||
impl EventHandler {
|
||||
/// Constructs a new instance of [`EventHandler`] and spawns a new thread to handle events.
|
||||
pub fn new() -> Self {
|
||||
let (sender, receiver) = mpsc::unbounded_channel();
|
||||
let actor = EventTask::new(sender.clone());
|
||||
tokio::spawn(async { actor.run().await });
|
||||
Self { sender, receiver }
|
||||
}
|
||||
|
||||
/// Receives an event from the sender.
|
||||
///
|
||||
/// This function blocks until an event is received.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This function returns an error if the sender channel is disconnected. This can happen if an
|
||||
/// error occurs in the event thread. In practice, this should not happen unless there is a
|
||||
/// problem with the underlying terminal.
|
||||
pub async fn next(&mut self) -> color_eyre::Result<Event> {
|
||||
self.receiver
|
||||
.recv()
|
||||
.await
|
||||
.ok_or_eyre("Failed to receive event")
|
||||
}
|
||||
|
||||
/// Queue an app event to be sent to the event receiver.
|
||||
///
|
||||
/// This is useful for sending events to the event handler which will be processed by the next
|
||||
/// iteration of the application's event loop.
|
||||
pub fn send(&mut self, app_event: AppEvent) {
|
||||
// Ignore the result as the reciever cannot be dropped while this struct still has a
|
||||
// reference to it
|
||||
let _ = self.sender.send(Event::App(app_event));
|
||||
}
|
||||
pub fn clone_sender(&self) -> UnboundedSender<Event> {
|
||||
self.sender.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A thread that handles reading crossterm events and emitting tick events on a regular schedule.
|
||||
struct EventTask {
|
||||
/// Event sender channel.
|
||||
sender: mpsc::UnboundedSender<Event>,
|
||||
}
|
||||
|
||||
impl EventTask {
|
||||
/// Constructs a new instance of [`EventThread`].
|
||||
fn new(sender: mpsc::UnboundedSender<Event>) -> Self {
|
||||
Self { sender }
|
||||
}
|
||||
|
||||
/// Runs the event thread.
|
||||
///
|
||||
/// This function emits tick events at a fixed rate and polls for crossterm events in between.
|
||||
async fn run(self) -> color_eyre::Result<()> {
|
||||
let tick_rate = Duration::from_secs_f64(1.0 / TICK_FPS);
|
||||
let mut reader = crossterm::event::EventStream::new();
|
||||
let mut tick = tokio::time::interval(tick_rate);
|
||||
loop {
|
||||
let tick_delay = tick.tick();
|
||||
let crossterm_event = reader.next().fuse();
|
||||
tokio::select! {
|
||||
_ = self.sender.closed() => {
|
||||
break;
|
||||
}
|
||||
_ = tick_delay => {
|
||||
self.send(Event::Tick);
|
||||
}
|
||||
Some(Ok(evt)) = crossterm_event => {
|
||||
self.send(Event::Crossterm(evt));
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends an event to the receiver.
|
||||
fn send(&self, event: Event) {
|
||||
// Ignores the result because shutting down the app drops the receiver, which causes the send
|
||||
// operation to fail. This is expected behavior and should not panic.
|
||||
let _ = self.sender.send(event);
|
||||
}
|
||||
}
|
||||
1
src/form.rs
Normal file
1
src/form.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub struct FormState {}
|
||||
41
src/main.rs
Normal file
41
src/main.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use std::{io, path::Path};
|
||||
|
||||
use color_eyre::Result;
|
||||
use crossterm::{
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{prelude::CrosstermBackend, Terminal};
|
||||
|
||||
use crate::app::App;
|
||||
|
||||
mod app;
|
||||
mod event;
|
||||
mod form;
|
||||
mod manifest;
|
||||
mod ui;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
// Set up panic hook to restore terminal
|
||||
let original_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |panic| {
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
||||
original_hook(panic);
|
||||
}));
|
||||
|
||||
// Run the app
|
||||
let result = App::new().run(&mut terminal).await;
|
||||
// Restore terminal
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
|
||||
result
|
||||
}
|
||||
79
src/manifest.rs
Normal file
79
src/manifest.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use color_eyre::{eyre::eyre, Result};
|
||||
use serde::Deserialize;
|
||||
use std::{collections::BTreeMap, fmt, path::Path};
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Top-level manifest from `nix eval .#secretsManifest --json`
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Manifest {
|
||||
#[serde(rename = "secretsDir")]
|
||||
pub secrets_dir: String,
|
||||
#[serde(rename = "masterKeys")]
|
||||
pub master_keys: Vec<String>,
|
||||
pub hosts: BTreeMap<String, HostInfo>,
|
||||
pub secrets: BTreeMap<String, SecretInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct HostInfo {
|
||||
pub keys: Vec<String>,
|
||||
}
|
||||
|
||||
/// Per-secret info, pre-computed by Nix
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SecretInfo {
|
||||
pub format: SecretFormat,
|
||||
pub global: bool,
|
||||
pub hosts: Vec<String>,
|
||||
#[serde(rename = "neededForUsers")]
|
||||
pub needed_for_users: bool,
|
||||
pub keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SecretFormat {
|
||||
Json,
|
||||
Yaml,
|
||||
Dotenv,
|
||||
Ini,
|
||||
Binary,
|
||||
}
|
||||
|
||||
impl fmt::Display for SecretFormat {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
SecretFormat::Json => write!(f, "json"),
|
||||
SecretFormat::Yaml => write!(f, "yaml"),
|
||||
SecretFormat::Dotenv => write!(f, "dotenv"),
|
||||
SecretFormat::Ini => write!(f, "ini"),
|
||||
SecretFormat::Binary => write!(f, "binary"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load manifest by running `nix eval .#secretsManifest --json`
|
||||
pub async fn load_manifest(path: &Path) -> Result<Manifest> {
|
||||
let path = path.to_string_lossy();
|
||||
let timeout_duration = std::time::Duration::from_secs(30);
|
||||
let result = tokio::time::timeout(timeout_duration, async {
|
||||
Command::new("nix")
|
||||
.args(["eval", &format!("{path}#secretsManifest"), "--json"])
|
||||
.output()
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(output)) => {
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(eyre!("nix eval failed:\n{}", stderr))
|
||||
} else {
|
||||
serde_json::from_slice(&output.stdout).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => Err(eyre!("Failed to run nix eval: {}", e)),
|
||||
Err(_) => Err(eyre!("nix eval timed out after 30s")),
|
||||
}
|
||||
}
|
||||
206
src/ui.rs
Normal file
206
src/ui.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style, Stylize},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, BorderType, Borders, List, Paragraph, StatefulWidget, Widget},
|
||||
};
|
||||
|
||||
use crate::app::App;
|
||||
|
||||
impl Widget for &mut App {
|
||||
/// Renders the user interface widgets.
|
||||
///
|
||||
// This is where you add new widgets.
|
||||
// See the following resources:
|
||||
// - https://docs.rs/ratatui/latest/ratatui/widgets/index.html
|
||||
// - https://github.com/ratatui/ratatui/tree/master/examples
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let items = if let Some(manifest) = &self.manifest {
|
||||
manifest
|
||||
.secrets
|
||||
.keys()
|
||||
.map(|v| Text::from(truncate_with_ellipsis(v, 50)))
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)])
|
||||
.split(area);
|
||||
let left = layout[0];
|
||||
let right = layout[1];
|
||||
let left_block = Block::default().title_bottom("meow").borders(Borders::ALL);
|
||||
|
||||
let list = List::default()
|
||||
.block(left_block)
|
||||
.highlight_style(Style::default().bg(Color::Blue).fg(Color::White))
|
||||
.highlight_symbol("\u{25b6} ")
|
||||
.items(items);
|
||||
StatefulWidget::render(list, left, buf, &mut self.list_state);
|
||||
|
||||
render_secret_detail(self, right, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_secret_detail(state: &App, area: Rect, buf: &mut Buffer) {
|
||||
let manifest = match &state.manifest {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
render_placeholder(area, buf, " Detail ", "No manifest loaded");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let selected_name = match state.selected_secret_name() {
|
||||
Some(name) => name.to_string(),
|
||||
None => {
|
||||
render_placeholder(area, buf, " Detail ", "No secret selected");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let secret = match manifest.secrets.get(&selected_name) {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let title = format!(" {} ", selected_name);
|
||||
let block = Block::default().title(title).borders(Borders::ALL);
|
||||
// Build detail lines
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Separator
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Format
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Format: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(secret.format.to_string(), Style::default().fg(Color::White)),
|
||||
]));
|
||||
|
||||
// Scope
|
||||
let scope = if secret.global {
|
||||
"global".to_string()
|
||||
} else {
|
||||
"host-specific".to_string()
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Scope: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(scope, Style::default().fg(Color::White)),
|
||||
]));
|
||||
if !secret.global {
|
||||
// Hosts
|
||||
let hosts_str = if secret.hosts.is_empty() {
|
||||
if secret.global {
|
||||
manifest
|
||||
.hosts
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
} else {
|
||||
"none".to_string()
|
||||
}
|
||||
} else {
|
||||
secret.hosts.join(", ")
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Hosts: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(hosts_str, Style::default().fg(Color::White)),
|
||||
]));
|
||||
}
|
||||
|
||||
// Recipients
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Recipients: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
format!("{} age keys", secret.keys.len()),
|
||||
Style::default().fg(Color::White),
|
||||
),
|
||||
]));
|
||||
|
||||
// Needed for users
|
||||
if secret.needed_for_users {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Users: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("yes", Style::default().fg(Color::Yellow)),
|
||||
]));
|
||||
}
|
||||
while lines.len() < 6 {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
// Key details section
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
" Keys:",
|
||||
Style::default()
|
||||
.fg(Color::DarkGray)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
for key in &secret.keys {
|
||||
let truncated = if key.len() > 20 {
|
||||
format!("{}...", &key[..20])
|
||||
} else {
|
||||
key.clone()
|
||||
};
|
||||
|
||||
// Check if this is a master key
|
||||
let label = if manifest.master_keys.contains(key) {
|
||||
Span::styled(" (master)", Style::default().fg(Color::Magenta))
|
||||
} else {
|
||||
// Try to find which host owns this key
|
||||
let host_label = manifest
|
||||
.hosts
|
||||
.iter()
|
||||
.find(|(_, info)| info.keys.contains(key))
|
||||
.map(|(name, _)| format!(" ({})", name))
|
||||
.unwrap_or_default();
|
||||
Span::styled(host_label, Style::default().fg(Color::Cyan))
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(truncated, Style::default().fg(Color::Green)),
|
||||
label,
|
||||
]));
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines).block(block).render(area, buf);
|
||||
}
|
||||
|
||||
fn render_placeholder(area: Rect, buf: &mut Buffer, title: &str, message: &str) {
|
||||
let block = Block::default()
|
||||
.title(title.to_string())
|
||||
.borders(Borders::ALL);
|
||||
Paragraph::new(message.to_string())
|
||||
.style(Style::default().fg(Color::DarkGray))
|
||||
.block(block)
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
/// Truncate a name with middle ellipsis, preserving the file extension
|
||||
fn truncate_with_ellipsis(name: &str, max_width: usize) -> String {
|
||||
if name.len() <= max_width {
|
||||
return name.to_string();
|
||||
}
|
||||
|
||||
if max_width < 5 {
|
||||
return name[..max_width].to_string();
|
||||
}
|
||||
|
||||
// Try to preserve the extension
|
||||
if let Some(dot_pos) = name.rfind('.') {
|
||||
let ext = &name[dot_pos..];
|
||||
if ext.len() < max_width - 3 {
|
||||
let prefix_len = max_width - 3 - ext.len();
|
||||
return format!("{}...{}", &name[..prefix_len], ext);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: just truncate with ellipsis at end
|
||||
let prefix_len = max_width - 3;
|
||||
format!("{}...", &name[..prefix_len])
|
||||
}
|
||||
Reference in New Issue
Block a user