Compare commits
41 Commits
74d3296882
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
ab6d768e3e
|
|||
|
df0730486e
|
|||
|
4cb3464e86
|
|||
| 11c0198e34 | |||
|
a31ae9669f
|
|||
| db05834fd1 | |||
| 1fd61e5fc4 | |||
| a88f9f6c88 | |||
| 90dd2532b5 | |||
| 507335168c | |||
| b25408d283 | |||
| 11476aecbc | |||
| fa0fd841b6 | |||
| ebb09c7cb9 | |||
| eef2104f25 | |||
| 15245d027c | |||
| 4c6884c947 | |||
| 7c651d0bb5 | |||
| 30fbc1caae | |||
| 2f55846f70 | |||
| 05613813de | |||
| 0dd173f113 | |||
| c60e6c8ce2 | |||
| 10a21d1f9d | |||
| e25746d5f0 | |||
| fc0e9bfafd | |||
| 1370e640fb | |||
| 1005e5a8e2 | |||
| 29c26ddda1 | |||
| 77352093c9 | |||
| b163cc440f | |||
| be2078779d | |||
| 509a66a609 | |||
| db0eb9ebc4 | |||
| d44a54002b | |||
| 14d404744c | |||
| 349bbe1557 | |||
| f8e8a9981a | |||
| ba884037b0 | |||
| 3031105000 | |||
| 03c5dadccb |
@@ -12,3 +12,24 @@ Cargo.lock
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
# Editor config
|
||||
.vscode/
|
||||
|
||||
# Build artifacts
|
||||
helwasm/helwasm.wasm
|
||||
|
||||
# Untouched rust-webpack-template scaffold (superseded by helwasm/ proper)
|
||||
helwasm/my-app/
|
||||
|
||||
# Local test scratch + PERSONAL DATA (real login-name exports — never commit)
|
||||
hel_dump
|
||||
logins.txt
|
||||
logins2.txt
|
||||
test.dat
|
||||
test.out
|
||||
test_dump
|
||||
test_init
|
||||
test_pb
|
||||
testrc
|
||||
x1
|
||||
|
||||
@@ -1,28 +1,289 @@
|
||||
use regex::Regex;
|
||||
use sha1::{Digest, Sha1};
|
||||
use std::cmp::min;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::parser::command_parser;
|
||||
use crate::password::fix_password_recursion;
|
||||
use crate::password::{Name, Password, PasswordRef};
|
||||
use crate::repl::LKEval;
|
||||
use crate::structs::{config_get, config_set, LKOut, Radix, CORRECT_FILE, DUMP_FILE};
|
||||
use crate::structs::{config_flag, 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};
|
||||
// call_cmd_with_input / get_cmd_args_from_command are only used by the native
|
||||
// (non-wasm) subprocess branches. copy_to_clipboards is native-only, so it is
|
||||
// referenced fully-qualified at its one call site (not imported here, which
|
||||
// would break the wasm build).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(unused_imports))]
|
||||
use crate::utils::{call_cmd_with_input, get_cmd_args_from_command, rnd};
|
||||
|
||||
// In the browser `pb` copies through the host page's clipboard (navigator.clipboard)
|
||||
// instead of shelling out to pbcopy/xclip.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = hel_clipboard_write)]
|
||||
fn hel_clipboard_write(text: &str);
|
||||
}
|
||||
|
||||
const HELP_OVERVIEW: &str = "\
|
||||
hel — deterministic S/KEY (RFC 2289) passwords. Your master plus an entry's
|
||||
name + sequence + date derive the password on the fly; nothing secret is
|
||||
stored. Default form is six short, memorable words (xkcd 936).
|
||||
|
||||
help <topic> detail for a command or concept, e.g. `help enc`, `help add`,
|
||||
`help modes`, `help config`, `help files`.
|
||||
|
||||
ENTRIES
|
||||
add <descriptor> define an entry (help add / help name)
|
||||
ls [regex] list entries by name
|
||||
ld [regex] list entries by date (oldest first)
|
||||
keep <id> save list row <id> (left column of ls/gen) to catalog
|
||||
mv <name> <folder> move entry under <folder> (folder `/` = top level)
|
||||
comment <name> [text] set or clear the comment
|
||||
rm <name> remove an entry
|
||||
|
||||
PASSWORDS
|
||||
enc <name|id> show an entry's password
|
||||
enc <command> encode the last name a command prints (ls/ld/gen)
|
||||
gen[N] <name> N variants; name ends G.. (all) or X.. (random) [N=10]
|
||||
pb <command> run a command, copy its output to the clipboard
|
||||
pass <name> [pw] cache a master/override for an entry's subtree
|
||||
unpass [name] forget cached master (unpass / = root, unpass = all)
|
||||
correct <name> trust this password's hash
|
||||
uncorrect <name> untrust it
|
||||
|
||||
CATALOG
|
||||
dump print the catalog as `add …` lines
|
||||
save [target] write it: file / key / - / |command (help save)
|
||||
source <target> load it: file / key / command|
|
||||
set <key> <value> runtime config (help config)
|
||||
|
||||
OTHER
|
||||
help [topic] this overview, or detail for one topic
|
||||
# text a comment (ignored)
|
||||
quit exit the REPL
|
||||
|
||||
An entry id is the number in the left column of `ls`/`ld`/`gen`; use it anywhere
|
||||
a <name> is expected until the next listing. `ls`/`ld` match the name
|
||||
case-insensitively as a regular expression.
|
||||
|
||||
MODES (the [len][mode] in a descriptor — `help modes` for examples)
|
||||
R six words (default) N hyphenated C CamelCase D decimal
|
||||
H hex B base64 U.. = UPPERCASE variant (UR UN UH UB)
|
||||
<len> truncates the result to <len> characters (e.g. 20R, 12UB)";
|
||||
|
||||
const HELP_NAME: &str = "\
|
||||
descriptor — used by `add`, `gen`, and every line of the dump/`save` format:
|
||||
|
||||
[prefix] <name> [<len>]<mode> [<seq>] [<date>] [comment] [^parent]
|
||||
|
||||
prefix optional literal glued onto the output, e.g. #W9 (to satisfy a
|
||||
\"must contain a symbol/digit\" rule). Part of the generated value.
|
||||
name entry key, no spaces; feeds the S/KEY hash.
|
||||
len optional: truncate the output to <len> characters.
|
||||
mode output form, default R (see `help modes`).
|
||||
seq S/KEY sequence count, default 99.
|
||||
date YYYY-MM-DD or `now`, default now. `ld` sorts by it; `enc ld <re>`
|
||||
takes the newest.
|
||||
comment free text (login, URL, notes).
|
||||
^parent place this entry under <parent>: the parent's generated password
|
||||
becomes the master for this entry (chained). The root master is the
|
||||
entry `/`, prompted once or set with `pass /`.
|
||||
|
||||
examples
|
||||
add github
|
||||
add github 20UR 2024-01-01 me@example.com ^work
|
||||
add #W9 ableton 99 2020-12-09 license note";
|
||||
|
||||
const HELP_MODES: &str = "\
|
||||
modes — output form; prefix with a length to truncate (e.g. 20R). For one fixed
|
||||
entry + master:
|
||||
|
||||
R six words, spaces ross beau week held yoga anti (default)
|
||||
UR R, upper-cased ROSS BEAU WEEK HELD YOGA ANTI
|
||||
N hyphenated ross-beau-week-held-yoga-anti
|
||||
UN N, upper-cased ROSS-BEAU-WEEK-HELD-YOGA-ANTI
|
||||
C CamelCase, no spaces RossBeauWeekHeldYogaAnti
|
||||
H hex e5a38ad29afc3fcb (UH = upper)
|
||||
B base64 0oqj5cs//Jo (UB = upper)
|
||||
D decimal words 1684 680 1995 1203 2046 619
|
||||
<len><mode> truncate to <len> characters, e.g. 20R, 12UB, 6D.
|
||||
A prefix (e.g. #Q3a) is prepended to every form.";
|
||||
|
||||
const HELP_LS: &str = "\
|
||||
ls [regex] list catalog entries sorted by name.
|
||||
ld [regex] list catalog entries sorted by date, oldest first (newest last).
|
||||
|
||||
The regex (default `.`) matches case-insensitively against the name, the full
|
||||
descriptor, and the comment. Each row gets an id (left column) reusable as a
|
||||
<name> in enc/keep/mv/etc. until the next listing. Under `pb`/`enc` the listing
|
||||
collapses to bare names (newest last for `ld`) — see `help pb`, `help enc`.";
|
||||
|
||||
const HELP_ENC: &str = "\
|
||||
enc <name|id> show an entry's generated password (to stdout).
|
||||
enc <command> run <command> and encode the LAST name in its output:
|
||||
enc ld <re> newest entry matching <re>
|
||||
enc ls <re> last entry by name
|
||||
enc gen <nm> the variant `gen <nm>` would list last
|
||||
|
||||
A literal name or list id is tried first; otherwise the argument is run as a
|
||||
command (like `pb`) and its last output line is taken as the entry name —
|
||||
`ls`/`ld`/`gen` emit bare names when consumed this way. On more than one
|
||||
candidate a `note:` reports the count and the chosen entry; to require a unique
|
||||
result instead:
|
||||
|
||||
set hel_enc_strict 1 # multiple candidates become an error
|
||||
|
||||
The password goes to stdout, notes/warnings to stderr, so `pb enc …` copies
|
||||
only the password. See `help pb`, `help gen`.";
|
||||
|
||||
const HELP_GEN: &str = "\
|
||||
gen[N] <name> show N variants of an entry, sorted by password length.
|
||||
|
||||
If <name> ends in one or more `G`, every numbered variant is generated
|
||||
(testG -> test1..test9, testGG -> test1..test99). If it ends in `X`, one random
|
||||
numbered variant is produced. Otherwise the single entry is shown. N defaults to
|
||||
10. Results populate the id list (left column) for `keep`/`enc`.
|
||||
|
||||
Under `pb`/`enc` it lists just the variant names: `pb gen tX` copies a random
|
||||
variant's name, `pb enc gen tX` copies that variant's password.";
|
||||
|
||||
const HELP_PB: &str = "\
|
||||
pb <command> run <command> and copy its stdout to the clipboard.
|
||||
|
||||
pb enc github copy github's password
|
||||
pb enc ld micro.*exa copy the newest matching entry's password
|
||||
pb ld micro copy the matching names (newest last)
|
||||
pb gen tX copy a random variant's name
|
||||
|
||||
If `hel_pb` (or $HEL_PB) is set, that one command receives the data on stdin.
|
||||
Otherwise hel copies to every clipboard found on PATH — pbcopy, wl-copy, xclip,
|
||||
xsel, and tmux inside a session — so a bare `pb` works on macOS, Wayland, X11
|
||||
and over SSH/tmux with no configuration. With none found, the data is left on
|
||||
stdout.";
|
||||
|
||||
const HELP_PASS: &str = "\
|
||||
pass <name> [pw] cache a master for <name> and its subtree for this session.
|
||||
`pass <name>` prompts; `pass <name> pw` sets it inline. Use
|
||||
`pass /` for the ROOT master used by top-level entries.
|
||||
Nothing is written to disk.
|
||||
unpass [name] forget a cached master: `unpass <name>` one, `unpass /` the
|
||||
root, `unpass` (no argument) all of them.";
|
||||
|
||||
const HELP_CORRECT: &str = "\
|
||||
correct <name> remember this password's hash as trusted, in ~/.hel_correct
|
||||
(names + hashes only, never secrets). Later, hel warns if a
|
||||
freshly derived password does not match a trusted hash —
|
||||
catching a mistyped master before you use the result.
|
||||
uncorrect <name> drop that trust.";
|
||||
|
||||
const HELP_KEEP: &str = "\
|
||||
keep <id> copy list row <id> (the left column of `ls`/`ld`/`gen`) into the
|
||||
catalog as a permanent entry. Useful after `gen` to keep one of the
|
||||
generated variants.";
|
||||
|
||||
const HELP_MV: &str = "\
|
||||
mv <name> <folder> move <name> under <folder> so <folder>'s password becomes
|
||||
its master (chained derivation). Use `/` as <folder> to
|
||||
move the entry back to the top level. This re-parents; it
|
||||
does not rename — to rename, `rm` and `add` again.";
|
||||
|
||||
const HELP_RM: &str = "\
|
||||
rm <name> remove an entry from the catalog. Other entries are unaffected;
|
||||
`save` to persist the change.";
|
||||
|
||||
const HELP_COMMENT: &str = "\
|
||||
comment <name> [text] set the entry's comment to <text>, or clear it when no
|
||||
text is given. The comment is searched by `ls`/`ld` and
|
||||
shown in listings.";
|
||||
|
||||
const HELP_CATALOG: &str = "\
|
||||
The catalog is just a script of `add …` lines.
|
||||
|
||||
dump print the whole catalog to the screen.
|
||||
save [target] persist it. <target>:
|
||||
(omitted) hel_dump / $HEL_DUMP / ~/.hel_dump
|
||||
<path> a file
|
||||
- print to screen (same as dump)
|
||||
|<command> pipe the dump into <command>'s stdin
|
||||
A diff (< removed, > added) vs the last load/save is shown.
|
||||
source <target> load a catalog. <target>:
|
||||
<path> a file (a localStorage key in the wasm build)
|
||||
<command>| run <command>, load its stdout as a script
|
||||
Notion: `save |hel store notion:<page>` and `source hel load notion:<page>|`
|
||||
store the catalog in a Notion code block (token via `set hel_notion_token …`).";
|
||||
|
||||
const HELP_CONFIG: &str = "\
|
||||
set <key> <value> set a runtime config value (typically from ~/.helrc). Keys
|
||||
are case-insensitive and each also reads the UPPER-CASE env
|
||||
var of the same name.
|
||||
|
||||
hel_pb clipboard command for `pb` (else the built-in multi-sink copy)
|
||||
hel_enc_strict 1/true/on -> `enc ls|ld <re>` errors when >1 entry matches
|
||||
hel_dump default `save`/`dump` target
|
||||
hel_notion_token token for the `hel store`/`hel load` Notion subcommands";
|
||||
|
||||
const HELP_FILES: &str = "\
|
||||
files and environment
|
||||
~/.helrc startup script, run once $HEL_INIT
|
||||
~/.hel_history REPL history $HEL_HISTORY
|
||||
~/.hel_dump default catalog file $HEL_DUMP
|
||||
~/.hel_correct trusted password hashes $HEL_CORRECT
|
||||
prompt string $HEL_PROMPT
|
||||
Non-interactive subcommands (run before the REPL, no ~/.helrc, no prompt):
|
||||
hel store <notion:ref> read a catalog on stdin and write it to Notion
|
||||
hel load <notion:ref> print the catalog stored in a Notion page";
|
||||
|
||||
const HELP_QUIT: &str = "\
|
||||
quit exit the REPL (also Ctrl-D / EOF). The catalog is NOT saved automatically
|
||||
— `save` first if you have unsaved changes.";
|
||||
|
||||
impl<'a> LKEval<'a> {
|
||||
/// `help [topic]`: the grouped command overview, or detail for one command
|
||||
/// or concept. Output goes to stdout; an unknown topic errors on stderr.
|
||||
pub fn cmd_help(&self, out: &LKOut, topic: &Option<String>) {
|
||||
let text: &str = match topic.as_deref().map(str::to_lowercase).as_deref() {
|
||||
None => HELP_OVERVIEW,
|
||||
Some("add" | "name" | "desc" | "descriptor" | "entry" | "parent") => HELP_NAME,
|
||||
Some("modes" | "mode") => HELP_MODES,
|
||||
Some("ls" | "ld" | "list") => HELP_LS,
|
||||
Some("enc") => HELP_ENC,
|
||||
Some("gen") => HELP_GEN,
|
||||
Some("pb") => HELP_PB,
|
||||
Some("pass" | "unpass") => HELP_PASS,
|
||||
Some("correct" | "uncorrect") => HELP_CORRECT,
|
||||
Some("keep") => HELP_KEEP,
|
||||
Some("mv" | "move") => HELP_MV,
|
||||
Some("rm" | "remove") => HELP_RM,
|
||||
Some("comment") => HELP_COMMENT,
|
||||
Some("dump" | "save" | "source" | "catalog") => HELP_CATALOG,
|
||||
Some("set" | "config") => HELP_CONFIG,
|
||||
Some("files" | "file" | "env" | "environment") => HELP_FILES,
|
||||
Some("quit" | "exit") => HELP_QUIT,
|
||||
Some(other) => {
|
||||
out.e(format!(
|
||||
"error: no help for {}; try `help` for the command list",
|
||||
other
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
out.o(text.to_string());
|
||||
}
|
||||
|
||||
pub fn get_password(&self, name: &String) -> Option<PasswordRef> {
|
||||
match self.state.lock().borrow().ls.get(name) {
|
||||
Some(pwd) => Some(pwd.clone()),
|
||||
None => match self.state.lock().borrow().db.get(name) {
|
||||
Some(pwd) => Some(pwd.clone()),
|
||||
None => None,
|
||||
},
|
||||
if let Some(pwd) = self.state.lock().borrow().ls.get(name) {
|
||||
return Some(pwd.clone());
|
||||
}
|
||||
if let Some(pwd) = self.state.lock().borrow().db.get(name) {
|
||||
return Some(pwd.clone());
|
||||
}
|
||||
// `gen` variants live only in the `ls` map under a numeric id (not in
|
||||
// `db`); also resolve them by entry name, so `enc <variant>` and
|
||||
// `enc gen …` (which yields a variant name) can re-encode them.
|
||||
self.state.lock().borrow().ls.values().find(|p| p.lock().borrow().name == *name).cloned()
|
||||
}
|
||||
|
||||
pub fn read_master(&self, out: &LKOut, pwd: PasswordRef, read: bool) -> Option<String> {
|
||||
@@ -207,25 +468,115 @@ impl<'a> LKEval<'a> {
|
||||
Some((name, pass))
|
||||
}
|
||||
|
||||
/// `enc <arg>`: pick which entry to encode, then encode it. Abstract, like
|
||||
/// `pb`: the argument is a name/id, or any command whose output names the
|
||||
/// entry. Precedence keeps `enc <name>`/`enc <id>` working even when a name
|
||||
/// collides with a command keyword:
|
||||
/// 1. `arg` resolves to a catalog entry or `ls` id -> encode it.
|
||||
/// 2. else `arg` parses as a command -> evaluate it capturing (so
|
||||
/// `ls`/`ld`/`gen` yield bare names), take the LAST non-empty output
|
||||
/// line as the entry name (newest for `ld`), and encode that. With >1
|
||||
/// candidate, `set hel_enc_strict 1` errors instead of taking the last.
|
||||
/// 3. else -> error.
|
||||
/// Any command is accepted as a producer (parity with `pb`); a producer that
|
||||
/// emits something that is not an entry name simply fails to resolve.
|
||||
pub fn cmd_enc_arg(&self, out: &LKOut, arg: &String) {
|
||||
if self.get_password(arg).is_some() {
|
||||
self.cmd_enc(out, arg);
|
||||
return;
|
||||
}
|
||||
let cmd = match command_parser::cmd(arg) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
out.e(format!("error: name {} not found", arg));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let print = LKEval::new(self.rl.clone(), cmd, self.state.clone(), self.read_password)
|
||||
.with_capture(true)
|
||||
.eval();
|
||||
// Surface the producer's own diagnostics (e.g. a bad regex).
|
||||
print.out.copy_err(out);
|
||||
let names: Vec<String> = print
|
||||
.out
|
||||
.data()
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|l| l.to_string())
|
||||
.collect();
|
||||
let name = match names.last() {
|
||||
Some(n) => n.clone(),
|
||||
None => {
|
||||
out.e(format!("error: no entry matches {}", arg));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if names.len() > 1 {
|
||||
if config_flag("hel_enc_strict") {
|
||||
out.e(format!(
|
||||
"error: {} entries match {}; refusing under hel_enc_strict (narrow the pattern or use an id)",
|
||||
names.len(),
|
||||
arg
|
||||
));
|
||||
return;
|
||||
}
|
||||
out.e(format!("note: {} names matched; encoding last: {}", names.len(), name));
|
||||
}
|
||||
self.cmd_enc(out, &name);
|
||||
}
|
||||
|
||||
pub fn cmd_pb(&self, out: &LKOut, command: &String) {
|
||||
match command_parser::cmd(command) {
|
||||
Ok(cmd) => {
|
||||
let print = LKEval::new(self.rl.clone(), cmd, self.state.clone(), self.read_password).eval();
|
||||
// capture=true so a wrapped `ls`/`ld` yields bare names.
|
||||
let print = LKEval::new(self.rl.clone(), cmd, self.state.clone(), self.read_password)
|
||||
.with_capture(true)
|
||||
.eval();
|
||||
let data = print.out.data();
|
||||
print.out.copy_err(&out);
|
||||
if data.len() > 0 {
|
||||
let (copy_command, copy_cmd_args) = get_copy_command_from_env();
|
||||
match call_cmd_with_input(©_command, ©_cmd_args, &data) {
|
||||
Ok(s) if s.len() > 0 => {
|
||||
out.o(format!(
|
||||
"Copied output with the command {}, and got following output:",
|
||||
copy_command
|
||||
));
|
||||
out.o(s.trim().to_string());
|
||||
// Clipboard copy shells out to pbcopy/xclip/tmux: native only.
|
||||
// In the browser the page provides a Copy button instead.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
hel_clipboard_write(&data);
|
||||
out.o(format!("Copied {} characters to the clipboard", data.chars().count()));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// Explicit override (`set hel_pb` or $HEL_PB): one command,
|
||||
// as before. Otherwise fan out to every present clipboard.
|
||||
match config_get("hel_pb").and_then(|s| get_cmd_args_from_command(&s).ok()) {
|
||||
Some((copy_command, copy_cmd_args)) => {
|
||||
match call_cmd_with_input(©_command, ©_cmd_args, &data) {
|
||||
Ok(s) if s.len() > 0 => {
|
||||
out.o(format!(
|
||||
"Copied output with the command {}, and got following output:",
|
||||
copy_command
|
||||
));
|
||||
out.o(s.trim().to_string());
|
||||
}
|
||||
Ok(_) => out.o(format!("Copied output with command {}", copy_command)),
|
||||
Err(e) => out.e(format!("error: failed to copy: {}", e.to_string())),
|
||||
};
|
||||
}
|
||||
None => {
|
||||
let report = crate::utils::copy_to_clipboards(&data);
|
||||
if !report.ok.is_empty() {
|
||||
out.o(format!(
|
||||
"Copied {} chars to clipboard ({})",
|
||||
data.chars().count(),
|
||||
report.ok.join(", ")
|
||||
));
|
||||
} else {
|
||||
// No sink and no override: never drop the payload.
|
||||
out.o(data.clone());
|
||||
out.e("error: no clipboard available; data left on stdout".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => out.o(format!("Copied output with command {}", copy_command)),
|
||||
Err(e) => out.e(format!("error: failed to copy: {}", e.to_string())),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => out.e(format!("error: failed to parse command {}: {}", command, e.to_string())),
|
||||
@@ -234,31 +585,42 @@ 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,
|
||||
let script: String;
|
||||
if source.trim().ends_with("|") {
|
||||
// Loading from a command's output needs a subprocess: native only.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
out.e("error: pipe source is not available in the browser".to_string());
|
||||
return false;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let (cmd, args) = match get_cmd_args_from_command(source.trim().trim_end_matches('|')) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
out.e(format!("error: failed to parse command {:?}: {}", source, e.to_string()));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
script = match call_cmd_with_input(&cmd, &args, "") {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
out.e(format!("error: failed to execute command {}: {}", cmd, e.to_string()));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// File path on native; localStorage key in the browser.
|
||||
let key = shellexpand::full(source).unwrap().into_owned();
|
||||
script = match crate::storage::read(&key) {
|
||||
Ok(script) => script,
|
||||
Err(e) => {
|
||||
out.e(format!("error: failed to parse command {:?}: {}", source, e.to_string()));
|
||||
out.e(format!("error: failed to read {}: {}", source, e.to_string()));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
match call_cmd_with_input(&cmd, &args, "") {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
out.e(format!("error: failed to execute command {}: {}", cmd, e.to_string()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let script = shellexpand::full(source).unwrap().into_owned();
|
||||
match std::fs::read_to_string(script) {
|
||||
Ok(script) => script,
|
||||
Err(e) => {
|
||||
out.e(format!("error: failed to read file {}: {}", source, e.to_string()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
match command_parser::script(&script) {
|
||||
Ok(cmd_list) => {
|
||||
for cmd in cmd_list {
|
||||
@@ -321,39 +683,37 @@ impl<'a> LKEval<'a> {
|
||||
None => config_get("hel_dump").unwrap_or_else(|| DUMP_FILE.to_str().unwrap().to_string()),
|
||||
};
|
||||
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);
|
||||
let mut vals = data.values().map(|v| v.clone()).collect::<Vec<PasswordRef>>();
|
||||
vals.sort_by(|a, b| a.lock().borrow().name.cmp(&b.lock().borrow().name));
|
||||
for pwd in vals {
|
||||
writeln!(writer, "add {}", pwd.lock().borrow().to_string())?
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
if script.trim().starts_with("|") {
|
||||
let (cmd, args) = match get_cmd_args_from_command(script.trim().trim_start_matches('|')) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
out.e(format!("error: failed to parse command {:?}: {}", script, e.to_string()));
|
||||
return;
|
||||
// Piping the dump to a command needs a subprocess: native only.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
out.e("error: pipe dump is not available in the browser".to_string());
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let (cmd, args) = match get_cmd_args_from_command(script.trim().trim_start_matches('|')) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
out.e(format!("error: failed to parse command {:?}: {}", script, e.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
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) => {
|
||||
out.e(format!("error: failed to execute command {}: {}", cmd, e.to_string()));
|
||||
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);
|
||||
} else {
|
||||
out.o(format!("Passwords saved to command {}", cmd));
|
||||
}
|
||||
};
|
||||
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) => {
|
||||
out.e(format!("error: failed to execute command {}: {}", cmd, e.to_string()));
|
||||
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);
|
||||
} else {
|
||||
out.o(format!("Passwords saved to command {}", cmd));
|
||||
}
|
||||
} else if script.trim() == "-" {
|
||||
let mut vals = (&self.state.lock().borrow().db).values().map(|v| v.clone()).collect::<Vec<PasswordRef>>();
|
||||
@@ -362,14 +722,15 @@ impl<'a> LKEval<'a> {
|
||||
out.o(format!("add {}", pwd.lock().borrow().to_string()))
|
||||
}
|
||||
} else {
|
||||
// File path on native; localStorage key in the browser.
|
||||
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 {
|
||||
// Trailing newline to match the historical file format (writeln per line).
|
||||
let body = if data.is_empty() { String::new() } else { format!("{}\n", data) };
|
||||
match crate::storage::write(&script, &body) {
|
||||
Ok(()) => {
|
||||
self.state.lock().borrow_mut().last_dump = Some(data);
|
||||
out.o(format!("Passwords saved to file {}", script));
|
||||
out.o(format!("Passwords saved to {}", script));
|
||||
}
|
||||
Err(e) => out.e(format!("error: failed to dump passwords to {}: {}", script, e.to_string())),
|
||||
};
|
||||
@@ -408,7 +769,14 @@ impl<'a> LKEval<'a> {
|
||||
let key = Radix::new(counter, 36).unwrap().to_string();
|
||||
counter += 1;
|
||||
self.state.lock().borrow_mut().ls.insert(key.clone(), pwd.clone());
|
||||
out.o(format!("{:>3} {}", key, pwd.lock().borrow().to_string()));
|
||||
// Captured (under `pb`/`enc`): emit just the entry name — a unique db
|
||||
// key that re-resolves via get_password, so it can feed `enc` and
|
||||
// makes `pb ls`/`pb ld` copy clean names. Interactive: rich rows.
|
||||
if self.capture {
|
||||
out.o(pwd.lock().borrow().name.to_string());
|
||||
} else {
|
||||
out.o(format!("{:>3} {}", key, pwd.lock().borrow().to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,11 +802,13 @@ impl<'a> LKEval<'a> {
|
||||
None => return,
|
||||
};
|
||||
fn load_lines() -> std::io::Result<HashSet<String>> {
|
||||
let file = fs::File::open(CORRECT_FILE.to_str().unwrap())?;
|
||||
let reader = BufReader::new(file);
|
||||
let content = crate::storage::read(CORRECT_FILE.to_str().unwrap())?;
|
||||
let mut lines = HashSet::new();
|
||||
for line in reader.lines() {
|
||||
lines.insert(line?.trim().to_owned());
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if !line.is_empty() {
|
||||
lines.insert(line.to_owned());
|
||||
}
|
||||
}
|
||||
Ok(lines)
|
||||
}
|
||||
@@ -469,12 +839,12 @@ impl<'a> LKEval<'a> {
|
||||
data.remove(&encpwd);
|
||||
}
|
||||
fn save_lines(data: &HashSet<String>) -> std::io::Result<()> {
|
||||
let file = fs::File::create(CORRECT_FILE.to_str().unwrap())?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
let mut content = String::new();
|
||||
for entry in data {
|
||||
writeln!(writer, "{}", entry)?;
|
||||
content.push_str(entry);
|
||||
content.push('\n');
|
||||
}
|
||||
Ok(())
|
||||
crate::storage::write(CORRECT_FILE.to_str().unwrap(), &content)
|
||||
}
|
||||
match save_lines(&data) {
|
||||
Ok(()) => out.o(format!(
|
||||
@@ -552,13 +922,22 @@ impl<'a> LKEval<'a> {
|
||||
encpwds.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
|
||||
self.state.lock().borrow_mut().ls.clear();
|
||||
let mut counter = 1;
|
||||
out.o(format!("{:>3} {:>36} {:>4} {}", "", "Password", "Len", "Name"));
|
||||
// Captured (under `pb`/`enc`): emit just the variant names, like `ls`/`ld`,
|
||||
// so `pb gen …` copies names and `enc gen …` resolves one. Interactive:
|
||||
// the full key/password/len/name table.
|
||||
if !self.capture {
|
||||
out.o(format!("{:>3} {:>36} {:>4} {}", "", "Password", "Len", "Name"));
|
||||
}
|
||||
for num in (encpwds.len() - min(genpwds.len(), num))..encpwds.len() {
|
||||
let (pwd, pass) = (encpwds[num].0.clone(), encpwds[num].1.to_string());
|
||||
let key = Radix::new(counter, 36).unwrap().to_string();
|
||||
counter += 1;
|
||||
self.state.lock().borrow_mut().ls.insert(key.clone(), pwd.clone());
|
||||
out.o(format!("{:>3} {:>36} {:>4} {}", key, pass, pass.len(), pwd.lock().borrow().to_string()));
|
||||
if self.capture {
|
||||
out.o(pwd.lock().borrow().name.to_string());
|
||||
} else {
|
||||
out.o(format!("{:>3} {:>36} {:>4} {}", key, pass, pass.len(), pwd.lock().borrow().to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@ pub mod parser;
|
||||
pub mod password;
|
||||
pub mod repl;
|
||||
pub mod skey;
|
||||
pub mod storage;
|
||||
pub mod structs;
|
||||
pub mod utils;
|
||||
|
||||
@@ -37,13 +37,22 @@ peg::parser! {
|
||||
{ Password::new(None, pn, pl, pm, ps, pd, pc) }
|
||||
rule mname() -> Password = &(word() _ word() _ num()? mode() _ date()) pr:word() _ pn:word() _ pl:num()? pm:mode() _ pd:date() pc:comment()?
|
||||
{ Password::new(Some(pr), pn, pl, pm, 99, pd, pc) }
|
||||
// prefix + name + [len]mode (no seq/date) -> defaults seq 99, date now
|
||||
rule npname() -> Password = &(word() _ word() _ num()? mode()) pr:word() _ pn:word() _ pl:num()? pm:mode() pc:comment()?
|
||||
{ Password::new(Some(pr), pn, pl, pm, 99, Date::now(), pc) }
|
||||
rule sname() -> Password = &(word() _ num()? mode() _ date()) pn:word() _ pl:num()? pm:mode() _ pd:date() pc:comment()?
|
||||
{ Password::new(None, pn, pl, pm, 99, pd, pc) }
|
||||
rule nname() -> Password = &(word() _ num()? mode()) pn:word() _ pl:num()? pm:mode()
|
||||
{ Password::new(None, pn, pl, pm, 99, Date::now(), None) }
|
||||
rule qname() -> Password = &(word()) pn:word()
|
||||
{ Password::new(None, pn, None, Mode::NoSpaceCamel, 99, Date::now(), None) }
|
||||
pub rule name() -> Password = name:(jname() / pname() / mname() / sname() / nname() / qname())? {?
|
||||
rule nname() -> Password = &(word() _ num()? mode()) pn:word() _ pl:num()? pm:mode() pc:comment()?
|
||||
{ Password::new(None, pn, pl, pm, 99, Date::now(), pc) }
|
||||
// prefix + name (+ optional comment) -> defaults mode R, seq 99, date now.
|
||||
// A `^parent` token is never a name, so a `^`-leading second word fails here and
|
||||
// falls through to qname, which keeps it as the comment (the parent marker).
|
||||
rule qpname() -> Password = &(word() _ word()) pr:word() _ pn:$(!"^" ['!'..='~']+) pc:comment()?
|
||||
{ Password::new(Some(pr), pn.to_string(), None, Mode::Regular, 99, Date::now(), pc) }
|
||||
// name (+ optional comment) -> e.g. `github` or `github ^work`
|
||||
rule qname() -> Password = pn:word() pc:comment()?
|
||||
{ Password::new(None, pn, None, Mode::Regular, 99, Date::now(), pc) }
|
||||
pub rule name() -> Password = name:(jname() / pname() / mname() / npname() / sname() / nname() / qpname() / qname())? {?
|
||||
match name { Some(n) => Ok(n), None => Err("failed to parse password description") }
|
||||
}
|
||||
|
||||
@@ -76,10 +85,13 @@ peg::parser! {
|
||||
_ => Err("unknown mode"),
|
||||
}
|
||||
}
|
||||
rule mode() -> Mode = m:(umode() / rmode()) { m }
|
||||
// A mode is a whole token: it must not be followed by another letter/digit, or
|
||||
// a single letter would be stolen from a longer word (e.g. the `n` of `now`,
|
||||
// making `name r now` mis-parse as name=`r`, mode=`n` instead of date=`now`).
|
||||
rule mode() -> Mode = m:(umode() / rmode()) !['0'..='9' | 'a'..='z' | 'A'..='Z'] { m }
|
||||
|
||||
rule noop_cmd() -> Command<'input> = ("#" [' '..='~']*)? { Command::Noop }
|
||||
rule help_cmd() -> Command<'input> = "help" { Command::Help }
|
||||
rule help_cmd() -> Command<'input> = "help" t:(_ w:word() { w })? { Command::Help(t) }
|
||||
rule quit_cmd() -> Command<'input> = "quit" { Command::Quit }
|
||||
rule pb_cmd() -> Command<'input> = "pb" _ e:$(([' '..='~'])+) { Command::PasteBuffer(e.to_string()) }
|
||||
rule save_cmd() -> Command<'input> = "save" _ s:$(([' '..='~'])+) { Command::Dump(Some(s.to_string())) }
|
||||
@@ -101,8 +113,10 @@ peg::parser! {
|
||||
rule pass_cmd() -> Command<'input> = p:(pass_long_cmd() / pass_short_cmd()) { p }
|
||||
rule correct_cmd() -> Command<'input> = "correct" _ name:word() { Command::Correct(name) }
|
||||
rule uncorrect_cmd() -> Command<'input> = "uncorrect" _ name:word() { Command::Uncorrect(name) }
|
||||
rule unpass_cmd() -> Command<'input> = "unpass" _ name:word() { Command::UnPass(name) }
|
||||
rule enc_cmd() -> Command<'input> = "enc" _ name:word() { Command::Enc(name) }
|
||||
rule unpass_cmd() -> Command<'input> = "unpass" name:(_ w:word() { w })? { Command::UnPass(name) }
|
||||
// `enc` takes the rest of the line (like `pb`): a bare name/id, or a
|
||||
// sub-command whose output names the entry to encode (e.g. `enc ld re`).
|
||||
rule enc_cmd() -> Command<'input> = "enc" _ e:$(([' '..='~'])+) { Command::Enc(e.to_string()) }
|
||||
rule rm_cmd() -> Command<'input> = "rm" _ name:word() { Command::Rm(name) }
|
||||
rule comment_cmd() -> Command<'input> = "comment" _ name:word() c:comment()? { Command::Comment(name, c) }
|
||||
}
|
||||
@@ -239,6 +253,72 @@ add t3 C 99 2022-12-14
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_enc_arg_test() {
|
||||
// `enc` now captures the rest of the line (like `pb`): a bare name/id,
|
||||
// or a sub-command (`ld <regex>`) resolved at eval time.
|
||||
assert_eq!(command_parser::cmd("enc t3"), Ok(Command::Enc("t3".to_string())));
|
||||
assert_eq!(command_parser::cmd("enc 3"), Ok(Command::Enc("3".to_string())));
|
||||
assert_eq!(
|
||||
command_parser::cmd("enc ld micro.*exa"),
|
||||
Ok(Command::Enc("ld micro.*exa".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
command_parser::cmd("enc ls foo"),
|
||||
Ok(Command::Enc("ls foo".to_string()))
|
||||
);
|
||||
// Display round-trips.
|
||||
assert_eq!(Command::Enc("ld micro.*exa".to_string()).to_string(), "enc ld micro.*exa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_help_test() {
|
||||
assert_eq!(command_parser::cmd("help"), Ok(Command::Help(None)));
|
||||
assert_eq!(command_parser::cmd("help enc"), Ok(Command::Help(Some("enc".to_string()))));
|
||||
assert_eq!(command_parser::cmd("help modes"), Ok(Command::Help(Some("modes".to_string()))));
|
||||
assert_eq!(Command::Help(Some("enc".to_string())).to_string(), "help enc");
|
||||
assert_eq!(Command::Help(None).to_string(), "help");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_short_parent_test() {
|
||||
// `name ^parent` (no mode/date): name is the name, ^parent stays in the comment
|
||||
// for fix_hierarchy — it must NOT be read as prefix+name.
|
||||
let p = command_parser::name("x ^acc").unwrap();
|
||||
assert_eq!(p.name, "x");
|
||||
assert_eq!(p.prefix, None);
|
||||
assert_eq!(p.comment, Some("^acc".to_string()));
|
||||
// `name [len]mode ^parent` keeps the parent in the comment too
|
||||
let p2 = command_parser::name("x 20R ^acc").unwrap();
|
||||
assert_eq!(p2.name, "x");
|
||||
assert_eq!(p2.length, Some(20));
|
||||
assert_eq!(p2.mode, Mode::Regular);
|
||||
assert_eq!(p2.comment, Some("^acc".to_string()));
|
||||
// a real prefix + name (second word not ^-led) still parses as prefix+name
|
||||
let p3 = command_parser::name("#W9 github").unwrap();
|
||||
assert_eq!(p3.prefix, Some("#W9".to_string()));
|
||||
assert_eq!(p3.name, "github");
|
||||
assert_eq!(p3.comment, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mode_word_boundary_test() {
|
||||
// `now` must be the date, not mode `n` + leftover `ow`: name=testG mode=Regular.
|
||||
let g = command_parser::name("testG r now").unwrap();
|
||||
assert_eq!(g.name, "testG");
|
||||
assert_eq!(g.prefix, None);
|
||||
assert_eq!(g.mode, Mode::Regular);
|
||||
// the whole gen command parses with no trailing-input error (so `now` was
|
||||
// consumed as the date, not as a mode that left `ow` dangling)
|
||||
assert!(command_parser::cmd("gen testG r now").is_ok());
|
||||
assert!(command_parser::cmd("gen testG R now").is_ok());
|
||||
// a length+mode token still works right up to a word boundary
|
||||
let h = command_parser::name("github 20R").unwrap();
|
||||
assert_eq!(h.name, "github");
|
||||
assert_eq!(h.length, Some(20));
|
||||
assert_eq!(h.mode, Mode::Regular);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_password_test() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -19,6 +19,12 @@ pub struct LKEval<'a> {
|
||||
pub cmd: Command<'a>,
|
||||
pub state: LKRef,
|
||||
pub read_password: fn(String) -> std::io::Result<String>,
|
||||
/// When true, listing commands (`ls`/`ld`) emit bare entry names instead of
|
||||
/// the rich `key name mode seq date comment` rows. Set by `pb`/`enc` on the
|
||||
/// sub-command they evaluate so the captured output is consumable (names
|
||||
/// feed `enc`, and `pb ls`/`pb ld` copy clean names). Interactive evals keep
|
||||
/// it false.
|
||||
pub capture: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -80,9 +86,16 @@ impl<'a> LKEval<'a> {
|
||||
cmd,
|
||||
state,
|
||||
read_password,
|
||||
capture: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder: mark this eval as capturing (see `LKEval::capture`).
|
||||
pub fn with_capture(mut self, capture: bool) -> Self {
|
||||
self.capture = capture;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn news(cmd: Command<'a>, state: LKRef) -> Self {
|
||||
LKEval::new(Editor::new(), cmd, state, |_| { Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "could not read password")) })
|
||||
}
|
||||
@@ -121,8 +134,8 @@ impl<'a> LKEval<'a> {
|
||||
}
|
||||
None => out.e(format!("error: password {} not found", name)),
|
||||
},
|
||||
Command::Enc(name) => {
|
||||
self.cmd_enc(&out, name);
|
||||
Command::Enc(arg) => {
|
||||
self.cmd_enc_arg(&out, arg);
|
||||
}
|
||||
Command::Gen(num, name) => self.cmd_gen(&out, &num, &name),
|
||||
Command::PasteBuffer(command) => self.cmd_pb(&out, command),
|
||||
@@ -133,16 +146,18 @@ impl<'a> LKEval<'a> {
|
||||
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) {
|
||||
Command::UnPass(Some(name)) => match self.state.lock().borrow_mut().secrets.remove(name) {
|
||||
Some(_) => out.o(format!("Removed saved password for {}", name)),
|
||||
None => out.e(format!("error: saved password for {} not found", name)),
|
||||
},
|
||||
Command::UnPass(None) => {
|
||||
self.state.lock().borrow_mut().secrets.clear();
|
||||
out.o("forgot all cached masters".to_string());
|
||||
},
|
||||
Command::Correct(name) => self.cmd_correct(&out, name, true, None),
|
||||
Command::Uncorrect(name) => self.cmd_correct(&out, name, false, None),
|
||||
Command::Noop => { to_history = false; },
|
||||
Command::Help => {
|
||||
out.o("HELP".to_string());
|
||||
}
|
||||
Command::Help(topic) => self.cmd_help(&out, topic),
|
||||
Command::Mv(name, folder) => self.cmd_mv(&out, &name, &folder),
|
||||
Command::Error(error) => {
|
||||
to_history = false;
|
||||
@@ -433,4 +448,123 @@ mod tests {
|
||||
LKEval::news(Command::Pass("t1".to_string(), Some("other pw".to_string())), lk.clone()).eval();
|
||||
assert_eq!(lk.lock().borrow().secrets[&"t1".to_string()], "other pw");
|
||||
}
|
||||
|
||||
fn mk(name: &str, y: i32, m: u32, d: u32) -> crate::password::PasswordRef {
|
||||
Password::from_password(Password {
|
||||
name: name.to_string(),
|
||||
prefix: None,
|
||||
length: None,
|
||||
mode: Mode::Regular,
|
||||
seq: 99,
|
||||
date: Date::new(y, m, d),
|
||||
comment: None,
|
||||
parent: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_names_test() {
|
||||
let lk = Arc::new(ReentrantMutex::new(RefCell::new(LK::new())));
|
||||
LKEval::news(Command::Add(mk("btest", 2022, 1, 2)), lk.clone()).eval();
|
||||
LKEval::news(Command::Add(mk("atest", 2024, 5, 6)), lk.clone()).eval();
|
||||
|
||||
// Captured ls -> bare names, sorted by name.
|
||||
let pr = LKEval::news(Command::Ls(".".to_string()), lk.clone()).with_capture(true).eval();
|
||||
assert_eq!(pr.out, LKOut::from_vecs(vec!["atest".to_string(), "btest".to_string()], vec![]));
|
||||
|
||||
// Captured ld -> bare names, sorted by date ascending (newest last).
|
||||
let pr = LKEval::news(Command::Ld(".".to_string()), lk.clone()).with_capture(true).eval();
|
||||
assert_eq!(pr.out, LKOut::from_vecs(vec!["btest".to_string(), "atest".to_string()], vec![]));
|
||||
|
||||
// Interactive ls keeps the rich rows (key + mode + date), not bare names.
|
||||
let pr = LKEval::news(Command::Ls(".".to_string()), lk.clone()).eval();
|
||||
let rows = pr.out.out.as_ref().unwrap().lock();
|
||||
assert!(rows.iter().any(|l| l.contains("atest R 99 2024-05-06")));
|
||||
assert!(rows.iter().all(|l| l.as_str() != "atest" && l.as_str() != "btest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_test() {
|
||||
let lk = Arc::new(ReentrantMutex::new(RefCell::new(LK::new())));
|
||||
// overview
|
||||
let pr = LKEval::news(Command::Help(None), lk.clone()).eval();
|
||||
assert!(pr.out.out.as_ref().unwrap().lock()[0].contains("ENTRIES"));
|
||||
// per-topic detail
|
||||
let pr = LKEval::news(Command::Help(Some("enc".to_string())), lk.clone()).eval();
|
||||
assert!(pr.out.out.as_ref().unwrap().lock()[0].contains("enc ld <re>"));
|
||||
// alias resolves to the same topic
|
||||
let pr = LKEval::news(Command::Help(Some("descriptor".to_string())), lk.clone()).eval();
|
||||
assert!(pr.out.out.as_ref().unwrap().lock()[0].contains("[prefix] <name>"));
|
||||
// unknown topic -> stderr error, empty stdout
|
||||
let pr = LKEval::news(Command::Help(Some("bogus".to_string())), lk.clone()).eval();
|
||||
assert_eq!(pr.out.out.as_ref().unwrap().lock().len(), 0);
|
||||
assert!(pr.out.err.as_ref().unwrap().lock()[0].contains("no help for bogus"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enc_consumer_test() {
|
||||
let lk = Arc::new(ReentrantMutex::new(RefCell::new(LK::new())));
|
||||
LKEval::news(Command::Add(mk("microexa1", 2024, 1, 10)), lk.clone()).eval();
|
||||
LKEval::news(Command::Add(mk("microexa2", 2025, 9, 1)), lk.clone()).eval();
|
||||
LKEval::news(Command::Add(mk("microexaadmin", 2026, 3, 4)), lk.clone()).eval();
|
||||
LKEval::news(Command::Add(mk("other", 2023, 1, 1)), lk.clone()).eval();
|
||||
let rp = |p: String| -> std::io::Result<String> {
|
||||
if p == "/" { Ok("a".to_string()) } else { Ok("".to_string()) }
|
||||
};
|
||||
|
||||
// enc ld <re>: 3 matches -> encode the newest (microexaadmin); note on stderr.
|
||||
let pr = LKEval::newd(command_parser::cmd("enc ld micro.*exa").unwrap(), lk.clone(), rp).eval();
|
||||
assert_eq!(pr.out.out.as_ref().unwrap().lock().len(), 1);
|
||||
let pass_newest = pr.out.out.as_ref().unwrap().lock()[0].clone();
|
||||
assert!(pr.out.err.as_ref().unwrap().lock().iter().any(|l| l == "note: 3 names matched; encoding last: microexaadmin"));
|
||||
|
||||
// Same password as encoding the newest entry by name directly.
|
||||
lk.lock().borrow_mut().secrets.clear();
|
||||
let pr2 = LKEval::newd(command_parser::cmd("enc microexaadmin").unwrap(), lk.clone(), rp).eval();
|
||||
assert_eq!(pr2.out.out.as_ref().unwrap().lock()[0].clone(), pass_newest);
|
||||
|
||||
// 0 matches -> error, empty stdout (so a wrapping `pb` copies nothing).
|
||||
let pr = LKEval::newd(command_parser::cmd("enc ld nomatch").unwrap(), lk.clone(), rp).eval();
|
||||
assert_eq!(pr.out.out.as_ref().unwrap().lock().len(), 0);
|
||||
assert!(pr.out.err.as_ref().unwrap().lock().iter().any(|l| l.contains("no entry matches")));
|
||||
|
||||
// strict: >1 match errors, nothing encoded.
|
||||
crate::structs::config_set("hel_enc_strict", "1");
|
||||
let pr = LKEval::newd(command_parser::cmd("enc ld micro.*exa").unwrap(), lk.clone(), rp).eval();
|
||||
assert_eq!(pr.out.out.as_ref().unwrap().lock().len(), 0);
|
||||
assert!(pr.out.err.as_ref().unwrap().lock().iter().any(|l| l.contains("hel_enc_strict")));
|
||||
crate::structs::config_set("hel_enc_strict", "0");
|
||||
|
||||
// literal-first: an entry named like a command keyword still encodes that
|
||||
// entry (not the listing) and emits no "names matched" note.
|
||||
LKEval::news(Command::Add(mk("ld", 2020, 1, 1)), lk.clone()).eval();
|
||||
lk.lock().borrow_mut().secrets.clear();
|
||||
let pr = LKEval::newd(command_parser::cmd("enc ld").unwrap(), lk.clone(), rp).eval();
|
||||
assert_eq!(pr.out.out.as_ref().unwrap().lock().len(), 1);
|
||||
assert!(!pr.out.err.as_ref().unwrap().lock().iter().any(|l| l.contains("names matched")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gen_capture_and_enc_test() {
|
||||
let lk = Arc::new(ReentrantMutex::new(RefCell::new(LK::new())));
|
||||
let rp = |p: String| -> std::io::Result<String> {
|
||||
if p == "/" { Ok("a".to_string()) } else { Ok("".to_string()) }
|
||||
};
|
||||
// Captured `gen` -> bare variant names (no header, no password columns),
|
||||
// so it composes with pb/enc like ls/ld.
|
||||
let pr = LKEval::newd(command_parser::cmd("gen testG").unwrap(), lk.clone(), rp)
|
||||
.with_capture(true)
|
||||
.eval();
|
||||
let names = pr.out.out.as_ref().unwrap().lock();
|
||||
assert!(!names.is_empty());
|
||||
assert!(names.iter().all(|l| l.starts_with("test") && !l.contains(' ')));
|
||||
assert!(!names.iter().any(|l| l.contains("Password")));
|
||||
drop(names);
|
||||
|
||||
// `enc gen` runs gen and encodes one variant — proves enc accepts any
|
||||
// command, not only ls/ld. `testX` yields a single random variant.
|
||||
let pr = LKEval::newd(command_parser::cmd("enc gen testX").unwrap(), lk.clone(), rp).eval();
|
||||
assert_eq!(pr.out.out.as_ref().unwrap().lock().len(), 1);
|
||||
assert!(pr.out.out.as_ref().unwrap().lock()[0].contains(' ')); // six S/KEY words, not a name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Key -> value persistence with a per-target backend.
|
||||
//!
|
||||
//! - Native: the key is a filesystem path; backed by `std::fs`.
|
||||
//! - WASM: the key is a localStorage key; backed by JS imports
|
||||
//! `hel_storage_get` / `hel_storage_set` (provided by the host page).
|
||||
//!
|
||||
//! This lets the same command code (`init`, `source`, `dump`/`save`, `correct`)
|
||||
//! persist to files on the CLI and to the browser's localStorage on the web,
|
||||
//! with no per-call-site branching.
|
||||
|
||||
use std::io;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read(key: &str) -> io::Result<String> {
|
||||
std::fs::read_to_string(key)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn write(key: &str, data: &str) -> io::Result<()> {
|
||||
std::fs::write(key, data)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod imp {
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = hel_storage_get)]
|
||||
pub fn get(key: &str) -> Option<String>;
|
||||
#[wasm_bindgen(js_name = hel_storage_set)]
|
||||
pub fn set(key: &str, val: &str);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn read(key: &str) -> io::Result<String> {
|
||||
match imp::get(key) {
|
||||
Some(v) => Ok(v),
|
||||
None => Err(io::Error::new(io::ErrorKind::NotFound, "key not found in localStorage")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn write(key: &str, data: &str) -> io::Result<()> {
|
||||
imp::set(key, data);
|
||||
Ok(())
|
||||
}
|
||||
@@ -67,6 +67,15 @@ pub fn config_get(key: &str) -> Option<String> {
|
||||
std::env::var(key.to_uppercase()).ok()
|
||||
}
|
||||
|
||||
/// A boolean config flag: true for `1/true/yes/on` (any case), false otherwise
|
||||
/// (including unset). Used for toggles like `hel_enc_strict`.
|
||||
pub fn config_flag(key: &str) -> bool {
|
||||
match config_get(key) {
|
||||
Some(v) => matches!(v.trim().to_lowercase().as_str(), "1" | "true" | "yes" | "on"),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`).
|
||||
@@ -97,7 +106,7 @@ pub enum Command<'a> {
|
||||
Enc(Name),
|
||||
Gen(u32, PasswordRef),
|
||||
Pass(Name, Option<String>),
|
||||
UnPass(Name),
|
||||
UnPass(Option<Name>),
|
||||
Correct(Name),
|
||||
Uncorrect(Name),
|
||||
PasteBuffer(String),
|
||||
@@ -107,7 +116,7 @@ pub enum Command<'a> {
|
||||
Comment(Name, Comment),
|
||||
Error(LKErr<'a>),
|
||||
Noop,
|
||||
Help,
|
||||
Help(Option<Name>),
|
||||
Quit,
|
||||
}
|
||||
|
||||
@@ -133,7 +142,7 @@ impl<'a> PartialEq for Command<'a> {
|
||||
(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,
|
||||
(Command::Help, Command::Help) => true,
|
||||
(Command::Help(s), Command::Help(o)) => s == o,
|
||||
(Command::Quit, Command::Quit) => true,
|
||||
_ => false,
|
||||
}
|
||||
@@ -153,7 +162,8 @@ impl<'a> std::fmt::Display for Command<'a> {
|
||||
Command::Gen(a, b) => write!(f, "gen{} {}", a, b.lock().borrow().to_string().trim()),
|
||||
Command::Pass(a, None) => write!(f, "pass {}", a),
|
||||
Command::Pass(a, Some(b)) => write!(f, "pass {} {}", a, b),
|
||||
Command::UnPass(s) => write!(f, "unpass {}", s),
|
||||
Command::UnPass(None) => write!(f, "unpass"),
|
||||
Command::UnPass(Some(s)) => write!(f, "unpass {}", s),
|
||||
Command::Correct(s) => write!(f, "correct {}", s),
|
||||
Command::Uncorrect(s) => write!(f, "uncorrect {}", s),
|
||||
Command::PasteBuffer(s) => write!(f, "pb {}", s),
|
||||
@@ -167,7 +177,8 @@ impl<'a> std::fmt::Display for Command<'a> {
|
||||
Command::Comment(a, Some(b)) => write!(f, "comment {} {}", a, b),
|
||||
Command::Error(s) => write!(f, "error {}", s),
|
||||
Command::Noop => write!(f, "noop"),
|
||||
Command::Help => write!(f, "help"),
|
||||
Command::Help(None) => write!(f, "help"),
|
||||
Command::Help(Some(t)) => write!(f, "help {}", t),
|
||||
Command::Quit => write!(f, "quit"),
|
||||
}
|
||||
}
|
||||
@@ -392,7 +403,7 @@ pub fn init() -> Option<LKRead> {
|
||||
let lk = Arc::new(ReentrantMutex::new(RefCell::new(LK::new())));
|
||||
let editor = Editor::new();
|
||||
|
||||
match std::fs::read_to_string(INIT_FILE.to_str().unwrap()) {
|
||||
match crate::storage::read(INIT_FILE.to_str().unwrap()) {
|
||||
Ok(script) => match command_parser::script(&script) {
|
||||
Ok(cmd_list) => {
|
||||
for cmd in cmd_list {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use shlex::split;
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
@@ -150,25 +148,32 @@ pub mod editor {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod editor {
|
||||
use crate::structs::LKErr;
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
// Mirror the unix editor's contract so repl.rs (which holds an `EditorRef`
|
||||
// and calls `.lock()`) compiles unchanged under wasm.
|
||||
pub type EditorRef = Arc<Mutex<Editor>>;
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = hel_read_password)]
|
||||
fn extern_read_password(prompt: &str);
|
||||
|
||||
#[wasm_bindgen(js_name = hel_current_password)]
|
||||
fn extern_current_password() -> Option<String>;
|
||||
// Synchronous: the host page returns the current master-password value.
|
||||
// (The old read/poll pair used thread::sleep, which deadlocks the single
|
||||
// browser thread — never use blocking polling under wasm.)
|
||||
#[wasm_bindgen(js_name = hel_get_password)]
|
||||
fn extern_get_password(prompt: &str) -> String;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Editor {
|
||||
#[allow(dead_code)]
|
||||
history: Vec<String>,
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn new() -> Self {
|
||||
Self { history: vec![] }
|
||||
pub fn new() -> EditorRef {
|
||||
Arc::new(Mutex::new(Self { history: vec![] }))
|
||||
}
|
||||
|
||||
pub fn clear_history(&mut self) {
|
||||
@@ -193,13 +198,7 @@ pub mod editor {
|
||||
}
|
||||
|
||||
pub fn password(prompt: String) -> std::io::Result<String> {
|
||||
extern_read_password(&prompt);
|
||||
loop {
|
||||
match extern_current_password() {
|
||||
Some(p) => return Ok(p),
|
||||
None => std::thread::sleep(std::time::Duration::from_millis(100)),
|
||||
}
|
||||
}
|
||||
Ok(extern_get_password(&prompt))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,14 +240,60 @@ pub fn get_cmd_args_from_command(command: &str) -> io::Result<(String, Vec<Strin
|
||||
Ok((shellexpand::full(&args[0]).unwrap().into_owned(), args[1..].to_vec()))
|
||||
}
|
||||
|
||||
pub fn get_copy_command_from_env() -> (String, Vec<String>) {
|
||||
let cmd_os_str = env::var_os("HEL_PB").unwrap_or_else(|| match env::consts::OS {
|
||||
_ if env::var("TMUX").is_ok() => OsString::from("tmux load-buffer -"),
|
||||
"macos" => OsString::from("pbcopy"),
|
||||
"linux" => OsString::from("xclip"),
|
||||
_ => OsString::from("cat"),
|
||||
});
|
||||
get_cmd_args_from_command(&cmd_os_str.to_string_lossy()).unwrap_or_else(|_| ("cat".to_string(), vec![]))
|
||||
/// Outcome of a built-in fan-out copy: which sinks accepted the data and which
|
||||
/// failed (best-effort, like the reference shell script's `2>/dev/null`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct CopyReport {
|
||||
pub ok: Vec<String>,
|
||||
pub err: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// True if `bin` is an existing file on any `$PATH` directory.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn bin_on_path(bin: &str) -> bool {
|
||||
match std::env::var_os("PATH") {
|
||||
Some(paths) => std::env::split_paths(&paths).any(|dir| dir.join(bin).is_file()),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in, zero-config clipboard copy: pipe `data` into **every** clipboard
|
||||
/// sink present on `$PATH`, so a single `pb` works across macOS, Wayland, X11
|
||||
/// and (inside a session) tmux without any `HEL_PB` script. Used only when no
|
||||
/// explicit override (`set hel_pb` / `HEL_PB`) is set. The sink set + flags
|
||||
/// mirror the reference script, with two fixes: `xclip -selection clipboard`
|
||||
/// (the script's bare `xclip` filled the X11 *primary*, so Ctrl/Cmd-V missed
|
||||
/// it) and `xsel -ib` (input-to-clipboard; the script's `-ob` is the *output*
|
||||
/// direction). `wl-copy` is added for Wayland.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn copy_to_clipboards(data: &str) -> CopyReport {
|
||||
let mut sinks: Vec<(&str, Vec<&str>)> = Vec::new();
|
||||
if bin_on_path("pbcopy") {
|
||||
sinks.push(("pbcopy", vec![]));
|
||||
}
|
||||
if bin_on_path("wl-copy") {
|
||||
sinks.push(("wl-copy", vec![]));
|
||||
}
|
||||
if bin_on_path("xclip") {
|
||||
sinks.push(("xclip", vec!["-selection", "clipboard"]));
|
||||
}
|
||||
if bin_on_path("xsel") {
|
||||
sinks.push(("xsel", vec!["-ib"]));
|
||||
}
|
||||
if std::env::var("TMUX").is_ok() && bin_on_path("tmux") {
|
||||
sinks.push(("tmux", vec!["load-buffer", "-"]));
|
||||
}
|
||||
|
||||
let mut report = CopyReport::default();
|
||||
for (bin, args) in sinks {
|
||||
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
||||
match call_cmd_with_input(bin, &args, data) {
|
||||
Ok(_) => report.ok.push(bin.to_string()),
|
||||
Err(e) => report.err.push((bin.to_string(), e.to_string())),
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -278,6 +323,19 @@ line 4"###
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bin_on_path_test() {
|
||||
// `sh` is on PATH on every unix; a nonsense name is not.
|
||||
assert!(bin_on_path("sh"));
|
||||
assert!(!bin_on_path("definitely-not-a-real-binary-xyzzy-42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_report_default_test() {
|
||||
let r = CopyReport::default();
|
||||
assert!(r.ok.is_empty() && r.err.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_correct_stdin() {
|
||||
let cmd = "cat";
|
||||
|
||||
@@ -16,7 +16,4 @@ hel = { version = "0.1.0", path = "../hel" }
|
||||
lazy_static = "1.4.0"
|
||||
wasm-bindgen = "0.2.83"
|
||||
parking_lot = "0.12.1"
|
||||
|
||||
[dependencies.web-sys]
|
||||
version = "0.3.4"
|
||||
features = [ 'Document' ]
|
||||
console_error_panic_hook = "0.1"
|
||||
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 251 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<image href="enso-base.png" x="25.6" y="18.8" width="460.8" height="474.4"/>
|
||||
<g><circle cx="256" cy="189" r="30" fill="none" stroke="#1b4161" stroke-width="22"/><rect x="246" y="211" width="20" height="120" rx="4" fill="#1b4161"/><rect x="266" y="288" width="28" height="13" rx="3" fill="#1b4161"/><rect x="266" y="310" width="18" height="13" rx="3" fill="#1b4161"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 466 B |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 630 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 136 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" fill="#f4f0e8"/>
|
||||
<image href="enso-base.png" x="25.6" y="18.8" width="460.8" height="474.4"/>
|
||||
<g><circle cx="256" cy="189" r="30" fill="none" stroke="#1b4161" stroke-width="22"/><rect x="246" y="211" width="20" height="120" rx="4" fill="#1b4161"/><rect x="266" y="288" width="28" height="13" rx="3" fill="#1b4161"/><rect x="266" y="310" width="18" height="13" rx="3" fill="#1b4161"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 514 B |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "LesS/KEY password generator",
|
||||
"short_name": "LesS/KEY",
|
||||
"description": "A password manager that stores nothing and runs entirely in your browser. A free tool by Kaizenkodo.",
|
||||
"id": "../",
|
||||
"start_url": "../",
|
||||
"scope": "../",
|
||||
"display": "standalone",
|
||||
"theme_color": "#0a2540",
|
||||
"background_color": "#f4f0e8",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "./icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# Build the helwasm web bundle into helwasm/pkg/ (committed so GitHub Pages can
|
||||
# serve the static dir directly, no CI). Requires the wasm32 target and a
|
||||
# wasm-bindgen CLI matching the wasm-bindgen crate version:
|
||||
# rustup target add wasm32-unknown-unknown
|
||||
# cargo install wasm-bindgen-cli --version <crate version>
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
cargo build --target wasm32-unknown-unknown -p helwasm --release
|
||||
wasm-bindgen --target web --out-dir helwasm/pkg \
|
||||
target/wasm32-unknown-unknown/release/helwasm.wasm
|
||||
echo "built helwasm/pkg/ (helwasm.js + helwasm_bg.wasm)"
|
||||
@@ -1,172 +1,710 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<style>
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background-color: #000;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
color: #fff;
|
||||
overflow-y: scroll;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
color: #0f0;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.input {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
outline: none;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
label {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.terminal p,
|
||||
.prompt,
|
||||
.input {
|
||||
font-family: monospace;
|
||||
font-size: 16px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
#passwordPrompt {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
tranform: translate(-50%, -50%);
|
||||
backrground-color: white;
|
||||
border: 1px solid black;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0a2540" />
|
||||
<title>LesS/KEY password generator</title>
|
||||
<meta name="description" content="A password manager that stores nothing and never talks to a server. Passwords are generated entirely in your browser with WebAssembly; your master password and your data never leave your device." />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="./assets/favicon-32x32.png?v=2" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="./assets/favicon-16x16.png?v=2" />
|
||||
<link rel="icon" href="./assets/favicon.ico?v=2" sizes="any" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="./assets/apple-touch-icon.png?v=2" />
|
||||
<link rel="manifest" href="./assets/site.webmanifest" />
|
||||
<!-- Installable as a home-screen app (iOS + Android), runs standalone. -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="LesS/KEY" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<!-- Fonts are self-hosted in style.css (./assets/fonts/); no external requests. -->
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module">
|
||||
import init, { hel_init, hel_command } from "./pkg/helwasm.js";
|
||||
init().then(() => {
|
||||
window.hel = {
|
||||
hel_init: hel_init,
|
||||
hel_command: hel_command,
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script type="text/babel">
|
||||
function Terminal() {
|
||||
const prompt = "> ";
|
||||
const [input, setInput] = React.useState("");
|
||||
const [output, setOutput] = React.useState([]);
|
||||
const [passwordPrompt, setPasswordPrompt] = React.useState(undefined);
|
||||
const [passwordInput, setPasswordInput] = React.useState("");
|
||||
const outputRef = React.useRef(null);
|
||||
<header class="site">
|
||||
<div class="wrap nav">
|
||||
<a class="backlink" href="../" title="Back to the Kaizenkodo site"><span class="arr" aria-hidden="true">←</span> Kaizenkodo</a>
|
||||
<a class="brand" href="./">
|
||||
<img class="enso" src="./assets/enso.png" width="36" height="36" alt="" aria-hidden="true" />
|
||||
<span class="wordmark">LesS/KEY</span>
|
||||
<span class="tagline">password generator</span>
|
||||
</a>
|
||||
<span class="nav-spacer"></span>
|
||||
<button class="btn ghost" id="importBtn">Import</button>
|
||||
<button class="btn ghost" id="exportBtn">Export</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
React.useEffect(() => {
|
||||
outputRef.current.scrollIntoView({ behaviour: "smooth" });
|
||||
}, [output]);
|
||||
|
||||
function handleInput(event) {
|
||||
setInput(event.target.value);
|
||||
}
|
||||
|
||||
function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
const result = executeCommand(input);
|
||||
setOutput([...output, { prompt: prompt, command: input }, { prompt: "", command: result }]);
|
||||
setInput("");
|
||||
}
|
||||
|
||||
function handlePasswordInput(event) {
|
||||
setPasswordInput(event.target.value);
|
||||
}
|
||||
|
||||
function handlePasswordSubmit(event) {
|
||||
window.hel_current_password_value = passwordInput;
|
||||
setPasswordPrompt(undefined);
|
||||
setPasswordInput("");
|
||||
}
|
||||
|
||||
function helReadPassword(prompt) {
|
||||
window.hel_current_password_value = null;
|
||||
setPasswordPrompt(prompt);
|
||||
}
|
||||
window.hel_read_password = helReadPassword;
|
||||
|
||||
function helCurrentPassword(prompt) {
|
||||
return window.hel_current_password_value;
|
||||
}
|
||||
window.hel_current_password = helCurrentPassword;
|
||||
|
||||
function executeCommand(command) {
|
||||
return window.hel.hel_command(command);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="terminal">
|
||||
{output.map(({ prompt, command }, index) => (<p key={index}>{prompt && (<span className="prompt">{prompt}</span>)}{command}</p>))}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label>
|
||||
<span className="prompt" ref={outputRef}>{prompt}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={handleInput}
|
||||
className="input"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
{passwordPrompt !== undefined && (<form onSubmit={handlePasswordSubmit}><label><span className="prompt">{passwordPrompt}</span><input type="password" value={passwordInput} onChange={handlePasswordInput} className="input" autoFocus /></label></form>)}
|
||||
<main class="wrap">
|
||||
<!-- About / how it works (collapsible) -->
|
||||
<details class="about" id="about">
|
||||
<summary>
|
||||
<span class="about-badge">Help</span>
|
||||
<span>What is LesS/KEY & how it works</span>
|
||||
<span class="about-hint">click to open</span>
|
||||
<span class="about-toggle" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="about-body">
|
||||
<p>
|
||||
LesS/KEY is a password manager that <strong>stores no passwords</strong>. It
|
||||
<strong>re-generates</strong> each one on demand from your single
|
||||
<em>master password</em> plus the <em>name</em> of the account. The same name and
|
||||
master always produce the same password, so there is nothing secret to leak,
|
||||
sync, or back up, and you only ever remember one phrase. By default each password
|
||||
is six short, memorable words. That is the <em>correct horse battery staple</em> idea
|
||||
from <a href="https://xkcd.com/936/" target="_blank" rel="noopener">xkcd 936</a>, built on
|
||||
the classic S/KEY one-time-password scheme (<a href="https://www.rfc-editor.org/rfc/rfc2289" target="_blank" rel="noopener">RFC 2289</a>).
|
||||
</p>
|
||||
<p class="about-privacy">
|
||||
<strong>Nothing leaves your device.</strong> Everything happens locally. Your master
|
||||
password and the passwords it generates are never sent anywhere, never stored on a
|
||||
server, and never logged. There is no account, no sync, and no tracking. The app is a
|
||||
single page plus a WebAssembly module, with its fonts bundled in. Once it has loaded,
|
||||
it makes no network requests at all.
|
||||
</p>
|
||||
<div class="about-grid">
|
||||
<div>
|
||||
<h4>Quick password</h4>
|
||||
<p>
|
||||
Type an account name and your master phrase. The password appears instantly,
|
||||
masked; click it (or the master) to reveal, or press <strong>Copy</strong>.
|
||||
The save button steps from <strong>Store</strong> (file the entry — its name
|
||||
and rules, never the password) to <strong>Correct</strong> (confirm this name +
|
||||
master); once confirmed the result shows in colour, so a typo stays black.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Console</h4>
|
||||
<p>
|
||||
The full command line; type <code>help</code> for every command. It shares the
|
||||
same catalog and session as the form above, so you can switch between them freely;
|
||||
<code>pb</code> copies a command's output to the clipboard. Your list lives only in
|
||||
this browser (localStorage); use <strong>Export</strong> or <strong>Import</strong>
|
||||
to move it yourself (for example, to and from a Notion page).
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Naming rules</h4>
|
||||
<p>
|
||||
After a name you can add a length and a mode. <code>R</code> is six memorable words
|
||||
(the default); <code>C</code> camel; <code>H</code> hex; <code>B</code> base64;
|
||||
<code>D</code> digits (<code>U…</code> = upper). For example, <code>github 20R</code>.
|
||||
End a name with <code>^folder</code> to derive it from a parent; you still type only
|
||||
your one master and the whole chain is computed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<p class="about-note">
|
||||
Runs the real hel engine compiled to WebAssembly, the exact same generator as the
|
||||
command-line tool. You can add it to your home screen and use it as an app. No server,
|
||||
no account, no tracking, nothing to leak.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Quick generate -->
|
||||
<section class="card">
|
||||
<div class="eyebrow">Generate</div>
|
||||
<h2>Quick password</h2>
|
||||
<p class="hint">
|
||||
Same name + master always make the same password, computed in your browser —
|
||||
your master stays in memory only, never stored or sent. <strong>Store</strong>
|
||||
saves the entry (never the password); <strong>Correct</strong> saves a one-way hash.
|
||||
</p>
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<label for="qname">Name (+ optional rules)</label>
|
||||
<input type="text" id="qname" placeholder="github or github 20R 99 2024-01-01" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" data-1p-ignore data-lpignore="true" data-bwignore="true" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="master">Master password</label>
|
||||
<input type="text" id="master" class="mask" placeholder="your master phrase" title="click to show or hide" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" data-1p-ignore data-lpignore="true" data-bwignore="true" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="result">
|
||||
<span class="tag">result</span>
|
||||
<span class="secret" id="qsecret" title="click to reveal"></span>
|
||||
<span class="len" id="qlen"></span>
|
||||
<span class="actions">
|
||||
<button class="btn ghost small" id="saveBtn" title="Save this name in your catalog" disabled>Store</button>
|
||||
<button class="btn small" id="copy">Copy</button>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Console -->
|
||||
<section class="card">
|
||||
<div class="eyebrow">Console</div>
|
||||
<h2>All commands</h2>
|
||||
<p class="hint">
|
||||
The full hel command line: <code>ls</code>, <code>add</code>,
|
||||
<code>enc</code>, <code>gen</code>, <code>comment</code>,
|
||||
<code>correct</code>, <code>help</code>, … Your catalog persists in this
|
||||
browser (localStorage). Generated passwords are masked; click to reveal.
|
||||
</p>
|
||||
<div class="console-out" id="cout"></div>
|
||||
<div class="console-in">
|
||||
<span class="prompt">></span>
|
||||
<input type="text" id="cin" placeholder="type a command and press Enter" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="foot wrap">
|
||||
A free tool by <a href="https://kaizenkodo.no" target="_blank" rel="noopener">Kaizenkodo</a> (kaizenkodo.no).
|
||||
Runs the real hel core compiled to WebAssembly, the same generator as the CLI.
|
||||
Everything runs in your browser; nothing you type is ever sent to a server.
|
||||
<button class="ver" id="verBtn" type="button" title="What's new — changelog"></button>
|
||||
</footer>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<!-- Import modal -->
|
||||
<div class="modal-bg" id="importModal">
|
||||
<div class="modal">
|
||||
<h3>Import catalog</h3>
|
||||
<p class="hint">Paste your catalog (e.g. the text of the Notion page). Existing entries with the same name are replaced.</p>
|
||||
<textarea id="importText" placeholder="add ..."></textarea>
|
||||
<div class="row">
|
||||
<button class="btn" id="importDo">Import</button>
|
||||
<button class="btn ghost" id="importCancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export modal -->
|
||||
<div class="modal-bg" id="exportModal">
|
||||
<div class="modal">
|
||||
<h3>Export catalog</h3>
|
||||
<p class="hint">Copy this and paste it back into your Notion page.</p>
|
||||
<textarea id="exportText" readonly></textarea>
|
||||
<div class="row">
|
||||
<button class="btn" id="exportCopy">Copy</button>
|
||||
<button class="btn ghost" id="exportClose">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password prompt modal (for the `pass` command) -->
|
||||
<div class="modal-bg" id="passModal">
|
||||
<div class="modal">
|
||||
<h3 id="passTitle">Set password</h3>
|
||||
<p class="hint">Cache a master/parent password for this name (used by <code>enc</code> in its subtree). Kept in memory only.</p>
|
||||
<div class="field">
|
||||
<label for="passInput">Password</label>
|
||||
<input type="text" id="passInput" class="mask" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" data-1p-ignore data-lpignore="true" data-bwignore="true" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="btn" id="passOk">Set</button>
|
||||
<button class="btn ghost" id="passCancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Changelog overlay (opened from the footer version badge) -->
|
||||
<div class="modal-bg" id="changelogModal">
|
||||
<div class="modal">
|
||||
<h3>What's new</h3>
|
||||
<p class="hint">Recent changes to LesS/KEY, newest first.</p>
|
||||
<div class="changelog" id="changelogBody"></div>
|
||||
<div class="row">
|
||||
<button class="btn ghost" id="changelogClose">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import init, { hel_init, hel_command, hel_load_script, hel_parse, hel_parse_name, hel_entry } from "./pkg/helwasm.js";
|
||||
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const masterEl = () => document.getElementById("master");
|
||||
const CATALOG_KEY = "hel_catalog";
|
||||
|
||||
// Single source of truth for the displayed version AND the changelog dialog.
|
||||
// Newest first: CHANGELOG[0].version is the footer badge and gets the "Latest"
|
||||
// flag. To cut a release, bump the top version and prepend an entry. Each change
|
||||
// is { tag: "New" | "Improved", text }; `text` may hold inline <code> for command
|
||||
// names (authored/trusted content, rendered as HTML). `title` = the release theme.
|
||||
const CHANGELOG = [
|
||||
{ version: "1.0.0", date: "2026-06-10", title: "Keyboard-first, and installable", changes: [
|
||||
{ tag: "New", text: "Press Enter in the name field to jump to the master; Enter in the master generates and copies." },
|
||||
{ tag: "New", text: "Install LesS/KEY to your home screen and run it as a standalone app." },
|
||||
{ tag: "Improved", text: "Fresh ensō + key icon; the result shrinks to stay on one line on narrow phones." },
|
||||
] },
|
||||
{ version: "0.9.0", date: "2026-06-09", title: "A smarter catalog", changes: [
|
||||
{ tag: "New", text: "Quick password follows <code>^parent</code> chains, all computed from your one master." },
|
||||
{ tag: "New", text: "<code>pb</code> copies a command's output to the clipboard; help now lists every command." },
|
||||
{ tag: "Improved", text: "One progressive Store → Correct button; verified results show in colour." },
|
||||
] },
|
||||
{ version: "0.8.0", date: "2026-06-08", title: "Public launch", changes: [
|
||||
{ tag: "New", text: "Runs entirely in your browser via WebAssembly — nothing you type is ever sent to a server." },
|
||||
{ tag: "New", text: "Self-hosted fonts, installable and mobile-friendly; a bare name makes six memorable S/KEY words by default." },
|
||||
] },
|
||||
];
|
||||
|
||||
// When launched as an installed app (home-screen / standalone), hide the
|
||||
// "back to Kaizenkodo" link — leaving the app context would feel wrong there.
|
||||
// navigator.standalone covers older iOS; the media query covers the rest.
|
||||
if (window.navigator.standalone === true ||
|
||||
(window.matchMedia && window.matchMedia("(display-mode: standalone)").matches)) {
|
||||
document.documentElement.classList.add("standalone");
|
||||
}
|
||||
|
||||
const container = document.getElementById('root');
|
||||
const root = ReactDOM.createRoot(container);
|
||||
root.render(<Terminal />);
|
||||
|
||||
// ---- host imports (wasm calls these by bare name → must be globals) ----
|
||||
// The master field is the single ROOT master. hel's read_master prompts "/" for
|
||||
// the root and the parent's NAME when climbing a ^parent chain; by answering only
|
||||
// the "/" prompt (and "" otherwise) we force hel to climb to the root and COMPUTE
|
||||
// every intermediate parent from one master — exactly what the CLI does, and the
|
||||
// same for the easy form and the console below, so both give identical results.
|
||||
window.hel_get_password = (prompt) =>
|
||||
prompt === "/" ? (masterEl() ? masterEl().value : "") || "" : "";
|
||||
window.hel_rnd_range = (s, e) => {
|
||||
if (e <= s) return s;
|
||||
const r = crypto.getRandomValues(new Uint32Array(1))[0] / 4294967296;
|
||||
return s + Math.floor(r * (e - s));
|
||||
};
|
||||
window.hel_storage_get = (k) => localStorage.getItem("hel:" + k);
|
||||
window.hel_storage_set = (k, v) => localStorage.setItem("hel:" + k, v);
|
||||
// `pb` copies to the system clipboard. Fire-and-forget: it runs inside the Enter
|
||||
// keypress handler, so the user-gesture requirement for clipboard write is met.
|
||||
window.hel_clipboard_write = (text) => { try { navigator.clipboard.writeText(text); } catch (e) {} };
|
||||
|
||||
// ---- helpers ----
|
||||
const persist = () => hel_command("save " + CATALOG_KEY);
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const stripNoise = (out) => out.split("\n").filter((l) => l && !/^(warning|error):/.test(l));
|
||||
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
let toastT;
|
||||
function toast(msg) {
|
||||
const t = $("#toast");
|
||||
t.textContent = msg;
|
||||
t.classList.add("show");
|
||||
clearTimeout(toastT);
|
||||
toastT = setTimeout(() => t.classList.remove("show"), 1600);
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast("Copied");
|
||||
} catch {
|
||||
toast("Copy failed");
|
||||
}
|
||||
}
|
||||
|
||||
function secretSpan(text) {
|
||||
const s = document.createElement("span");
|
||||
s.className = "secret";
|
||||
s.textContent = text;
|
||||
s.title = "click to reveal";
|
||||
s.addEventListener("click", () => s.classList.toggle("revealed"));
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---- quick generate ----
|
||||
// Pure sugar over the same engine the console drives: it only ever runs
|
||||
// hel_command(...) and DERIVES what it shows from the engine, so you can switch
|
||||
// to the console at any time and keep working on the same catalog, the same
|
||||
// cached master, and the same correct hashes. No private form-only state — the
|
||||
// master lives only in hel's in-memory `secrets` (never written to storage);
|
||||
// only the one-way "correct" hash is persisted on the device.
|
||||
let lastSecret = "";
|
||||
let appliedMaster = null; // last master value pushed to the engine (debounce hint)
|
||||
// One progressive button. It walks the natural states, each a real engine fact:
|
||||
// not in catalog -> "Store" (click: add the name)
|
||||
// in catalog, master shown -> "Correct" (click: mark this name+master correct)
|
||||
// verified -> "Correct ✓" (click: unmark)
|
||||
// in catalog, no master -> "Stored ✓" (disabled: nothing to verify yet)
|
||||
function refreshSaveBtn(name, hasResult, correct) {
|
||||
const b = $("#saveBtn");
|
||||
if (!b) return;
|
||||
const stored = name ? !!hel_entry(name) : false;
|
||||
b.classList.remove("is-correct", "is-stored");
|
||||
if (!name) {
|
||||
b.disabled = true; b.textContent = "Store";
|
||||
b.title = "Save this name in your catalog";
|
||||
} else if (!stored) {
|
||||
b.disabled = false; b.textContent = "Store";
|
||||
b.title = "Save this name in your catalog";
|
||||
} else if (correct) {
|
||||
b.disabled = false; b.textContent = "Correct ✓"; b.classList.add("is-correct");
|
||||
b.title = "This name + master is verified. Click to unmark.";
|
||||
} else if (hasResult) {
|
||||
b.disabled = false; b.textContent = "Correct";
|
||||
b.title = "Mark this name + master correct, so it shows in colour";
|
||||
} else {
|
||||
b.disabled = true; b.textContent = "Stored ✓"; b.classList.add("is-stored");
|
||||
b.title = "In your catalog. Type the master to mark it correct.";
|
||||
}
|
||||
}
|
||||
// Shrink the result font just enough to keep it on one line on very narrow screens
|
||||
// (iPhone) instead of wrapping. Falls back to wrapping only if even the floor size
|
||||
// can't fit. Masking doesn't change width (mono disc = mono char), so reveal is safe.
|
||||
function fitResult() {
|
||||
const sec = $("#qsecret");
|
||||
sec.style.fontSize = "";
|
||||
if (!sec.textContent) return;
|
||||
const avail = sec.clientWidth;
|
||||
if (!avail) return;
|
||||
const ws = sec.style.whiteSpace;
|
||||
sec.style.whiteSpace = "nowrap";
|
||||
const full = sec.scrollWidth;
|
||||
sec.style.whiteSpace = ws;
|
||||
if (full > avail) {
|
||||
const base = parseFloat(getComputedStyle(sec).fontSize);
|
||||
sec.style.fontSize = Math.max(11, Math.floor((base * avail) / full)) + "px";
|
||||
}
|
||||
}
|
||||
function quickGen(silent) {
|
||||
const spec = $("#qname").value.trim();
|
||||
const sec = $("#qsecret");
|
||||
const lenEl = $("#qlen");
|
||||
sec.classList.remove("revealed");
|
||||
// The entry name is NOT always the first token; a leading prefix
|
||||
// (like `*P0 test1 …`) means the name is the next word. Parse it.
|
||||
const name = spec ? hel_parse_name(spec) : "";
|
||||
if (!name) {
|
||||
sec.textContent = ""; sec.classList.remove("correct"); sec.style.fontSize = "";
|
||||
lenEl.textContent = ""; lastSecret = "";
|
||||
refreshSaveBtn("", false, false);
|
||||
return;
|
||||
}
|
||||
const master = masterEl().value;
|
||||
// A new master in the field is authoritative: forget the cached chain so every
|
||||
// parent recomputes from it. An empty field keeps whatever master is stored, so
|
||||
// names still generate without re-typing it (the auto-`pass`). `unpass` (no arg)
|
||||
// is the same command you can type in the console.
|
||||
if (master !== appliedMaster) {
|
||||
if (master.length) hel_command("unpass");
|
||||
appliedMaster = master;
|
||||
}
|
||||
let out = hel_command("enc " + name);
|
||||
if (/^error: name .* not found/m.test(out)) { // unstored leaf: ephemeral add/enc/rm
|
||||
hel_command("add " + spec);
|
||||
out = hel_command("enc " + name);
|
||||
hel_command("rm " + name);
|
||||
}
|
||||
const pw = stripNoise(out).pop() || "";
|
||||
// Colour = "is THIS name + master marked correct?" — read straight from the
|
||||
// leaf's own correct-check in the enc output (per name+password, not per master),
|
||||
// so changing the name (or mistyping the master) drops back to black until you
|
||||
// mark that exact combination. `correct` typed in the console is reflected too.
|
||||
const warnRe = new RegExp("^warning: password " + escapeRegExp(name) + " is not marked as correct$", "m");
|
||||
const correct = !!pw && !warnRe.test(out);
|
||||
lastSecret = pw;
|
||||
sec.textContent = pw;
|
||||
sec.classList.toggle("correct", correct);
|
||||
lenEl.textContent = pw ? "len " + pw.length : "";
|
||||
fitResult();
|
||||
refreshSaveBtn(name, !!pw, correct);
|
||||
if (!pw && !silent) toast("No output");
|
||||
}
|
||||
// One click advances the progressive button by one step (Store → Correct →
|
||||
// unmark). Each step is the same command you could type in the console.
|
||||
function saveStep() {
|
||||
const b = $("#saveBtn");
|
||||
if (!b || b.disabled) return;
|
||||
const spec = $("#qname").value.trim();
|
||||
const name = hel_parse_name(spec);
|
||||
if (!name) return;
|
||||
const stored = !!hel_entry(name);
|
||||
if (!stored) {
|
||||
hel_command("add " + spec); // file the name in the catalog
|
||||
persist();
|
||||
appendLine('<span class="muted"># stored ' + esc(name) + "</span>");
|
||||
toast("Stored " + name);
|
||||
} else if (b.classList.contains("is-correct")) {
|
||||
hel_command("uncorrect " + name); // stored entry → removes the hash
|
||||
toast("No longer marked correct");
|
||||
} else {
|
||||
hel_command("correct " + name); // stored + master present → verify (colour)
|
||||
toast("Marked correct");
|
||||
}
|
||||
quickGen(true);
|
||||
}
|
||||
|
||||
// ---- console ----
|
||||
// Scroll to the bottom after layout (rAF) so the view always follows output.
|
||||
function scrollBottom() {
|
||||
const out = $("#cout");
|
||||
requestAnimationFrame(() => { out.scrollTop = out.scrollHeight; });
|
||||
}
|
||||
function appendLine(html, cls) {
|
||||
const out = $("#cout");
|
||||
const div = document.createElement("div");
|
||||
if (cls) div.className = cls;
|
||||
div.innerHTML = html;
|
||||
out.appendChild(div);
|
||||
scrollBottom();
|
||||
}
|
||||
function appendNode(node) {
|
||||
const out = $("#cout");
|
||||
const div = document.createElement("div");
|
||||
div.appendChild(node);
|
||||
out.appendChild(div);
|
||||
scrollBottom();
|
||||
}
|
||||
// gen data line: {:>3} {:>36} {:>4} {} → key(0..3) ' ' pass(4..40) ' ' len(41..45) ' ' name(46..)
|
||||
// Return the column split so we mask ONLY the password (never the header).
|
||||
function parseGenLine(line) {
|
||||
if (line.length < 46) return null;
|
||||
const lenStr = line.slice(41, 45).trim();
|
||||
if (!/^\d+$/.test(lenStr)) return null; // header "Len" → not numeric → plain
|
||||
const len = parseInt(lenStr, 10);
|
||||
if (len <= 0 || len > 36) return null;
|
||||
return { prefix: line.slice(0, 40 - len), secret: line.slice(40 - len, 40), suffix: line.slice(40) };
|
||||
}
|
||||
function consoleRun(cmd) {
|
||||
appendLine('<span class="cmd">> ' + esc(cmd) + "</span>");
|
||||
const out = hel_command(cmd);
|
||||
persist();
|
||||
if (!out) return;
|
||||
const verb = cmd.trim().split(/\s+/)[0];
|
||||
const isGen = /^gen\d*$/.test(verb); // gen, gen3, gen10, …
|
||||
const isEnc = verb === "enc";
|
||||
for (const line of out.split("\n")) {
|
||||
if (/^(warning|error):/.test(line)) {
|
||||
appendLine(esc(line), "err");
|
||||
} else if (isGen) {
|
||||
const p = parseGenLine(line);
|
||||
if (!p) { appendLine(esc(line) || " "); continue; }
|
||||
const frag = document.createDocumentFragment();
|
||||
frag.appendChild(document.createTextNode(p.prefix));
|
||||
frag.appendChild(secretSpan(p.secret));
|
||||
frag.appendChild(document.createTextNode(p.suffix));
|
||||
appendNode(frag);
|
||||
} else if (isEnc && line.trim() && !line.startsWith("add ")) {
|
||||
appendNode(secretSpan(line));
|
||||
} else {
|
||||
appendLine(esc(line) || " ");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pass overlay (a browser can't block for input, so collect the
|
||||
// password in a modal, then run the long form `pass <name> <pw>`) ----
|
||||
let passPending = null;
|
||||
function openPassOverlay(name) {
|
||||
passPending = name;
|
||||
$("#passTitle").textContent = 'Password for "' + name + '"';
|
||||
$("#passInput").value = "";
|
||||
$("#passModal").classList.add("show");
|
||||
setTimeout(() => $("#passInput").focus(), 30);
|
||||
}
|
||||
function submitPass() {
|
||||
const name = passPending;
|
||||
passPending = null;
|
||||
const pw = $("#passInput").value;
|
||||
$("#passModal").classList.remove("show");
|
||||
$("#passInput").value = "";
|
||||
$("#cin").focus(); // return to the command line after the overlay closes
|
||||
if (name == null) return;
|
||||
appendLine('<span class="cmd">> pass ' + esc(name) + "</span>");
|
||||
if (!pw) {
|
||||
appendLine('<span class="muted"># cancelled</span>');
|
||||
return;
|
||||
}
|
||||
const out = hel_command("pass " + name + " " + pw); // long form, no prompt
|
||||
persist();
|
||||
if (out && out.trim()) {
|
||||
for (const l of out.split("\n")) appendLine(esc(l), /^(warning|error):/.test(l) ? "err" : null);
|
||||
}
|
||||
appendLine('<span class="muted"># password cached for ' + esc(name) + "</span>");
|
||||
}
|
||||
|
||||
// ---- changelog overlay ----
|
||||
// Render once from CHANGELOG (newest first). Each entry: a head with the
|
||||
// version + date, then a bullet list of changes. The <code> in a change is
|
||||
// authored, so innerHTML is safe here (no user input ever reaches this).
|
||||
function renderChangelog() {
|
||||
const body = $("#changelogBody");
|
||||
if (!body || body.childElementCount) return; // build once
|
||||
CHANGELOG.forEach((rel, i) => {
|
||||
const entry = document.createElement("div");
|
||||
entry.className = "cl-entry";
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "cl-head";
|
||||
const ver = document.createElement("span");
|
||||
ver.className = "cl-ver";
|
||||
ver.textContent = "v" + rel.version;
|
||||
const date = document.createElement("span");
|
||||
date.className = "cl-date";
|
||||
date.textContent = rel.date;
|
||||
head.append(ver, date);
|
||||
if (i === 0) { // flag the newest release
|
||||
const latest = document.createElement("span");
|
||||
latest.className = "cl-latest";
|
||||
latest.textContent = "Latest";
|
||||
head.appendChild(latest);
|
||||
}
|
||||
entry.appendChild(head);
|
||||
|
||||
if (rel.title) {
|
||||
const title = document.createElement("div");
|
||||
title.className = "cl-title";
|
||||
title.textContent = rel.title;
|
||||
entry.appendChild(title);
|
||||
}
|
||||
|
||||
const ul = document.createElement("ul");
|
||||
for (const c of rel.changes) {
|
||||
const li = document.createElement("li");
|
||||
const tag = document.createElement("span");
|
||||
tag.className = "cl-tag cl-tag-" + c.tag.toLowerCase();
|
||||
tag.textContent = c.tag;
|
||||
const text = document.createElement("span");
|
||||
text.className = "cl-text";
|
||||
text.innerHTML = c.text;
|
||||
li.append(tag, text);
|
||||
ul.appendChild(li);
|
||||
}
|
||||
entry.appendChild(ul);
|
||||
body.appendChild(entry);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- boot ----
|
||||
async function boot() {
|
||||
await init();
|
||||
hel_init();
|
||||
if (localStorage.getItem("hel:" + CATALOG_KEY)) hel_command("source " + CATALOG_KEY);
|
||||
|
||||
// remember the About panel's open/closed state across visits
|
||||
const about = $("#about");
|
||||
const aboutState = localStorage.getItem("hel:_about_open");
|
||||
if (aboutState !== null) about.open = aboutState === "1";
|
||||
about.addEventListener("toggle", () => localStorage.setItem("hel:_about_open", about.open ? "1" : "0"));
|
||||
|
||||
// live generation
|
||||
let liveT;
|
||||
const live = () => {
|
||||
clearTimeout(liveT);
|
||||
liveT = setTimeout(() => quickGen(true), 70);
|
||||
};
|
||||
$("#qname").addEventListener("input", live);
|
||||
$("#master").addEventListener("input", live);
|
||||
window.addEventListener("resize", fitResult); // re-fit on rotate / width change
|
||||
// Enter in the name field jumps to the master field (its blur then normalises
|
||||
// the name and regenerates). Works with the iOS keyboard "return" key too.
|
||||
$("#qname").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); $("#master").focus(); }
|
||||
});
|
||||
// On blur, rewrite the name field to the canonical form hel parsed it as
|
||||
// (name + mode + seq + date + comment), so you see exactly what was used.
|
||||
$("#qname").addEventListener("blur", () => {
|
||||
const spec = $("#qname").value.trim();
|
||||
if (!spec) return;
|
||||
// If the name is already in the catalog, use its FULL stored spec (real
|
||||
// mode/seq/date/parent), not a fresh default-extended parse of what was typed.
|
||||
const name = hel_parse_name(spec);
|
||||
const stored = name ? hel_entry(name) : "";
|
||||
const norm = stored || hel_parse(spec);
|
||||
if (norm && norm !== spec) {
|
||||
$("#qname").value = norm;
|
||||
quickGen(true);
|
||||
}
|
||||
});
|
||||
$("#qsecret").addEventListener("click", () => $("#qsecret").classList.toggle("revealed"));
|
||||
// Click the master field to show what you typed; click again to re-mask.
|
||||
$("#master").addEventListener("click", () => $("#master").classList.toggle("revealed"));
|
||||
// Enter in the master field copies the result, like pressing Copy (the keydown
|
||||
// gesture lets the clipboard write through). Generate first so it's current.
|
||||
$("#master").addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
quickGen(false);
|
||||
if (lastSecret) copyText(lastSecret); else toast("Nothing to copy");
|
||||
});
|
||||
$("#copy").onclick = () => (lastSecret ? copyText(lastSecret) : toast("Nothing to copy"));
|
||||
$("#saveBtn").onclick = saveStep;
|
||||
|
||||
// pass overlay wiring
|
||||
$("#passOk").onclick = submitPass;
|
||||
$("#passInput").addEventListener("keydown", (e) => e.key === "Enter" && submitPass());
|
||||
$("#passCancel").onclick = () => {
|
||||
passPending = null;
|
||||
$("#passModal").classList.remove("show");
|
||||
$("#passInput").value = "";
|
||||
$("#cin").focus();
|
||||
};
|
||||
|
||||
const ci = $("#cin");
|
||||
// command history with Up/Down (persisted across reloads)
|
||||
const HKEY = "_console_history";
|
||||
const history = JSON.parse(localStorage.getItem("hel:" + HKEY) || "[]");
|
||||
let hi = history.length; // pointer; === length means "current draft"
|
||||
let draft = "";
|
||||
const moveCaretEnd = () => requestAnimationFrame(() => ci.setSelectionRange(ci.value.length, ci.value.length));
|
||||
ci.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
const v = ci.value.trim();
|
||||
ci.value = "";
|
||||
if (!v) return;
|
||||
if (history[history.length - 1] !== v) history.push(v);
|
||||
if (history.length > 300) history.shift();
|
||||
localStorage.setItem("hel:" + HKEY, JSON.stringify(history));
|
||||
hi = history.length;
|
||||
draft = "";
|
||||
const pm = v.match(/^pass\s+(\S+)\s*$/); // `pass <name>` → ask in an overlay
|
||||
if (pm) { openPassOverlay(pm[1]); return; }
|
||||
consoleRun(v);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
if (hi > 0) {
|
||||
if (hi === history.length) draft = ci.value;
|
||||
ci.value = history[--hi];
|
||||
e.preventDefault();
|
||||
moveCaretEnd();
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
if (hi < history.length) {
|
||||
hi++;
|
||||
ci.value = hi === history.length ? draft : history[hi];
|
||||
e.preventDefault();
|
||||
moveCaretEnd();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$("#importBtn").onclick = () => $("#importModal").classList.add("show");
|
||||
$("#importCancel").onclick = () => $("#importModal").classList.remove("show");
|
||||
$("#importDo").onclick = () => {
|
||||
const txt = $("#importText").value;
|
||||
if (txt.trim()) {
|
||||
hel_load_script(txt);
|
||||
persist();
|
||||
toast("Imported");
|
||||
appendLine('<span class="muted"># imported catalog</span>');
|
||||
}
|
||||
$("#importModal").classList.remove("show");
|
||||
$("#importText").value = "";
|
||||
};
|
||||
$("#exportBtn").onclick = () => {
|
||||
$("#exportText").value = hel_command("dump"); // `dump` prints the catalog
|
||||
$("#exportModal").classList.add("show");
|
||||
};
|
||||
$("#exportClose").onclick = () => $("#exportModal").classList.remove("show");
|
||||
$("#exportCopy").onclick = () => copyText($("#exportText").value);
|
||||
|
||||
// changelog overlay: footer badge shows the top version and opens the dialog
|
||||
const changelog = $("#changelogModal");
|
||||
$("#verBtn").textContent = "v" + CHANGELOG[0].version;
|
||||
renderChangelog();
|
||||
$("#verBtn").onclick = () => changelog.classList.add("show");
|
||||
$("#changelogClose").onclick = () => changelog.classList.remove("show");
|
||||
// Esc closes the changelog (scoped — leaves the other modals' behaviour alone).
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && changelog.classList.contains("show")) changelog.classList.remove("show");
|
||||
});
|
||||
|
||||
// click backdrop closes modals
|
||||
document.querySelectorAll(".modal-bg").forEach((bg) =>
|
||||
bg.addEventListener("click", (e) => e.target === bg && bg.classList.remove("show"))
|
||||
);
|
||||
|
||||
appendLine('<span class="muted"># LesS/KEY ready. Runs entirely in your browser (WASM); nothing is sent to a server. Try: help</span>');
|
||||
}
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
/**
|
||||
* Run a single hel command line and return its combined output.
|
||||
*/
|
||||
export function hel_command(cmd: string): string;
|
||||
|
||||
/**
|
||||
* Look up an entry by its exact name and return its canonical stored form
|
||||
* (`name [len]mode seq date comment ^parent`), or "" if it is not in the catalog.
|
||||
* Read-only. The UI uses this to detect "already stored" and to use the real stored
|
||||
* spec (its mode/seq/date/parent) instead of the bare name the user typed.
|
||||
*/
|
||||
export function hel_entry(name: string): string;
|
||||
|
||||
/**
|
||||
* Call once at page load: routes Rust panics to the browser console with a
|
||||
* readable message + stack instead of an opaque "unreachable" trap.
|
||||
*/
|
||||
export function hel_init(): void;
|
||||
|
||||
/**
|
||||
* Run a whole multi-line script (every `add …` line, `set …`, etc.) against the
|
||||
* shared state in one call. Used to bulk-import a pasted catalog (e.g. the text
|
||||
* of the Notion page) and to load the persisted catalog from localStorage.
|
||||
*/
|
||||
export function hel_load_script(script: string): string;
|
||||
|
||||
/**
|
||||
* Parse a password spec (e.g. `exa91` or `exa91 20R 99 2020-01-01`) and return
|
||||
* the canonical normalized form hel actually uses (name + mode + seq + date +
|
||||
* comment), without touching state. Returns the input unchanged if it does not
|
||||
* parse. Used by the UI to rewrite the name field on blur.
|
||||
*/
|
||||
export function hel_parse(spec: string): string;
|
||||
|
||||
/**
|
||||
* Parse a spec and return just the entry name hel resolves it to. Handles a
|
||||
* leading prefix (e.g. `*P0 test1 …` → `test1`), which a naive first-token
|
||||
* split would get wrong. Falls back to the first whitespace token.
|
||||
*/
|
||||
export function hel_parse_name(spec: string): string;
|
||||
|
||||
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||
|
||||
export interface InitOutput {
|
||||
readonly memory: WebAssembly.Memory;
|
||||
readonly hel_command: (a: number, b: number) => [number, number];
|
||||
readonly hel_entry: (a: number, b: number) => [number, number];
|
||||
readonly hel_load_script: (a: number, b: number) => [number, number];
|
||||
readonly hel_parse: (a: number, b: number) => [number, number];
|
||||
readonly hel_parse_name: (a: number, b: number) => [number, number];
|
||||
readonly hel_init: () => void;
|
||||
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
||||
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
readonly __wbindgen_externrefs: WebAssembly.Table;
|
||||
readonly __wbindgen_start: () => void;
|
||||
}
|
||||
|
||||
export type SyncInitInput = BufferSource | WebAssembly.Module;
|
||||
|
||||
/**
|
||||
* Instantiates the given `module`, which can either be bytes or
|
||||
* a precompiled `WebAssembly.Module`.
|
||||
*
|
||||
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
|
||||
*
|
||||
* @returns {InitOutput}
|
||||
*/
|
||||
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
|
||||
|
||||
/**
|
||||
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
|
||||
* for everything else, calls `WebAssembly.instantiate` directly.
|
||||
*
|
||||
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
|
||||
*
|
||||
* @returns {Promise<InitOutput>}
|
||||
*/
|
||||
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
|
||||
@@ -0,0 +1,391 @@
|
||||
/* @ts-self-types="./helwasm.d.ts" */
|
||||
|
||||
/**
|
||||
* Run a single hel command line and return its combined output.
|
||||
* @param {string} cmd
|
||||
* @returns {string}
|
||||
*/
|
||||
export function hel_command(cmd) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(cmd, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.hel_command(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up an entry by its exact name and return its canonical stored form
|
||||
* (`name [len]mode seq date comment ^parent`), or "" if it is not in the catalog.
|
||||
* Read-only. The UI uses this to detect "already stored" and to use the real stored
|
||||
* spec (its mode/seq/date/parent) instead of the bare name the user typed.
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function hel_entry(name) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.hel_entry(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call once at page load: routes Rust panics to the browser console with a
|
||||
* readable message + stack instead of an opaque "unreachable" trap.
|
||||
*/
|
||||
export function hel_init() {
|
||||
wasm.hel_init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a whole multi-line script (every `add …` line, `set …`, etc.) against the
|
||||
* shared state in one call. Used to bulk-import a pasted catalog (e.g. the text
|
||||
* of the Notion page) and to load the persisted catalog from localStorage.
|
||||
* @param {string} script
|
||||
* @returns {string}
|
||||
*/
|
||||
export function hel_load_script(script) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(script, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.hel_load_script(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a password spec (e.g. `exa91` or `exa91 20R 99 2020-01-01`) and return
|
||||
* the canonical normalized form hel actually uses (name + mode + seq + date +
|
||||
* comment), without touching state. Returns the input unchanged if it does not
|
||||
* parse. Used by the UI to rewrite the name field on blur.
|
||||
* @param {string} spec
|
||||
* @returns {string}
|
||||
*/
|
||||
export function hel_parse(spec) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(spec, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.hel_parse(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a spec and return just the entry name hel resolves it to. Handles a
|
||||
* leading prefix (e.g. `*P0 test1 …` → `test1`), which a naive first-token
|
||||
* split would get wrong. Falls back to the first whitespace token.
|
||||
* @param {string} spec
|
||||
* @returns {string}
|
||||
*/
|
||||
export function hel_parse_name(spec) {
|
||||
let deferred2_0;
|
||||
let deferred2_1;
|
||||
try {
|
||||
const ptr0 = passStringToWasm0(spec, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.hel_parse_name(ptr0, len0);
|
||||
deferred2_0 = ret[0];
|
||||
deferred2_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
|
||||
}
|
||||
}
|
||||
function __wbg_get_imports() {
|
||||
const import0 = {
|
||||
__proto__: null,
|
||||
__wbg___wbindgen_throw_1506f2235d1bdba0: function(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
},
|
||||
__wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
try {
|
||||
deferred0_0 = arg0;
|
||||
deferred0_1 = arg1;
|
||||
console.error(getStringFromWasm0(arg0, arg1));
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
|
||||
}
|
||||
},
|
||||
__wbg_getTime_00b3f7db575e4ef5: function(arg0) {
|
||||
const ret = arg0.getTime();
|
||||
return ret;
|
||||
},
|
||||
__wbg_getTimezoneOffset_08e2892156231088: function(arg0) {
|
||||
const ret = arg0.getTimezoneOffset();
|
||||
return ret;
|
||||
},
|
||||
__wbg_hel_clipboard_write_efa8177dd67c65ec: function(arg0, arg1) {
|
||||
hel_clipboard_write(getStringFromWasm0(arg0, arg1));
|
||||
},
|
||||
__wbg_hel_get_password_271a2beac04c29db: function(arg0, arg1, arg2) {
|
||||
const ret = hel_get_password(getStringFromWasm0(arg1, arg2));
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
},
|
||||
__wbg_hel_rnd_range_d01857a65a482c6d: function(arg0, arg1) {
|
||||
const ret = hel_rnd_range(arg0 >>> 0, arg1 >>> 0);
|
||||
return ret;
|
||||
},
|
||||
__wbg_hel_storage_get_9915f302e24bdacb: function(arg0, arg1, arg2) {
|
||||
const ret = hel_storage_get(getStringFromWasm0(arg1, arg2));
|
||||
var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
var len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
},
|
||||
__wbg_hel_storage_set_78052521669832fa: function(arg0, arg1, arg2, arg3) {
|
||||
hel_storage_set(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3));
|
||||
},
|
||||
__wbg_new_0_445c13a750296eb6: function() {
|
||||
const ret = new Date();
|
||||
return ret;
|
||||
},
|
||||
__wbg_new_227d7c05414eb861: function() {
|
||||
const ret = new Error();
|
||||
return ret;
|
||||
},
|
||||
__wbg_new_6d75fd236f920a62: function(arg0) {
|
||||
const ret = new Date(arg0);
|
||||
return ret;
|
||||
},
|
||||
__wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) {
|
||||
const ret = arg1.stack;
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
},
|
||||
__wbindgen_cast_0000000000000001: function(arg0) {
|
||||
// Cast intrinsic for `F64 -> Externref`.
|
||||
const ret = arg0;
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_init_externref_table: function() {
|
||||
const table = wasm.__wbindgen_externrefs;
|
||||
const offset = table.grow(4);
|
||||
table.set(0, undefined);
|
||||
table.set(offset + 0, undefined);
|
||||
table.set(offset + 1, null);
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
},
|
||||
};
|
||||
return {
|
||||
__proto__: null,
|
||||
"./helwasm_bg.js": import0,
|
||||
};
|
||||
}
|
||||
|
||||
let cachedDataViewMemory0 = null;
|
||||
function getDataViewMemory0() {
|
||||
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
||||
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
||||
}
|
||||
return cachedDataViewMemory0;
|
||||
}
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
return decodeText(ptr >>> 0, len);
|
||||
}
|
||||
|
||||
let cachedUint8ArrayMemory0 = null;
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||
}
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
function isLikeNone(x) {
|
||||
return x === undefined || x === null;
|
||||
}
|
||||
|
||||
function passStringToWasm0(arg, malloc, realloc) {
|
||||
if (realloc === undefined) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
const ptr = malloc(buf.length, 1) >>> 0;
|
||||
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||
WASM_VECTOR_LEN = buf.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let len = arg.length;
|
||||
let ptr = malloc(len, 1) >>> 0;
|
||||
|
||||
const mem = getUint8ArrayMemory0();
|
||||
|
||||
let offset = 0;
|
||||
|
||||
for (; offset < len; offset++) {
|
||||
const code = arg.charCodeAt(offset);
|
||||
if (code > 0x7F) break;
|
||||
mem[ptr + offset] = code;
|
||||
}
|
||||
if (offset !== len) {
|
||||
if (offset !== 0) {
|
||||
arg = arg.slice(offset);
|
||||
}
|
||||
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
||||
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||
const ret = cachedTextEncoder.encodeInto(arg, view);
|
||||
|
||||
offset += ret.written;
|
||||
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||
}
|
||||
|
||||
WASM_VECTOR_LEN = offset;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||
let numBytesDecoded = 0;
|
||||
function decodeText(ptr, len) {
|
||||
numBytesDecoded += len;
|
||||
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
||||
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
numBytesDecoded = len;
|
||||
}
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
const cachedTextEncoder = new TextEncoder();
|
||||
|
||||
if (!('encodeInto' in cachedTextEncoder)) {
|
||||
cachedTextEncoder.encodeInto = function (arg, view) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
view.set(buf);
|
||||
return {
|
||||
read: arg.length,
|
||||
written: buf.length
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
let wasmModule, wasmInstance, wasm;
|
||||
function __wbg_finalize_init(instance, module) {
|
||||
wasmInstance = instance;
|
||||
wasm = instance.exports;
|
||||
wasmModule = module;
|
||||
cachedDataViewMemory0 = null;
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
wasm.__wbindgen_start();
|
||||
return wasm;
|
||||
}
|
||||
|
||||
async function __wbg_load(module, imports) {
|
||||
if (typeof Response === 'function' && module instanceof Response) {
|
||||
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||
try {
|
||||
return await WebAssembly.instantiateStreaming(module, imports);
|
||||
} catch (e) {
|
||||
const validResponse = module.ok && expectedResponseType(module.type);
|
||||
|
||||
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
|
||||
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||
|
||||
} else { throw e; }
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = await module.arrayBuffer();
|
||||
return await WebAssembly.instantiate(bytes, imports);
|
||||
} else {
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
|
||||
if (instance instanceof WebAssembly.Instance) {
|
||||
return { instance, module };
|
||||
} else {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
function expectedResponseType(type) {
|
||||
switch (type) {
|
||||
case 'basic': case 'cors': case 'default': return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function initSync(module) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (module !== undefined) {
|
||||
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||
({module} = module)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
const imports = __wbg_get_imports();
|
||||
if (!(module instanceof WebAssembly.Module)) {
|
||||
module = new WebAssembly.Module(module);
|
||||
}
|
||||
const instance = new WebAssembly.Instance(module, imports);
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
async function __wbg_init(module_or_path) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (module_or_path !== undefined) {
|
||||
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||
({module_or_path} = module_or_path)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
if (module_or_path === undefined) {
|
||||
module_or_path = new URL('helwasm_bg.wasm', import.meta.url);
|
||||
}
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||
module_or_path = fetch(module_or_path);
|
||||
}
|
||||
|
||||
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
export { initSync, __wbg_init as default };
|
||||
@@ -0,0 +1,14 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const memory: WebAssembly.Memory;
|
||||
export const hel_command: (a: number, b: number) => [number, number];
|
||||
export const hel_entry: (a: number, b: number) => [number, number];
|
||||
export const hel_load_script: (a: number, b: number) => [number, number];
|
||||
export const hel_parse: (a: number, b: number) => [number, number];
|
||||
export const hel_parse_name: (a: number, b: number) => [number, number];
|
||||
export const hel_init: () => void;
|
||||
export const __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
export const __wbindgen_externrefs: WebAssembly.Table;
|
||||
export const __wbindgen_start: () => void;
|
||||
@@ -1,22 +1,79 @@
|
||||
use hel::lk::{LK, LKRef};
|
||||
use hel::repl::LKRead;
|
||||
use hel::utils::editor::Editor;
|
||||
use std::sync::Arc;
|
||||
use hel::parser::command_parser;
|
||||
use hel::repl::{LKEval, LKRead};
|
||||
use hel::structs::LKOut;
|
||||
use hel::utils::editor::{password, Editor};
|
||||
use parking_lot::ReentrantMutex;
|
||||
use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
lazy_static! {
|
||||
static ref STATE: LKRef = Arc::new(ReentrantMutex::new(RefCell::new(LK::new())));
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
/// Run a single hel command line and return its combined output.
|
||||
#[wasm_bindgen]
|
||||
pub fn hel_command(cmd: String) -> String {
|
||||
let editor = Editor::new();
|
||||
let mut lkread = LKRead::new(editor, "> ".to_string(), STATE.clone());
|
||||
lkread.input = Some(cmd.to_string());
|
||||
lkread.input = Some(cmd);
|
||||
let lkeval = lkread.read();
|
||||
let lkprint = lkeval.eval();
|
||||
lkprint.out.output().join("\n")
|
||||
}
|
||||
|
||||
/// Parse a password spec (e.g. `exa91` or `exa91 20R 99 2020-01-01`) and return
|
||||
/// the canonical normalized form hel actually uses (name + mode + seq + date +
|
||||
/// comment), without touching state. Returns the input unchanged if it does not
|
||||
/// parse. Used by the UI to rewrite the name field on blur.
|
||||
#[wasm_bindgen]
|
||||
pub fn hel_parse(spec: String) -> String {
|
||||
match command_parser::name(&spec) {
|
||||
Ok(p) => p.to_string().trim().to_string(),
|
||||
Err(_) => spec,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a spec and return just the entry name hel resolves it to. Handles a
|
||||
/// leading prefix (e.g. `*P0 test1 …` → `test1`), which a naive first-token
|
||||
/// split would get wrong. Falls back to the first whitespace token.
|
||||
#[wasm_bindgen]
|
||||
pub fn hel_parse_name(spec: String) -> String {
|
||||
match command_parser::name(&spec) {
|
||||
Ok(p) => p.name,
|
||||
Err(_) => spec.split_whitespace().next().unwrap_or("").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up an entry by its exact name and return its canonical stored form
|
||||
/// (`name [len]mode seq date comment ^parent`), or "" if it is not in the catalog.
|
||||
/// Read-only. The UI uses this to detect "already stored" and to use the real stored
|
||||
/// spec (its mode/seq/date/parent) instead of the bare name the user typed.
|
||||
#[wasm_bindgen]
|
||||
pub fn hel_entry(name: String) -> String {
|
||||
let cell = STATE.lock();
|
||||
let lk = cell.borrow();
|
||||
match lk.db.get(&name).or_else(|| lk.ls.get(&name)) {
|
||||
Some(p) => p.lock().borrow().to_string().trim().to_string(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a whole multi-line script (every `add …` line, `set …`, etc.) against the
|
||||
/// shared state in one call. Used to bulk-import a pasted catalog (e.g. the text
|
||||
/// of the Notion page) and to load the persisted catalog from localStorage.
|
||||
#[wasm_bindgen]
|
||||
pub fn hel_load_script(script: String) -> String {
|
||||
let out = LKOut::new();
|
||||
match command_parser::script(&script) {
|
||||
Ok(cmds) => {
|
||||
for cmd in cmds {
|
||||
let print = LKEval::new(Editor::new(), cmd, STATE.clone(), password).eval();
|
||||
print.out.copy(&out);
|
||||
}
|
||||
}
|
||||
Err(e) => out.e(format!("error: {}", e)),
|
||||
}
|
||||
out.output().join("\n")
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ extern crate hel;
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn ok_add(a: i32, b: i32) -> i32 {
|
||||
a + b + 1
|
||||
}
|
||||
|
||||
mod hel_state;
|
||||
|
||||
/// Call once at page load: routes Rust panics to the browser console with a
|
||||
/// readable message + stack instead of an opaque "unreachable" trap.
|
||||
#[wasm_bindgen]
|
||||
pub fn hel_init() {
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
/* LesS/KEY web app. Visual language mirrored from kaizenkodo.org
|
||||
(~/Repos/Identity/website/main.css): washi-paper cream, ocean blue, hanko red,
|
||||
Poppins / Inter / JetBrains Mono. Light, responsive, restrained. */
|
||||
|
||||
:root {
|
||||
--cream: #f4f0e8;
|
||||
--paper: #fbf8f2;
|
||||
--ink: #15181b;
|
||||
--muted: #6a675e;
|
||||
--line: #ddd6c6;
|
||||
--ocean: #1b4161;
|
||||
--ocean-2: #2f6286;
|
||||
--deep: #0a2540;
|
||||
--seal: #e23b2e;
|
||||
--disp: "Poppins", system-ui, sans-serif;
|
||||
--body: "Inter", system-ui, sans-serif;
|
||||
--mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
--maxw: 1320px;
|
||||
--gutter: clamp(16px, 3.5vw, 48px);
|
||||
--radius: 16px;
|
||||
}
|
||||
|
||||
/* Self-hosted fonts, bundled with the app so it makes NO external requests and
|
||||
talks to no third party (no Google Fonts, no CDN). Latin subset only. */
|
||||
@font-face { font-family:"Poppins"; font-style:normal; font-weight:600; font-display:swap; src:url("./assets/fonts/Poppins-600.woff2") format("woff2"); }
|
||||
@font-face { font-family:"Poppins"; font-style:normal; font-weight:700; font-display:swap; src:url("./assets/fonts/Poppins-700.woff2") format("woff2"); }
|
||||
@font-face { font-family:"Inter"; font-style:normal; font-weight:400; font-display:swap; src:url("./assets/fonts/Inter-400.woff2") format("woff2"); }
|
||||
@font-face { font-family:"Inter"; font-style:normal; font-weight:500; font-display:swap; src:url("./assets/fonts/Inter-500.woff2") format("woff2"); }
|
||||
@font-face { font-family:"Inter"; font-style:normal; font-weight:600; font-display:swap; src:url("./assets/fonts/Inter-600.woff2") format("woff2"); }
|
||||
@font-face { font-family:"JetBrains Mono"; font-style:normal; font-weight:400; font-display:swap; src:url("./assets/fonts/JetBrainsMono-400.woff2") format("woff2"); }
|
||||
@font-face { font-family:"JetBrains Mono"; font-style:normal; font-weight:500; font-display:swap; src:url("./assets/fonts/JetBrainsMono-500.woff2") format("woff2"); }
|
||||
/* Masked-secret font: dots glyphs so the real chars stay selectable/copyable.
|
||||
WebKit/Blink use -webkit-text-security; Firefox falls back to this font. */
|
||||
@font-face { font-family:"text-security-disc"; font-display:swap; src:url("./assets/fonts/text-security-disc.woff2") format("woff2"); }
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { height: 100%; overflow-x: hidden; overscroll-behavior-x: none; }
|
||||
|
||||
body {
|
||||
min-height: 100%;
|
||||
overflow-x: hidden; /* no sideways pan; page is single column */
|
||||
overscroll-behavior-x: none; /* kill horizontal rubber-band / swipe-nav */
|
||||
font-family: var(--body);
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: var(--ink);
|
||||
background-color: var(--cream);
|
||||
background-image:
|
||||
linear-gradient(rgba(27, 65, 97, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(27, 65, 97, 0.05) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
.wrap { width: 100%; max-width: var(--maxw); margin: 0 auto; padding-inline: var(--gutter); }
|
||||
|
||||
/* Header */
|
||||
.site {
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
background: rgba(244, 240, 232, 0.82);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.nav { display: flex; align-items: center; gap: 14px; height: 68px; }
|
||||
.brand { display: inline-flex; align-items: center; gap: 11px; text-decoration: none; }
|
||||
.enso { width: 36px; height: 36px; display: block; flex: none; object-fit: contain; }
|
||||
.wordmark { font-family: var(--disp); font-weight: 700; font-size: 22px; color: var(--ocean); letter-spacing: -0.01em; }
|
||||
.tagline { font-family: var(--mono); font-size: 11px; color: var(--muted); letter-spacing: 0.08em; }
|
||||
.nav-spacer { flex: 1; }
|
||||
/* Always-visible link back to the main Kaizenkodo site. */
|
||||
.backlink { display: inline-flex; align-items: center; gap: 5px; flex: none; font-family: var(--mono); font-size: 12px; letter-spacing: 0.03em; color: var(--muted); text-decoration: none; white-space: nowrap; padding: 6px 11px; border-radius: 999px; border: 1px solid var(--line); }
|
||||
.backlink:hover { color: var(--ocean); border-color: var(--ocean); background: rgba(27, 65, 97, 0.05); }
|
||||
.backlink .arr { font-size: 14px; line-height: 1; }
|
||||
/* Installed as a home-screen app (standalone launch): hide the back-to-site link.
|
||||
With it gone there's room, so show the wordmark again even on narrow phones
|
||||
(higher specificity than the <=540px hide, so it wins regardless of order). */
|
||||
@media (display-mode: standalone) { .backlink { display: none; } }
|
||||
html.standalone .backlink { display: none; }
|
||||
html.standalone .wordmark { display: inline; }
|
||||
|
||||
/* Eyebrow label */
|
||||
.eyebrow {
|
||||
font-family: var(--mono); font-size: 12px; letter-spacing: 0.16em;
|
||||
text-transform: uppercase; color: var(--ocean);
|
||||
display: inline-flex; align-items: center; gap: 9px; margin-bottom: 12px;
|
||||
}
|
||||
.eyebrow::before {
|
||||
content: ""; width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--seal); box-shadow: 0 0 0 3px rgba(226, 59, 46, 0.16);
|
||||
}
|
||||
|
||||
/* minmax(0,1fr) lets the cards shrink below their content's intrinsic width, so
|
||||
the console's pre/no-wrap lines scroll inside it instead of widening the page. */
|
||||
main { padding: clamp(20px, 3vw, 34px) 0 56px; display: grid; grid-template-columns: minmax(0, 1fr); gap: clamp(16px, 2.4vw, 24px); }
|
||||
|
||||
/* About / how-it-works (collapsible <details>) */
|
||||
.about { background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
|
||||
.about > summary {
|
||||
list-style: none; cursor: pointer; display: flex; align-items: center; gap: 12px;
|
||||
padding: clamp(14px, 2.2vw, 19px) clamp(16px, 2.6vw, 28px);
|
||||
font-family: var(--disp); font-weight: 600; font-size: clamp(16px, 2vw, 20px); color: var(--ocean);
|
||||
}
|
||||
.about > summary::-webkit-details-marker { display: none; }
|
||||
.about > summary:hover { color: var(--ocean-2); }
|
||||
/* attention badge; draws the eye to open the help panel; hidden once open */
|
||||
.about-badge {
|
||||
font-family: var(--mono); font-size: 10.5px; font-weight: 500;
|
||||
text-transform: uppercase; letter-spacing: 0.1em;
|
||||
color: #fff; background: var(--seal); border-radius: 999px; padding: 3px 10px; flex: none;
|
||||
animation: badgePulse 2.2s ease-out infinite;
|
||||
}
|
||||
@keyframes badgePulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(226, 59, 46, 0.45); }
|
||||
70% { box-shadow: 0 0 0 9px rgba(226, 59, 46, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(226, 59, 46, 0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .about-badge { animation: none; } }
|
||||
.about-hint { font-family: var(--mono); font-size: 11px; font-weight: 400; color: var(--muted); }
|
||||
.about[open] .about-badge, .about[open] .about-hint { display: none; }
|
||||
@media (max-width: 680px) { .about-hint { display: none; } }
|
||||
.about .about-toggle {
|
||||
margin-left: auto; width: 9px; height: 9px; flex: none;
|
||||
border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
|
||||
transform: rotate(45deg); transition: transform .2s;
|
||||
}
|
||||
.about[open] .about-toggle { transform: rotate(-135deg); }
|
||||
.about-body { padding: 0 clamp(16px, 2.6vw, 28px) clamp(16px, 2.6vw, 24px); }
|
||||
.about-body p { color: var(--muted); font-size: 14.5px; }
|
||||
.about-body > p:first-child { color: var(--ink); font-size: 15.5px; }
|
||||
.about-body code { font-family: var(--mono); font-size: 12px; color: var(--ocean-2); background: rgba(27,65,97,.06); padding: 1px 5px; border-radius: 5px; }
|
||||
.about-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin: 18px 0 6px; }
|
||||
.about-grid h4 { font-family: var(--disp); font-weight: 600; font-size: 14px; color: var(--ink); margin-bottom: 5px; }
|
||||
.about-grid p { font-size: 13.5px; }
|
||||
.about-note { font-family: var(--mono); font-size: 12px; margin-top: 10px; }
|
||||
/* Privacy callout; make the "no server" promise visually prominent. */
|
||||
.about-privacy {
|
||||
margin: 6px 0 2px; padding: 13px 16px; border-radius: 12px;
|
||||
border: 1px solid var(--line); border-left: 3px solid var(--ocean);
|
||||
background: rgba(27, 65, 97, 0.045); color: var(--ink); font-size: 14.5px;
|
||||
}
|
||||
.about-privacy strong { color: var(--ocean); }
|
||||
@media (max-width: 680px) { .about-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--paper); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: clamp(16px, 2.6vw, 28px);
|
||||
}
|
||||
.card h2 { font-family: var(--disp); font-weight: 600; font-size: clamp(20px, 2.6vw, 27px); color: var(--ink); margin-bottom: 4px; }
|
||||
.card .hint { color: var(--muted); font-size: 14px; margin-bottom: 18px; }
|
||||
.card .hint code { font-family: var(--mono); font-size: 12.5px; color: var(--ocean-2); background: rgba(27,65,97,0.06); padding: 1px 5px; border-radius: 5px; }
|
||||
|
||||
/* Form fields */
|
||||
.fields { display: grid; grid-template-columns: 1.5fr 1fr; gap: 14px; align-items: end; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.field label { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); }
|
||||
input[type="text"], input[type="password"] {
|
||||
/* 16px min: iOS Safari auto-zooms (and stays zoomed) when a focused input is
|
||||
under 16px. Keep all focusable fields >= 16px to stop that. */
|
||||
font-family: var(--mono); font-size: 16px; color: var(--ink);
|
||||
background: #fff; border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 12px 14px; width: 100%; outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
input:focus { border-color: var(--ocean); box-shadow: 0 0 0 3px rgba(27, 65, 97, 0.12); }
|
||||
/* Masked TEXT inputs (master, pass overlay): visually masked but type=text, so
|
||||
the browser never treats them as password fields, so no autofill / save / "suggest
|
||||
strong password". Firefox uses the bundled disc font. */
|
||||
input.mask { -webkit-text-security: disc; }
|
||||
@supports not (-webkit-text-security: disc) { input.mask { font-family: "text-security-disc", var(--mono); } }
|
||||
/* Click the masked master field to reveal what you typed; click again to re-mask. */
|
||||
#master { cursor: pointer; }
|
||||
input.mask.revealed { -webkit-text-security: none; }
|
||||
@supports not (-webkit-text-security: disc) { input.mask.revealed { font-family: var(--mono); } }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 8px; cursor: pointer; white-space: nowrap;
|
||||
font-family: var(--disp); font-weight: 600; font-size: 14px;
|
||||
background: var(--ocean); color: var(--cream);
|
||||
border: 1px solid var(--ocean); border-radius: 999px; padding: 10px 20px;
|
||||
transition: transform .15s, background .15s, border-color .15s;
|
||||
}
|
||||
.btn:hover { transform: translateY(-1px); background: var(--deep); border-color: var(--deep); }
|
||||
.btn:active { transform: none; }
|
||||
.btn.ghost { background: transparent; color: var(--ocean); }
|
||||
.btn.ghost:hover { background: rgba(27, 65, 97, 0.06); color: var(--ocean); border-color: var(--ocean); }
|
||||
.btn.small { padding: 7px 15px; font-size: 13px; }
|
||||
/* Confirmed states ("Correct ✓" / "Stored ✓"): same filled-ocean look for both. */
|
||||
.btn.small.is-correct,
|
||||
.btn.small.is-stored { background: var(--ocean); color: var(--cream); border-color: var(--ocean); }
|
||||
.btn.small.is-correct:hover { background: var(--deep); border-color: var(--deep); }
|
||||
.btn:disabled { opacity: .4; cursor: not-allowed; pointer-events: none; }
|
||||
/* is-stored is disabled (re-storing is a no-op); keep it fully opaque, not dimmed. */
|
||||
.btn.small.is-stored { opacity: 1; }
|
||||
|
||||
/* Quick-gen result row */
|
||||
.result {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 12px;
|
||||
margin-top: 20px; padding: 14px 16px; border: 1px solid var(--line);
|
||||
border-radius: 12px; background: #fff; min-height: 58px;
|
||||
}
|
||||
.result .tag { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); }
|
||||
.result .actions { margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.result .len { font-family: var(--mono); font-size: 11px; color: var(--muted); }
|
||||
/* Quick-gen result: dynamic font (scales down on narrow phones) and wraps so a
|
||||
long password stays inside the card. min-width:0 lets it shrink/wrap within
|
||||
the flex row. Scoped so the console table keeps its fixed-width alignment. */
|
||||
.result .secret { font-size: clamp(13px, 4vw, 19px); white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; }
|
||||
|
||||
/* Secret rendering: real text in the DOM, visually masked. Click toggles.
|
||||
Mask via -webkit-text-security on the MONO font so each disc keeps the mono
|
||||
advance width, so gen tables stay aligned. (No letter-spacing, no disc webfont
|
||||
here, both of which would break the column widths.) */
|
||||
.secret {
|
||||
font-family: var(--mono);
|
||||
-webkit-text-security: disc;
|
||||
font-size: 19px; color: var(--ink);
|
||||
cursor: pointer; user-select: text; white-space: pre;
|
||||
}
|
||||
/* Reveal flips masking only; colour is reserved for correctness (below). */
|
||||
.secret.revealed { -webkit-text-security: none; }
|
||||
/* Quick-gen result: ocean = this master is marked correct; otherwise ink ("black"). */
|
||||
.result .secret.correct { color: var(--ocean); font-weight: 500; }
|
||||
/* Console keeps its old cue: a revealed secret turns ocean. */
|
||||
.console-out .secret.revealed { color: var(--ocean); }
|
||||
/* Firefox lacks -webkit-text-security: fall back to the disc webfont for masking
|
||||
(single-line result only; the console table is a WebKit/Blink concern). */
|
||||
@supports not (-webkit-text-security: disc) {
|
||||
.secret { font-family: "text-security-disc", var(--mono); }
|
||||
.secret.revealed { font-family: var(--mono); }
|
||||
}
|
||||
.secret:empty::before { content: "type a name + master"; color: var(--muted); font-size: 14px; -webkit-text-security: none; font-family: var(--body); cursor: default; }
|
||||
|
||||
/* Console */
|
||||
.console-out {
|
||||
font-family: var(--mono); font-size: 13.5px; line-height: 1.55;
|
||||
background: #fff; border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 14px; height: clamp(280px, 46vh, 560px);
|
||||
overflow: auto; white-space: pre; /* no wrap, scroll left/right */
|
||||
}
|
||||
.console-out > div { min-width: max-content; }
|
||||
.console-out .cmd { color: var(--ocean); font-weight: 500; }
|
||||
.console-out .err { color: var(--seal); }
|
||||
.console-out .muted { color: var(--muted); }
|
||||
.console-out .secret { font-size: 13.5px; vertical-align: baseline; }
|
||||
.console-in { display: flex; align-items: center; gap: 8px; margin-top: 10px;
|
||||
background: #fff; border: 1px solid var(--line); border-radius: 10px; padding: 4px 14px; }
|
||||
.console-in:focus-within { border-color: var(--ocean); box-shadow: 0 0 0 3px rgba(27,65,97,0.12); }
|
||||
.console-in .prompt { font-family: var(--mono); color: var(--ocean); font-weight: 600; }
|
||||
.console-in input { border: none; box-shadow: none; padding: 9px 0; background: transparent; }
|
||||
.console-in input:focus { box-shadow: none; }
|
||||
|
||||
.toast {
|
||||
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%);
|
||||
background: var(--deep); color: var(--cream); font-family: var(--mono); font-size: 13px;
|
||||
padding: 10px 18px; border-radius: 999px; box-shadow: 0 8px 24px rgba(10, 16, 24, 0.35);
|
||||
opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 80;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
|
||||
/* Modal (import/export) */
|
||||
.modal-bg {
|
||||
position: fixed; inset: 0; background: rgba(10, 16, 24, 0.45);
|
||||
display: none; align-items: center; justify-content: center; padding: 20px; z-index: 90;
|
||||
}
|
||||
.modal-bg.show { display: flex; }
|
||||
.modal { background: var(--paper); border-radius: 18px; border: 1px solid var(--line);
|
||||
width: 100%; max-width: 680px; padding: 24px; }
|
||||
.modal h3 { font-family: var(--disp); font-weight: 600; margin-bottom: 10px; }
|
||||
.modal textarea {
|
||||
width: 100%; height: 260px; resize: vertical; font-family: var(--mono); font-size: 16px;
|
||||
border: 1px solid var(--line); border-radius: 10px; padding: 12px; outline: none; white-space: pre;
|
||||
}
|
||||
.modal textarea:focus { border-color: var(--ocean); }
|
||||
.modal .row { display: flex; gap: 10px; margin-top: 14px; }
|
||||
|
||||
/* padding-block only: keep .wrap's inline gutter so the footer lines up with the
|
||||
body cards instead of running to the window edge. */
|
||||
.foot { color: var(--muted); font-family: var(--mono); font-size: 12px; text-align: center; padding-block: 8px 40px; }
|
||||
.foot a { color: var(--ocean); text-decoration: none; border-bottom: 1px solid rgba(27, 65, 97, 0.3); }
|
||||
.foot a:hover { border-bottom-color: var(--ocean); }
|
||||
/* Version badge: a quiet mono pill under the footer prose (mirrors .backlink).
|
||||
Decent and out of the way; click opens the changelog overlay. */
|
||||
.ver {
|
||||
display: inline-block; margin-top: 10px;
|
||||
font-family: var(--mono); font-size: 11px; letter-spacing: 0.04em;
|
||||
color: var(--muted); background: transparent;
|
||||
border: 1px solid var(--line); border-radius: 999px; padding: 3px 11px;
|
||||
cursor: pointer; transition: color .15s, border-color .15s, background .15s;
|
||||
}
|
||||
.ver:hover { color: var(--ocean); border-color: var(--ocean); background: rgba(27, 65, 97, 0.05); }
|
||||
.ver:focus-visible { outline: none; border-color: var(--ocean); box-shadow: 0 0 0 3px rgba(27, 65, 97, 0.12); }
|
||||
|
||||
/* Changelog overlay body (inside the shared .modal). Scrolls if it ever grows. */
|
||||
.changelog { max-height: 60vh; overflow: auto; margin-top: 4px; }
|
||||
.cl-entry { padding: 16px 0; border-top: 1px solid var(--line); }
|
||||
.cl-entry:first-child { border-top: none; padding-top: 2px; }
|
||||
.cl-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 3px; }
|
||||
.cl-ver { font-family: var(--disp); font-weight: 700; font-size: 16px; color: var(--ocean); letter-spacing: -0.01em; }
|
||||
.cl-date { font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em; color: var(--muted); }
|
||||
/* "Latest" flag, pushed to the right edge of the newest release's head. */
|
||||
.cl-latest {
|
||||
margin-left: auto; flex: none; align-self: center;
|
||||
font-family: var(--mono); font-size: 9.5px; font-weight: 500;
|
||||
text-transform: uppercase; letter-spacing: 0.1em;
|
||||
color: #fff; background: var(--seal); border-radius: 999px; padding: 2px 9px;
|
||||
}
|
||||
/* Release theme line under the version. */
|
||||
.cl-title { font-family: var(--disp); font-weight: 600; font-size: 14px; color: var(--ink); margin: 0 0 12px; }
|
||||
.changelog ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 9px; }
|
||||
.changelog li { display: flex; align-items: baseline; gap: 12px; color: var(--ink); font-size: 14px; line-height: 1.5; }
|
||||
/* Category tag in a fixed-width column, so the change text shares one clean left edge. */
|
||||
.cl-tag {
|
||||
flex: none; width: 68px; text-align: center; box-sizing: border-box;
|
||||
font-family: var(--mono); font-size: 9.5px; font-weight: 500;
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
padding: 3px 0; border-radius: 6px; transform: translateY(-1px);
|
||||
}
|
||||
.cl-tag-new { color: var(--ocean); background: rgba(27, 65, 97, 0.10); }
|
||||
.cl-tag-improved { color: var(--muted); background: rgba(106, 103, 94, 0.13); }
|
||||
.cl-text { min-width: 0; }
|
||||
.changelog code { font-family: var(--mono); font-size: 12px; color: var(--ocean-2); background: rgba(27,65,97,.06); padding: 1px 5px; border-radius: 5px; }
|
||||
/* On phones the fixed tag column would crowd the text — let each item stack. */
|
||||
@media (max-width: 540px) {
|
||||
.changelog li { flex-wrap: wrap; gap: 5px 10px; }
|
||||
.cl-tag { width: auto; padding: 3px 9px; }
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.fields { grid-template-columns: 1fr; align-items: stretch; }
|
||||
.nav { gap: 9px; height: 60px; }
|
||||
.tagline { display: none; }
|
||||
body { font-size: 15px; }
|
||||
/* free space for the back link: the ensō icon alone carries the brand on phones */
|
||||
.backlink { padding: 5px 9px; }
|
||||
/* result row stacks cleanly on phones: the password on its own full-width line,
|
||||
then a tidy meta line (result + len ........ Copy / Store). */
|
||||
.result { align-items: center; row-gap: 12px; }
|
||||
.result .secret { order: 1; flex: 1 1 100%; font-size: clamp(15px, 5.2vw, 19px); }
|
||||
.result .tag { order: 2; }
|
||||
.result .len { order: 3; margin-right: auto; }
|
||||
.result .actions { order: 4; margin-left: 0; }
|
||||
}
|
||||
/* On narrow phones drop the wordmark so the back link + ensō + buttons all fit. */
|
||||
@media (max-width: 540px) { .wordmark { display: none; } }
|
||||