feat: Notion backend for the catalog + set command + ls/source/save UX
Persist the entry catalog to a Notion page instead of an external script. - `hel store notion:<ref>` reads the dump from stdin and writes it into a single code block on the page; `hel load notion:<ref>` prints it back. `<ref>` is a page id (UUID) or a page title (case-insensitive search). Token from HEL_NOTION_TOKEN. Networking lives in helcli (ureq + serde_json); the core lib and the wasm build are untouched. argv is dispatched before the REPL starts, so the subcommands never read ~/.helrc or prompt for a master. - `set <key> <value>`: runtime config (e.g. hel_dump, hel_notion_token) from ~/.helrc instead of env vars. The map is exported (uppercased) into spawned pipe/source children, so `set hel_notion_token ...` reaches `hel store`. Values are redacted in history. - `ls` now matches case-insensitively. - `source` echoes `source <target>`. - `save` prints a `< removed` / `> added` diff against the last load/save snapshot so removals are noticed before they persist.
This commit is contained in:
+59
-17
@@ -10,7 +10,7 @@ use crate::parser::command_parser;
|
||||
use crate::password::fix_password_recursion;
|
||||
use crate::password::{Name, Password, PasswordRef};
|
||||
use crate::repl::LKEval;
|
||||
use crate::structs::{LKOut, Radix, CORRECT_FILE, DUMP_FILE};
|
||||
use crate::structs::{config_get, config_set, LKOut, Radix, CORRECT_FILE, DUMP_FILE};
|
||||
use crate::utils::editor::password;
|
||||
use crate::utils::{call_cmd_with_input, get_cmd_args_from_command, get_copy_command_from_env, rnd};
|
||||
|
||||
@@ -233,6 +233,7 @@ impl<'a> LKEval<'a> {
|
||||
}
|
||||
|
||||
pub fn cmd_source(&self, out: &LKOut, source: &String) -> bool {
|
||||
out.o(format!("source {}", source));
|
||||
let script = if source.trim().ends_with("|") {
|
||||
let (cmd, args) = match get_cmd_args_from_command(source.trim().trim_end_matches('|')) {
|
||||
Ok(c) => c,
|
||||
@@ -272,15 +273,54 @@ impl<'a> LKEval<'a> {
|
||||
out.e(format!("error: {}", e.to_string()));
|
||||
}
|
||||
};
|
||||
// Baseline for the next save diff: the just-loaded state is the new
|
||||
// "previously persisted" reference.
|
||||
let snapshot = self.serialize_db();
|
||||
self.state.lock().borrow_mut().last_dump = Some(snapshot);
|
||||
false
|
||||
}
|
||||
|
||||
/// All entries serialized as sorted `add …` lines (the dump format).
|
||||
fn serialize_db(&self) -> String {
|
||||
let mut vals: Vec<PasswordRef> = self.state.lock().borrow().db.values().cloned().collect();
|
||||
vals.sort_by(|a, b| a.lock().borrow().name.cmp(&b.lock().borrow().name));
|
||||
vals.iter()
|
||||
.map(|v| format!("add {}", v.lock().borrow().to_string()))
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Emit a `< removed` / `> added` line diff of `new` against the last saved/
|
||||
/// loaded snapshot (set-based, so dump ordering doesn't matter). No-op until
|
||||
/// a baseline exists.
|
||||
fn show_dump_diff(&self, out: &LKOut, new: &str) {
|
||||
let prev = self.state.lock().borrow().last_dump.clone();
|
||||
if let Some(prev) = prev {
|
||||
use std::collections::BTreeSet;
|
||||
let p: BTreeSet<&str> = prev.lines().filter(|l| !l.is_empty()).collect();
|
||||
let n: BTreeSet<&str> = new.lines().filter(|l| !l.is_empty()).collect();
|
||||
for l in p.difference(&n) {
|
||||
out.o(format!("< {}", l));
|
||||
}
|
||||
for l in n.difference(&p) {
|
||||
out.o(format!("> {}", l));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cmd_set(&self, out: &LKOut, key: &String, value: &String) {
|
||||
config_set(key, value);
|
||||
// Confirm without echoing the value — it may be a secret.
|
||||
out.o(format!("set {}", key.to_lowercase()));
|
||||
}
|
||||
|
||||
pub fn cmd_dump(&self, out: &LKOut, script: &Option<String>) {
|
||||
let script = match script {
|
||||
Some(p) => p,
|
||||
None => DUMP_FILE.to_str().unwrap(),
|
||||
// Default dump target: `set hel_dump …` > $HEL_DUMP > ~/.hel_dump.
|
||||
let script: String = match script {
|
||||
Some(p) => p.clone(),
|
||||
None => config_get("hel_dump").unwrap_or_else(|| DUMP_FILE.to_str().unwrap().to_string()),
|
||||
};
|
||||
let script = shellexpand::full(script).unwrap().into_owned();
|
||||
let script = shellexpand::full(&script).unwrap().into_owned();
|
||||
fn save_dump(data: &HashMap<Name, PasswordRef>, script: &String) -> std::io::Result<()> {
|
||||
let file = fs::File::create(script)?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
@@ -299,15 +339,8 @@ impl<'a> LKEval<'a> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let data = self
|
||||
.state
|
||||
.lock()
|
||||
.borrow()
|
||||
.db
|
||||
.values()
|
||||
.map(|v| format!("add {}", v.lock().borrow().to_string()))
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n");
|
||||
let data = self.serialize_db();
|
||||
self.show_dump_diff(out, &data);
|
||||
let output = match call_cmd_with_input(&cmd, &args, data.as_str()) {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
@@ -315,6 +348,7 @@ impl<'a> LKEval<'a> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.state.lock().borrow_mut().last_dump = Some(data);
|
||||
if output.len() > 0 {
|
||||
out.e(format!("Passwords saved to command {} and got following output:", cmd));
|
||||
out.o(output);
|
||||
@@ -328,8 +362,15 @@ impl<'a> LKEval<'a> {
|
||||
out.o(format!("add {}", pwd.lock().borrow().to_string()))
|
||||
}
|
||||
} else {
|
||||
match save_dump(&self.state.lock().borrow().db, &script) {
|
||||
Ok(()) => out.o(format!("Passwords saved to file {}", script)),
|
||||
let data = self.serialize_db();
|
||||
self.show_dump_diff(out, &data);
|
||||
// Bind first so the immutable borrow drops before the borrow_mut below.
|
||||
let res = save_dump(&self.state.lock().borrow().db, &script);
|
||||
match res {
|
||||
Ok(()) => {
|
||||
self.state.lock().borrow_mut().last_dump = Some(data);
|
||||
out.o(format!("Passwords saved to file {}", script));
|
||||
}
|
||||
Err(e) => out.e(format!("error: failed to dump passwords to {}: {}", script, e.to_string())),
|
||||
};
|
||||
}
|
||||
@@ -339,7 +380,8 @@ impl<'a> LKEval<'a> {
|
||||
where
|
||||
F: Fn(&PasswordRef, &PasswordRef) -> std::cmp::Ordering,
|
||||
{
|
||||
let re = match Regex::new(&filter) {
|
||||
// Case-insensitive search; an explicit (?-i) in the filter still wins.
|
||||
let re = match Regex::new(&format!("(?i){}", filter)) {
|
||||
Ok(re) => re,
|
||||
Err(e) => {
|
||||
out.e(format!("error: failed to parse re: {:?}", e));
|
||||
|
||||
@@ -12,6 +12,9 @@ pub struct LK {
|
||||
pub db: HashMap<Name, PasswordRef>,
|
||||
pub ls: HashMap<String, PasswordRef>,
|
||||
pub secrets: HashMap<Name, String>,
|
||||
/// Serialized dump as of the last load (`source`) or save (`dump`). Used to
|
||||
/// show a `< removed` / `> added` diff on save so removals are noticed.
|
||||
pub last_dump: Option<String>,
|
||||
}
|
||||
|
||||
impl LK {
|
||||
@@ -20,6 +23,7 @@ impl LK {
|
||||
db: HashMap::new(),
|
||||
ls: HashMap::new(),
|
||||
secrets: HashMap::new(),
|
||||
last_dump: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -9,7 +9,7 @@ peg::parser! {
|
||||
pub rule cmd() -> Command<'input> = c:(info_cmd_list() / mod_cmd_list() / enc_cmd_list() / asides_cmd_list()) { c }
|
||||
pub rule info_cmd_list() -> Command<'input> = space()* c:(ls_cmd() / ld_cmd() / pb_cmd() / save_cmd() / save_def_cmd() / dump_cmd()) { c }
|
||||
pub rule mod_cmd_list() -> Command<'input> = space()* c:(add_cmd() / keep_cmd() / mv_cmd() / rm_cmd() / comment_cmd ()) { c }
|
||||
pub rule asides_cmd_list() -> Command<'input> = space()* c:(help_cmd() / source_cmd() / quit_cmd() / noop_cmd() / error_cmd()) { c }
|
||||
pub rule asides_cmd_list() -> Command<'input> = space()* c:(help_cmd() / source_cmd() / set_cmd() / quit_cmd() / noop_cmd() / error_cmd()) { c }
|
||||
pub rule enc_cmd_list() -> Command<'input> = space()* c:(enc_cmd() / gen_cmd() / pass_cmd() / unpass_cmd() / correct_cmd() / uncorrect_cmd()) { c }
|
||||
pub rule script() -> Vec<Command<'input>> = c:(info_cmd_list() / mod_cmd_list() / enc_cmd_list() / asides_cmd_list()) ++ "\n" { c }
|
||||
|
||||
@@ -86,6 +86,7 @@ peg::parser! {
|
||||
rule save_def_cmd() -> Command<'input> = "save" { Command::Dump(None) }
|
||||
rule dump_cmd() -> Command<'input> = "dump" { Command::Dump(Some("-".to_string())) }
|
||||
rule source_cmd() -> Command<'input> = "source" _ s:$(([' '..='~'])+) { Command::Source(s.to_string()) }
|
||||
rule set_cmd() -> Command<'input> = "set" _ k:word() _ v:$(([' '..='~'])+) { Command::Set(k, v.to_string()) }
|
||||
rule ls_cmd() -> Command<'input> = "ls" f:comment()? { Command::Ls(f.unwrap_or(".".to_string())) }
|
||||
rule ld_cmd() -> Command<'input> = "ld" f:comment()? { Command::Ld(f.unwrap_or(".".to_string())) }
|
||||
rule add_cmd() -> Command<'input> = "add" _ name:name() { Command::Add(Password::from_password(name)) }
|
||||
|
||||
@@ -130,6 +130,7 @@ impl<'a> LKEval<'a> {
|
||||
quit = self.cmd_source(&out, script);
|
||||
}
|
||||
Command::Dump(script) => self.cmd_dump(&out, script),
|
||||
Command::Set(key, value) => { to_history = false; self.cmd_set(&out, key, value); }
|
||||
Command::Pass(name, None) => self.cmd_pass(&out, &name, &None),
|
||||
Command::Pass(name, pass) => { to_history = false; self.cmd_pass(&out, &name, &pass); },
|
||||
Command::UnPass(name) => match self.state.lock().borrow_mut().secrets.remove(name) {
|
||||
|
||||
@@ -3,6 +3,7 @@ use num_integer::Integer;
|
||||
use parking_lot::Mutex;
|
||||
use parking_lot::ReentrantMutex;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
@@ -44,6 +45,33 @@ lazy_static! {
|
||||
_ => home::dir().join(".hel_dump").into_boxed_path(),
|
||||
}
|
||||
};
|
||||
/// Runtime configuration set via the `set <key> <value>` command (e.g. from
|
||||
/// `~/.helrc`). Lets the user keep settings like `hel_dump` and
|
||||
/// `hel_notion_token` in the init script instead of environment variables.
|
||||
/// Keys are stored lowercase; `config_get` falls back to the uppercased
|
||||
/// environment variable, and `config_envs` exports the map (uppercased) into
|
||||
/// child processes spawned by the pipe/source plumbing.
|
||||
pub static ref CONFIG: Mutex<HashMap<String, String>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
/// Store a runtime config value under its lowercased key.
|
||||
pub fn config_set(key: &str, value: &str) {
|
||||
CONFIG.lock().insert(key.to_lowercase(), value.to_string());
|
||||
}
|
||||
|
||||
/// Look up a config value: runtime `set` map first, then the uppercased env var.
|
||||
pub fn config_get(key: &str) -> Option<String> {
|
||||
if let Some(v) = CONFIG.lock().get(&key.to_lowercase()) {
|
||||
return Some(v.clone());
|
||||
}
|
||||
std::env::var(key.to_uppercase()).ok()
|
||||
}
|
||||
|
||||
/// The runtime config as `(UPPERCASE_KEY, value)` pairs, for injection into the
|
||||
/// environment of child processes (so `set hel_notion_token …` reaches a spawned
|
||||
/// `hel store`/`hel load`).
|
||||
pub fn config_envs() -> Vec<(String, String)> {
|
||||
CONFIG.lock().iter().map(|(k, v)| (k.to_uppercase(), v.clone())).collect()
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug, PartialEq)]
|
||||
@@ -75,6 +103,7 @@ pub enum Command<'a> {
|
||||
PasteBuffer(String),
|
||||
Source(String),
|
||||
Dump(Option<String>),
|
||||
Set(String, String),
|
||||
Comment(Name, Comment),
|
||||
Error(LKErr<'a>),
|
||||
Noop,
|
||||
@@ -100,6 +129,7 @@ impl<'a> PartialEq for Command<'a> {
|
||||
(Command::PasteBuffer(s), Command::PasteBuffer(o)) => s == o,
|
||||
(Command::Source(s), Command::Source(o)) => s == o,
|
||||
(Command::Dump(s), Command::Dump(o)) => s == o,
|
||||
(Command::Set(a, b), Command::Set(x, y)) => a == x && b == y,
|
||||
(Command::Comment(a, b), Command::Comment(x, y)) => a == x && b == y,
|
||||
(Command::Error(s), Command::Error(o)) => s == o,
|
||||
(Command::Noop, Command::Noop) => true,
|
||||
@@ -130,6 +160,9 @@ impl<'a> std::fmt::Display for Command<'a> {
|
||||
Command::Source(s) => write!(f, "source {}", s),
|
||||
Command::Dump(None) => write!(f, "dump"),
|
||||
Command::Dump(Some(s)) => write!(f, "dump {}", s),
|
||||
// Value redacted: a `set` may carry a secret (e.g. hel_notion_token)
|
||||
// and this Display feeds the history entry.
|
||||
Command::Set(a, _) => write!(f, "set {} ***", a),
|
||||
Command::Comment(a, None) => write!(f, "comment {}", a),
|
||||
Command::Comment(a, Some(b)) => write!(f, "comment {} {}", a, b),
|
||||
Command::Error(s) => write!(f, "error {}", s),
|
||||
|
||||
@@ -206,6 +206,9 @@ pub mod editor {
|
||||
pub fn call_cmd_with_input(cmd: &str, args: &Vec<String>, input: &str) -> io::Result<String> {
|
||||
let mut cmd = Command::new(cmd)
|
||||
.args(args)
|
||||
// Export runtime `set …` config (uppercased) into the child, so e.g.
|
||||
// `set hel_notion_token …` reaches a spawned `hel store`/`hel load`.
|
||||
.envs(crate::structs::config_envs())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
Reference in New Issue
Block a user