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
|
/target
|
||||||
/.direnv
|
/.direnv
|
||||||
/result
|
/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": {
|
"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": {
|
"flake-parts": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
@@ -51,8 +66,30 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"crane": "crane",
|
||||||
"flake-parts": "flake-parts",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
161
flake.nix
161
flake.nix
@@ -4,86 +4,87 @@
|
|||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
|
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
|
||||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||||
|
crane.url = "github:ipetkov/crane";
|
||||||
# crane.url = "github:ipetkov/crane";
|
rust-overlay = {
|
||||||
# flake-utils.url = "github:numtide/flake-utils";
|
url = "github:oxalica/rust-overlay";
|
||||||
# rust-overlay = {
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
# url = "github:oxalica/rust-overlay";
|
|
||||||
# inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
# };
|
|
||||||
};
|
|
||||||
outputs =
|
|
||||||
{ flake-parts, ... }@inputs:
|
|
||||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
|
||||||
flake.flakeModules.default = ./nix/flakeModule.nix;
|
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
# outputs =
|
outputs =
|
||||||
# {
|
{
|
||||||
# nixpkgs,
|
flake-parts,
|
||||||
# crane,
|
crane,
|
||||||
# flake-utils,
|
rust-overlay,
|
||||||
# rust-overlay,
|
...
|
||||||
# ...
|
}@inputs:
|
||||||
# }:
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
# flake-utils.lib.eachDefaultSystem (
|
systems = [
|
||||||
# system:
|
"x86_64-linux"
|
||||||
# let
|
"aarch64-linux"
|
||||||
# pkgs = import nixpkgs {
|
"x86_64-darwin"
|
||||||
# inherit system;
|
"aarch64-darwin"
|
||||||
# overlays = [
|
];
|
||||||
# (import rust-overlay)
|
flake.flakeModules.default = ./nix/flakeModule.nix;
|
||||||
# ];
|
perSystem =
|
||||||
# };
|
{
|
||||||
# inherit (pkgs) lib;
|
pkgs,
|
||||||
#
|
lib,
|
||||||
# craneLib = (crane.mkLib pkgs).overrideToolchain (
|
system,
|
||||||
# p:
|
...
|
||||||
# p.rust-bin.nightly.latest.default.override {
|
}:
|
||||||
# extensions = [
|
let
|
||||||
# "rustc-codegen-cranelift-preview"
|
craneLib = (crane.mkLib pkgs).overrideToolchain (
|
||||||
# "rust-analyzer"
|
p:
|
||||||
# "rust-src"
|
p.rust-bin.nightly.latest.default.override {
|
||||||
# ];
|
extensions = [
|
||||||
# }
|
"rustc-codegen-cranelift-preview"
|
||||||
# );
|
"rust-analyzer"
|
||||||
# src = craneLib.cleanCargoSource ./.;
|
"rust-src"
|
||||||
# commonArgs = {
|
];
|
||||||
# inherit src;
|
}
|
||||||
# strictDeps = true;
|
);
|
||||||
# };
|
src = craneLib.cleanCargoSource ./.;
|
||||||
# cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
commonArgs = {
|
||||||
# individualCrateArgs = commonArgs // {
|
inherit src;
|
||||||
# inherit cargoArtifacts;
|
strictDeps = true;
|
||||||
# inherit (craneLib.crateNameFromCargoToml { inherit src; }) version;
|
};
|
||||||
# };
|
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||||
#
|
individualCrateArgs = commonArgs // {
|
||||||
# fileSetForCrate =
|
inherit cargoArtifacts;
|
||||||
# crate:
|
inherit (craneLib.crateNameFromCargoToml { inherit src; }) version;
|
||||||
# lib.fileset.toSource {
|
};
|
||||||
# root = ./.;
|
|
||||||
# fileset = lib.fileset.unions [
|
fileSetForCrate =
|
||||||
# ./Cargo.toml
|
crate:
|
||||||
# ./Cargo.lock
|
lib.fileset.toSource {
|
||||||
# ];
|
root = ./.;
|
||||||
# };
|
fileset = lib.fileset.unions [
|
||||||
# server = craneLib.buildPackage (
|
./Cargo.toml
|
||||||
# individualCrateArgs
|
./Cargo.lock
|
||||||
# // {
|
];
|
||||||
# }
|
};
|
||||||
# );
|
server = craneLib.buildPackage (
|
||||||
# in
|
individualCrateArgs
|
||||||
# {
|
// {
|
||||||
# devShells.default = craneLib.devShell {
|
}
|
||||||
# packages = with pkgs; [
|
);
|
||||||
# mold
|
in
|
||||||
# llvmPackages.clang
|
{
|
||||||
# llvmPackages.lld
|
_module.args.pkgs = import inputs.nixpkgs {
|
||||||
# sea-orm-cli
|
inherit system;
|
||||||
# watchexec
|
overlays = [ inputs.rust-overlay.overlays.default ];
|
||||||
# pnpm
|
};
|
||||||
# ];
|
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 (
|
secret_map = lib.mapAttrs (
|
||||||
name: value:
|
name: value:
|
||||||
let
|
let
|
||||||
keys = lib.unique (
|
sopskeys = lib.unique (
|
||||||
lib.flatten (map (k: per_host_keys.${k}) value.hosts)
|
lib.flatten (map (k: per_host_keys.${k}) value.hosts)
|
||||||
++ (lib.optionals value.global all_keys)
|
++ (lib.optionals value.global all_keys)
|
||||||
++ cfg.masterKeys
|
++ cfg.masterKeys
|
||||||
@@ -76,35 +76,71 @@ in
|
|||||||
hosts
|
hosts
|
||||||
global
|
global
|
||||||
;
|
;
|
||||||
inherit keys;
|
keys = value.keys or [ ];
|
||||||
|
inherit sopskeys;
|
||||||
}
|
}
|
||||||
) secrets;
|
) secrets;
|
||||||
|
|
||||||
rules = lib.mapAttrsToList (name: value: {
|
rules = lib.mapAttrsToList (name: value: {
|
||||||
path_regex = "${cfg.secretsDir}/${name}$";
|
path_regex = "${cfg.secretsDir}/${name}$";
|
||||||
key_groups = [ { age = value.keys; } ];
|
key_groups = [ { age = value.sopskeys; } ];
|
||||||
}) secret_map;
|
}) secret_map;
|
||||||
sops_secrets_map = lib.concatMapAttrs (
|
sops_secrets_map =
|
||||||
name: value:
|
host:
|
||||||
let
|
lib.mkMerge (
|
||||||
hasHost = (lib.elem "wawa" value.hosts) || value.global;
|
lib.mapAttrsToList (
|
||||||
in
|
name: value:
|
||||||
if hasHost then
|
let
|
||||||
{
|
hasHost = (lib.elem host value.hosts) || value.global;
|
||||||
${name} = {
|
isYamlOrJson = value.format == "yaml" || value.format == "json";
|
||||||
inherit (value) format neededForUsers;
|
in
|
||||||
sopsFile = inputs.self + "/${cfg.secretsDir}/${name}";
|
(
|
||||||
};
|
(
|
||||||
}
|
[
|
||||||
else
|
(
|
||||||
{ }
|
if hasHost && !(isYamlOrJson && value.keys != [ ]) then
|
||||||
) secret_map;
|
{
|
||||||
|
${name} = {
|
||||||
|
inherit (value) format neededForUsers;
|
||||||
|
sopsFile = inputs.self + "/${cfg.secretsDir}/${name}";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{ }
|
||||||
|
)
|
||||||
|
]
|
||||||
|
++ (lib.map (v: {
|
||||||
|
"${name}-${v}" = {
|
||||||
|
inherit (value) format neededForUsers;
|
||||||
|
sopsFile = inputs.self + "/${cfg.secretsDir}/${name}";
|
||||||
|
key = v;
|
||||||
|
};
|
||||||
|
}) value.keys)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) secret_map
|
||||||
|
);
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
secrets.nixosModule =
|
flake.secretsManifest = {
|
||||||
{ ... }:
|
secretsDir = cfg.secretsDir;
|
||||||
{
|
masterKeys = cfg.masterKeys;
|
||||||
config.sops.secrets = sops_secrets_map;
|
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.networking.hostName;
|
||||||
|
};
|
||||||
|
};
|
||||||
perSystem =
|
perSystem =
|
||||||
{ pkgs, self', ... }:
|
{ pkgs, self', ... }:
|
||||||
let
|
let
|
||||||
|
|||||||
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